detached 3293762

feat(omnibox): add visual search shortcut badges to input

Badge System:
- Dynamically build CommandBadge array from search shortcuts store
- Show prefix badge when input matches a shortcut (e.g., "gh:", "npm:")
- Badge displays shortcut description (e.g., "GitHub search")
- Badge positioned absolutely with dynamic left offset
- Styled with accent background, border, and proper typography

Input Padding Logic:
- Add normalizePadding() to handle CSS padding strings
- Dynamically adjust left padding when badge is active
- Calculate padding offset (64px) to make room for badge
- Reactive effects to update padding based on active badge

Input Styling:
- Apply monospace font (JetBrains Mono, Fira Code)
- Remove border-radius for sharp corners
- Update border width (1px) and colors
- Add letter-spacing to input and placeholder
- Uppercase placeholder text for consistency

Fade Behavior:
- Only apply fade effect in find mode when navigating results
- Keep omnibox mode unfaded during navigation
commit
329376210e89b731be2a478a68bd77094f5bb6bb
author
bamen · · 2025-10-06 18:22 UTC
committer
bamen · · 2025-10-06 19:07 UTC
parent
27396fb
signature
unsigned
1 file · +126 -28
M gui/src/components/omnibox/OmniboxInput.svelte +126 -28
@@ -14,6 +14,7 @@
1414 let selectedIndex = $derived(omniboxStore.selectedIndex);
1515 let suggestions = $derived(omniboxStore.suggestions);
1616 let matches = $derived(omniboxStore.matches);
17+ let searchShortcuts = $derived(omniboxStore.searchShortcuts);
1718
1819 // Input element ref and responsive styles (props for parent to bind)
1920 interface Props {
@@ -28,6 +29,21 @@
2829
2930 const ACCENT_FALLBACK = 'var(--dynamic-accent)';
3031
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+
3147 function resolveAccentColor(): string {
3248 if (!inputElement) return ACCENT_FALLBACK;
3349 const inlineAccent = inputElement.style.getPropertyValue('--dumber-input-accent');
@@ -48,6 +64,50 @@
4864 mode === 'find' ? 'Find in page…' : 'Type URL or search…'
4965 );
5066
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+
51111 // Handle input changes
52112 function handleInput(event: Event) {
53113 const target = event.target as HTMLInputElement;
@@ -133,8 +193,12 @@
133193 omniboxStore.selectPrevious();
134194 }
135195
136- // Fade overlay while navigating results
137- omniboxStore.setFaded(true);
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+ }
138202
139203 // For find mode, reveal the current match
140204 if (mode === 'find') {
@@ -218,37 +282,50 @@
218282 }
219283 </script>
220284
221-<input
222- bind:this={inputElement}
223- type="text"
224- {placeholder}
225- value={inputValue}
226- class="w-full box-border omnibox-input omnibox-input-field rounded focus:outline-none"
227- style="padding: {responsiveStyles?.inputPadding || '10px 12px'};
228- font-size: {responsiveStyles?.fontSize || '16px'};
229- box-sizing: border-box;"
230- oninput={handleInput}
231- onkeydown={handleKeyDown}
232- onmousedown={handleMouseDown}
233- onclick={handleClick}
234- onfocus={handleFocus}
235- onblur={handleBlur}
236- onmouseenter={handleMouseEnter}
237- autocomplete="off"
238- spellcheck="false"
239- role="combobox"
240- aria-expanded={omniboxStore.hasContent}
241- aria-controls="omnibox-list"
242- aria-haspopup="listbox"
243- aria-owns="omnibox-list"
244- aria-activedescendant={selectedIndex >= 0 ? `omnibox-item-${selectedIndex}` : undefined}
245-/>
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>
246318
247319 <style>
320+ .omnibox-input-wrapper {
321+ position: relative;
322+ }
323+
248324 .omnibox-input-field {
249325 background: var(--dumber-input-bg, var(--dynamic-bg));
250326 color: var(--dynamic-text);
251- border: 2px solid var(--dumber-input-border-color, var(--dynamic-border));
327+ border: 1px solid var(--dumber-input-border-color, var(--dynamic-border));
328+ border-radius: 0;
252329 transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease;
253330 }
254331
@@ -259,4 +336,25 @@
259336 .omnibox-input-field:focus {
260337 color: var(--dynamic-text);
261338 }
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+ }
262360 </style>
@@ -14,6 +14,7 @@
14 let selectedIndex = $derived(omniboxStore.selectedIndex);14 let selectedIndex = $derived(omniboxStore.selectedIndex);
15 let suggestions = $derived(omniboxStore.suggestions);15 let suggestions = $derived(omniboxStore.suggestions);
16 let matches = $derived(omniboxStore.matches);16 let matches = $derived(omniboxStore.matches);
17+ let searchShortcuts = $derived(omniboxStore.searchShortcuts);
17 18
18 // Input element ref and responsive styles (props for parent to bind)19 // Input element ref and responsive styles (props for parent to bind)
19 interface Props {20 interface Props {
@@ -28,6 +29,21 @@
28 29
29 const ACCENT_FALLBACK = 'var(--dynamic-accent)';30 const ACCENT_FALLBACK = 'var(--dynamic-accent)';
30 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+
31 function resolveAccentColor(): string {47 function resolveAccentColor(): string {
32 if (!inputElement) return ACCENT_FALLBACK;48 if (!inputElement) return ACCENT_FALLBACK;
33 const inlineAccent = inputElement.style.getPropertyValue('--dumber-input-accent');49 const inlineAccent = inputElement.style.getPropertyValue('--dumber-input-accent');
@@ -48,6 +64,50 @@
48 mode === 'find' ? 'Find in page…' : 'Type URL or search…'64 mode === 'find' ? 'Find in page…' : 'Type URL or search…'
49 );65 );
50 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+
51 // Handle input changes111 // Handle input changes
52 function handleInput(event: Event) {112 function handleInput(event: Event) {
53 const target = event.target as HTMLInputElement;113 const target = event.target as HTMLInputElement;
@@ -133,8 +193,12 @@
133 omniboxStore.selectPrevious();193 omniboxStore.selectPrevious();
134 }194 }
135 195
136- // Fade overlay while navigating results196+ // Fade overlay while navigating results only for find mode
137- omniboxStore.setFaded(true);197+ if (mode === 'find') {
198+ omniboxStore.setFaded(true);
199+ } else {
200+ omniboxStore.setFaded(false);
201+ }
138 202
139 // For find mode, reveal the current match203 // For find mode, reveal the current match
140 if (mode === 'find') {204 if (mode === 'find') {
@@ -218,37 +282,50 @@
218 }282 }
219 </script>283 </script>
220 284
221-<input285+<div class="omnibox-input-wrapper">
222- bind:this={inputElement}286+ {#if activeBadge}
223- type="text"287+ <span
224- {placeholder}288+ class="omnibox-prefix-badge"
225- value={inputValue}289+ aria-hidden="true"
226- class="w-full box-border omnibox-input omnibox-input-field rounded focus:outline-none"290+ style={`left: ${baseLeftPadding};`}
227- style="padding: {responsiveStyles?.inputPadding || '10px 12px'};291+ >{activeBadge.label}</span>
228- font-size: {responsiveStyles?.fontSize || '16px'};292+ {/if}
229- box-sizing: border-box;"293+
230- oninput={handleInput}294+ <input
231- onkeydown={handleKeyDown}295+ bind:this={inputElement}
232- onmousedown={handleMouseDown}296+ type="text"
233- onclick={handleClick}297+ {placeholder}
234- onfocus={handleFocus}298+ value={inputValue}
235- onblur={handleBlur}299+ class="w-full box-border omnibox-input omnibox-input-field focus:outline-none"
236- onmouseenter={handleMouseEnter}300+ style={`padding: ${inputPadding}; font-size: ${inputFontSize}; box-sizing: border-box;`}
237- autocomplete="off"301+ oninput={handleInput}
238- spellcheck="false"302+ onkeydown={handleKeyDown}
239- role="combobox"303+ onmousedown={handleMouseDown}
240- aria-expanded={omniboxStore.hasContent}304+ onclick={handleClick}
241- aria-controls="omnibox-list"305+ onfocus={handleFocus}
242- aria-haspopup="listbox"306+ onblur={handleBlur}
243- aria-owns="omnibox-list"307+ onmouseenter={handleMouseEnter}
244- aria-activedescendant={selectedIndex >= 0 ? `omnibox-item-${selectedIndex}` : undefined}308+ autocomplete="off"
245-/>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>
246 318
247 <style>319 <style>
320+ .omnibox-input-wrapper {
321+ position: relative;
322+ }
323+
248 .omnibox-input-field {324 .omnibox-input-field {
249 background: var(--dumber-input-bg, var(--dynamic-bg));325 background: var(--dumber-input-bg, var(--dynamic-bg));
250 color: var(--dynamic-text);326 color: var(--dynamic-text);
251- border: 2px solid var(--dumber-input-border-color, var(--dynamic-border));327+ border: 1px solid var(--dumber-input-border-color, var(--dynamic-border));
328+ border-radius: 0;
252 transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease;329 transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease;
253 }330 }
254 331
@@ -259,4 +336,25 @@
259 .omnibox-input-field:focus {336 .omnibox-input-field:focus {
260 color: var(--dynamic-text);337 color: var(--dynamic-text);
261 }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+ }
262 </style>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