v0.14.0 76d1892

Omnibox.svelte RawHistoryFolder

1<!--
2  Omnibox Component
3
4  Main container for omnibox/find functionality using Svelte 5 runes
5-->
6<script lang="ts">
7  import { onMount, onDestroy } from 'svelte';
8  import OmniboxInput from './OmniboxInput.svelte';
9  import OmniboxSuggestions from './OmniboxSuggestions.svelte';
10  import OmniboxFind from './OmniboxFind.svelte';
11  import { omniboxStore } from './stores.svelte.ts';
12  import { omniboxBridge } from './messaging';
13
14  // Reactive state from store
15  let visible = $derived(omniboxStore.visible);
16  let mode = $derived(omniboxStore.mode);
17  let faded = $derived(omniboxStore.faded);
18
19  // Component refs
20  let boxElement: HTMLDivElement;
21  let blurLayerElement: HTMLDivElement;
22  let inputElement = $state<HTMLInputElement>();
23
24  // Responsive styling state
25  let responsiveStyles = $state({
26    width: 'min(90vw, 720px)',
27    padding: '8px 12px',
28    fontSize: '16px',
29    inputPadding: '10px 12px'
30  });
31
32  // Track theme mode derived from injected color-scheme module
33  let isDarkMode = $state(false);
34  let themeObserver: MutationObserver | null = null;
35
36  function syncTheme() {
37    if (typeof document === 'undefined') return;
38
39    const globalWindow = typeof window === 'undefined' ? undefined : window;
40
41    if (document.documentElement.classList.contains('dark')) {
42      isDarkMode = true;
43      return;
44    }
45
46    if (globalWindow?.matchMedia) {
47      try {
48        const prefersDark = globalWindow.matchMedia('(prefers-color-scheme: dark)').matches;
49        if (typeof prefersDark === 'boolean') {
50          isDarkMode = prefersDark;
51          return;
52        }
53      } catch {
54        // matchMedia might be unavailable in some environments
55      }
56    }
57
58    // Fallback to injected GTK preference flag if available
59    isDarkMode = Boolean((globalWindow as any)?.__dumber_gtk_prefers_dark);
60  }
61
62  function observeThemeChanges() {
63    if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') {
64      return;
65    }
66
67    if (themeObserver) {
68      themeObserver.disconnect();
69    }
70
71    themeObserver = new MutationObserver(syncTheme);
72    themeObserver.observe(document.documentElement, {
73      attributes: true,
74      attributeFilter: ['class']
75    });
76  }
77
78  // Handle click outside to close
79  function handleOverlayClick(event: MouseEvent) {
80    const target = event.target as HTMLElement;
81    if (target && boxElement && !boxElement.contains(target)) {
82      event.preventDefault();
83      event.stopPropagation();
84      omniboxStore.close();
85    }
86  }
87
88  // Handle box click to prevent propagation
89  function handleBoxClick(event: MouseEvent) {
90    event.stopPropagation();
91  }
92
93  // Handle mouse enter to focus input and clear fade
94  function handleMouseEnter() {
95    if (visible && inputElement) {
96      try {
97        inputElement.focus({ preventScroll: true });
98      } catch {
99        inputElement.focus();
100      }
101      omniboxStore.setFaded(false);
102    }
103  }
104
105  // Update responsive styles based on viewport width (handles zoom levels)
106  function updateResponsiveStyles() {
107    const vw = window.innerWidth;
108    const isSmall = vw < 640;
109    const isLarge = vw > 1920;
110
111    responsiveStyles = {
112      width: isSmall ? '95vw' : isLarge ? 'min(90vw, 840px)' : 'min(90vw, 720px)',
113      padding: isSmall ? '6px 8px' : '8px 12px',
114      fontSize: isSmall ? '14px' : isLarge ? '17px' : '16px',
115      inputPadding: isSmall ? '8px 10px' : '10px 12px'
116    };
117  }
118
119  // Focus input when component becomes visible and manage page event blocking
120  $effect(() => {
121    if (visible && inputElement) {
122      inputElement.focus();
123      // Enable keyboard event blocking in main world when omnibox opens
124      enablePageEventBlocking();
125    } else if (!visible) {
126      // Disable keyboard event blocking when omnibox closes
127      disablePageEventBlocking();
128    }
129  });
130
131  // Functions to control main-world event blocking
132  function enablePageEventBlocking() {
133    try {
134      // Send message to Go backend to enable keyboard blocking
135      if (window.webkit?.messageHandlers?.dumber) {
136        window.webkit.messageHandlers.dumber.postMessage(JSON.stringify({
137          type: 'keyboard_blocking',
138          action: 'enable'
139        }));
140      }
141    } catch (error) {
142      console.warn('[omnibox] Failed to enable page event blocking:', error);
143    }
144  }
145
146  function disablePageEventBlocking() {
147    try {
148      // Send message to Go backend to disable keyboard blocking
149      if (window.webkit?.messageHandlers?.dumber) {
150        window.webkit.messageHandlers.dumber.postMessage(JSON.stringify({
151          type: 'keyboard_blocking',
152          action: 'disable'
153        }));
154      }
155    } catch (error) {
156      console.warn('[omnibox] Failed to disable page event blocking:', error);
157    }
158  }
159
160
161  // Apply faded styling effect (matching original JS implementation)
162  // NOTE: to avoid text becoming blurry while animating, we animate the
163  // blur via an absolutely-positioned layer's opacity instead of
164  // animating `backdrop-filter` on the container. This keeps the text
165  // on a separate compositing layer and prevents browser antialiasing
166  // artifacts during transitions.
167  $effect(() => {
168    if (!boxElement || !inputElement || !blurLayerElement) return;
169
170    // Ensure subtle transitions are present on the container so
171    // background-color (and other cheap properties) animate smoothly.
172    try {
173      if (!boxElement.style.transition) {
174        boxElement.style.transition = 'background-color 120ms ease, border-color 120ms ease';
175      }
176    } catch (e) {
177      // defensive: ignore if setting styles fails for some reason
178    }
179
180    const surfaceMix = isDarkMode ? '55%' : '88%';
181    const blurMix = isDarkMode ? '22%' : '12%';
182
183    const applyFade = faded && mode === 'find';
184
185    // GTK4-style: Container is slightly raised (use surface)
186    const containerBackground = applyFade
187      ? `color-mix(in srgb, var(--dynamic-surface) ${surfaceMix}, transparent)`
188      : 'var(--dynamic-surface)';
189
190    // GTK4-style: Input is recessed (darker background)
191    const inputBackground = applyFade
192      ? isDarkMode
193        ? `color-mix(in srgb, color-mix(in srgb, var(--dynamic-bg) 85%, black 15%) ${surfaceMix}, transparent)`
194        : `color-mix(in srgb, color-mix(in srgb, var(--dynamic-bg) 95%, black 5%) ${surfaceMix}, transparent)`
195      : isDarkMode
196        ? 'color-mix(in srgb, var(--dynamic-bg) 85%, black 15%)'
197        : 'color-mix(in srgb, var(--dynamic-bg) 95%, black 5%)';
198
199    // GTK4-style: Use palette border directly, slightly darker for input
200    const containerBorderColor = applyFade
201      ? `color-mix(in srgb, var(--dynamic-border) ${surfaceMix}, transparent)`
202      : 'var(--dynamic-border)';
203
204    const inputBorderColor = applyFade
205      ? isDarkMode
206        ? `color-mix(in srgb, color-mix(in srgb, var(--dynamic-border) 80%, black 20%) ${surfaceMix}, transparent)`
207        : `color-mix(in srgb, color-mix(in srgb, var(--dynamic-border) 85%, black 15%) ${surfaceMix}, transparent)`
208      : isDarkMode
209        ? 'color-mix(in srgb, var(--dynamic-border) 80%, black 20%)'
210        : 'color-mix(in srgb, var(--dynamic-border) 85%, black 15%)';
211
212    const blurTint = `color-mix(in srgb, var(--dynamic-bg) ${blurMix}, transparent)`;
213
214    boxElement.style.background = containerBackground;
215    boxElement.style.color = 'var(--dynamic-text)';
216    boxElement.style.setProperty('--dumber-border-color', containerBorderColor);
217    // Force the omnibox to use our neutral surfaces so it stays in sync with the shell palette
218    boxElement.style.setProperty('--dumber-omnibox-surface', containerBackground);
219
220    inputElement.style.background = inputBackground;
221    inputElement.style.color = 'var(--dynamic-text)';
222    inputElement.style.setProperty('--dumber-input-bg', inputBackground);
223    inputElement.style.setProperty('--dumber-input-border-base', inputBorderColor);
224    inputElement.style.setProperty('--dumber-placeholder-color', 'var(--dynamic-muted)');
225
226    blurLayerElement.style.setProperty('--dumber-blur-color', blurTint);
227    blurLayerElement.style.background = 'var(--dumber-blur-color)';
228
229    let accentColor = 'var(--dynamic-accent)';
230    try {
231      const computedAccent = getComputedStyle(boxElement).getPropertyValue('--dynamic-accent');
232      if (computedAccent && computedAccent.trim()) {
233        accentColor = computedAccent.trim();
234      }
235    } catch {
236      // If computed styles fail, keep fallback accent color
237    }
238    inputElement.style.setProperty('--dumber-input-accent', accentColor);
239    boxElement.style.setProperty('--dumber-input-accent', 'var(--dynamic-muted)');
240
241    const isFocused = document.activeElement === inputElement;
242
243    if (!isFocused) {
244      inputElement.style.setProperty('--dumber-input-border-color', 'var(--dynamic-border)');
245    }
246
247    if (applyFade) {
248      blurLayerElement.style.opacity = '1';
249    } else {
250      blurLayerElement.style.opacity = '0';
251    }
252  });
253
254  // Global click handler to close omnibox when clicking anywhere outside
255  function handleGlobalClick(event: MouseEvent) {
256    if (!visible) return;
257
258    const target = event.target as HTMLElement;
259    const omniboxContainer = document.getElementById('dumber-omnibox-root');
260
261    // Close if clicking outside the omnibox container
262    if (omniboxContainer && !omniboxContainer.contains(target)) {
263      omniboxStore.close();
264    }
265  }
266
267
268
269  onMount(async () => {
270    syncTheme();
271    observeThemeChanges();
272
273    console.log('🔧 Omnibox component mounted');
274
275    // Initialize responsive styles
276    updateResponsiveStyles();
277
278    // Add resize listener for responsive updates (handles zoom changes)
279    window.addEventListener('resize', updateResponsiveStyles);
280
281    // Add global click listener
282    document.addEventListener('click', handleGlobalClick, true);
283
284    // Load search shortcuts and favorites on mount
285    try {
286      await Promise.all([
287        omniboxBridge.fetchSearchShortcuts(),
288        omniboxBridge.fetchFavorites()
289      ]);
290      console.log('✅ Search shortcuts and favorites loaded on mount');
291    } catch (error) {
292      console.warn('Failed to load initial data:', error);
293    }
294
295    try {
296      // Set up global API for Go bridge
297      window.__dumber_omnibox = {
298        setSuggestions: (suggestions) => {
299          try {
300            console.log('📋 setSuggestions called with:', suggestions?.length || 0, 'items');
301            omniboxBridge.setSuggestions(suggestions);
302          } catch (error) {
303            console.error('Error in setSuggestions:', error);
304          }
305        },
306        toggle: () => {
307          try {
308            console.log('🔄 Omnibox toggle called, current visible:', omniboxStore.visible);
309            omniboxStore.toggle();
310            console.log('🔄 After toggle, visible:', omniboxStore.visible);
311          } catch (error) {
312            console.error('Error in toggle:', error);
313          }
314        },
315        open: (mode, query) => {
316          try {
317            console.log('🚪 Omnibox open called with mode:', mode, 'query:', query);
318            omniboxStore.open(mode as any, query);
319            console.log('🚪 After open, visible:', omniboxStore.visible, 'mode:', omniboxStore.mode);
320          } catch (error) {
321            console.error('Error in open:', error);
322          }
323        },
324        close: () => {
325          try {
326            console.log('🚪 Omnibox close called');
327            omniboxStore.close();
328          } catch (error) {
329            console.error('Error in close:', error);
330          }
331        },
332        findQuery: (query) => {
333          try {
334            console.log('🔍 Find query called with:', query);
335            if (omniboxStore.mode !== 'find') {
336              omniboxStore.setMode('find');
337            }
338            if (!omniboxStore.visible) {
339              omniboxStore.setVisible(true);
340            }
341            omniboxStore.setInputValue(query || '');
342            // Find will be triggered by input value change
343          } catch (error) {
344            console.error('Error in findQuery:', error);
345          }
346        },
347        setActive: (active) => {
348          try {
349            console.log('🎯 Omnibox setActive called with:', active);
350            // For now, this is a no-op but maintains API compatibility
351            // In the future, this could control focus management or visibility
352          } catch (error) {
353            console.error('Error in setActive:', error);
354          }
355        }
356      };
357
358      console.log('✅ Omnibox API exposed:', Object.keys(window.__dumber_omnibox));
359
360      // Check for pending suggestions that arrived before API was ready
361      if ((window as any).__dumber_omnibox_pending_suggestions) {
362        console.log('🔄 [OMNIBOX] Processing pending suggestions');
363        const pending = (window as any).__dumber_omnibox_pending_suggestions;
364        omniboxBridge.setSuggestions(pending);
365        delete (window as any).__dumber_omnibox_pending_suggestions;
366      }
367
368      // Auto-open omnibox on about:blank pages
369      if (window.location.href === 'about:blank') {
370        console.log('🚀 Auto-opening omnibox for about:blank');
371        omniboxStore.open('omnibox');
372      }
373    } catch (error) {
374      console.error('❌ Failed to set up omnibox global API:', error);
375    }
376  });
377
378  onDestroy(() => {
379    // Cleanup resize listener
380    window.removeEventListener('resize', updateResponsiveStyles);
381
382    // Cleanup global click listener
383    document.removeEventListener('click', handleGlobalClick, true);
384
385    if (themeObserver) {
386      themeObserver.disconnect();
387      themeObserver = null;
388    }
389
390    // Reset omnibox state completely
391    omniboxStore.reset();
392
393    // Cleanup global API
394    if (window.__dumber_omnibox) {
395      delete window.__dumber_omnibox;
396    }
397  });
398</script>
399
400<!-- Overlay container -->
401<div
402  class="fixed inset-0 z-[2147483647]"
403  style="display: {visible ? 'block' : 'none'};
404         position: fixed !important;
405         top: 0 !important;
406         left: 0 !important;
407         right: 0 !important;
408         bottom: 0 !important;
409         z-index: 2147483647 !important;
410         pointer-events: none !important;
411         margin: 0 !important;
412         padding: 0 !important;
413         isolation: isolate;"
414  onmousedown={handleOverlayClick}
415  role="presentation"
416>
417  <!-- Main omnibox container -->
418  <div
419    bind:this={boxElement}
420    class="dumber-omnibox-container omnibox-base terminal-omnibox"
421    style="position: relative !important;
422           left: 50% !important;
423           transform: translateX(-50%) !important;
424           margin-top: 20vh !important;
425           margin-left: 0 !important;
426           margin-right: 0 !important;
427           width: {responsiveStyles.width};
428           padding: {responsiveStyles.padding};
429           font-family: 'Fira Sans', system-ui, -apple-system, 'Segoe UI', 'Ubuntu', 'Cantarell', sans-serif;
430           pointer-events: auto !important;
431           box-sizing: border-box !important;
432           background: var(--dumber-omnibox-surface, var(--dynamic-surface));
433           color: var(--dynamic-text);
434           border: 1px solid var(--dumber-border-color, var(--dynamic-border));
435           border-radius: 3px;
436           box-shadow:
437             0 4px 12px rgba(0, 0, 0, 0.2),
438             0 2px 6px rgba(0, 0, 0, 0.15),
439             0 1px 3px rgba(0, 0, 0, 0.1),
440             inset 0 1px 0 rgba(255, 255, 255, 0.04);
441           --dumber-border-color: var(--dynamic-border);"
442    onmousedown={handleBoxClick}
443    onmouseenter={handleMouseEnter}
444    role="dialog"
445    tabindex="-1"
446    aria-modal="true"
447    aria-label={mode === 'find' ? 'Find in page' : 'Omnibox search'}
448  >
449    <!-- Blur layer: absolute full-cover element providing backdrop blur.
450         We animate its opacity rather than the container's backdrop-filter
451         to avoid causing text to blur during transitions. -->
452    <div
453      bind:this={blurLayerElement}
454      class="dumber-omnibox-blur-layer"
455      aria-hidden="true"
456      style="position: absolute; inset: 0; pointer-events: none; border-radius: 0; opacity: 0;"
457    ></div>
458    <div class="dumber-omnibox-content">
459      <!-- Input component -->
460      <OmniboxInput bind:inputElement {responsiveStyles} />
461
462      <!-- Content based on mode -->
463      {#if mode === 'omnibox'}
464        <OmniboxSuggestions />
465      {:else if mode === 'find'}
466        <OmniboxFind />
467      {/if}
468    </div>
469  </div>
470</div>
471
472<style>
473  .dumber-omnibox-container {
474    background: var(--dumber-omnibox-surface, var(--dynamic-surface));
475    color: var(--dynamic-text);
476    border: 1px solid var(--dumber-border-color, var(--dynamic-border));
477    transition: background-color 140ms ease, border-color 140ms ease, box-shadow 140ms ease;
478    border-radius: 3px;
479    box-shadow:
480      0 4px 12px rgba(0, 0, 0, 0.2),
481      0 2px 6px rgba(0, 0, 0, 0.15),
482      0 1px 3px rgba(0, 0, 0, 0.1),
483      inset 0 1px 0 rgba(255, 255, 255, 0.04);
484  }
485
486  .dumber-omnibox-container:focus-within {
487    border-color: var(--dynamic-accent);
488    box-shadow:
489      0 6px 16px rgba(0, 0, 0, 0.24),
490      0 3px 8px rgba(0, 0, 0, 0.18),
491      0 2px 4px rgba(0, 0, 0, 0.12),
492      inset 0 1px 0 rgba(255, 255, 255, 0.06),
493      0 0 0 1px var(--dynamic-accent);
494  }
495
496  :global(.dumber-omnibox-container input) {
497    background: var(--dumber-input-bg, var(--dynamic-bg));
498    color: var(--dynamic-text);
499    border: 1px solid var(--dumber-input-border-color, var(--dynamic-border));
500    border-radius: 2px;
501    box-shadow:
502      inset 0 1px 2px rgba(0, 0, 0, 0.15),
503      inset 0 0 0 1px rgba(0, 0, 0, 0.03);
504    transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease, box-shadow 120ms ease;
505    font-family:
506      "Fira Sans",
507      system-ui,
508      -apple-system,
509      "Segoe UI",
510      "Ubuntu",
511      "Cantarell",
512      sans-serif;
513    font-size: inherit;
514    letter-spacing: normal;
515    text-transform: none;
516  }
517
518  :global(.dumber-omnibox-container input::placeholder) {
519    color: var(--dynamic-muted);
520    letter-spacing: normal;
521    text-transform: none;
522  }
523
524  /* Blur layer styles: use `backdrop-filter` but animate opacity only. */
525  /* GTK4-style: Minimal blur for faded state */
526  .dumber-omnibox-blur-layer {
527    backdrop-filter: blur(0.5px) saturate(102%);
528    -webkit-backdrop-filter: blur(0.5px) saturate(102%);
529    background: var(--dumber-blur-color, color-mix(in srgb, var(--dynamic-bg) 8%, transparent));
530    transition: opacity 140ms ease;
531    will-change: opacity;
532    z-index: 0;
533    mix-blend-mode: normal;
534    border-radius: 0;
535  }
536
537  /* Ensure the actual content (input, suggestions) sits above the blur layer */
538  .dumber-omnibox-content {
539    position: relative;
540    z-index: 1;
541    /* promote to its own layer to avoid being rasterized with the blur */
542    transform: translateZ(0);
543    backface-visibility: hidden;
544  }
545  /* Additional custom styles if needed */
546</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