v0.23.2 bfd5eb4

keyboard.ts RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close