v0.13.0 976176a

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 inputValue = $derived(omniboxStore.inputValue);
14  let selectedIndex = $derived(omniboxStore.selectedIndex);
15  let suggestions = $derived(omniboxStore.suggestions);
16  let matches = $derived(omniboxStore.matches);
17  let searchShortcuts = $derived(omniboxStore.searchShortcuts);
18
19  // Input element ref and responsive styles (props for parent to bind)
20  interface Props {
21    inputElement?: HTMLInputElement;
22    responsiveStyles?: {
23      fontSize: string;
24      inputPadding: string;
25    };
26  }
27
28  let { inputElement = $bindable(), responsiveStyles }: Props = $props();
29
30  const ACCENT_FALLBACK = 'var(--dynamic-accent)';
31
32  interface CommandBadge {
33    prefix: string;
34    label: string;
35  }
36
37  // Build command badges dynamically from search shortcuts
38  let COMMAND_BADGES = $derived(
39    Object.entries(searchShortcuts).map(([key, shortcut]) => ({
40      prefix: `${key}:`,
41      label: shortcut.description || key.toUpperCase()
42    }))
43  );
44
45  const BADGE_LEFT_OFFSET = '64px';
46
47  function resolveAccentColor(): string {
48    if (!inputElement) return ACCENT_FALLBACK;
49    const inlineAccent = inputElement.style.getPropertyValue('--dumber-input-accent');
50    if (inlineAccent && inlineAccent.trim()) {
51      return inlineAccent.trim();
52    }
53    try {
54      const computedAccent = getComputedStyle(inputElement).getPropertyValue('--dynamic-accent');
55      if (computedAccent && computedAccent.trim()) {
56        return computedAccent.trim();
57      }
58    } catch { /* no-op */ }
59    return ACCENT_FALLBACK;
60  }
61
62  // Computed placeholder
63  let placeholder = $derived(
64    mode === 'find' ? 'Find in page…' : 'Type URL or search…'
65  );
66
67  const DEFAULT_PADDING: [string, string, string, string] = ['10px', '12px', '10px', '12px'];
68
69  function normalizePadding(padding?: string): [string, string, string, string] {
70    if (!padding) return DEFAULT_PADDING;
71    const parts = padding.trim().split(/\s+/).filter(Boolean);
72    if (parts.length === 0) return DEFAULT_PADDING;
73    const p0 = parts[0] ?? DEFAULT_PADDING[0];
74    const p1 = parts[1] ?? p0;
75    const p2 = parts[2] ?? p0;
76    const p3 = parts[3] ?? p1;
77
78    if (parts.length === 1) return [p0, p0, p0, p0];
79    if (parts.length === 2) return [p0, p1, p0, p1];
80    if (parts.length === 3) return [p0, p1, p2, p1];
81    return [p0, p1, p2, p3];
82  }
83
84  let activeBadge = $state<CommandBadge | null>(null);
85  let basePaddingSegments = $state<[string, string, string, string]>(DEFAULT_PADDING);
86  let inputPadding = $state(DEFAULT_PADDING.join(' '));
87  let baseLeftPadding = $state(DEFAULT_PADDING[3]);
88  let inputFontSize = $state(responsiveStyles?.fontSize || '16px');
89
90  $effect(() => {
91    inputFontSize = responsiveStyles?.fontSize || '16px';
92  });
93
94  $effect(() => {
95    basePaddingSegments = normalizePadding(responsiveStyles?.inputPadding);
96  });
97
98  $effect(() => {
99    const value = inputValue || '';
100    const trimmedValue = value.trimStart();
101    activeBadge = COMMAND_BADGES.find(({ prefix }) => trimmedValue.startsWith(prefix)) || null;
102  });
103
104  $effect(() => {
105    const [top, right, bottom, left] = basePaddingSegments;
106    baseLeftPadding = left;
107    const leftPadding = activeBadge ? `calc(${left} + ${BADGE_LEFT_OFFSET})` : left;
108    inputPadding = `${top} ${right} ${bottom} ${leftPadding}`;
109  });
110
111  // Handle input changes
112  function handleInput(event: Event) {
113    const target = event.target as HTMLInputElement;
114    const value = target.value || '';
115
116    omniboxStore.setInputValue(value);
117    omniboxStore.setFaded(false);
118
119    if (mode === 'omnibox') {
120      // Debounced search for omnibox
121      debouncedQuery(value);
122    } else if (mode === 'find') {
123      // Immediate find for search
124      findInPage(value);
125    }
126  }
127
128  // Handle key events
129  function handleKeyDown(event: KeyboardEvent) {
130    switch (event.key) {
131      case 'Escape':
132        event.preventDefault();
133        event.stopPropagation();
134        omniboxStore.close();
135        break;
136
137      case 'Enter':
138        event.preventDefault();
139        event.stopPropagation();
140        handleEnterKey(event);
141        break;
142
143      case 'ArrowDown':
144      case 'ArrowUp':
145        event.preventDefault();
146        event.stopPropagation();
147        handleArrowKeys(event.key);
148        break;
149
150      default:
151        // For normal typing keys, only stop propagation to prevent page handlers
152        // but don't prevent default so typing still works in the input
153        event.stopPropagation();
154        // Any other key should restore full opacity
155        omniboxStore.setFaded(false);
156        break;
157    }
158  }
159
160  // Handle Enter key based on mode
161  function handleEnterKey(event: KeyboardEvent) {
162    if (mode === 'omnibox') {
163      const selectedSuggestion = selectedIndex >= 0 ? suggestions[selectedIndex] : null;
164      const url = selectedSuggestion?.url || inputValue || '';
165
166      if (url) {
167        omniboxBridge.navigate(url);
168        omniboxStore.close();
169      }
170    } else if (mode === 'find') {
171      if (event.shiftKey) {
172        // Shift+Enter: jump to previous match
173        jumpToMatch(-1);
174      } else if (event.altKey) {
175        // Alt+Enter: center on current match but keep overlay open
176        revealCurrentMatch();
177      } else {
178        // Enter: center and close
179        revealCurrentMatch();
180        omniboxStore.close();
181      }
182    }
183  }
184
185  // Handle arrow key navigation
186  function handleArrowKeys(key: string) {
187    const totalItems = mode === 'omnibox' ? suggestions.length : matches.length;
188
189    if (totalItems > 0) {
190      if (key === 'ArrowDown') {
191        omniboxStore.selectNext();
192      } else {
193        omniboxStore.selectPrevious();
194      }
195
196      // Fade overlay while navigating results only for find mode
197      if (mode === 'find') {
198        omniboxStore.setFaded(true);
199      } else {
200        omniboxStore.setFaded(false);
201      }
202
203      // For find mode, reveal the current match
204      if (mode === 'find') {
205        revealCurrentMatch();
206      }
207    }
208  }
209
210  // Jump to match (for find mode)
211  function jumpToMatch(delta: number) {
212    if (mode !== 'find' || matches.length === 0) return;
213
214    const currentIndex = selectedIndex || 0;
215    const newIndex = ((currentIndex + delta) % matches.length + matches.length) % matches.length;
216
217    omniboxStore.setSelectedIndex(newIndex);
218    revealCurrentMatch();
219  }
220
221  // Reveal current match (for find mode)
222  function revealCurrentMatch() {
223    if (mode !== 'find' || selectedIndex < 0 || !matches[selectedIndex]) return;
224
225    // Remove previous active class
226    const prevMatch = matches[omniboxStore.activeIndex];
227    if (prevMatch?.element?.classList) {
228      prevMatch.element.classList.remove('find-active');
229    }
230
231    // Add active class to current match
232    const currentMatch = matches[selectedIndex];
233    if (currentMatch?.element?.classList) {
234      currentMatch.element.classList.add('find-active');
235      omniboxStore.setActiveIndex(selectedIndex);
236
237      // Scroll into view
238      try {
239        currentMatch.element.scrollIntoView({
240          block: 'center',
241          inline: 'nearest'
242        });
243      } catch {
244        currentMatch.element.scrollIntoView();
245      }
246    }
247  }
248
249  // Handle mouse events
250  function handleMouseDown() {
251    omniboxStore.setFaded(false);
252  }
253
254  function handleClick() {
255    omniboxStore.setFaded(false);
256  }
257
258  function handleFocus() {
259    omniboxStore.setFaded(false);
260    if (inputElement) {
261      const accent = resolveAccentColor();
262      inputElement.style.setProperty('--dumber-input-border-color', accent);
263    }
264  }
265
266  function handleBlur() {
267    if (!inputElement) return;
268    const base = inputElement.style.getPropertyValue('--dumber-input-border-base');
269    const fallback = (base && base.trim()) || 'var(--dynamic-border)';
270    inputElement.style.setProperty('--dumber-input-border-color', fallback);
271  }
272
273  function handleMouseEnter() {
274    if (omniboxStore.visible && inputElement) {
275      try {
276        inputElement.focus({ preventScroll: true });
277        inputElement.select();
278      } catch {
279        inputElement.focus();
280      }
281    }
282  }
283</script>
284
285<div class="omnibox-input-wrapper">
286  {#if activeBadge}
287    <span
288      class="omnibox-prefix-badge"
289      aria-hidden="true"
290      style={`left: ${baseLeftPadding};`}
291    >{activeBadge.label}</span>
292  {/if}
293
294  <input
295    bind:this={inputElement}
296    type="text"
297    {placeholder}
298    value={inputValue}
299    class="w-full box-border omnibox-input omnibox-input-field focus:outline-none"
300    style={`padding: ${inputPadding}; font-size: ${inputFontSize}; box-sizing: border-box;`}
301    oninput={handleInput}
302    onkeydown={handleKeyDown}
303    onmousedown={handleMouseDown}
304    onclick={handleClick}
305    onfocus={handleFocus}
306    onblur={handleBlur}
307    onmouseenter={handleMouseEnter}
308    autocomplete="off"
309    spellcheck="false"
310    role="combobox"
311    aria-expanded={omniboxStore.hasContent}
312    aria-controls="omnibox-list"
313    aria-haspopup="listbox"
314    aria-owns="omnibox-list"
315    aria-activedescendant={selectedIndex >= 0 ? `omnibox-item-${selectedIndex}` : undefined}
316  />
317</div>
318
319<style>
320  .omnibox-input-wrapper {
321    position: relative;
322  }
323
324  .omnibox-input-field {
325    background: var(--dumber-input-bg, var(--dynamic-bg));
326    color: var(--dynamic-text);
327    border: 1px solid var(--dumber-input-border-color, var(--dynamic-border));
328    border-radius: 0;
329    transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease;
330  }
331
332  .omnibox-input-field::placeholder {
333    color: var(--dynamic-muted);
334  }
335
336  .omnibox-input-field:focus {
337    color: var(--dynamic-text);
338  }
339
340  .omnibox-prefix-badge {
341    position: absolute;
342    top: 50%;
343    transform: translateY(-50%);
344    padding: 0.2rem 0.6rem;
345    border-radius: 0;
346    border: 1px solid var(--dynamic-border, transparent);
347    background: var(--dumber-input-badge-bg, var(--dynamic-accent));
348    color: var(--dumber-input-badge-text, var(--dynamic-bg));
349    font-size: 0.75rem;
350    font-weight: 600;
351    display: inline-flex;
352    align-items: center;
353    gap: 4px;
354    line-height: 1;
355    pointer-events: none;
356    white-space: nowrap;
357    box-shadow: 0 0 0 1px var(--dynamic-border, transparent);
358    z-index: 1;
359  }
360</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