1<script lang="ts">
2 import { onMount, tick } from 'svelte';
3 import { homepageState } from '../state.svelte';
4 import { navigateTo } from '../messaging';
5 import type { CommandPaletteItem } from '../types';
6 import { Search, SearchX, History, Star, BarChart3, Trash2, Globe } from '@lucide/svelte';
7
8 // Input element reference
9 let inputRef: HTMLInputElement | null = $state(null);
10 let listRef: HTMLDivElement | null = $state(null);
11
12 // Local state for command palette
13 let query = $state('');
14 let selectedIndex = $state(0);
15
16 // Build command items from available actions
17 const staticCommands: CommandPaletteItem[] = [
18 {
19 id: 'nav:history',
20 label: 'Go to History',
21 description: 'View browsing history timeline',
22 icon: History,
23 shortcut: 'g h',
24 action: () => {
25 homepageState.setActivePanel('history');
26 homepageState.closeCommandPalette();
27 },
28 },
29 {
30 id: 'nav:favorites',
31 label: 'Go to Favorites',
32 description: 'View bookmarks and folders',
33 icon: Star,
34 shortcut: 'g f',
35 action: () => {
36 homepageState.setActivePanel('favorites');
37 homepageState.closeCommandPalette();
38 },
39 },
40 {
41 id: 'nav:analytics',
42 label: 'Go to Analytics',
43 description: 'View browsing statistics',
44 icon: BarChart3,
45 shortcut: 'g a',
46 action: () => {
47 homepageState.setActivePanel('analytics');
48 homepageState.closeCommandPalette();
49 },
50 },
51 {
52 id: 'action:cleanup',
53 label: 'Clean History',
54 description: 'Remove history entries by time range',
55 icon: Trash2,
56 action: () => {
57 homepageState.closeCommandPalette();
58 homepageState.openCleanupModal();
59 },
60 },
61 ];
62
63 // Fuzzy match function
64 function fuzzyMatch(text: string, pattern: string): { match: boolean; score: number } {
65 if (!pattern) return { match: true, score: 0 };
66
67 const lower = text.toLowerCase();
68 const pLower = pattern.toLowerCase();
69
70 // Exact substring match scores highest
71 if (lower.includes(pLower)) {
72 return { match: true, score: 100 - lower.indexOf(pLower) };
73 }
74
75 // Fuzzy character matching
76 let pIdx = 0;
77 let score = 0;
78 let consecutive = 0;
79
80 for (let i = 0; i < lower.length && pIdx < pLower.length; i++) {
81 if (lower[i] === pLower[pIdx]) {
82 pIdx++;
83 consecutive++;
84 score += consecutive * 2;
85 } else {
86 consecutive = 0;
87 }
88 }
89
90 return { match: pIdx === pLower.length, score };
91 }
92
93 // Build filtered items based on query
94 const filteredItems = $derived.by(() => {
95 const results: Array<{ item: CommandPaletteItem; score: number }> = [];
96
97 // Add static commands
98 for (const cmd of staticCommands) {
99 const labelMatch = fuzzyMatch(cmd.label, query);
100 const descMatch = cmd.description ? fuzzyMatch(cmd.description, query) : { match: false, score: 0 };
101
102 if (labelMatch.match || descMatch.match) {
103 results.push({
104 item: cmd,
105 score: Math.max(labelMatch.score, descMatch.score * 0.8),
106 });
107 }
108 }
109
110 // Add history items as navigable commands
111 for (const entry of homepageState.history.slice(0, 50)) {
112 const titleMatch = fuzzyMatch(entry.title || '', query);
113 const urlMatch = fuzzyMatch(entry.url, query);
114
115 if (titleMatch.match || urlMatch.match) {
116 results.push({
117 item: {
118 id: `history:${entry.id}`,
119 label: entry.title || getDomain(entry.url),
120 description: entry.url,
121 icon: Globe,
122 action: () => {
123 navigateTo(entry.url);
124 },
125 },
126 score: Math.max(titleMatch.score, urlMatch.score * 0.7),
127 });
128 }
129 }
130
131 // Add favorites as navigable commands
132 for (const fav of homepageState.favorites) {
133 const titleMatch = fuzzyMatch(fav.title || '', query);
134 const urlMatch = fuzzyMatch(fav.url, query);
135
136 if (titleMatch.match || urlMatch.match) {
137 results.push({
138 item: {
139 id: `fav:${fav.id}`,
140 label: fav.title || getDomain(fav.url),
141 description: fav.url,
142 icon: Star,
143 shortcut: fav.shortcut_key ? `${fav.shortcut_key}` : undefined,
144 action: () => {
145 navigateTo(fav.url);
146 },
147 },
148 score: Math.max(titleMatch.score, urlMatch.score * 0.7) + 10, // Boost favorites
149 });
150 }
151 }
152
153 // Sort by score and return items
154 results.sort((a, b) => b.score - a.score);
155 return results.map(r => r.item).slice(0, 20);
156 });
157
158 function getDomain(url: string): string {
159 try {
160 return new URL(url).hostname;
161 } catch {
162 return url;
163 }
164 }
165
166 // Reset selection when query changes
167 $effect(() => {
168 query; // Track query
169 selectedIndex = 0;
170 });
171
172 // Scroll selected item into view
173 $effect(() => {
174 if (listRef) {
175 const selected = listRef.querySelector(`[data-index="${selectedIndex}"]`);
176 selected?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
177 }
178 });
179
180 function handleKeydown(e: KeyboardEvent) {
181 switch (e.key) {
182 case 'ArrowDown':
183 case 'j':
184 if (e.key === 'j' && !e.ctrlKey) break;
185 e.preventDefault();
186 selectedIndex = Math.min(selectedIndex + 1, filteredItems.length - 1);
187 break;
188
189 case 'ArrowUp':
190 case 'k':
191 if (e.key === 'k' && !e.ctrlKey) break;
192 e.preventDefault();
193 selectedIndex = Math.max(selectedIndex - 1, 0);
194 break;
195
196 case 'n':
197 if (e.ctrlKey) {
198 e.preventDefault();
199 selectedIndex = Math.min(selectedIndex + 1, filteredItems.length - 1);
200 }
201 break;
202
203 case 'p':
204 if (e.ctrlKey) {
205 e.preventDefault();
206 selectedIndex = Math.max(selectedIndex - 1, 0);
207 }
208 break;
209
210 case 'Enter':
211 e.preventDefault();
212 executeSelected();
213 break;
214
215 case 'Escape':
216 e.preventDefault();
217 homepageState.closeCommandPalette();
218 break;
219 }
220 }
221
222 function executeSelected() {
223 const item = filteredItems[selectedIndex];
224 if (item) {
225 item.action();
226 }
227 }
228
229 function handleItemClick(index: number) {
230 selectedIndex = index;
231 executeSelected();
232 }
233
234 function handleOverlayInteraction(event: MouseEvent | KeyboardEvent) {
235 if (event instanceof KeyboardEvent) {
236 if (event.key === 'Escape' || event.key === 'Enter' || event.key === ' ') {
237 event.preventDefault();
238 homepageState.closeCommandPalette();
239 }
240 return;
241 }
242
243 if (event.currentTarget === event.target) {
244 homepageState.closeCommandPalette();
245 }
246 }
247
248 onMount(async () => {
249 await tick();
250 inputRef?.focus();
251 });
252</script>
253
254<div
255 class="palette-overlay"
256 role="button"
257 tabindex="0"
258 aria-label="Close command palette"
259 onclick={handleOverlayInteraction}
260 onkeydown={handleOverlayInteraction}
261>
262 <div class="palette-container" onclick={(e) => e.stopPropagation()} role="presentation">
263 <!-- Search Header -->
264 <div class="palette-header">
265 <div class="search-row">
266 <span class="search-icon">
267 <Search size={16} strokeWidth={2} />
268 </span>
269 <input
270 bind:this={inputRef}
271 bind:value={query}
272 type="text"
273 class="search-input"
274 placeholder="Search commands, history, favorites..."
275 spellcheck="false"
276 autocomplete="off"
277 onkeydown={handleKeydown}
278 />
279 <div class="search-meta">
280 <span class="result-count">{filteredItems.length}</span>
281 <kbd class="esc-hint">esc</kbd>
282 </div>
283 </div>
284 </div>
285
286 <!-- Results List -->
287 <div class="palette-results" bind:this={listRef}>
288 {#if filteredItems.length === 0}
289 <div class="empty-results">
290 <span class="empty-icon">
291 <SearchX size={24} strokeWidth={1.5} />
292 </span>
293 <span class="empty-text">No matches found</span>
294 </div>
295 {:else}
296 {#each filteredItems as item, index (item.id)}
297 <button
298 class="result-item"
299 class:selected={index === selectedIndex}
300 data-index={index}
301 type="button"
302 onclick={() => handleItemClick(index)}
303 onmouseenter={() => selectedIndex = index}
304 >
305 <span class="item-icon">
306 {#if typeof item.icon === 'function'}
307 <item.icon size={16} strokeWidth={2} />
308 {:else if item.icon}
309 {item.icon}
310 {/if}
311 </span>
312 <div class="item-content">
313 <span class="item-label">{item.label}</span>
314 {#if item.description}
315 <span class="item-description">{item.description}</span>
316 {/if}
317 </div>
318 {#if item.shortcut}
319 <kbd class="item-shortcut">{item.shortcut}</kbd>
320 {/if}
321 </button>
322 {/each}
323 {/if}
324 </div>
325
326 <!-- Footer Hints -->
327 <div class="palette-footer">
328 <div class="hint-group">
329 <kbd></kbd><kbd></kbd>
330 <span>navigate</span>
331 </div>
332 <div class="hint-group">
333 <kbd></kbd>
334 <span>select</span>
335 </div>
336 <div class="hint-group">
337 <kbd>esc</kbd>
338 <span>close</span>
339 </div>
340 </div>
341 </div>
342</div>
343
344<style>
345 .palette-overlay {
346 position: fixed;
347 inset: 0;
348 z-index: 200;
349 display: flex;
350 align-items: flex-start;
351 justify-content: center;
352 padding-top: 12vh;
353 background: rgb(0 0 0 / 0.65);
354 backdrop-filter: blur(8px);
355 animation: overlay-in 150ms ease;
356 }
357
358 @keyframes overlay-in {
359 from {
360 opacity: 0;
361 backdrop-filter: blur(0);
362 }
363 }
364
365 .palette-container {
366 width: 100%;
367 max-width: 580px;
368 margin: 0 1rem;
369 display: flex;
370 flex-direction: column;
371 background: var(--card);
372 border-width: 1px;
373 border-style: solid;
374 border-color: var(--border);
375 box-shadow:
376 0 0 0 1px color-mix(in srgb, var(--border) 30%, transparent),
377 0 32px 64px -16px rgb(0 0 0 / 0.7);
378 animation: palette-in 200ms cubic-bezier(0.16, 1, 0.3, 1);
379 }
380
381 @keyframes palette-in {
382 from {
383 opacity: 0;
384 transform: scale(0.96) translateY(-12px);
385 }
386 }
387
388 /* Header */
389 .palette-header {
390 padding: 0;
391 border-bottom: 1px solid var(--border);
392 background: color-mix(in srgb, var(--background) 70%, transparent);
393 }
394
395 .search-row {
396 display: flex;
397 align-items: center;
398 gap: 0.75rem;
399 padding: 0.85rem 1rem;
400 }
401
402 .search-icon {
403 font-size: 1rem;
404 color: var(--primary, #4ade80);
405 flex-shrink: 0;
406 }
407
408 .search-input {
409 flex: 1;
410 background: transparent;
411 border: none;
412 outline: none;
413 font-size: 0.95rem;
414 font-family: inherit;
415 color: var(--foreground);
416 caret-color: var(--primary, #4ade80);
417 }
418
419 .search-input::placeholder {
420 color: var(--muted-foreground);
421 opacity: 0.7;
422 }
423
424 .search-meta {
425 display: flex;
426 align-items: center;
427 gap: 0.5rem;
428 flex-shrink: 0;
429 }
430
431 .result-count {
432 font-size: 0.7rem;
433 color: var(--muted-foreground);
434 letter-spacing: 0.05em;
435 }
436
437 .esc-hint {
438 padding: 0.2rem 0.4rem;
439 font-size: 0.65rem;
440 color: var(--muted-foreground);
441 background: var(--background);
442 border-width: 1px;
443 border-style: solid;
444 border-color: var(--border);
445 }
446
447 /* Results */
448 .palette-results {
449 max-height: 360px;
450 overflow-y: auto;
451 overscroll-behavior: contain;
452 }
453
454 .empty-results {
455 display: flex;
456 flex-direction: column;
457 align-items: center;
458 justify-content: center;
459 gap: 0.5rem;
460 padding: 2.5rem 1rem;
461 color: var(--muted-foreground);
462 }
463
464 .empty-icon {
465 font-size: 1.5rem;
466 opacity: 0.5;
467 }
468
469 .empty-text {
470 font-size: 0.8rem;
471 letter-spacing: 0.05em;
472 }
473
474 .result-item {
475 width: 100%;
476 display: flex;
477 align-items: center;
478 gap: 0.75rem;
479 padding: 0.7rem 1rem;
480 background: transparent;
481 border: none;
482 border-bottom: 1px solid color-mix(in srgb, var(--border) 40%, transparent);
483 cursor: pointer;
484 text-align: left;
485 transition: background 80ms ease;
486 }
487
488 .result-item:last-child {
489 border-bottom: none;
490 }
491
492 .result-item:hover {
493 background: color-mix(in srgb, var(--card) 80%, var(--background) 20%);
494 }
495
496 .result-item.selected {
497 background: color-mix(in srgb, var(--primary, #4ade80) 12%, transparent);
498 box-shadow: inset 2px 0 0 var(--primary, #4ade80);
499 }
500
501 .item-icon {
502 font-size: 1rem;
503 color: var(--muted-foreground);
504 flex-shrink: 0;
505 width: 1.5rem;
506 text-align: center;
507 }
508
509 .result-item.selected .item-icon {
510 color: var(--primary, #4ade80);
511 }
512
513 .item-content {
514 flex: 1;
515 min-width: 0;
516 display: flex;
517 flex-direction: column;
518 gap: 0.15rem;
519 }
520
521 .item-label {
522 font-size: 0.85rem;
523 color: var(--foreground);
524 white-space: nowrap;
525 overflow: hidden;
526 text-overflow: ellipsis;
527 }
528
529 .item-description {
530 font-size: 0.72rem;
531 color: var(--muted-foreground);
532 white-space: nowrap;
533 overflow: hidden;
534 text-overflow: ellipsis;
535 }
536
537 .item-shortcut {
538 padding: 0.2rem 0.45rem;
539 font-size: 0.65rem;
540 color: var(--muted-foreground);
541 background: var(--background);
542 border-width: 1px;
543 border-style: solid;
544 border-color: var(--border);
545 flex-shrink: 0;
546 }
547
548 .result-item.selected .item-shortcut {
549 border-color: color-mix(in srgb, var(--primary, #4ade80) 40%, var(--border) 60%);
550 color: var(--foreground);
551 }
552
553 /* Footer */
554 .palette-footer {
555 display: flex;
556 align-items: center;
557 justify-content: center;
558 gap: 1.5rem;
559 padding: 0.65rem 1rem;
560 border-top: 1px solid var(--border);
561 background: color-mix(in srgb, var(--background) 70%, transparent);
562 }
563
564 .hint-group {
565 display: flex;
566 align-items: center;
567 gap: 0.35rem;
568 font-size: 0.68rem;
569 color: var(--muted-foreground);
570 }
571
572 .hint-group kbd {
573 padding: 0.15rem 0.35rem;
574 font-size: 0.62rem;
575 background: var(--background);
576 border-width: 1px;
577 border-style: solid;
578 border-color: var(--border);
579 }
580
581 /* Scrollbar */
582 .palette-results::-webkit-scrollbar {
583 width: 6px;
584 }
585
586 .palette-results::-webkit-scrollbar-track {
587 background: transparent;
588 }
589
590 .palette-results::-webkit-scrollbar-thumb {
591 background: var(--border);
592 }
593
594 .palette-results::-webkit-scrollbar-thumb:hover {
595 background: var(--muted-foreground);
596 }
597
598 @media (max-width: 480px) {
599 .palette-overlay {
600 padding-top: 2rem;
601 align-items: flex-start;
602 }
603
604 .palette-container {
605 max-height: calc(100vh - 4rem);
606 }
607
608 .palette-footer {
609 flex-wrap: wrap;
610 gap: 0.75rem;
611 }
612 }
613</style>