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

View File

@ -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, &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
}
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

View File

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

View File

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

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-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; }

View File

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

View File

@ -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': 'Редактировать ссылку',

View File

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

View File

@ -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();