feat: restore global search in app header

This commit is contained in:
2026-06-06 02:39:29 +08:00
parent cf770262e5
commit 0cd8a79049
15 changed files with 513 additions and 65 deletions
+94 -45
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; }
+36
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>
+188
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>
+1 -3
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; }
+3
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',
+3
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': 'Редактировать ссылку',