1// ═══════════════════════════════════════════════════════════════════════════════
2// Homepage Keyboard Navigation (Telescope-style)
3// Vim-inspired navigation with fuzzy filtering and quick shortcuts
4// ═══════════════════════════════════════════════════════════════════════════════
5
6import { homepageState } from './state.svelte';
7import {
8 navigateTo,
9 deleteHistoryEntry,
10 deleteHistoryByRange,
11 clearAllHistory,
12 deleteHistoryByDomain,
13 getFavoriteByShortcut,
14 searchHistoryFTS,
15} from './messaging';
16import type { HistoryEntry, Favorite, HistoryCleanupRange } from './types';
17
18// ═══════════════════════════════════════════════════════════════════════════════
19// TYPES
20// ═══════════════════════════════════════════════════════════════════════════════
21
22interface KeyBinding {
23 key: string;
24 ctrl?: boolean;
25 shift?: boolean;
26 alt?: boolean;
27 meta?: boolean;
28 description: string;
29 action: () => void | Promise<void>;
30 context?: 'global' | 'history' | 'favorites' | 'command-palette' | 'cleanup';
31}
32
33// ═══════════════════════════════════════════════════════════════════════════════
34// STATE
35// ═══════════════════════════════════════════════════════════════════════════════
36
37let pendingPrefix: string | null = null;
38let prefixTimeout: ReturnType<typeof setTimeout> | null = null;
39const PREFIX_TIMEOUT_MS = 1000;
40
41// ═══════════════════════════════════════════════════════════════════════════════
42// HELPER FUNCTIONS
43// ═══════════════════════════════════════════════════════════════════════════════
44
45function getDomain(url: string): string {
46 try {
47 return new URL(url).hostname;
48 } catch {
49 return url;
50 }
51}
52
53function getCurrentItem(): HistoryEntry | Favorite | null {
54 const list = homepageState.currentList;
55 const idx = homepageState.focusedIndex;
56 return list[idx] ?? null;
57}
58
59function clearPendingPrefix(): void {
60 pendingPrefix = null;
61 homepageState.setPendingKeyPrefix(null);
62 if (prefixTimeout) {
63 clearTimeout(prefixTimeout);
64 prefixTimeout = null;
65 }
66}
67
68function setPendingPrefix(prefix: string): void {
69 pendingPrefix = prefix;
70 homepageState.setPendingKeyPrefix(prefix);
71 if (prefixTimeout) {
72 clearTimeout(prefixTimeout);
73 }
74 prefixTimeout = setTimeout(clearPendingPrefix, PREFIX_TIMEOUT_MS);
75}
76
77// ═══════════════════════════════════════════════════════════════════════════════
78// ACTIONS
79// ═══════════════════════════════════════════════════════════════════════════════
80
81async function openCurrentItem(): Promise<void> {
82 const item = getCurrentItem();
83 if (item && 'url' in item) {
84 navigateTo(item.url);
85 }
86}
87
88async function deleteCurrentHistoryEntry(): Promise<void> {
89 if (homepageState.activePanel !== 'history') return;
90
91 const item = getCurrentItem() as HistoryEntry | null;
92 if (!item) return;
93
94 await deleteHistoryEntry(item.id);
95
96 // Adjust focus if needed
97 const newLength = homepageState.currentListLength;
98 if (homepageState.focusedIndex >= newLength) {
99 homepageState.setFocusedIndex(Math.max(0, newLength - 1));
100 }
101}
102
103async function handleCleanupShortcut(range: HistoryCleanupRange): Promise<void> {
104 if (homepageState.activePanel !== 'history') return;
105
106 const messages: Record<HistoryCleanupRange, string> = {
107 hour: 'Clear history from the last hour?',
108 day: 'Clear history from the last day?',
109 week: 'Clear history from the last week?',
110 month: 'Clear history from the last month?',
111 all: 'Clear ALL history? This cannot be undone.',
112 };
113
114 homepageState.showConfirm(messages[range], async () => {
115 if (range === 'all') {
116 await clearAllHistory();
117 } else {
118 await deleteHistoryByRange(range);
119 }
120 });
121}
122
123async function clearCurrentDomain(): Promise<void> {
124 if (homepageState.activePanel !== 'history') return;
125
126 const item = getCurrentItem() as HistoryEntry | null;
127 if (!item) return;
128
129 const domain = getDomain(item.url);
130 homepageState.showConfirm(
131 `Clear all history for ${domain}?`,
132 async () => {
133 await deleteHistoryByDomain(domain);
134 }
135 );
136}
137
138async function handleQuickShortcut(key: number): Promise<void> {
139 const favorite = await getFavoriteByShortcut(key);
140 if (favorite) {
141 navigateTo(favorite.url);
142 }
143}
144
145// ═══════════════════════════════════════════════════════════════════════════════
146// KEY BINDINGS
147// ═══════════════════════════════════════════════════════════════════════════════
148
149const globalBindings: KeyBinding[] = [
150 // Command palette
151 {
152 key: 'p',
153 ctrl: true,
154 description: 'Open command palette',
155 action: () => homepageState.openCommandPalette(),
156 },
157 // Note: "/" may require Shift on some keyboard layouts (e.g., AZERTY)
158 // We handle it specially in handleKeyDown to ignore Shift state
159 {
160 key: '/',
161 description: 'Open command palette / search',
162 action: () => homepageState.openCommandPalette(),
163 },
164
165 // Quick navigation (1-9)
166 ...Array.from({ length: 9 }, (_, i) => ({
167 key: String(i + 1),
168 description: `Navigate to favorite shortcut ${i + 1}`,
169 action: () => handleQuickShortcut(i + 1),
170 })),
171
172 // Panel navigation (g prefix)
173 {
174 key: 'h',
175 description: 'Go to History panel',
176 action: () => {
177 if (pendingPrefix === 'g') {
178 homepageState.setActivePanel('history');
179 clearPendingPrefix();
180 }
181 },
182 context: 'global',
183 },
184 {
185 key: 'f',
186 description: 'Go to Favorites panel',
187 action: () => {
188 if (pendingPrefix === 'g') {
189 homepageState.setActivePanel('favorites');
190 clearPendingPrefix();
191 }
192 },
193 context: 'global',
194 },
195 {
196 key: 'a',
197 description: 'Go to Analytics panel',
198 action: () => {
199 if (pendingPrefix === 'g') {
200 homepageState.setActivePanel('analytics');
201 clearPendingPrefix();
202 }
203 },
204 context: 'global',
205 },
206
207 // Escape
208 {
209 key: 'Escape',
210 description: 'Close modals / clear search',
211 action: () => {
212 clearPendingPrefix();
213 if (homepageState.commandPaletteOpen) {
214 homepageState.closeCommandPalette();
215 } else if (homepageState.confirmModalOpen) {
216 homepageState.cancelConfirm();
217 } else if (homepageState.cleanupModalOpen) {
218 homepageState.closeCleanupModal();
219 } else if (homepageState.historySearchQuery) {
220 homepageState.setHistorySearchQuery('');
221 }
222 },
223 },
224
225 // Tab - cycle through panels
226 {
227 key: 'Tab',
228 description: 'Next panel',
229 action: () => {
230 const panels = ['history', 'favorites', 'analytics'] as const;
231 const currentIdx = panels.indexOf(homepageState.activePanel);
232 const nextIdx = (currentIdx + 1) % panels.length;
233 const nextPanel = panels[nextIdx];
234 if (nextPanel) {
235 homepageState.setActivePanel(nextPanel);
236 }
237 },
238 },
239 {
240 key: 'Tab',
241 shift: true,
242 description: 'Previous panel',
243 action: () => {
244 const panels = ['history', 'favorites', 'analytics'] as const;
245 const currentIdx = panels.indexOf(homepageState.activePanel);
246 const prevIdx = (currentIdx - 1 + panels.length) % panels.length;
247 const prevPanel = panels[prevIdx];
248 if (prevPanel) {
249 homepageState.setActivePanel(prevPanel);
250 }
251 },
252 },
253];
254
255const listBindings: KeyBinding[] = [
256 // Navigation
257 {
258 key: 'j',
259 description: 'Move down',
260 action: () => homepageState.focusNext(),
261 },
262 {
263 key: 'ArrowDown',
264 description: 'Move down',
265 action: () => homepageState.focusNext(),
266 },
267 {
268 key: 'k',
269 description: 'Move up',
270 action: () => homepageState.focusPrev(),
271 },
272 {
273 key: 'ArrowUp',
274 description: 'Move up',
275 action: () => homepageState.focusPrev(),
276 },
277 {
278 key: 'n',
279 ctrl: true,
280 description: 'Move down',
281 action: () => homepageState.focusNext(),
282 },
283 {
284 key: 'p',
285 ctrl: true,
286 description: 'Move up',
287 action: () => homepageState.focusPrev(),
288 },
289 {
290 key: 'g',
291 description: 'Go to first / prefix for panel nav',
292 action: () => {
293 if (pendingPrefix === 'g') {
294 homepageState.focusFirst();
295 clearPendingPrefix();
296 } else {
297 setPendingPrefix('g');
298 }
299 },
300 },
301 {
302 key: 'G',
303 shift: true,
304 description: 'Go to last',
305 action: () => homepageState.focusLast(),
306 },
307
308 // Selection
309 {
310 key: 'Enter',
311 description: 'Open selected item',
312 action: openCurrentItem,
313 },
314 {
315 key: 'o',
316 description: 'Open selected item',
317 action: openCurrentItem,
318 },
319];
320
321const historyBindings: KeyBinding[] = [
322 // Delete
323 {
324 key: 'd',
325 description: 'Delete entry / prefix for cleanup',
326 action: () => {
327 if (pendingPrefix === 'D') {
328 // D d - clear last day
329 handleCleanupShortcut('day');
330 clearPendingPrefix();
331 } else {
332 deleteCurrentHistoryEntry();
333 }
334 },
335 context: 'history',
336 },
337 {
338 key: 'x',
339 description: 'Delete entry',
340 action: deleteCurrentHistoryEntry,
341 context: 'history',
342 },
343
344 // Cleanup shortcuts (D prefix)
345 {
346 key: 'D',
347 shift: true,
348 description: 'Cleanup prefix / Clear ALL',
349 action: () => {
350 if (pendingPrefix === 'D') {
351 // D D - clear all
352 handleCleanupShortcut('all');
353 clearPendingPrefix();
354 } else {
355 setPendingPrefix('D');
356 }
357 },
358 context: 'history',
359 },
360 {
361 key: 'h',
362 description: 'Clear last hour',
363 action: () => {
364 if (pendingPrefix === 'D') {
365 handleCleanupShortcut('hour');
366 clearPendingPrefix();
367 }
368 },
369 context: 'history',
370 },
371 {
372 key: 'w',
373 description: 'Clear last week',
374 action: () => {
375 if (pendingPrefix === 'D') {
376 handleCleanupShortcut('week');
377 clearPendingPrefix();
378 }
379 },
380 context: 'history',
381 },
382 {
383 key: 'm',
384 description: 'Clear last month',
385 action: () => {
386 if (pendingPrefix === 'D') {
387 handleCleanupShortcut('month');
388 clearPendingPrefix();
389 }
390 },
391 context: 'history',
392 },
393 {
394 key: '@',
395 shift: true,
396 description: 'Clear current domain',
397 action: () => {
398 if (pendingPrefix === 'D') {
399 clearCurrentDomain();
400 clearPendingPrefix();
401 }
402 },
403 context: 'history',
404 },
405];
406
407const commandPaletteBindings: KeyBinding[] = [
408 {
409 key: 'j',
410 ctrl: true,
411 description: 'Move down',
412 action: () => homepageState.focusNext(),
413 context: 'command-palette',
414 },
415 {
416 key: 'n',
417 ctrl: true,
418 description: 'Move down',
419 action: () => homepageState.focusNext(),
420 context: 'command-palette',
421 },
422 {
423 key: 'ArrowDown',
424 description: 'Move down',
425 action: () => homepageState.focusNext(),
426 context: 'command-palette',
427 },
428 {
429 key: 'k',
430 ctrl: true,
431 description: 'Move up',
432 action: () => homepageState.focusPrev(),
433 context: 'command-palette',
434 },
435 {
436 key: 'p',
437 ctrl: true,
438 description: 'Move up',
439 action: () => homepageState.focusPrev(),
440 context: 'command-palette',
441 },
442 {
443 key: 'ArrowUp',
444 description: 'Move up',
445 action: () => homepageState.focusPrev(),
446 context: 'command-palette',
447 },
448 {
449 key: 'Enter',
450 description: 'Execute selected command',
451 action: () => {
452 // This will be handled by the command palette component
453 },
454 context: 'command-palette',
455 },
456];
457
458// ═══════════════════════════════════════════════════════════════════════════════
459// KEY HANDLER
460// ═══════════════════════════════════════════════════════════════════════════════
461
462function matchBinding(e: KeyboardEvent, binding: KeyBinding): boolean {
463 if (binding.key !== e.key) return false;
464 if (!!binding.ctrl !== e.ctrlKey) return false;
465 if (!!binding.shift !== e.shiftKey) return false;
466 if (!!binding.alt !== e.altKey) return false;
467 if (!!binding.meta !== e.metaKey) return false;
468 return true;
469}
470
471function getActiveBindings(): KeyBinding[] {
472 const bindings: KeyBinding[] = [...globalBindings];
473
474 if (homepageState.commandPaletteOpen) {
475 bindings.push(...commandPaletteBindings);
476 } else {
477 bindings.push(...listBindings);
478
479 switch (homepageState.activePanel) {
480 case 'history':
481 bindings.push(...historyBindings);
482 break;
483 case 'favorites':
484 // Add favorites-specific bindings here
485 break;
486 }
487 }
488
489 return bindings;
490}
491
492export function handleKeyDown(e: KeyboardEvent): void {
493 // Skip if focused on input/textarea (except for navigation keys)
494 const target = e.target as HTMLElement;
495 const isInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA';
496
497 // Special handling for "/" - opens command palette from anywhere
498 // Ignore modifier keys since "/" may require Shift on some layouts (AZERTY)
499 if (e.key === '/') {
500 e.preventDefault();
501 e.stopPropagation();
502 if (isInput) {
503 (target as HTMLInputElement).blur();
504 }
505 homepageState.openCommandPalette();
506 return;
507 }
508
509 if (isInput) {
510 // Allow specific keys when in input
511 const allowedInInput = ['Escape', 'ArrowUp', 'ArrowDown', 'Enter'];
512 const ctrlAllowed = ['j', 'k', 'n', 'p'];
513
514 if (!allowedInInput.includes(e.key) && !(e.ctrlKey && ctrlAllowed.includes(e.key))) {
515 return;
516 }
517 }
518
519 const bindings = getActiveBindings();
520
521 for (const binding of bindings) {
522 if (matchBinding(e, binding)) {
523 // Check context
524 if (binding.context) {
525 switch (binding.context) {
526 case 'history':
527 if (homepageState.activePanel !== 'history') continue;
528 break;
529 case 'favorites':
530 if (homepageState.activePanel !== 'favorites') continue;
531 break;
532 case 'command-palette':
533 if (!homepageState.commandPaletteOpen) continue;
534 break;
535 }
536 }
537
538 e.preventDefault();
539 e.stopPropagation();
540 binding.action();
541 return;
542 }
543 }
544
545 // Handle 'g' prefix standalone
546 if (e.key === 'g' && !e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey && !isInput) {
547 if (!pendingPrefix) {
548 e.preventDefault();
549 setPendingPrefix('g');
550 }
551 }
552}
553
554// ═══════════════════════════════════════════════════════════════════════════════
555// TIERED SEARCH (Client-side first, then FTS)
556// ═══════════════════════════════════════════════════════════════════════════════
557
558let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
559const SEARCH_DEBOUNCE_MS = 400;
560const MIN_FTS_QUERY_LENGTH = 3;
561
562/**
563 * Fuzzy match score calculator.
564 * Supports words in any order (fzf-style).
565 * Returns score > 0 if match, 0 if no match. Higher = better.
566 */
567function fuzzyScore(text: string, query: string): number {
568 const lowerText = text.toLowerCase();
569 const lowerQuery = query.toLowerCase();
570
571 // Split query into words for "any order" matching
572 const queryWords = lowerQuery.split(/\s+/).filter(w => w.length > 0);
573 if (queryWords.length === 0) return 0;
574
575 let totalScore = 0;
576
577 for (const word of queryWords) {
578 // Exact substring match (highest score)
579 const exactIdx = lowerText.indexOf(word);
580 if (exactIdx !== -1) {
581 // Bonus for word boundary match (start of text or after space/punctuation)
582 const atBoundary = exactIdx === 0 || /[\s\-_./]/.test(lowerText[exactIdx - 1] || '');
583 totalScore += 100 + (atBoundary ? 50 : 0) + (word.length * 10);
584 continue;
585 }
586
587 // Fuzzy: chars in sequence (lower score)
588 let qi = 0;
589 let lastMatchIdx = -1;
590 let consecutiveBonus = 0;
591
592 for (let i = 0; i < lowerText.length && qi < word.length; i++) {
593 if (lowerText[i] === word[qi]) {
594 // Bonus for consecutive matches
595 if (lastMatchIdx === i - 1) consecutiveBonus += 5;
596 lastMatchIdx = i;
597 qi++;
598 }
599 }
600
601 if (qi === word.length) {
602 // All chars found in order
603 totalScore += 30 + consecutiveBonus;
604 } else {
605 // This query word not found - no match
606 return 0;
607 }
608 }
609
610 return totalScore;
611}
612
613interface ScoredEntry {
614 entry: HistoryEntry;
615 score: number;
616}
617
618/**
619 * Filter history entries client-side using fuzzy matching.
620 * Combines title + URL so words can match across both fields.
621 * E.g. "google search" matches title "Google" + URL containing "search"
622 */
623function filterHistoryLocal(query: string): HistoryEntry[] {
624 const q = query.trim();
625 if (!q) return [];
626
627 const scored: ScoredEntry[] = [];
628
629 for (const entry of homepageState.history) {
630 const title = entry.title || '';
631 const url = entry.url;
632
633 // Combine title and URL for cross-field word matching
634 // Title gets priority (appears first), URL stripped of protocol
635 const urlClean = url.replace(/^https?:\/\//, '').replace(/\/$/, '');
636 const combined = `${title} ${urlClean}`;
637
638 const score = fuzzyScore(combined, q);
639
640 // Boost score if title matches directly (more relevant)
641 const titleBonus = fuzzyScore(title, q) > 0 ? 50 : 0;
642
643 if (score > 0) {
644 scored.push({ entry, score: score + titleBonus });
645 }
646 }
647
648 // Sort by score descending, limit results
649 scored.sort((a, b) => b.score - a.score);
650 return scored.slice(0, 50).map(s => s.entry);
651}
652
653export function handleSearchInput(query: string): void {
654 homepageState.setHistorySearchQuery(query);
655
656 if (searchDebounceTimer) {
657 clearTimeout(searchDebounceTimer);
658 searchDebounceTimer = null;
659 }
660
661 const trimmed = query.trim();
662 if (!trimmed) {
663 homepageState.setHistorySearchResults([]);
664 return;
665 }
666
667 // Tier 1: Instant client-side fuzzy filter on cached history
668 const localResults = filterHistoryLocal(trimmed);
669 homepageState.setHistorySearchResults(localResults);
670
671 // Tier 2: If local returns 0 results AND query is long enough, try FTS
672 // Also try FTS after debounce if query is substantial (deeper search)
673 if (trimmed.length >= MIN_FTS_QUERY_LENGTH) {
674 searchDebounceTimer = setTimeout(() => {
675 // Only call FTS if local results are sparse
676 if (localResults.length < 5) {
677 searchHistoryFTS(trimmed);
678 }
679 }, SEARCH_DEBOUNCE_MS);
680 }
681}
682
683// ═══════════════════════════════════════════════════════════════════════════════
684// KEYBOARD HINTS FOR UI
685// ═══════════════════════════════════════════════════════════════════════════════
686
687export interface KeyboardHint {
688 keys: string[];
689 description: string;
690}
691
692export function getContextualHints(): KeyboardHint[] {
693 const hints: KeyboardHint[] = [];
694
695 if (homepageState.commandPaletteOpen) {
696 hints.push(
697 { keys: ['Ctrl+j', '↓'], description: 'Move down' },
698 { keys: ['Ctrl+k', '↑'], description: 'Move up' },
699 { keys: ['Enter'], description: 'Execute' },
700 { keys: ['Escape'], description: 'Close' }
701 );
702 } else {
703 hints.push(
704 { keys: ['j', '↓'], description: 'Move down' },
705 { keys: ['k', '↑'], description: 'Move up' },
706 { keys: ['Enter', 'o'], description: 'Open' },
707 { keys: ['/'], description: 'Search' }
708 );
709
710 if (homepageState.activePanel === 'history') {
711 hints.push(
712 { keys: ['d', 'x'], description: 'Delete' },
713 { keys: ['D h'], description: 'Clear hour' },
714 { keys: ['D d'], description: 'Clear day' },
715 { keys: ['D D'], description: 'Clear all' }
716 );
717 }
718
719 hints.push(
720 { keys: ['g h'], description: 'History' },
721 { keys: ['g f'], description: 'Favorites' },
722 { keys: ['g a'], description: 'Analytics' },
723 { keys: ['1-9'], description: 'Quick nav' }
724 );
725 }
726
727 return hints;
728}
729
730// ═══════════════════════════════════════════════════════════════════════════════
731// INITIALIZATION
732// ═══════════════════════════════════════════════════════════════════════════════
733
734export function initializeKeyboard(): () => void {
735 document.addEventListener('keydown', handleKeyDown);
736
737 return () => {
738 document.removeEventListener('keydown', handleKeyDown);
739 clearPendingPrefix();
740 if (searchDebounceTimer) {
741 clearTimeout(searchDebounceTimer);
742 }
743 };
744}