verstak/frontend/src/lib/GlobalSearch.svelte

217 lines
6.7 KiB
Svelte

<script>
import { createEventDispatcher, onDestroy, onMount, tick } from 'svelte'
import { t } from './i18n'
import { expandKeyboardVariants } from './util/keyboardLayout'
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 {
// Build query variants: original + keyboard-swapped + lowercased
const variants = expandKeyboardVariants(q)
// Deduplicate preserving order
const seenVariants = new Set()
const queries = []
for (const v of variants) {
if (!seenVariants.has(v)) {
seenVariants.add(v)
queries.push(v)
}
}
// Execute all variant queries in parallel, collect results
const allResults = await Promise.all(
queries.map(v => wailsCall('Search', v).catch(() => []))
)
// Merge and deduplicate by node ID (or type+title for links/actions)
const merged = new Map()
// First pass: results from the original query get priority
for (let qi = 0; qi < allResults.length; qi++) {
const arr = allResults[qi] || []
for (const r of arr) {
const key = r.type + ':' + r.nodeId + ':' + (r.targetId || '') + ':' + r.title
if (!merged.has(key)) {
merged.set(key, r)
}
}
}
results = Array.from(merged.values())
selectedIndex = 0
open = results.length > 0
} 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>