v0.20.1 d9a391f

CommandPalette.svelte RawHistoryFolder

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>

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