feat: restore global search in app header
This commit is contained in:
parent
cf770262e5
commit
0cd8a79049
|
|
@ -209,10 +209,13 @@ type WorklogDTO struct {
|
|||
}
|
||||
|
||||
type SearchResultDTO struct {
|
||||
NodeID string `json:"nodeId"`
|
||||
Title string `json:"title"`
|
||||
Snippet string `json:"snippet"`
|
||||
Type string `json:"type"`
|
||||
NodeID string `json:"nodeId"`
|
||||
TargetID string `json:"targetId,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Snippet string `json:"snippet"`
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
type EventDTO struct {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"verstak/internal/core/config"
|
||||
"verstak/internal/core/nodes"
|
||||
|
|
@ -262,25 +263,140 @@ func (a *App) Search(query string) ([]SearchResultDTO, error) {
|
|||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return []SearchResultDTO{}, nil
|
||||
}
|
||||
out := []SearchResultDTO{}
|
||||
seen := map[string]bool{}
|
||||
add := func(r SearchResultDTO) {
|
||||
if r.Title == "" {
|
||||
return
|
||||
}
|
||||
key := r.Type + ":" + r.NodeID + ":" + r.TargetID + ":" + r.Title
|
||||
if seen[key] {
|
||||
return
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, r)
|
||||
}
|
||||
results, err := a.search.Search(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]SearchResultDTO, len(results))
|
||||
for i, r := range results {
|
||||
out[i] = SearchResultDTO{
|
||||
for _, r := range results {
|
||||
path := r.Path
|
||||
if path == "" && r.NodeID != "" {
|
||||
path = a.nodes.Path(r.NodeID)
|
||||
}
|
||||
add(SearchResultDTO{
|
||||
NodeID: r.NodeID,
|
||||
Title: r.Title,
|
||||
Snippet: r.Snippet,
|
||||
Type: r.Type,
|
||||
Path: path,
|
||||
})
|
||||
}
|
||||
|
||||
if len(out) < 20 {
|
||||
nodes, err := a.nodes.Search(query, 20-len(out))
|
||||
if err == nil {
|
||||
for i := range nodes {
|
||||
if nodes[i].IsDeleted() {
|
||||
continue
|
||||
}
|
||||
add(SearchResultDTO{
|
||||
NodeID: nodes[i].ID,
|
||||
Title: nodes[i].Title,
|
||||
Type: nodes[i].Type,
|
||||
Path: a.nodes.Path(nodes[i].ID),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(out) < 20 {
|
||||
rows, err := a.db.Query(
|
||||
`SELECT l.id,l.node_id,l.title,l.url,l.hostname,COALESCE(l.note,''),n.deleted_at
|
||||
FROM links l
|
||||
LEFT JOIN nodes n ON n.id = l.node_id
|
||||
WHERE n.deleted_at IS NULL
|
||||
AND (LOWER(l.title) LIKE ? OR LOWER(l.url) LIKE ? OR LOWER(l.hostname) LIKE ? OR LOWER(COALESCE(l.note,'')) LIKE ?)
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT ?`,
|
||||
likeQuery(query), likeQuery(query), likeQuery(query), likeQuery(query), 20-len(out))
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id, nodeID, title, url, hostname, note string
|
||||
var deletedAt interface{}
|
||||
if err := rows.Scan(&id, &nodeID, &title, &url, &hostname, ¬e, &deletedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snippet := url
|
||||
if note != "" {
|
||||
snippet = note
|
||||
}
|
||||
add(SearchResultDTO{
|
||||
NodeID: nodeID,
|
||||
TargetID: id,
|
||||
Title: title,
|
||||
Snippet: snippet,
|
||||
Type: "link",
|
||||
Path: a.nodes.Path(nodeID),
|
||||
URL: url,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(out) < 20 {
|
||||
rows, err := a.db.Query(
|
||||
`SELECT ac.id,ac.node_id,ac.title,ac.kind,COALESCE(ac.url,''),COALESCE(ac.command,''),n.deleted_at
|
||||
FROM actions ac
|
||||
LEFT JOIN nodes n ON n.id = ac.node_id
|
||||
WHERE n.deleted_at IS NULL
|
||||
AND (LOWER(ac.title) LIKE ? OR LOWER(ac.kind) LIKE ? OR LOWER(COALESCE(ac.url,'')) LIKE ? OR LOWER(COALESCE(ac.command,'')) LIKE ?)
|
||||
ORDER BY ac.created_at DESC
|
||||
LIMIT ?`,
|
||||
likeQuery(query), likeQuery(query), likeQuery(query), likeQuery(query), 20-len(out))
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id, nodeID, title, kind, url, command string
|
||||
var deletedAt interface{}
|
||||
if err := rows.Scan(&id, &nodeID, &title, &kind, &url, &command, &deletedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snippet := url
|
||||
if snippet == "" {
|
||||
snippet = command
|
||||
}
|
||||
add(SearchResultDTO{
|
||||
NodeID: nodeID,
|
||||
TargetID: id,
|
||||
Title: title,
|
||||
Snippet: snippet,
|
||||
Type: "action",
|
||||
Path: a.nodes.Path(nodeID),
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func likeQuery(query string) string {
|
||||
return "%" + strings.ToLower(strings.TrimSpace(query)) + "%"
|
||||
}
|
||||
|
||||
func (a *App) VerstakVersion() string {
|
||||
return "verstak-gui/v2"
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -19,8 +19,8 @@
|
|||
background: #13131f;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/main-Co5H5J-5.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-BW6W9uAx.css">
|
||||
<script type="module" crossorigin src="/assets/main-2ehud8s_.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-N_NpVgn3.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
import SettingsWindow from './lib/SettingsWindow.svelte'
|
||||
import SyncStatus from './lib/SyncStatus.svelte'
|
||||
import TodayScreen from './lib/TodayScreen.svelte'
|
||||
import AppHeader from './lib/AppHeader.svelte'
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { actionIcon } from './lib/actionIcons.js'
|
||||
import { canPreviewFile, needsBase64Preview, needsTextPreview } from './lib/fileUtils.js'
|
||||
|
|
@ -207,6 +208,12 @@
|
|||
let unlistenDrop = null
|
||||
|
||||
$: visibleTrashNodes = computeVisibleTrashNodes(trashInfo?.nodes || [], trashFolderId)
|
||||
$: currentViewTitle = selectedNode
|
||||
? selectedNode.title
|
||||
: selectedSection
|
||||
? (systemViews.find(view => view.id === selectedSection)?.label || '')
|
||||
: t('nav.selectPrompt')
|
||||
$: currentViewSubtitle = selectedNode ? nodeKindLabel(selectedNode.type) : ''
|
||||
|
||||
$: sortedInboxNodes = sortInboxNodes(inboxNodes, inboxSortBy, inboxSortDir)
|
||||
$: groupedInboxNodes = inboxGroupByContext ? groupInboxByContext(sortedInboxNodes) : null
|
||||
|
|
@ -1805,6 +1812,10 @@
|
|||
const labels = { 'project': t('kind.project'), 'client': t('kind.client'), 'document': t('kind.document'), 'recipe': t('kind.recipe'), 'folder': t('kind.folder'), 'note': t('kind.note'), 'file': t('kind.file'), 'archive': t('kind.archive'), 'case': t('kind.case'), 'link': t('kind.link') }
|
||||
return labels[kind] || kind || t('kind.case')
|
||||
}
|
||||
function searchResultTypeLabel(kind) {
|
||||
if (kind === 'action') return t('kind.action')
|
||||
return nodeKindLabel(kind)
|
||||
}
|
||||
function captureKindLabel(kind) {
|
||||
if (!kind) return ''
|
||||
return t('capture.kind.' + kind)
|
||||
|
|
@ -2384,6 +2395,65 @@
|
|||
return openActivityTarget(ev)
|
||||
}
|
||||
|
||||
async function openSearchResult(result) {
|
||||
if (!result || !result.nodeId) return
|
||||
try {
|
||||
if (result.type === 'link') {
|
||||
const node = await wailsCall('GetNodeDetail', result.nodeId)
|
||||
if (node) {
|
||||
await selectNode(node)
|
||||
setActiveTab('links')
|
||||
}
|
||||
return
|
||||
}
|
||||
if (result.type === 'action') {
|
||||
const node = await wailsCall('GetNodeDetail', result.nodeId)
|
||||
if (node) {
|
||||
await selectNode(node)
|
||||
setActiveTab('actions')
|
||||
}
|
||||
return
|
||||
}
|
||||
const detail = await wailsCall('GetNodeDetail', result.nodeId)
|
||||
if (!detail) return
|
||||
if (result.type === 'note') {
|
||||
const parentId = detail.parent_id || detail.parentId || ''
|
||||
const parent = parentId ? await wailsCall('GetNodeDetail', parentId) : null
|
||||
if (parent) {
|
||||
await selectNode(parent)
|
||||
setActiveTab('notes')
|
||||
notes = await wailsCall('ListNotes', parent.id) || []
|
||||
const note = notes.find(item => item.id === detail.id)
|
||||
if (note) await openNote(note)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (result.type === 'file') {
|
||||
const parentId = detail.parent_id || detail.parentId || ''
|
||||
const parent = parentId ? await wailsCall('GetNodeDetail', parentId) : null
|
||||
if (parent) {
|
||||
await selectNode(parent)
|
||||
setActiveTab('files')
|
||||
await loadFolder(parent.id)
|
||||
const fileItem = fileItems.find(item => item.id === detail.id)
|
||||
if (fileItem && canPreviewFile(fileItem)) {
|
||||
await openPreview(fileItem)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (result.type === 'folder') {
|
||||
await selectNode(detail)
|
||||
setActiveTab('files')
|
||||
await loadFolder(detail.id)
|
||||
return
|
||||
}
|
||||
await selectNode(detail)
|
||||
} catch (e) {
|
||||
error = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Sync =====
|
||||
async function loadSyncStatus() {
|
||||
try {
|
||||
|
|
@ -2537,20 +2607,31 @@
|
|||
|
||||
<!-- Main -->
|
||||
<main class="main">
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
{#if selectedNode}
|
||||
<span class="crumb">{selectedNode.title}</span>
|
||||
<span class="crumb-type">{nodeKindLabel(selectedNode.type)}</span>
|
||||
{:else if selectedSection}
|
||||
<span class="crumb">{#each systemViews as v}{v.id === selectedSection ? v.label : ''}{/each}</span>
|
||||
{:else}
|
||||
<span class="crumb placeholder">{t('nav.selectPrompt')}</span>
|
||||
<AppHeader
|
||||
title={currentViewTitle}
|
||||
subtitle={currentViewSubtitle}
|
||||
{wailsCall}
|
||||
typeLabel={searchResultTypeLabel}
|
||||
onOpenSearchResult={openSearchResult}
|
||||
>
|
||||
<div slot="actions" class="header-actions">
|
||||
{#if selectedSection === 'inbox'}
|
||||
<button class="btn btn-primary" on:click={captureClipboard} disabled={inboxCaptureBusy}>
|
||||
{inboxCaptureBusy ? t('common.loading') : t('inbox.pasteClipboard')}
|
||||
</button>
|
||||
{#if inboxCaptureStatus}
|
||||
<span class="inbox-capture-status">{inboxCaptureStatus}</span>
|
||||
{/if}
|
||||
{:else if selectedSection === 'trash'}
|
||||
{#if trashSelectedIds.length > 0}
|
||||
<button class="btn btn-sm btn-primary" on:click={() => restoreTrash(trashSelectedIds)}>{@html actionIcon('restore', 14)} {t('trash.restore')} ({trashSelectedIds.length})</button>
|
||||
<button class="btn btn-sm btn-danger" on:click={() => purgeTrash(trashSelectedIds)}>{@html actionIcon('purge', 14)} {t('common.delete')} ({trashSelectedIds.length})</button>
|
||||
{/if}
|
||||
<button class="btn btn-sm btn-danger" on:click={emptyTrash}>{@html actionIcon('emptyTrash', 14)} {t('trash.emptyTrash')}</button>
|
||||
<button class="btn btn-sm" on:click={() => wailsCall('OpenTrashFolder')}>{t('trash.openFolder')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="header-right">
|
||||
</div>
|
||||
</header>
|
||||
</AppHeader>
|
||||
|
||||
{#if error}
|
||||
<div class="error-banner" role="button" tabindex="0" on:click={() => error = ''} on:keydown={onKeyActivate(() => error = '')}>
|
||||
|
|
@ -2985,17 +3066,8 @@
|
|||
on:drop={handleInboxDrop}>
|
||||
<div class="inbox-header">
|
||||
<div>
|
||||
<h2>{t('nav.inbox')}</h2>
|
||||
<p>{t('inbox.subtitle')}</p>
|
||||
</div>
|
||||
<div class="inbox-header-actions">
|
||||
<button class="btn btn-primary" on:click={captureClipboard} disabled={inboxCaptureBusy}>
|
||||
{inboxCaptureBusy ? t('common.loading') : t('inbox.pasteClipboard')}
|
||||
</button>
|
||||
{#if inboxCaptureStatus}
|
||||
<span class="inbox-capture-status">{inboxCaptureStatus}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if inboxNodes.length > 0}
|
||||
|
|
@ -3102,17 +3174,8 @@
|
|||
<div class="trash-screen">
|
||||
<div class="trash-header">
|
||||
<div>
|
||||
<h2>{t('nav.trash')}</h2>
|
||||
<p>{currentTrashPath}</p>
|
||||
</div>
|
||||
<div class="trash-actions">
|
||||
{#if trashSelectedIds.length > 0}
|
||||
<button class="btn btn-sm btn-primary" on:click={() => restoreTrash(trashSelectedIds)}>{@html actionIcon('restore', 14)} {t('trash.restore')} ({trashSelectedIds.length})</button>
|
||||
<button class="btn btn-sm btn-danger" on:click={() => purgeTrash(trashSelectedIds)}>{@html actionIcon('purge', 14)} {t('common.delete')} ({trashSelectedIds.length})</button>
|
||||
{/if}
|
||||
<button class="btn btn-sm btn-danger" on:click={emptyTrash}>{@html actionIcon('emptyTrash', 14)} {t('trash.emptyTrash')}</button>
|
||||
<button class="btn btn-sm" on:click={() => wailsCall('OpenTrashFolder')}>{t('trash.openFolder')}</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if !trashInfo || (trashInfo.nodes || []).length === 0}
|
||||
<div class="empty-state">
|
||||
|
|
@ -3434,9 +3497,6 @@
|
|||
|
||||
{:else if selectedSection === 'activity'}
|
||||
<div class="activity-feed">
|
||||
<div class="activity-feed-header">
|
||||
<h2>{t('activity.title')}</h2>
|
||||
</div>
|
||||
{#if activityFeed.length === 0}
|
||||
<div class="empty-state"><p>{t('activity.empty')}</p></div>
|
||||
{:else}
|
||||
|
|
@ -3803,16 +3863,11 @@
|
|||
|
||||
/* Main */
|
||||
.main { flex: 1; display: flex; flex-direction: column; height: 100vh; min-width: 0; overflow: hidden; background: #13131f; }
|
||||
.header { padding: 12px 24px; border-bottom: 1px solid #2a2a3c; display: flex; align-items: center; flex-shrink: 0; min-height: 48px; }
|
||||
.header-left { display: flex; align-items: center; gap: 8px; flex: 1; }
|
||||
.header-right { display: flex; align-items: center; gap: 8px; }
|
||||
.header-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.sidebar-settings-btn { background: transparent; border: none; border-radius: 6px; padding: 6px; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; color: #666; font-family: inherit; width: 32px; height: 32px; }
|
||||
.sidebar-settings-btn:hover { background: #1e1e38; color: #a5b4fc; }
|
||||
.sidebar-settings-btn:active { background: #252545; color: #818cf8; }
|
||||
|
||||
.crumb { font-size: 14px; font-weight: 500; }
|
||||
.crumb.placeholder { color: #666; }
|
||||
.crumb-type { font-size: 11px; color: #555; background: #1e1e2e; padding: 2px 8px; border-radius: 10px; margin-left: 8px; }
|
||||
.error-banner { background: #3a2222; color: #ff8888; padding: 8px 24px; font-size: 12px; border-bottom: 1px solid #4a2222; flex-shrink: 0; cursor: pointer; display: flex; justify-content: space-between; align-items: center; }
|
||||
.dismiss-btn { background: none; border: none; color: #ff6666; cursor: pointer; padding: 2px; display: flex; align-items: center; border-radius: 2px; }
|
||||
.dismiss-btn:hover { color: #ff4444; }
|
||||
|
|
@ -3900,9 +3955,7 @@
|
|||
.inbox-screen { padding: 24px; overflow-y: auto; flex: 1; }
|
||||
.inbox-screen.drop-valid { outline: 2px dashed #4ade80; outline-offset: -4px; background: rgba(74, 222, 128, 0.04); }
|
||||
.inbox-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
|
||||
.inbox-header h2 { margin: 0 0 6px; }
|
||||
.inbox-header p { margin: 0; color: #a0a0b8; font-size: 13px; }
|
||||
.inbox-header-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.inbox-capture-status { color: #8ee6b1; font-size: 12px; }
|
||||
.inbox-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.inbox-item { display: flex; align-items: center; gap: 12px; padding: 12px; border: 1px solid #2a2a3c; border-radius: 8px; background: #1a1a28; cursor: pointer; }
|
||||
|
|
@ -3948,9 +4001,7 @@
|
|||
/* Trash screen */
|
||||
.trash-screen { padding: 24px; overflow-y: auto; flex: 1; }
|
||||
.trash-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
|
||||
.trash-header h2 { margin: 0 0 6px; }
|
||||
.trash-header p { margin: 0; color: #8888a0; font-size: 12px; }
|
||||
.trash-actions { display: flex; gap: 8px; align-items: center; justify-content: flex-end; flex-wrap: wrap; }
|
||||
.trash-browser { max-width: 980px; }
|
||||
.trash-section { min-width: 0; }
|
||||
.trash-section h3 { margin: 0 0 10px; font-size: 13px; color: #a5b4fc; text-transform: uppercase; letter-spacing: 0.3px; }
|
||||
|
|
@ -4169,8 +4220,6 @@
|
|||
|
||||
/* Activity feed (global section) */
|
||||
.activity-feed { padding: 24px; overflow-y: auto; flex: 1; }
|
||||
.activity-feed-header { margin-bottom: 20px; }
|
||||
.activity-feed-header h2 { font-size: 24px; }
|
||||
.activity-feed-events { display: flex; flex-direction: column; gap: 2px; }
|
||||
.activity-feed-event { display: flex; align-items: flex-start; gap: 10px; padding: 8px 12px; border-radius: 8px; cursor: pointer; }
|
||||
.activity-feed-event:hover { background: #1a1a28; color: #e4e4ef; }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
<script>
|
||||
import GlobalSearch from './GlobalSearch.svelte'
|
||||
|
||||
export let title = ''
|
||||
export let subtitle = ''
|
||||
export let wailsCall = async () => []
|
||||
export let typeLabel = (type) => type || ''
|
||||
export let onOpenSearchResult = () => {}
|
||||
</script>
|
||||
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<span class="header-title" class:placeholder={!title}>{title || ''}</span>
|
||||
{#if subtitle}
|
||||
<span class="header-subtitle">{subtitle}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<GlobalSearch {wailsCall} {typeLabel} on:open={(e) => onOpenSearchResult(e.detail)} />
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.header { padding: 10px 24px; border-bottom: 1px solid #2a2a3c; display: flex; align-items: center; gap: 16px; flex-shrink: 0; min-height: 52px; }
|
||||
.header-left { display: flex; align-items: center; gap: 8px; min-width: 120px; flex: 0 1 auto; overflow: hidden; }
|
||||
.header-title { font-size: 15px; font-weight: 600; color: #e4e4ef; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.header-title.placeholder { color: #666; font-weight: 500; }
|
||||
.header-subtitle { font-size: 11px; color: #8888a0; background: #1e1e2e; padding: 2px 8px; border-radius: 10px; white-space: nowrap; }
|
||||
.header-right { flex: 1; min-width: 0; display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
|
||||
@media (max-width: 760px) {
|
||||
.header { padding: 8px 12px; gap: 10px; align-items: flex-start; flex-wrap: wrap; }
|
||||
.header-left { flex: 1 1 100%; }
|
||||
.header-right { flex: 1 1 100%; justify-content: stretch; }
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
<script>
|
||||
import { createEventDispatcher, onDestroy, onMount, tick } from 'svelte'
|
||||
import { t } from './i18n'
|
||||
|
||||
export let wailsCall = async () => []
|
||||
export let typeLabel = (type) => type || ''
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let query = ''
|
||||
let results = []
|
||||
let open = false
|
||||
let loading = false
|
||||
let selectedIndex = 0
|
||||
let inputEl
|
||||
let searchTimer
|
||||
|
||||
function clean(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function resultPath(result) {
|
||||
return clean(result.path)
|
||||
}
|
||||
|
||||
function resultMeta(result) {
|
||||
const parts = []
|
||||
const type = typeLabel(result.type)
|
||||
const path = resultPath(result)
|
||||
if (type) parts.push(type)
|
||||
if (path) parts.push(path)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
async function runSearch() {
|
||||
const q = clean(query)
|
||||
if (q.length < 2) {
|
||||
results = []
|
||||
open = false
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
loading = true
|
||||
try {
|
||||
results = await wailsCall('Search', q) || []
|
||||
selectedIndex = 0
|
||||
open = true
|
||||
} catch (e) {
|
||||
results = []
|
||||
open = false
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSearch() {
|
||||
clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(runSearch, 180)
|
||||
}
|
||||
|
||||
function closeResults() {
|
||||
open = false
|
||||
selectedIndex = 0
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
query = ''
|
||||
results = []
|
||||
closeResults()
|
||||
}
|
||||
|
||||
function openResult(result) {
|
||||
if (!result) return
|
||||
dispatch('open', result)
|
||||
clearSearch()
|
||||
inputEl?.blur()
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
scheduleSearch()
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
clearSearch()
|
||||
inputEl?.blur()
|
||||
return
|
||||
}
|
||||
if (!open || results.length === 0) return
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
selectedIndex = Math.min(results.length - 1, selectedIndex + 1)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
selectedIndex = Math.max(0, selectedIndex - 1)
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
openResult(results[selectedIndex])
|
||||
}
|
||||
}
|
||||
|
||||
async function focusSearch() {
|
||||
await tick()
|
||||
inputEl?.focus()
|
||||
inputEl?.select()
|
||||
}
|
||||
|
||||
function handleWindowKeydown(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault()
|
||||
focusSearch()
|
||||
return
|
||||
}
|
||||
if (e.key === '/' && !isEditableTarget(e.target)) {
|
||||
e.preventDefault()
|
||||
focusSearch()
|
||||
}
|
||||
}
|
||||
|
||||
function isEditableTarget(target) {
|
||||
if (!target || !(target instanceof Element)) return false
|
||||
return !!target.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]')
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener('keydown', handleWindowKeydown)
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('keydown', handleWindowKeydown)
|
||||
clearTimeout(searchTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="global-search">
|
||||
<input
|
||||
class="global-search-input"
|
||||
bind:this={inputEl}
|
||||
bind:value={query}
|
||||
type="search"
|
||||
placeholder={t('search.placeholder')}
|
||||
aria-label={t('search.placeholder')}
|
||||
autocomplete="off"
|
||||
on:input={handleInput}
|
||||
on:focus={() => { if (results.length > 0) open = true }}
|
||||
on:keydown={handleKeydown}
|
||||
on:blur={() => setTimeout(closeResults, 160)}
|
||||
/>
|
||||
{#if open}
|
||||
<div class="global-search-results">
|
||||
{#if loading}
|
||||
<div class="global-search-empty">{t('common.loading')}</div>
|
||||
{:else if results.length === 0}
|
||||
<div class="global-search-empty">{t('search.noResults')}</div>
|
||||
{:else}
|
||||
{#each results as result, index}
|
||||
<button
|
||||
class="global-search-result"
|
||||
class:selected={index === selectedIndex}
|
||||
on:mousedown|preventDefault={() => openResult(result)}
|
||||
>
|
||||
<span class="global-search-title">{result.title}</span>
|
||||
{#if resultMeta(result)}
|
||||
<span class="global-search-meta">{resultMeta(result)}</span>
|
||||
{/if}
|
||||
{#if result.snippet}
|
||||
<span class="global-search-snippet">{result.snippet}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.global-search { position: relative; flex: 1 1 360px; min-width: 180px; max-width: 520px; }
|
||||
.global-search-input { width: 100%; height: 32px; padding: 0 12px; border: 1px solid #2a2a3c; border-radius: 6px; background: #10101a; color: #e4e4ef; font: inherit; font-size: 13px; }
|
||||
.global-search-input:focus { outline: none; border-color: #6366f1; box-shadow: 0 0 0 2px rgba(99,102,241,0.18); }
|
||||
.global-search-results { position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 160; max-height: min(360px, calc(100vh - 88px)); overflow-y: auto; background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; box-shadow: 0 14px 36px rgba(0,0,0,0.38); padding: 4px; }
|
||||
.global-search-result { display: flex; flex-direction: column; gap: 2px; width: 100%; border: 0; background: transparent; color: #e4e4ef; text-align: left; padding: 8px 10px; border-radius: 6px; cursor: pointer; font: inherit; }
|
||||
.global-search-result:hover, .global-search-result.selected { background: #252545; }
|
||||
.global-search-title { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.global-search-meta { font-size: 11px; color: #a0a0bc; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.global-search-snippet { font-size: 12px; color: #767690; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.global-search-empty { padding: 10px 12px; color: #8888a0; font-size: 12px; }
|
||||
</style>
|
||||
|
|
@ -154,7 +154,6 @@
|
|||
|
||||
<div class="today-screen">
|
||||
<div class="today-header">
|
||||
<h2>{t('today.title')}</h2>
|
||||
<span class="today-date">{todayDashboard?.date || ''}</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -334,8 +333,7 @@
|
|||
|
||||
<style>
|
||||
.today-screen { padding: 24px; overflow-y: auto; flex: 1; }
|
||||
.today-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; }
|
||||
.today-header h2 { font-size: 24px; margin: 0; }
|
||||
.today-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; min-height: 20px; }
|
||||
.today-date { font-size: 13px; color: #666; }
|
||||
|
||||
.today-tabs { display: flex; gap: 0; border-bottom: 1px solid #2a2a3c; margin-bottom: 16px; }
|
||||
|
|
|
|||
|
|
@ -108,6 +108,8 @@ export default {
|
|||
'common.created': 'Created',
|
||||
'common.empty': 'None',
|
||||
'common.newName': 'New name',
|
||||
'search.placeholder': 'Search Verstak...',
|
||||
'search.noResults': 'No results found',
|
||||
'welcome.title': 'Verstak',
|
||||
'welcome.selectSection': 'Select a section in the sidebar.',
|
||||
'welcome.createCase': 'Or create a new case with the + button.',
|
||||
|
|
@ -140,6 +142,7 @@ export default {
|
|||
'kind.archive': 'Archive',
|
||||
'kind.case': 'Case',
|
||||
'kind.link': 'Link',
|
||||
'kind.action': 'Action',
|
||||
'links.empty': 'No links yet',
|
||||
'links.editTitle': 'Edit link',
|
||||
'links.note': 'Note',
|
||||
|
|
|
|||
|
|
@ -113,6 +113,8 @@ export default {
|
|||
'common.created': 'Создано',
|
||||
'common.empty': 'Нет',
|
||||
'common.newName': 'Новое имя',
|
||||
'search.placeholder': 'Поиск по Верстаку...',
|
||||
'search.noResults': 'Ничего не найдено',
|
||||
|
||||
'welcome.title': 'Верстак',
|
||||
'welcome.selectSection': 'Выберите раздел в боковой панели.',
|
||||
|
|
@ -148,6 +150,7 @@ export default {
|
|||
'kind.archive': 'Архив',
|
||||
'kind.case': 'Дело',
|
||||
'kind.link': 'Ссылка',
|
||||
'kind.action': 'Действие',
|
||||
|
||||
'links.empty': 'Ссылок пока нет',
|
||||
'links.editTitle': 'Редактировать ссылку',
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ type Result struct {
|
|||
NodeID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Snippet string `json:"snippet,omitempty"`
|
||||
}
|
||||
|
||||
|
|
@ -61,7 +62,7 @@ func (s *Service) Search(query string) ([]Result, error) {
|
|||
fts := sanitizeFTS(query)
|
||||
|
||||
rows, err := s.db.Query(
|
||||
`SELECT node_id, title, type, snippet(search_index, 0, '', '', '...', 32) as snip
|
||||
`SELECT node_id, title, type, path, snippet(search_index, 0, '', '', '...', 32) as snip
|
||||
FROM search_index WHERE search_index MATCH ?
|
||||
ORDER BY rank LIMIT 20`, fts)
|
||||
if err != nil {
|
||||
|
|
@ -74,9 +75,13 @@ func (s *Service) Search(query string) ([]Result, error) {
|
|||
for rows.Next() {
|
||||
var r Result
|
||||
var snip sqlNullString
|
||||
if err := rows.Scan(&r.NodeID, &r.Title, &r.Type, &snip); err != nil {
|
||||
var path sqlNullString
|
||||
if err := rows.Scan(&r.NodeID, &r.Title, &r.Type, &path, &snip); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if path.Valid {
|
||||
r.Path = path.String
|
||||
}
|
||||
if snip.Valid {
|
||||
r.Snippet = snip.String
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,6 +114,20 @@ async function runReadyScenario(cdp, url) {
|
|||
await assertText(cdp, 'Верстак', 'ready: brand visible')
|
||||
await assertEval(cdp, `document.querySelectorAll('.nav-item').length >= 3`, 'ready: system navigation rendered')
|
||||
await assertEval(cdp, `document.querySelectorAll('.tree-item').length >= 2`, 'ready: workspace tree rendered')
|
||||
await waitForSelector(cdp, '.global-search-input')
|
||||
await assertEval(cdp, `document.querySelector('.global-search-input')?.placeholder === 'Поиск по Верстаку...'`, 'header: global search is visible')
|
||||
await dispatchShortcut(cdp, 'k', true)
|
||||
await assertEval(cdp, `document.activeElement === document.querySelector('.global-search-input')`, 'header: Ctrl+K focuses global search')
|
||||
await setInputValue(cdp, '.global-search-input', 'Smoke')
|
||||
await waitForSelector(cdp, '.global-search-results')
|
||||
await assertText(cdp, 'Проект · Верстак / Smoke Project', 'search: node result shows type and breadcrumbs')
|
||||
await assertText(cdp, 'Заметка · Верстак / Smoke Project', 'search: note result shows type and breadcrumbs')
|
||||
await assertText(cdp, 'Файл · Верстак / Smoke Project / Assets', 'search: file result shows type and breadcrumbs')
|
||||
await assertText(cdp, 'Ссылка · Верстак / Smoke Project', 'search: link result shows type and breadcrumbs')
|
||||
await clickText(cdp, '.global-search-result', 'Smoke note')
|
||||
await assertText(cdp, 'Rendered by GUI smoke.', 'search: note result opens note editor')
|
||||
await dispatchBodyKeydown(cdp, 'Backspace')
|
||||
await waitForGone(cdp, '.note-editor')
|
||||
await screenshot(cdp, 'ready-main.png')
|
||||
|
||||
await click(cdp, '.sidebar-settings-btn')
|
||||
|
|
@ -136,12 +150,13 @@ async function runReadyScenario(cdp, url) {
|
|||
|
||||
await clickText(cdp, '.nav-item', 'Неразобранное')
|
||||
await assertText(cdp, 'Неразобранное', 'inbox: system view opens')
|
||||
await assertEval(cdp, `!document.querySelector('.inbox-header h2')`, 'inbox: screen title is not duplicated inside body')
|
||||
await assertText(cdp, 'Inbox Smoke Item', 'inbox: captured item visible')
|
||||
await assertText(cdp, 'Текст', 'inbox: capture kind visible')
|
||||
await assertText(cdp, 'Буфер обмена', 'inbox: capture source visible')
|
||||
await assertEval(cdp, `!document.querySelector('.inbox-screen')?.innerText.includes('Manual Root Item')`, 'inbox: manual root is hidden')
|
||||
await setClipboardText(cdp, 'https://example.test/from-clipboard')
|
||||
await clickText(cdp, '.inbox-header .btn', 'Вставить из буфера')
|
||||
await clickText(cdp, '.header-actions .btn', 'Вставить из буфера')
|
||||
await assertText(cdp, 'example.test', 'inbox: clipboard URL captured')
|
||||
await assertEval(cdp, `
|
||||
(() => {
|
||||
|
|
@ -171,7 +186,7 @@ async function runReadyScenario(cdp, url) {
|
|||
await waitForGone(cdp, '.modal-overlay')
|
||||
await assertEval(cdp, `!document.querySelector('.inbox-screen')?.innerText.includes('example.test')`, 'inbox: assigned link leaves inbox')
|
||||
await setClipboardText(cdp, 'mirv.top')
|
||||
await clickText(cdp, '.inbox-header .btn', 'Вставить из буфера')
|
||||
await clickText(cdp, '.header-actions .btn', 'Вставить из буфера')
|
||||
await assertText(cdp, 'mirv.top', 'inbox: bare domain captured as URL')
|
||||
await clickInboxItemButton(cdp, 'mirv.top', 'Открыть')
|
||||
await assertEval(cdp, `window.__VERSTAK_GUI_SMOKE__.state.openedUrls.includes('https://mirv.top')`, 'inbox: bare domain opens normalized URL')
|
||||
|
|
@ -219,6 +234,7 @@ async function runReadyScenario(cdp, url) {
|
|||
await clickText(cdp, '.note-editor-actions .btn', 'Закрыть')
|
||||
|
||||
await clickText(cdp, '.nav-item', 'Корзина')
|
||||
await assertEval(cdp, `!document.querySelector('.trash-header h2')`, 'trash: screen title is not duplicated inside body')
|
||||
await assertText(cdp, 'Trash Smoke Folder', 'trash: deleted node visible')
|
||||
await assertEval(cdp, `!document.body.innerText.includes('.verstak/trash') && !document.body.innerText.includes('node-trash_Trash-Smoke-Folder')`, 'trash: physical implementation entries are hidden')
|
||||
await click(cdp, '.trash-row.folder .trash-row-icon')
|
||||
|
|
@ -324,10 +340,12 @@ async function runReadyScenario(cdp, url) {
|
|||
|
||||
await clickText(cdp, '.nav-item', 'Сегодня')
|
||||
await assertText(cdp, 'Сегодня', 'today: system view opens')
|
||||
await assertEval(cdp, `!document.querySelector('.today-header h2')`, 'today: screen title is not duplicated inside body')
|
||||
await assertText(cdp, 'Smoke Project', 'today: dashboard data visible')
|
||||
|
||||
await clickText(cdp, '.nav-item', 'Журнал')
|
||||
await waitForSelector(cdp, '.journal-screen')
|
||||
await assertEval(cdp, `!document.querySelector('.journal-screen h2')`, 'journal: screen title is not duplicated inside body')
|
||||
await clickText(cdp, '.journal-tab', 'Журнал работы')
|
||||
await screenshot(cdp, 'journal.png')
|
||||
await assertEval(cdp, `document.body.innerText.toLowerCase().includes('фильтры')`, 'journal: filter section visible')
|
||||
|
|
@ -335,6 +353,7 @@ async function runReadyScenario(cdp, url) {
|
|||
|
||||
await clickText(cdp, '.nav-item', 'Активность')
|
||||
await assertText(cdp, 'Активность', 'activity feed: system view opens')
|
||||
await assertEval(cdp, `!document.querySelector('.activity-feed-header h2')`, 'activity: screen title is not duplicated inside body')
|
||||
await assertText(cdp, 'Smoke activity', 'activity feed: event visible')
|
||||
|
||||
await click(cdp, '.nav-add-btn')
|
||||
|
|
@ -478,6 +497,24 @@ async function dispatchBodyKeydown(cdp, key) {
|
|||
await sleep(150)
|
||||
}
|
||||
|
||||
async function dispatchShortcut(cdp, key, ctrlKey = false) {
|
||||
await cdp.send('Runtime.evaluate', {
|
||||
expression: `
|
||||
(() => {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: ${JSON.stringify(key)},
|
||||
ctrlKey: ${ctrlKey ? 'true' : 'false'},
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
})()
|
||||
`,
|
||||
returnByValue: true,
|
||||
})
|
||||
await sleep(150)
|
||||
}
|
||||
|
||||
async function dispatchMouseBack(cdp) {
|
||||
await cdp.send('Runtime.evaluate', {
|
||||
expression: `
|
||||
|
|
@ -891,6 +928,7 @@ function wailsMockSource() {
|
|||
};
|
||||
|
||||
const fileNodeDetails = {
|
||||
'note-1': { id: 'note-1', title: 'Smoke note', type: 'note', parent_id: 'node-project', createdAt: now, has_children: false },
|
||||
'file-folder': { id: 'file-folder', title: 'Assets', type: 'folder', parent_id: 'node-project', createdAt: now, has_children: false, children: [] },
|
||||
'file-brief': { id: 'file-brief', title: 'brief.md', type: 'file', parent_id: 'node-project', createdAt: now, has_children: false },
|
||||
'file-nested': { id: 'file-nested', title: 'nested.txt', type: 'file', parent_id: 'file-folder', createdAt: now, has_children: false },
|
||||
|
|
@ -1164,6 +1202,15 @@ function wailsMockSource() {
|
|||
SetTemplateEnabled: async () => true,
|
||||
GetNodeDetail: async (id) => clone(findNode(id) || fileNodeDetails[id] || null),
|
||||
GetNodeTitle: async (id) => findNode(id)?.title || '',
|
||||
Search: async (query) => {
|
||||
const q = String(query || '').toLowerCase();
|
||||
return [
|
||||
{ nodeId: 'node-project', title: 'Smoke Project', type: 'project', path: 'Верстак / Smoke Project', snippet: '' },
|
||||
{ nodeId: 'note-1', title: 'Smoke note', type: 'note', path: 'Верстак / Smoke Project', snippet: 'Rendered by GUI smoke.' },
|
||||
{ nodeId: 'file-brief', title: 'brief.md', type: 'file', path: 'Верстак / Smoke Project / Assets', snippet: '' },
|
||||
{ nodeId: 'link-smoke', title: 'example.test', type: 'link', path: 'Верстак / Smoke Project', snippet: 'https://example.test' },
|
||||
].filter((item) => (item.title + ' ' + item.snippet + ' ' + item.path).toLowerCase().includes(q));
|
||||
},
|
||||
SearchNodes: async (query) => allNodes().filter((node) => node.title.toLowerCase().includes(String(query || '').toLowerCase())).map((node) => ({ id: node.id, title: node.title, path: '/Smoke/' + node.title, type: node.type })),
|
||||
CreateNodeFromTemplate: async (parentId, title, templateId) => {
|
||||
const id = 'node-created-' + Date.now();
|
||||
|
|
|
|||
Loading…
Reference in New Issue