diff --git a/web/src/lib/components/search/CommandPalette.svelte b/web/src/lib/components/search/CommandPalette.svelte index 3388dd46..432d93d7 100644 --- a/web/src/lib/components/search/CommandPalette.svelte +++ b/web/src/lib/components/search/CommandPalette.svelte @@ -2,60 +2,171 @@ import { goto } from '$app/navigation'; import { api } from '$lib/api/client'; import { workspaceStore } from '$lib/stores/workspace.svelte'; + import { collectionStore } from '$lib/stores/collections.svelte'; import { uiStore } from '$lib/stores/ui.svelte'; - import type { SearchResult } from '$lib/types'; + import type { SearchResult, SearchFacets, SearchFilters } from '$lib/types'; import { getFieldValue, itemUrlId, formatItemRef } from '$lib/types'; + import { relativeTime } from '$lib/utils/markdown'; + + const RECENT_SEARCHES_KEY = 'pad-recent-searches'; + const MAX_RECENT = 10; + const PAGE_SIZE = 20; let query = $state(''); let results = $state([]); + let total = $state(0); + let facets = $state(undefined); let selectedIdx = $state(0); + let loading = $state(false); + let loadingMore = $state(false); let searchTimeout: ReturnType; let inputEl = $state(); + // Filters + let filterCollection = $state(null); + let filterStatus = $state(null); + + // Recent searches + let recentSearches = $state(loadRecentSearches()); + + // Derived: whether any filter is active + let hasFilters = $derived(filterCollection !== null || filterStatus !== null); + + // Derived: group results by collection when not filtering by collection + let groupedResults = $derived.by(() => { + if (filterCollection || results.length === 0) return null; + const groups: Record = {}; + for (const r of results) { + const slug = r.item.collection_slug || 'unknown'; + if (!groups[slug]) { + const coll = collectionStore.collections.find((c) => c.slug === slug); + groups[slug] = { + icon: r.item.collection_icon || coll?.icon || '📦', + name: coll?.name || slug, + results: [] + }; + } + groups[slug].results.push(r); + } + return groups; + }); + + // Derived: flat index list for keyboard navigation + let flatResults = $derived(results); + $effect(() => { if (uiStore.searchOpen) { requestAnimationFrame(() => inputEl?.focus()); } else { query = ''; results = []; + total = 0; + facets = undefined; selectedIdx = 0; + filterCollection = null; + filterStatus = null; + loading = false; } }); + function buildFilters(offset = 0): SearchFilters { + const filters: SearchFilters = { + workspace: workspaceStore.current?.slug, + limit: PAGE_SIZE, + offset + }; + if (filterCollection) filters.collection = filterCollection; + if (filterStatus) filters.status = filterStatus; + return filters; + } + function doSearch() { clearTimeout(searchTimeout); if (!query.trim()) { results = []; + total = 0; + facets = undefined; + selectedIdx = 0; + loading = false; return; } + loading = true; searchTimeout = setTimeout(async () => { - const ws = workspaceStore.current?.slug; try { - const resp = await api.search(query, { workspace: ws }); + const resp = await api.search(query, buildFilters(0)); results = resp.results; + total = resp.total; + facets = resp.facets; selectedIdx = 0; } catch { results = []; + total = 0; + facets = undefined; + } finally { + loading = false; } }, 200); } + async function loadMore() { + if (loadingMore || results.length >= total) return; + loadingMore = true; + const snapshotQuery = query; + const snapshotCollection = filterCollection; + const snapshotStatus = filterStatus; + try { + const resp = await api.search(query, buildFilters(results.length)); + // Discard if query or filters changed while loading + if (query !== snapshotQuery || filterCollection !== snapshotCollection || filterStatus !== snapshotStatus) return; + results = [...results, ...resp.results]; + } catch { + // ignore + } finally { + loadingMore = false; + } + } + + function applyFilter(type: 'collection' | 'status', value: string) { + if (type === 'collection') { + filterCollection = filterCollection === value ? null : value; + } else { + filterStatus = filterStatus === value ? null : value; + } + doSearch(); + } + + function clearFilters() { + filterCollection = null; + filterStatus = null; + doSearch(); + } + + function scrollSelectedIntoView() { + requestAnimationFrame(() => { + const el = document.querySelector('.result.selected'); + el?.scrollIntoView({ block: 'nearest' }); + }); + } + function handleKeydown(e: KeyboardEvent) { if (e.key === 'Escape') { uiStore.closeSearch(); } else if (e.key === 'ArrowDown') { e.preventDefault(); - selectedIdx = Math.min(selectedIdx + 1, results.length - 1); + selectedIdx = Math.min(selectedIdx + 1, flatResults.length - 1); + scrollSelectedIntoView(); } else if (e.key === 'ArrowUp') { e.preventDefault(); selectedIdx = Math.max(selectedIdx - 1, 0); - } else if (e.key === 'Enter' && results.length > 0) { + scrollSelectedIntoView(); + } else if (e.key === 'Enter' && flatResults.length > 0) { e.preventDefault(); - selectResult(results[selectedIdx]); + selectResult(flatResults[selectedIdx]); } } function selectResult(r: SearchResult) { + saveRecentSearch(query.trim()); const ws = workspaceStore.current?.slug; const wsUsername = workspaceStore.current?.owner_username; const collSlug = r.item.collection_slug; @@ -65,18 +176,72 @@ uiStore.closeSearch(); } + function useRecentSearch(q: string) { + query = q; + doSearch(); + requestAnimationFrame(() => inputEl?.focus()); + } + function stripHtml(s: string): string { return s.replace(/<[^>]*>/g, ''); } function statusColor(status: string): string { const s = status?.toLowerCase().replace(/-/g, '_'); - if (['done', 'completed', 'fixed', 'implemented', 'resolved'].includes(s)) return 'var(--accent-green)'; + if (['done', 'completed', 'fixed', 'implemented', 'resolved'].includes(s)) + return 'var(--accent-green)'; if (['in_progress', 'exploring', 'fixing'].includes(s)) return 'var(--accent-amber)'; if (['open', 'new', 'draft', 'todo', 'planned'].includes(s)) return 'var(--accent-blue)'; if (s === 'active') return 'var(--accent-cyan)'; return 'var(--text-muted)'; } + + function priorityDot(priority: string): string { + const p = priority?.toLowerCase(); + if (p === 'critical') return '\u{1F534}'; + if (p === 'high') return '\u{1F7E0}'; + if (p === 'medium') return '\u{1F7E1}'; + if (p === 'low') return '\u26AA'; + return ''; + } + + // Recent searches persistence + function loadRecentSearches(): string[] { + try { + const raw = localStorage.getItem(RECENT_SEARCHES_KEY); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } + } + + function saveRecentSearch(q: string) { + if (!q) return; + const filtered = recentSearches.filter((s) => s !== q); + recentSearches = [q, ...filtered].slice(0, MAX_RECENT); + try { + localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(recentSearches)); + } catch { + // ignore + } + } + + function clearRecentSearches() { + recentSearches = []; + try { + localStorage.removeItem(RECENT_SEARCHES_KEY); + } catch { + // ignore + } + } + + function renderResultCard(r: SearchResult, i: number): { ref: string | null; status: string | undefined; priority: string | undefined } { + return { + ref: formatItemRef(r.item), + status: getFieldValue(r.item, 'status'), + priority: getFieldValue(r.item, 'priority') + }; + } {#if uiStore.searchOpen} @@ -84,8 +249,23 @@
uiStore.closeSearch()}>
e.stopPropagation()} onkeydown={handleKeydown}> +
- + + {#if loading} + + {/if} esc
+ + {#if facets && query.trim()} +
+
+ {#if facets.collections && Object.keys(facets.collections).length > 0} + {#each Object.entries(facets.collections) as [slug, count] (slug)} + {@const coll = collectionStore.collections.find((c) => c.slug === slug)} + + {/each} + {/if} + {#if facets.statuses && Object.keys(facets.statuses).length > 0} + {#each Object.entries(facets.statuses) as [status, count] (status)} + + {/each} + {/if} +
+ {#if hasFilters} + + {/if} +
+ {/if} + + + {#if results.length > 0 && query.trim()} +
+ {total} result{total === 1 ? '' : 's'} +
+ {/if} + + {#if results.length > 0}
- {#each results as r, i} - {@const ref = formatItemRef(r.item)} - {@const status = getFieldValue(r.item, 'status')} - + {/each}
- {#if r.snippet} -
{stripHtml(r.snippet)}
- {/if} - - {/each} + {/each} + {:else} + + {#each results as r, i (r.item.id || r.item.slug)} + {@const meta = renderResultCard(r, i)} + + {/each} + {/if} + + + {#if results.length < total} +
+ +
+ {/if}
- {:else if query.trim()} + {:else if query.trim() && !loading}
No results for "{query}"
- {:else} -
- Try searching for - task names, ideas, docs, or any text -
+ {:else if !query.trim()} + + {#if recentSearches.length > 0} +
+
+ Recent searches + +
+ {#each recentSearches as recent, i (recent)} + + {/each} +
+ {:else} +
+ Try searching for + task names, ideas, docs, or any text +
+ {/if} {/if}
@@ -144,17 +494,17 @@ z-index: 50; display: flex; justify-content: center; - padding-top: 15vh; + padding-top: 12vh; } .palette { width: 100%; - max-width: 560px; + max-width: 640px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); overflow: hidden; - max-height: 60vh; + max-height: 65vh; display: flex; flex-direction: column; } @@ -164,6 +514,7 @@ gap: var(--space-3); padding: 0 var(--space-4); border-bottom: 1px solid var(--border); + flex-shrink: 0; } .search-icon { color: var(--text-muted); @@ -180,6 +531,20 @@ .search-input:focus { border: none; } + .search-spinner { + width: 16px; + height: 16px; + border: 2px solid var(--border); + border-top-color: var(--text-muted); + border-radius: 50%; + animation: spin 0.6s linear infinite; + flex-shrink: 0; + } + @keyframes spin { + to { + transform: rotate(360deg); + } + } .search-hint { font-size: 0.7em; color: var(--text-muted); @@ -190,18 +555,125 @@ font-family: var(--font-mono); flex-shrink: 0; } + + /* Filter chips */ + .filters-row { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + border-bottom: 1px solid var(--border); + flex-shrink: 0; + flex-wrap: wrap; + } + .filter-chips { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); + flex: 1; + } + .filter-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.75em; + background: var(--bg-tertiary); + border: 1px solid var(--border); + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s ease; + } + .filter-chip:hover { + background: var(--bg-hover); + } + .filter-chip.active { + background: color-mix(in srgb, var(--accent-blue) 20%, transparent); + border-color: var(--accent-blue); + color: var(--accent-blue); + } + .chip-icon { + font-size: 1em; + } + .chip-label { + text-transform: capitalize; + } + .chip-count { + color: var(--text-muted); + font-size: 0.9em; + } + .chip-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; + } + .clear-filters { + font-size: 0.72em; + color: var(--text-muted); + background: none; + border: none; + cursor: pointer; + padding: 2px 6px; + border-radius: var(--radius); + flex-shrink: 0; + } + .clear-filters:hover { + color: var(--text-primary); + background: var(--bg-hover); + } + + /* Result count */ + .result-count { + padding: var(--space-1) var(--space-4); + font-size: 0.75em; + color: var(--text-muted); + border-bottom: 1px solid var(--border); + flex-shrink: 0; + } + + /* Results */ .results { overflow-y: auto; padding: var(--space-2); } + + /* Group headers */ + .result-group { + margin-bottom: var(--space-2); + } + .group-header { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + font-size: 0.75em; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 600; + } + .group-icon { + font-size: 1.1em; + } + .group-name { + flex: 1; + } + .group-count { + font-weight: 400; + } + + /* Result cards */ .result { display: block; width: 100%; text-align: left; - padding: var(--space-3) var(--space-3); + padding: var(--space-2) var(--space-3); border-radius: var(--radius); } - .result:hover, .result.selected { + .result:hover, + .result.selected { background: var(--bg-hover); } .result-main { @@ -209,14 +681,28 @@ align-items: center; gap: var(--space-2); } - .result-icon { font-size: 1em; flex-shrink: 0; } + .result-icon { + font-size: 1em; + flex-shrink: 0; + } .result-ref { font-family: var(--font-mono); font-size: 0.75em; color: var(--text-muted); flex-shrink: 0; } - .result-title { font-weight: 500; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .result-title { + font-weight: 500; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .result-priority { + font-size: 0.8em; + flex-shrink: 0; + line-height: 1; + } .result-status { font-size: 0.7em; padding: 2px 8px; @@ -224,6 +710,12 @@ flex-shrink: 0; text-transform: capitalize; } + .result-date { + font-size: 0.7em; + color: var(--text-muted); + flex-shrink: 0; + white-space: nowrap; + } .result-snippet { font-size: 0.85em; color: var(--text-muted); @@ -233,6 +725,31 @@ text-overflow: ellipsis; white-space: nowrap; } + + /* Load more */ + .load-more-row { + padding: var(--space-2) var(--space-3); + text-align: center; + } + .load-more-btn { + font-size: 0.8em; + color: var(--text-secondary); + background: var(--bg-tertiary); + border: 1px solid var(--border); + padding: var(--space-2) var(--space-4); + border-radius: var(--radius); + cursor: pointer; + width: 100%; + } + .load-more-btn:hover:not(:disabled) { + background: var(--bg-hover); + } + .load-more-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + /* No results / tips */ .no-results { padding: var(--space-4); text-align: center; @@ -253,4 +770,60 @@ font-size: 0.85em; color: var(--text-secondary); } + + /* Recent searches */ + .recent-searches { + padding: var(--space-2); + } + .recent-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-1) var(--space-3); + margin-bottom: var(--space-1); + } + .recent-label { + font-size: 0.75em; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 600; + } + .clear-recent { + font-size: 0.72em; + color: var(--text-muted); + background: none; + border: none; + cursor: pointer; + padding: 2px 6px; + border-radius: var(--radius); + } + .clear-recent:hover { + color: var(--text-primary); + background: var(--bg-hover); + } + .recent-item { + display: flex; + align-items: center; + gap: var(--space-2); + width: 100%; + text-align: left; + padding: var(--space-2) var(--space-3); + border-radius: var(--radius); + font-size: 0.9em; + color: var(--text-secondary); + } + .recent-item:hover, + .recent-item.selected { + background: var(--bg-hover); + } + .recent-icon { + color: var(--text-muted); + flex-shrink: 0; + } + .recent-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + }