feat: restore global search in app header

This commit is contained in:
mirivlad 2026-06-06 02:39:29 +08:00
parent cf770262e5
commit 0cd8a79049
15 changed files with 513 additions and 65 deletions

View File

@ -209,10 +209,13 @@ type WorklogDTO struct {
} }
type SearchResultDTO struct { type SearchResultDTO struct {
NodeID string `json:"nodeId"` NodeID string `json:"nodeId"`
Title string `json:"title"` TargetID string `json:"targetId,omitempty"`
Snippet string `json:"snippet"` Title string `json:"title"`
Type string `json:"type"` Snippet string `json:"snippet"`
Type string `json:"type"`
Path string `json:"path,omitempty"`
URL string `json:"url,omitempty"`
} }
type EventDTO struct { type EventDTO struct {

View File

@ -5,6 +5,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings"
"verstak/internal/core/config" "verstak/internal/core/config"
"verstak/internal/core/nodes" "verstak/internal/core/nodes"
@ -262,25 +263,140 @@ func (a *App) Search(query string) ([]SearchResultDTO, error) {
if err := a.requireVault(); err != nil { if err := a.requireVault(); err != nil {
return nil, err return nil, err
} }
query = strings.TrimSpace(query)
if query == "" { if query == "" {
return []SearchResultDTO{}, nil 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) results, err := a.search.Search(query)
if err != nil { if err != nil {
return nil, err return nil, err
} }
out := make([]SearchResultDTO, len(results)) for _, r := range results {
for i, r := range results { path := r.Path
out[i] = SearchResultDTO{ if path == "" && r.NodeID != "" {
path = a.nodes.Path(r.NodeID)
}
add(SearchResultDTO{
NodeID: r.NodeID, NodeID: r.NodeID,
Title: r.Title, Title: r.Title,
Snippet: r.Snippet, Snippet: r.Snippet,
Type: r.Type, 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, &note, &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 return out, nil
} }
func likeQuery(query string) string {
return "%" + strings.ToLower(strings.TrimSpace(query)) + "%"
}
func (a *App) VerstakVersion() string { func (a *App) VerstakVersion() string {
return "verstak-gui/v2" 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

View File

@ -19,8 +19,8 @@
background: #13131f; background: #13131f;
} }
</style> </style>
<script type="module" crossorigin src="/assets/main-Co5H5J-5.js"></script> <script type="module" crossorigin src="/assets/main-2ehud8s_.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-BW6W9uAx.css"> <link rel="stylesheet" crossorigin href="/assets/main-N_NpVgn3.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -10,6 +10,7 @@
import SettingsWindow from './lib/SettingsWindow.svelte' import SettingsWindow from './lib/SettingsWindow.svelte'
import SyncStatus from './lib/SyncStatus.svelte' import SyncStatus from './lib/SyncStatus.svelte'
import TodayScreen from './lib/TodayScreen.svelte' import TodayScreen from './lib/TodayScreen.svelte'
import AppHeader from './lib/AppHeader.svelte'
import { onMount, onDestroy } from 'svelte' import { onMount, onDestroy } from 'svelte'
import { actionIcon } from './lib/actionIcons.js' import { actionIcon } from './lib/actionIcons.js'
import { canPreviewFile, needsBase64Preview, needsTextPreview } from './lib/fileUtils.js' import { canPreviewFile, needsBase64Preview, needsTextPreview } from './lib/fileUtils.js'
@ -207,6 +208,12 @@
let unlistenDrop = null let unlistenDrop = null
$: visibleTrashNodes = computeVisibleTrashNodes(trashInfo?.nodes || [], trashFolderId) $: 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) $: sortedInboxNodes = sortInboxNodes(inboxNodes, inboxSortBy, inboxSortDir)
$: groupedInboxNodes = inboxGroupByContext ? groupInboxByContext(sortedInboxNodes) : null $: 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') } 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') return labels[kind] || kind || t('kind.case')
} }
function searchResultTypeLabel(kind) {
if (kind === 'action') return t('kind.action')
return nodeKindLabel(kind)
}
function captureKindLabel(kind) { function captureKindLabel(kind) {
if (!kind) return '' if (!kind) return ''
return t('capture.kind.' + kind) return t('capture.kind.' + kind)
@ -2384,6 +2395,65 @@
return openActivityTarget(ev) 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 ===== // ===== Sync =====
async function loadSyncStatus() { async function loadSyncStatus() {
try { try {
@ -2537,20 +2607,31 @@
<!-- Main --> <!-- Main -->
<main class="main"> <main class="main">
<header class="header"> <AppHeader
<div class="header-left"> title={currentViewTitle}
{#if selectedNode} subtitle={currentViewSubtitle}
<span class="crumb">{selectedNode.title}</span> {wailsCall}
<span class="crumb-type">{nodeKindLabel(selectedNode.type)}</span> typeLabel={searchResultTypeLabel}
{:else if selectedSection} onOpenSearchResult={openSearchResult}
<span class="crumb">{#each systemViews as v}{v.id === selectedSection ? v.label : ''}{/each}</span> >
{:else} <div slot="actions" class="header-actions">
<span class="crumb placeholder">{t('nav.selectPrompt')}</span> {#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} {/if}
</div> </div>
<div class="header-right"> </AppHeader>
</div>
</header>
{#if error} {#if error}
<div class="error-banner" role="button" tabindex="0" on:click={() => error = ''} on:keydown={onKeyActivate(() => error = '')}> <div class="error-banner" role="button" tabindex="0" on:click={() => error = ''} on:keydown={onKeyActivate(() => error = '')}>
@ -2985,17 +3066,8 @@
on:drop={handleInboxDrop}> on:drop={handleInboxDrop}>
<div class="inbox-header"> <div class="inbox-header">
<div> <div>
<h2>{t('nav.inbox')}</h2>
<p>{t('inbox.subtitle')}</p> <p>{t('inbox.subtitle')}</p>
</div> </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> </div>
{#if inboxNodes.length > 0} {#if inboxNodes.length > 0}
@ -3102,17 +3174,8 @@
<div class="trash-screen"> <div class="trash-screen">
<div class="trash-header"> <div class="trash-header">
<div> <div>
<h2>{t('nav.trash')}</h2>
<p>{currentTrashPath}</p> <p>{currentTrashPath}</p>
</div> </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> </div>
{#if !trashInfo || (trashInfo.nodes || []).length === 0} {#if !trashInfo || (trashInfo.nodes || []).length === 0}
<div class="empty-state"> <div class="empty-state">
@ -3434,9 +3497,6 @@
{:else if selectedSection === 'activity'} {:else if selectedSection === 'activity'}
<div class="activity-feed"> <div class="activity-feed">
<div class="activity-feed-header">
<h2>{t('activity.title')}</h2>
</div>
{#if activityFeed.length === 0} {#if activityFeed.length === 0}
<div class="empty-state"><p>{t('activity.empty')}</p></div> <div class="empty-state"><p>{t('activity.empty')}</p></div>
{:else} {:else}
@ -3803,16 +3863,11 @@
/* Main */ /* Main */
.main { flex: 1; display: flex; flex-direction: column; height: 100vh; min-width: 0; overflow: hidden; background: #13131f; } .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-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
.header-left { display: flex; align-items: center; gap: 8px; flex: 1; }
.header-right { display: flex; align-items: center; gap: 8px; }
.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 { 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:hover { background: #1e1e38; color: #a5b4fc; }
.sidebar-settings-btn:active { background: #252545; color: #818cf8; } .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; } .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 { background: none; border: none; color: #ff6666; cursor: pointer; padding: 2px; display: flex; align-items: center; border-radius: 2px; }
.dismiss-btn:hover { color: #ff4444; } .dismiss-btn:hover { color: #ff4444; }
@ -3900,9 +3955,7 @@
.inbox-screen { padding: 24px; overflow-y: auto; flex: 1; } .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-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 { 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 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-capture-status { color: #8ee6b1; font-size: 12px; }
.inbox-list { display: flex; flex-direction: column; gap: 8px; } .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; } .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 */
.trash-screen { padding: 24px; overflow-y: auto; flex: 1; } .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 { 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-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-browser { max-width: 980px; }
.trash-section { min-width: 0; } .trash-section { min-width: 0; }
.trash-section h3 { margin: 0 0 10px; font-size: 13px; color: #a5b4fc; text-transform: uppercase; letter-spacing: 0.3px; } .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 (global section) */
.activity-feed { padding: 24px; overflow-y: auto; flex: 1; } .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-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 { display: flex; align-items: flex-start; gap: 10px; padding: 8px 12px; border-radius: 8px; cursor: pointer; }
.activity-feed-event:hover { background: #1a1a28; color: #e4e4ef; } .activity-feed-event:hover { background: #1a1a28; color: #e4e4ef; }

View File

@ -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>

View File

@ -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>

View File

@ -154,7 +154,6 @@
<div class="today-screen"> <div class="today-screen">
<div class="today-header"> <div class="today-header">
<h2>{t('today.title')}</h2>
<span class="today-date">{todayDashboard?.date || ''}</span> <span class="today-date">{todayDashboard?.date || ''}</span>
</div> </div>
@ -334,8 +333,7 @@
<style> <style>
.today-screen { padding: 24px; overflow-y: auto; flex: 1; } .today-screen { padding: 24px; overflow-y: auto; flex: 1; }
.today-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; } .today-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; min-height: 20px; }
.today-header h2 { font-size: 24px; margin: 0; }
.today-date { font-size: 13px; color: #666; } .today-date { font-size: 13px; color: #666; }
.today-tabs { display: flex; gap: 0; border-bottom: 1px solid #2a2a3c; margin-bottom: 16px; } .today-tabs { display: flex; gap: 0; border-bottom: 1px solid #2a2a3c; margin-bottom: 16px; }

View File

@ -108,6 +108,8 @@ export default {
'common.created': 'Created', 'common.created': 'Created',
'common.empty': 'None', 'common.empty': 'None',
'common.newName': 'New name', 'common.newName': 'New name',
'search.placeholder': 'Search Verstak...',
'search.noResults': 'No results found',
'welcome.title': 'Verstak', 'welcome.title': 'Verstak',
'welcome.selectSection': 'Select a section in the sidebar.', 'welcome.selectSection': 'Select a section in the sidebar.',
'welcome.createCase': 'Or create a new case with the + button.', 'welcome.createCase': 'Or create a new case with the + button.',
@ -140,6 +142,7 @@ export default {
'kind.archive': 'Archive', 'kind.archive': 'Archive',
'kind.case': 'Case', 'kind.case': 'Case',
'kind.link': 'Link', 'kind.link': 'Link',
'kind.action': 'Action',
'links.empty': 'No links yet', 'links.empty': 'No links yet',
'links.editTitle': 'Edit link', 'links.editTitle': 'Edit link',
'links.note': 'Note', 'links.note': 'Note',

View File

@ -113,6 +113,8 @@ export default {
'common.created': 'Создано', 'common.created': 'Создано',
'common.empty': 'Нет', 'common.empty': 'Нет',
'common.newName': 'Новое имя', 'common.newName': 'Новое имя',
'search.placeholder': 'Поиск по Верстаку...',
'search.noResults': 'Ничего не найдено',
'welcome.title': 'Верстак', 'welcome.title': 'Верстак',
'welcome.selectSection': 'Выберите раздел в боковой панели.', 'welcome.selectSection': 'Выберите раздел в боковой панели.',
@ -148,6 +150,7 @@ export default {
'kind.archive': 'Архив', 'kind.archive': 'Архив',
'kind.case': 'Дело', 'kind.case': 'Дело',
'kind.link': 'Ссылка', 'kind.link': 'Ссылка',
'kind.action': 'Действие',
'links.empty': 'Ссылок пока нет', 'links.empty': 'Ссылок пока нет',
'links.editTitle': 'Редактировать ссылку', 'links.editTitle': 'Редактировать ссылку',

View File

@ -11,6 +11,7 @@ type Result struct {
NodeID string `json:"id"` NodeID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Type string `json:"type"` Type string `json:"type"`
Path string `json:"path,omitempty"`
Snippet string `json:"snippet,omitempty"` Snippet string `json:"snippet,omitempty"`
} }
@ -61,7 +62,7 @@ func (s *Service) Search(query string) ([]Result, error) {
fts := sanitizeFTS(query) fts := sanitizeFTS(query)
rows, err := s.db.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 ? FROM search_index WHERE search_index MATCH ?
ORDER BY rank LIMIT 20`, fts) ORDER BY rank LIMIT 20`, fts)
if err != nil { if err != nil {
@ -74,9 +75,13 @@ func (s *Service) Search(query string) ([]Result, error) {
for rows.Next() { for rows.Next() {
var r Result var r Result
var snip sqlNullString 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 return nil, err
} }
if path.Valid {
r.Path = path.String
}
if snip.Valid { if snip.Valid {
r.Snippet = snip.String r.Snippet = snip.String
} }

View File

@ -114,6 +114,20 @@ async function runReadyScenario(cdp, url) {
await assertText(cdp, 'Верстак', 'ready: brand visible') await assertText(cdp, 'Верстак', 'ready: brand visible')
await assertEval(cdp, `document.querySelectorAll('.nav-item').length >= 3`, 'ready: system navigation rendered') 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 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 screenshot(cdp, 'ready-main.png')
await click(cdp, '.sidebar-settings-btn') await click(cdp, '.sidebar-settings-btn')
@ -136,12 +150,13 @@ async function runReadyScenario(cdp, url) {
await clickText(cdp, '.nav-item', 'Неразобранное') await clickText(cdp, '.nav-item', 'Неразобранное')
await assertText(cdp, 'Неразобранное', 'inbox: system view opens') 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 Smoke Item', 'inbox: captured item visible')
await assertText(cdp, 'Текст', 'inbox: capture kind visible') await assertText(cdp, 'Текст', 'inbox: capture kind visible')
await assertText(cdp, 'Буфер обмена', 'inbox: capture source 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 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 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 assertText(cdp, 'example.test', 'inbox: clipboard URL captured')
await assertEval(cdp, ` await assertEval(cdp, `
(() => { (() => {
@ -171,7 +186,7 @@ async function runReadyScenario(cdp, url) {
await waitForGone(cdp, '.modal-overlay') await waitForGone(cdp, '.modal-overlay')
await assertEval(cdp, `!document.querySelector('.inbox-screen')?.innerText.includes('example.test')`, 'inbox: assigned link leaves inbox') await assertEval(cdp, `!document.querySelector('.inbox-screen')?.innerText.includes('example.test')`, 'inbox: assigned link leaves inbox')
await setClipboardText(cdp, 'mirv.top') 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 assertText(cdp, 'mirv.top', 'inbox: bare domain captured as URL')
await clickInboxItemButton(cdp, 'mirv.top', 'Открыть') await clickInboxItemButton(cdp, 'mirv.top', 'Открыть')
await assertEval(cdp, `window.__VERSTAK_GUI_SMOKE__.state.openedUrls.includes('https://mirv.top')`, 'inbox: bare domain opens normalized URL') 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, '.note-editor-actions .btn', 'Закрыть')
await clickText(cdp, '.nav-item', 'Корзина') 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 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 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') await click(cdp, '.trash-row.folder .trash-row-icon')
@ -324,10 +340,12 @@ async function runReadyScenario(cdp, url) {
await clickText(cdp, '.nav-item', 'Сегодня') await clickText(cdp, '.nav-item', 'Сегодня')
await assertText(cdp, 'Сегодня', 'today: system view opens') 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 assertText(cdp, 'Smoke Project', 'today: dashboard data visible')
await clickText(cdp, '.nav-item', 'Журнал') await clickText(cdp, '.nav-item', 'Журнал')
await waitForSelector(cdp, '.journal-screen') 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 clickText(cdp, '.journal-tab', 'Журнал работы')
await screenshot(cdp, 'journal.png') await screenshot(cdp, 'journal.png')
await assertEval(cdp, `document.body.innerText.toLowerCase().includes('фильтры')`, 'journal: filter section visible') 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 clickText(cdp, '.nav-item', 'Активность')
await assertText(cdp, 'Активность', 'activity feed: system view opens') 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 assertText(cdp, 'Smoke activity', 'activity feed: event visible')
await click(cdp, '.nav-add-btn') await click(cdp, '.nav-add-btn')
@ -478,6 +497,24 @@ async function dispatchBodyKeydown(cdp, key) {
await sleep(150) 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) { async function dispatchMouseBack(cdp) {
await cdp.send('Runtime.evaluate', { await cdp.send('Runtime.evaluate', {
expression: ` expression: `
@ -891,6 +928,7 @@ function wailsMockSource() {
}; };
const fileNodeDetails = { 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-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-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 }, '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, SetTemplateEnabled: async () => true,
GetNodeDetail: async (id) => clone(findNode(id) || fileNodeDetails[id] || null), GetNodeDetail: async (id) => clone(findNode(id) || fileNodeDetails[id] || null),
GetNodeTitle: async (id) => findNode(id)?.title || '', 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 })), 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) => { CreateNodeFromTemplate: async (parentId, title, templateId) => {
const id = 'node-created-' + Date.now(); const id = 'node-created-' + Date.now();