v0.10.0 0f4e9eb

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 ? '65%' : '92%';
181    const inputMix = isDarkMode ? '60%' : '94%';
182    const borderMix = isDarkMode ? '70%' : '90%';
183    const blurMix = isDarkMode ? '28%' : '16%';
184
185    const containerBackground = faded
186      ? `color-mix(in srgb, var(--dynamic-surface) ${surfaceMix}, transparent)`
187      : 'var(--dynamic-surface)';
188    const inputBackground = faded
189      ? `color-mix(in srgb, var(--dynamic-bg) ${inputMix}, transparent)`
190      : 'var(--dynamic-bg)';
191    const borderColor = faded
192      ? `color-mix(in srgb, var(--dynamic-border) ${borderMix}, transparent)`
193      : 'var(--dynamic-border)';
194    const blurTint = `color-mix(in srgb, var(--dynamic-bg) ${blurMix}, transparent)`;
195
196    boxElement.style.background = containerBackground;
197    boxElement.style.color = 'var(--dynamic-text)';
198    boxElement.style.setProperty('--dumber-border-color', borderColor);
199    boxElement.style.setProperty('--dumber-omnibox-surface', containerBackground);
200
201    inputElement.style.background = inputBackground;
202    inputElement.style.color = 'var(--dynamic-text)';
203    inputElement.style.setProperty('--dumber-input-bg', inputBackground);
204    inputElement.style.setProperty('--dumber-input-border-base', borderColor);
205    inputElement.style.setProperty('--dumber-placeholder-color', 'var(--dynamic-muted)');
206
207    blurLayerElement.style.setProperty('--dumber-blur-color', blurTint);
208    blurLayerElement.style.background = 'var(--dumber-blur-color)';
209
210    let accentColor = 'var(--dynamic-accent)';
211    try {
212      const computedAccent = getComputedStyle(boxElement).getPropertyValue('--dynamic-accent');
213      if (computedAccent && computedAccent.trim()) {
214        accentColor = computedAccent.trim();
215      }
216    } catch {
217      // If computed styles fail, keep fallback accent color
218    }
219    inputElement.style.setProperty('--dumber-input-accent', accentColor);
220    boxElement.style.setProperty('--dumber-input-accent', accentColor);
221
222    const isFocused = document.activeElement === inputElement;
223
224    if (!isFocused) {
225      inputElement.style.setProperty('--dumber-input-border-color', borderColor);
226    }
227
228    if (faded) {
229      const textShadow = isDarkMode
230        ? '1px 1px 1px rgba(0,0,0,0.65)'
231        : '1px 1px 1px rgba(255,255,255,0.65)';
232      inputElement.style.textShadow = textShadow;
233      boxElement.style.textShadow = textShadow;
234      blurLayerElement.style.opacity = '1';
235    } else {
236      inputElement.style.textShadow = '';
237      boxElement.style.textShadow = '';
238      blurLayerElement.style.opacity = '0';
239    }
240  });
241
242  // Global click handler to close omnibox when clicking anywhere outside
243  function handleGlobalClick(event: MouseEvent) {
244    if (!visible) return;
245
246    const target = event.target as HTMLElement;
247    const omniboxContainer = document.getElementById('dumber-omnibox-root');
248
249    // Close if clicking outside the omnibox container
250    if (omniboxContainer && !omniboxContainer.contains(target)) {
251      omniboxStore.close();
252    }
253  }
254
255
256
257  onMount(() => {
258    syncTheme();
259    observeThemeChanges();
260
261    console.log('🔧 Omnibox component mounted');
262
263    // Initialize responsive styles
264    updateResponsiveStyles();
265
266    // Add resize listener for responsive updates (handles zoom changes)
267    window.addEventListener('resize', updateResponsiveStyles);
268
269    // Add global click listener
270    document.addEventListener('click', handleGlobalClick, true);
271
272    try {
273      // Set up global API for Go bridge
274      window.__dumber_omnibox = {
275        setSuggestions: (suggestions) => {
276          try {
277            console.log('📋 setSuggestions called with:', suggestions?.length || 0, 'items');
278            omniboxBridge.setSuggestions(suggestions);
279          } catch (error) {
280            console.error('Error in setSuggestions:', error);
281          }
282        },
283        toggle: () => {
284          try {
285            console.log('🔄 Omnibox toggle called, current visible:', omniboxStore.visible);
286            omniboxStore.toggle();
287            console.log('🔄 After toggle, visible:', omniboxStore.visible);
288          } catch (error) {
289            console.error('Error in toggle:', error);
290          }
291        },
292        open: (mode, query) => {
293          try {
294            console.log('🚪 Omnibox open called with mode:', mode, 'query:', query);
295            omniboxStore.open(mode as any, query);
296            console.log('🚪 After open, visible:', omniboxStore.visible, 'mode:', omniboxStore.mode);
297          } catch (error) {
298            console.error('Error in open:', error);
299          }
300        },
301        close: () => {
302          try {
303            console.log('🚪 Omnibox close called');
304            omniboxStore.close();
305          } catch (error) {
306            console.error('Error in close:', error);
307          }
308        },
309        findQuery: (query) => {
310          try {
311            console.log('🔍 Find query called with:', query);
312            if (omniboxStore.mode !== 'find') {
313              omniboxStore.setMode('find');
314            }
315            if (!omniboxStore.visible) {
316              omniboxStore.setVisible(true);
317            }
318            omniboxStore.setInputValue(query || '');
319            // Find will be triggered by input value change
320          } catch (error) {
321            console.error('Error in findQuery:', error);
322          }
323        },
324        setActive: (active) => {
325          try {
326            console.log('🎯 Omnibox setActive called with:', active);
327            // For now, this is a no-op but maintains API compatibility
328            // In the future, this could control focus management or visibility
329          } catch (error) {
330            console.error('Error in setActive:', error);
331          }
332        }
333      };
334
335      console.log('✅ Omnibox API exposed:', Object.keys(window.__dumber_omnibox));
336      
337      // Check for pending suggestions that arrived before API was ready
338      if ((window as any).__dumber_omnibox_pending_suggestions) {
339        console.log('🔄 [OMNIBOX] Processing pending suggestions');
340        const pending = (window as any).__dumber_omnibox_pending_suggestions;
341        omniboxBridge.setSuggestions(pending);
342        delete (window as any).__dumber_omnibox_pending_suggestions;
343      }
344    } catch (error) {
345      console.error('❌ Failed to set up omnibox global API:', error);
346    }
347  });
348
349  onDestroy(() => {
350    // Cleanup resize listener
351    window.removeEventListener('resize', updateResponsiveStyles);
352
353    // Cleanup global click listener
354    document.removeEventListener('click', handleGlobalClick, true);
355
356    if (themeObserver) {
357      themeObserver.disconnect();
358      themeObserver = null;
359    }
360
361    // Reset omnibox state completely
362    omniboxStore.reset();
363
364    // Cleanup global API
365    if (window.__dumber_omnibox) {
366      delete window.__dumber_omnibox;
367    }
368  });
369</script>
370
371<!-- Overlay container -->
372<div
373  class="fixed inset-0 z-[2147483647]"
374  style="display: {visible ? 'block' : 'none'};
375         position: fixed !important;
376         top: 0 !important;
377         left: 0 !important;
378         right: 0 !important;
379         bottom: 0 !important;
380         z-index: 2147483647 !important;
381         pointer-events: none !important;
382         margin: 0 !important;
383         padding: 0 !important;
384         isolation: isolate;"
385  onmousedown={handleOverlayClick}
386  role="presentation"
387>
388  <!-- Main omnibox container -->
389  <div
390    bind:this={boxElement}
391    class="dumber-omnibox-container omnibox-base rounded shadow-[0_10px_30px_rgba(0,0,0,0.6)]"
392    style="position: relative !important;
393           left: 50% !important;
394           transform: translateX(-50%) !important;
395           margin-top: 8vh !important;
396           margin-left: 0 !important;
397           margin-right: 0 !important;
398           width: {responsiveStyles.width};
399           padding: {responsiveStyles.padding};
400           font-family: 'Fira sans', system-ui, -apple-system, 'Segoe UI', Roboto, Ubuntu, 'Helvetica Neue', Arial, sans-serif;
401           pointer-events: auto !important;
402           box-sizing: border-box !important;
403           background: var(--dumber-omnibox-surface, var(--dynamic-surface));
404           color: var(--dynamic-text);
405           border: 2px solid var(--dumber-border-color, var(--dynamic-border));
406           --dumber-border-color: var(--dynamic-border);"
407    onmousedown={handleBoxClick}
408    onmouseenter={handleMouseEnter}
409    role="dialog"
410    tabindex="-1"
411    aria-modal="true"
412    aria-label={mode === 'find' ? 'Find in page' : 'Omnibox search'}
413  >
414    <!-- Blur layer: absolute full-cover element providing backdrop blur.
415         We animate its opacity rather than the container's backdrop-filter
416         to avoid causing text to blur during transitions. -->
417    <div
418      bind:this={blurLayerElement}
419      class="dumber-omnibox-blur-layer"
420      aria-hidden="true"
421      style="position: absolute; inset: 0; pointer-events: none; border-radius: inherit; opacity: 0;"
422    ></div>
423    <div class="dumber-omnibox-content">
424      <!-- Input component -->
425      <OmniboxInput bind:inputElement {responsiveStyles} />
426
427      <!-- Content based on mode -->
428      {#if mode === 'omnibox'}
429        <OmniboxSuggestions />
430      {:else if mode === 'find'}
431        <OmniboxFind />
432      {/if}
433    </div>
434  </div>
435</div>
436
437<style>
438  .dumber-omnibox-container {
439    background: var(--dumber-omnibox-surface, var(--dynamic-surface));
440    color: var(--dynamic-text);
441    border: 2px solid var(--dumber-border-color, var(--dynamic-border));
442    transition: background-color 160ms ease, border-color 160ms ease;
443  }
444
445  .dumber-omnibox-container:focus-within {
446    border-color: var(--dumber-input-accent, var(--dynamic-accent));
447  }
448
449  :global(.dumber-omnibox-container input) {
450    background: var(--dumber-input-bg, var(--dynamic-bg));
451    color: var(--dynamic-text);
452    border-color: var(--dumber-input-border-color, var(--dynamic-border));
453    transition: border-color 120ms ease, background-color 120ms ease;
454  }
455
456  :global(.dumber-omnibox-container input::placeholder) {
457    color: var(--dynamic-muted);
458  }
459
460  /* Blur layer styles: use `backdrop-filter` but animate opacity only. */
461  .dumber-omnibox-blur-layer {
462    backdrop-filter: blur(2px) saturate(110%);
463    -webkit-backdrop-filter: blur(2px) saturate(110%);
464    background: var(--dumber-blur-color, color-mix(in srgb, var(--dynamic-bg) 16%, transparent));
465    transition: opacity 160ms ease;
466    will-change: opacity;
467    z-index: 0;
468    mix-blend-mode: normal;
469  }
470
471  /* Ensure the actual content (input, suggestions) sits above the blur layer */
472  .dumber-omnibox-content {
473    position: relative;
474    z-index: 1;
475    /* promote to its own layer to avoid being rasterized with the blur */
476    transform: translateZ(0);
477    backface-visibility: hidden;
478  }
479  /* Additional custom styles if needed */
480</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