v0.14.0 76d1892

OmniboxInput.svelte RawHistoryFolder

1<!--
2  Omnibox Input Component
3
4  Input field with keyboard handling and debounced search
5-->
6<script lang="ts">
7  import { omniboxStore } from './stores.svelte.ts';
8  import { debouncedQuery, omniboxBridge } from './messaging';
9  import { findInPage } from './find';
10
11  // Reactive state
12  let mode = $derived(omniboxStore.mode);
13  let viewMode = $derived(omniboxStore.viewMode);
14  let inputValue = $derived(omniboxStore.inputValue);
15  let selectedIndex = $derived(omniboxStore.selectedIndex);
16  let suggestions = $derived(omniboxStore.suggestions);
17  let favorites = $derived(omniboxStore.favorites);
18  let matches = $derived(omniboxStore.matches);
19  let searchShortcuts = $derived(omniboxStore.searchShortcuts);
20
21  // Input element ref and responsive styles (props for parent to bind)
22  interface Props {
23    inputElement?: HTMLInputElement;
24    responsiveStyles?: {
25      fontSize: string;
26      inputPadding: string;
27    };
28  }
29
30  let { inputElement = $bindable(), responsiveStyles }: Props = $props();
31
32  const ACCENT_FALLBACK = 'var(--dynamic-accent)';
33
34  interface CommandBadge {
35    prefix: string;
36    label: string;
37  }
38
39  // Build command badges dynamically from search shortcuts
40  let COMMAND_BADGES = $derived(
41    Object.entries(searchShortcuts).map(([key, shortcut]) => ({
42      prefix: `${key}:`,
43      label: shortcut.description || key.toUpperCase()
44    }))
45  );
46
47  const BADGE_LEFT_OFFSET = '64px';
48
49  function resolveAccentColor(): string {
50    if (!inputElement) return ACCENT_FALLBACK;
51    const inlineAccent = inputElement.style.getPropertyValue('--dumber-input-accent');
52    if (inlineAccent && inlineAccent.trim()) {
53      return inlineAccent.trim();
54    }
55    try {
56      const computedAccent = getComputedStyle(inputElement).getPropertyValue('--dynamic-accent');
57      if (computedAccent && computedAccent.trim()) {
58        return computedAccent.trim();
59      }
60    } catch { /* no-op */ }
61    return ACCENT_FALLBACK;
62  }
63
64  // Computed placeholder
65  let placeholder = $derived(
66    mode === 'find' ? 'Find in page…' : 'Type URL or search…'
67  );
68
69  const DEFAULT_PADDING: [string, string, string, string] = ['10px', '12px', '10px', '12px'];
70
71  function normalizePadding(padding?: string): [string, string, string, string] {
72    if (!padding) return DEFAULT_PADDING;
73    const parts = padding.trim().split(/\s+/).filter(Boolean);
74    if (parts.length === 0) return DEFAULT_PADDING;
75    const p0 = parts[0] ?? DEFAULT_PADDING[0];
76    const p1 = parts[1] ?? p0;
77    const p2 = parts[2] ?? p0;
78    const p3 = parts[3] ?? p1;
79
80    if (parts.length === 1) return [p0, p0, p0, p0];
81    if (parts.length === 2) return [p0, p1, p0, p1];
82    if (parts.length === 3) return [p0, p1, p2, p1];
83    return [p0, p1, p2, p3];
84  }
85
86  let activeBadge = $state<CommandBadge | null>(null);
87  let basePaddingSegments = $state<[string, string, string, string]>(DEFAULT_PADDING);
88  let inputPadding = $state(DEFAULT_PADDING.join(' '));
89  let baseLeftPadding = $state(DEFAULT_PADDING[3]);
90  let inputFontSize = $state(responsiveStyles?.fontSize || '16px');
91
92  $effect(() => {
93    inputFontSize = responsiveStyles?.fontSize || '16px';
94  });
95
96  $effect(() => {
97    basePaddingSegments = normalizePadding(responsiveStyles?.inputPadding);
98  });
99
100  $effect(() => {
101    const value = inputValue || '';
102    const trimmedValue = value.trimStart();
103    activeBadge = COMMAND_BADGES.find(({ prefix }) => trimmedValue.startsWith(prefix)) || null;
104  });
105
106  $effect(() => {
107    const [top, right, bottom, left] = basePaddingSegments;
108    baseLeftPadding = left;
109    const leftPadding = activeBadge ? `calc(${left} + ${BADGE_LEFT_OFFSET})` : left;
110    inputPadding = `${top} ${right} ${bottom} ${leftPadding}`;
111  });
112
113  // Handle input changes
114  function handleInput(event: Event) {
115    const target = event.target as HTMLInputElement;
116    const value = target.value || '';
117
118    omniboxStore.setInputValue(value);
119    omniboxStore.setFaded(false);
120
121    if (mode === 'omnibox') {
122      // Only query backend if we're in history view
123      // In favorites view, filtering happens locally via computed state
124      if (viewMode === 'history') {
125        debouncedQuery(value);
126      }
127    } else if (mode === 'find') {
128      // Immediate find for search
129      findInPage(value);
130    }
131  }
132
133  // Handle key events
134  function handleKeyDown(event: KeyboardEvent) {
135    switch (event.key) {
136      case 'Escape':
137        event.preventDefault();
138        event.stopPropagation();
139
140        if (inputValue && inputValue.trim() !== '') {
141          // Input has text - clear it but keep omnibox open
142          omniboxStore.setInputValue('');
143
144          // In find mode, clear the search highlights
145          if (mode === 'find') {
146            findInPage('');
147          }
148        } else {
149          // Input is empty - close omnibox immediately
150          omniboxStore.close();
151        }
152        break;
153
154      case 'Enter':
155        event.preventDefault();
156        event.stopPropagation();
157        handleEnterKey(event);
158        break;
159
160      case 'Tab':
161        // Only handle Tab in omnibox mode (not in find mode)
162        if (mode === 'omnibox') {
163          event.preventDefault();
164          event.stopPropagation();
165          // Toggle between history and favorites views
166          const newViewMode = viewMode === 'history' ? 'favorites' : 'history';
167          omniboxStore.setViewMode(newViewMode);
168        }
169        break;
170
171      case ' ':
172        // Only handle Space in omnibox mode when an item is selected
173        if (mode === 'omnibox' && selectedIndex >= 0) {
174          event.preventDefault();
175          event.stopPropagation();
176          handleSpaceKey();
177        }
178        break;
179
180      case 'ArrowDown':
181      case 'ArrowUp':
182        event.preventDefault();
183        event.stopPropagation();
184        handleArrowKeys(event.key);
185        break;
186
187      default:
188        // Handle Ctrl+1 through Ctrl+0 for quick navigation to first 10 results
189        // Use event.code for keyboard layout independence (QWERTY, AZERTY, QWERTZ, etc.)
190        if (event.ctrlKey && mode === 'omnibox') {
191          const code = event.code;
192          let targetIndex = -1;
193
194          // Map physical keys: Digit1->0, Digit2->1, ..., Digit9->8, Digit0->9
195          if (code >= 'Digit1' && code <= 'Digit9') {
196            targetIndex = parseInt(code.substring(5), 10) - 1;
197          } else if (code === 'Digit0') {
198            targetIndex = 9;
199          }
200
201          if (targetIndex >= 0) {
202            event.preventDefault();
203            event.stopPropagation();
204            handleNumberKey(targetIndex);
205            return;
206          }
207        }
208
209        // For normal typing keys, only stop propagation to prevent page handlers
210        // but don't prevent default so typing still works in the input
211        event.stopPropagation();
212        // Any other key should restore full opacity
213        omniboxStore.setFaded(false);
214        break;
215    }
216  }
217
218  // Handle Enter key based on mode
219  function handleEnterKey(event: KeyboardEvent) {
220    if (mode === 'omnibox') {
221      // Get selected item from current view (history or favorites)
222      const currentList = viewMode === 'history' ? suggestions : favorites;
223      const selectedItem = selectedIndex >= 0 ? currentList[selectedIndex] : null;
224      const url = selectedItem?.url || inputValue || '';
225
226      if (url) {
227        omniboxBridge.navigate(url);
228        omniboxStore.close();
229      }
230    } else if (mode === 'find') {
231      if (event.shiftKey) {
232        // Shift+Enter: jump to previous match
233        jumpToMatch(-1);
234      } else if (event.altKey) {
235        // Alt+Enter: center on current match but keep overlay open
236        revealCurrentMatch();
237      } else {
238        // Enter: center and close
239        revealCurrentMatch();
240        omniboxStore.close();
241      }
242    }
243  }
244
245  // Handle Space key to toggle favorite
246  function handleSpaceKey() {
247    if (mode !== 'omnibox' || selectedIndex < 0) return;
248
249    // Get the selected item based on current view mode
250    const item = viewMode === 'history'
251      ? suggestions[selectedIndex]
252      : favorites[selectedIndex];
253
254    if (!item || !item.url) return;
255
256    // Extract title and favicon
257    const title = 'title' in item ? item.title : '';
258    const faviconURL = item.favicon_url || item.favicon || '';
259
260    // Toggle the favorite status
261    omniboxBridge.toggleFavorite(item.url, title, faviconURL);
262
263    // Refresh the favorites list after toggling
264    setTimeout(() => {
265      omniboxBridge.fetchFavorites();
266    }, 100);
267  }
268
269  // Handle Ctrl+Number key for quick navigation
270  function handleNumberKey(targetIndex: number) {
271    if (mode !== 'omnibox') return;
272
273    // Get the current list based on view mode
274    const currentList = viewMode === 'history' ? suggestions : favorites;
275
276    // Check if the target index is valid
277    if (targetIndex >= currentList.length) return;
278
279    const item = currentList[targetIndex];
280    if (!item || !item.url) return;
281
282    // Navigate to the item and close omnibox
283    omniboxBridge.navigate(item.url);
284    omniboxStore.close();
285  }
286
287  // Handle arrow key navigation
288  function handleArrowKeys(key: string) {
289    let totalItems: number;
290    if (mode === 'omnibox') {
291      // Get count from current view (history or favorites)
292      totalItems = viewMode === 'history' ? suggestions.length : favorites.length;
293    } else {
294      totalItems = matches.length;
295    }
296
297    if (totalItems > 0) {
298      if (key === 'ArrowDown') {
299        omniboxStore.selectNext();
300      } else {
301        omniboxStore.selectPrevious();
302      }
303
304      // Fade overlay while navigating results only for find mode
305      if (mode === 'find') {
306        omniboxStore.setFaded(true);
307      } else {
308        omniboxStore.setFaded(false);
309      }
310
311      // For find mode, reveal the current match
312      if (mode === 'find') {
313        revealCurrentMatch();
314      }
315    }
316  }
317
318  // Jump to match (for find mode)
319  function jumpToMatch(delta: number) {
320    if (mode !== 'find' || matches.length === 0) return;
321
322    const currentIndex = selectedIndex || 0;
323    const newIndex = ((currentIndex + delta) % matches.length + matches.length) % matches.length;
324
325    omniboxStore.setSelectedIndex(newIndex);
326    revealCurrentMatch();
327  }
328
329  // Reveal current match (for find mode)
330  function revealCurrentMatch() {
331    if (mode !== 'find' || selectedIndex < 0 || !matches[selectedIndex]) return;
332
333    // Remove previous active class
334    const prevMatch = matches[omniboxStore.activeIndex];
335    if (prevMatch?.element?.classList) {
336      prevMatch.element.classList.remove('find-active');
337    }
338
339    // Add active class to current match
340    const currentMatch = matches[selectedIndex];
341    if (currentMatch?.element?.classList) {
342      currentMatch.element.classList.add('find-active');
343      omniboxStore.setActiveIndex(selectedIndex);
344
345      // Scroll into view
346      try {
347        currentMatch.element.scrollIntoView({
348          block: 'center',
349          inline: 'nearest'
350        });
351      } catch {
352        currentMatch.element.scrollIntoView();
353      }
354    }
355  }
356
357  // Handle mouse events
358  function handleMouseDown() {
359    omniboxStore.setFaded(false);
360  }
361
362  function handleClick() {
363    omniboxStore.setFaded(false);
364  }
365
366  function handleFocus() {
367    omniboxStore.setFaded(false);
368    if (inputElement) {
369      const accent = resolveAccentColor();
370      inputElement.style.setProperty('--dumber-input-border-color', accent);
371    }
372  }
373
374  function handleBlur() {
375    if (!inputElement) return;
376    const base = inputElement.style.getPropertyValue('--dumber-input-border-base');
377    const fallback = (base && base.trim()) || 'var(--dynamic-border)';
378    inputElement.style.setProperty('--dumber-input-border-color', fallback);
379  }
380
381  function handleMouseEnter() {
382    if (omniboxStore.visible && inputElement) {
383      try {
384        inputElement.focus({ preventScroll: true });
385        inputElement.select();
386      } catch {
387        inputElement.focus();
388      }
389    }
390  }
391</script>
392
393<div class="omnibox-input-wrapper">
394  {#if activeBadge}
395    <span
396      class="omnibox-prefix-badge"
397      aria-hidden="true"
398      style={`left: ${baseLeftPadding};`}
399    >{activeBadge.label}</span>
400  {/if}
401
402  <input
403    bind:this={inputElement}
404    type="text"
405    {placeholder}
406    value={inputValue}
407    class="w-full box-border omnibox-input omnibox-input-field focus:outline-none"
408    style={`padding: ${inputPadding}; font-size: ${inputFontSize}; box-sizing: border-box;`}
409    oninput={handleInput}
410    onkeydown={handleKeyDown}
411    onmousedown={handleMouseDown}
412    onclick={handleClick}
413    onfocus={handleFocus}
414    onblur={handleBlur}
415    onmouseenter={handleMouseEnter}
416    autocomplete="off"
417    spellcheck="false"
418    role="combobox"
419    aria-expanded={omniboxStore.hasContent}
420    aria-controls="omnibox-list"
421    aria-haspopup="listbox"
422    aria-owns="omnibox-list"
423    aria-activedescendant={selectedIndex >= 0 ? `omnibox-item-${selectedIndex}` : undefined}
424  />
425</div>
426
427<style>
428  .omnibox-input-wrapper {
429    position: relative;
430  }
431
432  .omnibox-input-field {
433    background: var(--dumber-input-bg, var(--dynamic-bg));
434    color: var(--dynamic-text);
435    border: 1px solid var(--dumber-input-border-color, var(--dynamic-border));
436    border-radius: 2px;
437    box-shadow:
438      inset 0 1px 2px rgba(0, 0, 0, 0.15),
439      inset 0 0 0 1px rgba(0, 0, 0, 0.03);
440    transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease, box-shadow 120ms ease;
441    font-family: 'Fira Sans', system-ui, -apple-system, 'Segoe UI', 'Ubuntu', 'Cantarell', sans-serif;
442    letter-spacing: normal;
443  }
444
445  .omnibox-input-field::placeholder {
446    color: var(--dynamic-muted);
447    letter-spacing: normal;
448  }
449
450  .omnibox-input-field:focus {
451    color: var(--dynamic-text);
452    border-color: var(--dynamic-accent);
453    box-shadow:
454      inset 0 1px 2px rgba(0, 0, 0, 0.15),
455      0 0 0 1px var(--dynamic-accent);
456  }
457
458  .omnibox-prefix-badge {
459    position: absolute;
460    top: 50%;
461    transform: translateY(-50%);
462    padding: 0.2rem 0.6rem;
463    border-radius: 0;
464    border: 1px solid var(--dynamic-border, transparent);
465    background: var(--dumber-input-badge-bg, var(--dynamic-accent));
466    color: var(--dumber-input-badge-text, var(--dynamic-bg));
467    font-size: 0.75rem;
468    font-weight: 600;
469    display: inline-flex;
470    align-items: center;
471    gap: 4px;
472    line-height: 1;
473    pointer-events: none;
474    white-space: nowrap;
475    box-shadow: 0 0 0 1px var(--dynamic-border, transparent);
476    z-index: 1;
477  }
478</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