feat(omnibox): add fish-style inline URL suggestions with ghost text
- commit
- 31d7d6fa1756526f4f6775c426771e7e07cce7c0
- author
- bnema · · 2025-11-29 17:10 UTC
- parent
- 7d324aa
- signature
- GPG Unverified
2B82207D370127A0The key is unknown
14 files · +549 -7
M CHANGELOG.md +1 -0
| @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. | |||
| 5 | 5 | ## [Unreleased] | |
| 6 | 6 | ||
| 7 | 7 | ### Added | |
| 8 | + | - **Inline URL suggestions**: Fish/zsh-style ghost text completion in omnibox. As you type, shows grayed-out URL completion from history. Accept with Right Arrow (full) or Ctrl+Right (word). Ctrl+Y also accepts full suggestion. Uses trie-based prefix search with debounced queries for smooth typing. | |
| 8 | 9 | - **Filter list auto-update**: Async version checking using HTTP HEAD request with ETag. 2-minute startup delay, skips if checked within 24 hours. Stores version per filter list. | |
| 9 | 10 | - **Configurable filter lists**: New `content_filtering.filter_lists` config option. Defaults include EasyList, EasyPrivacy, and uBlock filters (annoyances, quick-fixes). | |
| 10 | 11 | - **Demo video**: Added feature showcase video to README (docs/media/dumber-demo.mp4). | |
| @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. | |||||
| 5 | ## [Unreleased] | 5 | ## [Unreleased] | ||
| 6 | 6 | ||||
| 7 | ### Added | 7 | ### Added | ||
| 8 | + | - **Inline URL suggestions**: Fish/zsh-style ghost text completion in omnibox. As you type, shows grayed-out URL completion from history. Accept with Right Arrow (full) or Ctrl+Right (word). Ctrl+Y also accepts full suggestion. Uses trie-based prefix search with debounced queries for smooth typing. | |||
| 8 | - **Filter list auto-update**: Async version checking using HTTP HEAD request with ETag. 2-minute startup delay, skips if checked within 24 hours. Stores version per filter list. | 9 | - **Filter list auto-update**: Async version checking using HTTP HEAD request with ETag. 2-minute startup delay, skips if checked within 24 hours. Stores version per filter list. | ||
| 9 | - **Configurable filter lists**: New `content_filtering.filter_lists` config option. Defaults include EasyList, EasyPrivacy, and uBlock filters (annoyances, quick-fixes). | 10 | - **Configurable filter lists**: New `content_filtering.filter_lists` config option. Defaults include EasyList, EasyPrivacy, and uBlock filters (annoyances, quick-fixes). | ||
| 10 | - **Demo video**: Added feature showcase video to README (docs/media/dumber-demo.mp4). | 11 | - **Demo video**: Added feature showcase video to README (docs/media/dumber-demo.mp4). | ||
M gui/src/components/omnibox/Omnibox.svelte +8 -0
| @@ -358,6 +358,14 @@ | |||
| 358 | 358 | } catch (error) { | |
| 359 | 359 | console.error('Error in setActive:', error); | |
| 360 | 360 | } | |
| 361 | + | }, | |
| 362 | + | setInlineSuggestion: (url) => { | |
| 363 | + | try { | |
| 364 | + | console.log('[INLINE] window.__dumber_omnibox.setInlineSuggestion called:', url); | |
| 365 | + | omniboxBridge.setInlineSuggestion(url); | |
| 366 | + | } catch (error) { | |
| 367 | + | console.error('Error in setInlineSuggestion:', error); | |
| 368 | + | } | |
| 361 | 369 | } | |
| 362 | 370 | }; | |
| 363 | 371 | ||
| @@ -358,6 +358,14 @@ | |||||
| 358 | } catch (error) { | 358 | } catch (error) { | ||
| 359 | console.error('Error in setActive:', error); | 359 | console.error('Error in setActive:', error); | ||
| 360 | } | 360 | } | ||
| 361 | + | }, | |||
| 362 | + | setInlineSuggestion: (url) => { | |||
| 363 | + | try { | |||
| 364 | + | console.log('[INLINE] window.__dumber_omnibox.setInlineSuggestion called:', url); | |||
| 365 | + | omniboxBridge.setInlineSuggestion(url); | |||
| 366 | + | } catch (error) { | |||
| 367 | + | console.error('Error in setInlineSuggestion:', error); | |||
| 368 | + | } | |||
| 361 | } | 369 | } | ||
| 362 | }; | 370 | }; | ||
| 363 | 371 | ||||
M gui/src/components/omnibox/OmniboxInput.svelte +180 -6
| @@ -5,7 +5,7 @@ | |||
| 5 | 5 | --> | |
| 6 | 6 | <script lang="ts"> | |
| 7 | 7 | import { omniboxStore } from './stores.svelte.ts'; | |
| 8 | - | import { debouncedQuery, debouncedFind, omniboxBridge } from './messaging'; | |
| 8 | + | import { debouncedQuery, debouncedFind, debouncedPrefixQuery, omniboxBridge } from './messaging'; | |
| 9 | 9 | import { findInPage } from './find'; | |
| 10 | 10 | ||
| 11 | 11 | // Reactive state | |
| @@ -17,6 +17,11 @@ | |||
| 17 | 17 | let favorites = $derived(omniboxStore.favorites); | |
| 18 | 18 | let matches = $derived(omniboxStore.matches); | |
| 19 | 19 | let searchShortcuts = $derived(omniboxStore.searchShortcuts); | |
| 20 | + | let inlineCompletion = $derived(omniboxStore.inlineCompletion); | |
| 21 | + | let inlineSuggestion = $derived(omniboxStore.inlineSuggestion); | |
| 22 | + | ||
| 23 | + | // Cursor tracking for ghost text visibility | |
| 24 | + | let cursorAtEnd = $state(true); | |
| 20 | 25 | ||
| 21 | 26 | // Input element ref and responsive styles (props for parent to bind) | |
| 22 | 27 | interface Props { | |
| @@ -84,6 +89,14 @@ | |||
| 84 | 89 | } | |
| 85 | 90 | ||
| 86 | 91 | let activeBadge = $state<CommandBadge | null>(null); | |
| 92 | + | // Ghost text visibility - only show when cursor is at end and no command badge active | |
| 93 | + | let showGhostText = $derived( | |
| 94 | + | mode === 'omnibox' && | |
| 95 | + | inlineCompletion && | |
| 96 | + | cursorAtEnd && | |
| 97 | + | inputValue.length > 0 && | |
| 98 | + | !activeBadge | |
| 99 | + | ); | |
| 87 | 100 | let basePaddingSegments = $state<[string, string, string, string]>(DEFAULT_PADDING); | |
| 88 | 101 | let inputPadding = $state(DEFAULT_PADDING.join(' ')); | |
| 89 | 102 | let baseLeftPadding = $state(DEFAULT_PADDING[3]); | |
| @@ -97,6 +110,19 @@ | |||
| 97 | 110 | basePaddingSegments = normalizePadding(responsiveStyles?.inputPadding); | |
| 98 | 111 | }); | |
| 99 | 112 | ||
| 113 | + | // Debug: log when inline suggestion state changes | |
| 114 | + | $effect(() => { | |
| 115 | + | console.log('[INLINE] State changed:', { | |
| 116 | + | inlineCompletion, | |
| 117 | + | inlineSuggestion, | |
| 118 | + | showGhostText, | |
| 119 | + | cursorAtEnd, | |
| 120 | + | mode, | |
| 121 | + | inputValueLength: inputValue?.length, | |
| 122 | + | activeBadge: !!activeBadge | |
| 123 | + | }); | |
| 124 | + | }); | |
| 125 | + | ||
| 100 | 126 | $effect(() => { | |
| 101 | 127 | const value = inputValue || ''; | |
| 102 | 128 | const trimmedValue = value.trimStart(); | |
| @@ -110,6 +136,57 @@ | |||
| 110 | 136 | inputPadding = `${top} ${right} ${bottom} ${leftPadding}`; | |
| 111 | 137 | }); | |
| 112 | 138 | ||
| 139 | + | // Track cursor position for ghost text visibility | |
| 140 | + | function updateCursorPosition() { | |
| 141 | + | if (inputElement) { | |
| 142 | + | cursorAtEnd = inputElement.selectionStart === inputValue.length && | |
| 143 | + | inputElement.selectionEnd === inputValue.length; | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // Accept inline suggestion (full or word-by-word) | |
| 148 | + | function acceptInlineSuggestion(acceptMode: 'full' | 'word') { | |
| 149 | + | if (!inlineSuggestion || !inlineCompletion) return; | |
| 150 | + | ||
| 151 | + | switch (acceptMode) { | |
| 152 | + | case 'full': | |
| 153 | + | // Accept entire suggestion | |
| 154 | + | omniboxStore.setInputValue(inlineSuggestion); | |
| 155 | + | omniboxStore.clearInlineSuggestion(); | |
| 156 | + | // Query for next prefix match | |
| 157 | + | debouncedPrefixQuery(inlineSuggestion); | |
| 158 | + | // Also update the search results | |
| 159 | + | debouncedQuery(inlineSuggestion); | |
| 160 | + | break; | |
| 161 | + | ||
| 162 | + | case 'word': | |
| 163 | + | // Accept next word at cursor position | |
| 164 | + | const cursorPos = inputElement?.selectionStart ?? inputValue.length; | |
| 165 | + | const remainingCompletion = inlineSuggestion.slice(cursorPos); | |
| 166 | + | ||
| 167 | + | // Find next word boundary (handles /, ., -, _ as boundaries) | |
| 168 | + | const wordMatch = remainingCompletion.match(/^([^\s\/\.\-\_]+[\s\/\.\-\_]?)/); | |
| 169 | + | if (wordMatch && wordMatch[1]) { | |
| 170 | + | const wordToInsert = wordMatch[1]; | |
| 171 | + | const newValue = inputValue.slice(0, cursorPos) + wordToInsert + inputValue.slice(cursorPos); | |
| 172 | + | const newCursorPos = cursorPos + wordToInsert.length; | |
| 173 | + | ||
| 174 | + | omniboxStore.setInputValue(newValue); | |
| 175 | + | ||
| 176 | + | requestAnimationFrame(() => { | |
| 177 | + | if (inputElement) { | |
| 178 | + | inputElement.setSelectionRange(newCursorPos, newCursorPos); | |
| 179 | + | updateCursorPosition(); | |
| 180 | + | } | |
| 181 | + | }); | |
| 182 | + | ||
| 183 | + | debouncedPrefixQuery(newValue); | |
| 184 | + | debouncedQuery(newValue); | |
| 185 | + | } | |
| 186 | + | break; | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | ||
| 113 | 190 | // Handle input changes | |
| 114 | 191 | function handleInput(event: Event) { | |
| 115 | 192 | const target = event.target as HTMLInputElement; | |
| @@ -117,6 +194,7 @@ | |||
| 117 | 194 | ||
| 118 | 195 | omniboxStore.setInputValue(value); | |
| 119 | 196 | omniboxStore.setFaded(false); | |
| 197 | + | updateCursorPosition(); | |
| 120 | 198 | ||
| 121 | 199 | if (mode === 'omnibox') { | |
| 122 | 200 | // Only query backend if we're in history view | |
| @@ -125,9 +203,12 @@ | |||
| 125 | 203 | if (value === '') { | |
| 126 | 204 | // Input is empty - fetch initial history based on config | |
| 127 | 205 | omniboxBridge.fetchInitialHistory(); | |
| 206 | + | omniboxStore.clearInlineSuggestion(); | |
| 128 | 207 | } else { | |
| 129 | 208 | // Input has content - perform search | |
| 130 | 209 | debouncedQuery(value); | |
| 210 | + | // Also query for inline suggestion (fish-style ghost text) | |
| 211 | + | debouncedPrefixQuery(value); | |
| 131 | 212 | } | |
| 132 | 213 | } | |
| 133 | 214 | } else if (mode === 'find') { | |
| @@ -190,6 +271,33 @@ | |||
| 190 | 271 | handleArrowKeys(event.key); | |
| 191 | 272 | break; | |
| 192 | 273 | ||
| 274 | + | case 'ArrowRight': | |
| 275 | + | // Handle inline suggestion acceptance (fish-style) | |
| 276 | + | if (mode === 'omnibox' && inlineCompletion) { | |
| 277 | + | if (event.ctrlKey || event.metaKey) { | |
| 278 | + | // Ctrl+Right: accept next word | |
| 279 | + | event.preventDefault(); | |
| 280 | + | event.stopPropagation(); | |
| 281 | + | acceptInlineSuggestion('word'); | |
| 282 | + | } else if (cursorAtEnd) { | |
| 283 | + | // Right at end: accept full suggestion | |
| 284 | + | event.preventDefault(); | |
| 285 | + | event.stopPropagation(); | |
| 286 | + | acceptInlineSuggestion('full'); | |
| 287 | + | } | |
| 288 | + | // If cursor not at end and no modifier, let default behavior happen | |
| 289 | + | } | |
| 290 | + | break; | |
| 291 | + | ||
| 292 | + | case 'y': | |
| 293 | + | // Ctrl+Y: accept full inline suggestion (alternative to Right Arrow) | |
| 294 | + | if (event.ctrlKey && mode === 'omnibox' && inlineCompletion) { | |
| 295 | + | event.preventDefault(); | |
| 296 | + | event.stopPropagation(); | |
| 297 | + | acceptInlineSuggestion('full'); | |
| 298 | + | } | |
| 299 | + | break; | |
| 300 | + | ||
| 193 | 301 | default: | |
| 194 | 302 | // Handle Ctrl+1 through Ctrl+0 for quick navigation to first 10 results | |
| 195 | 303 | // Use event.code for keyboard layout independence (QWERTY, AZERTY, QWERTZ, etc.) | |
| @@ -365,10 +473,6 @@ | |||
| 365 | 473 | omniboxStore.setFaded(false); | |
| 366 | 474 | } | |
| 367 | 475 | ||
| 368 | - | function handleClick() { | |
| 369 | - | omniboxStore.setFaded(false); | |
| 370 | - | } | |
| 371 | - | ||
| 372 | 476 | function handleFocus() { | |
| 373 | 477 | omniboxStore.setFaded(false); | |
| 374 | 478 | if (inputElement) { | |
| @@ -394,6 +498,17 @@ | |||
| 394 | 498 | } | |
| 395 | 499 | } | |
| 396 | 500 | } | |
| 501 | + | ||
| 502 | + | // Handle selection changes (for cursor tracking) | |
| 503 | + | function handleSelect() { | |
| 504 | + | updateCursorPosition(); | |
| 505 | + | } | |
| 506 | + | ||
| 507 | + | // Handle click with cursor tracking | |
| 508 | + | function handleClickWithCursor() { | |
| 509 | + | omniboxStore.setFaded(false); | |
| 510 | + | updateCursorPosition(); | |
| 511 | + | } | |
| 397 | 512 | </script> | |
| 398 | 513 | ||
| 399 | 514 | <div class="omnibox-input-wrapper"> | |
| @@ -405,17 +520,30 @@ | |||
| 405 | 520 | >{activeBadge.label}</span> | |
| 406 | 521 | {/if} | |
| 407 | 522 | ||
| 523 | + | <!-- Ghost text overlay (fish-style inline suggestion) --> | |
| 524 | + | {#if showGhostText} | |
| 525 | + | <div | |
| 526 | + | class="omnibox-ghost-text" | |
| 527 | + | aria-hidden="true" | |
| 528 | + | style={`padding: ${inputPadding}; font-size: ${inputFontSize};`} | |
| 529 | + | > | |
| 530 | + | <span class="ghost-spacer">{inputValue}</span><span class="ghost-completion">{inlineCompletion}</span> | |
| 531 | + | </div> | |
| 532 | + | {/if} | |
| 533 | + | ||
| 408 | 534 | <input | |
| 409 | 535 | bind:this={inputElement} | |
| 410 | 536 | type="text" | |
| 411 | 537 | {placeholder} | |
| 412 | 538 | value={inputValue} | |
| 413 | 539 | class="w-full box-border omnibox-input omnibox-input-field focus:outline-none" | |
| 540 | + | class:has-ghost-text={showGhostText} | |
| 414 | 541 | style={`padding: ${inputPadding}; font-size: ${inputFontSize}; box-sizing: border-box;`} | |
| 415 | 542 | oninput={handleInput} | |
| 416 | 543 | onkeydown={handleKeyDown} | |
| 417 | 544 | onmousedown={handleMouseDown} | |
| 418 | - | onclick={handleClick} | |
| 545 | + | onclick={handleClickWithCursor} | |
| 546 | + | onselect={handleSelect} | |
| 419 | 547 | onfocus={handleFocus} | |
| 420 | 548 | onblur={handleBlur} | |
| 421 | 549 | onmouseenter={handleMouseEnter} | |
| @@ -481,4 +609,50 @@ | |||
| 481 | 609 | box-shadow: 0 0 0 1px var(--dynamic-border, transparent); | |
| 482 | 610 | z-index: 1; | |
| 483 | 611 | } | |
| 612 | + | ||
| 613 | + | /* Ghost text overlay for fish-style inline suggestions */ | |
| 614 | + | .omnibox-ghost-text { | |
| 615 | + | position: absolute; | |
| 616 | + | top: 0; | |
| 617 | + | left: 0; | |
| 618 | + | right: 0; | |
| 619 | + | bottom: 0; | |
| 620 | + | pointer-events: none; | |
| 621 | + | display: flex; | |
| 622 | + | align-items: center; | |
| 623 | + | white-space: nowrap; | |
| 624 | + | overflow: hidden; | |
| 625 | + | font-family: 'Fira Sans', system-ui, -apple-system, 'Segoe UI', 'Ubuntu', 'Cantarell', sans-serif; | |
| 626 | + | letter-spacing: normal; | |
| 627 | + | z-index: 0; | |
| 628 | + | box-sizing: border-box; | |
| 629 | + | border: 1px solid transparent; | |
| 630 | + | } | |
| 631 | + | ||
| 632 | + | .ghost-spacer { | |
| 633 | + | visibility: hidden; | |
| 634 | + | white-space: pre; | |
| 635 | + | } | |
| 636 | + | ||
| 637 | + | .ghost-completion { | |
| 638 | + | color: var(--dynamic-muted); | |
| 639 | + | opacity: 0.5; | |
| 640 | + | white-space: pre; | |
| 641 | + | transition: opacity 100ms ease-out; | |
| 642 | + | } | |
| 643 | + | ||
| 644 | + | .omnibox-ghost-text { | |
| 645 | + | transition: opacity 80ms ease-out; | |
| 646 | + | } | |
| 647 | + | ||
| 648 | + | /* Make input transparent when showing ghost text */ | |
| 649 | + | .omnibox-input-field.has-ghost-text { | |
| 650 | + | background: transparent !important; | |
| 651 | + | } | |
| 652 | + | ||
| 653 | + | /* Ensure wrapper provides background when ghost text is active */ | |
| 654 | + | .omnibox-input-wrapper:has(.has-ghost-text) { | |
| 655 | + | background: var(--dumber-input-bg, var(--dynamic-bg)); | |
| 656 | + | border-radius: 2px; | |
| 657 | + | } | |
| 484 | 658 | </style> | |
| @@ -5,7 +5,7 @@ | |||||
| 5 | --> | 5 | --> | ||
| 6 | <script lang="ts"> | 6 | <script lang="ts"> | ||
| 7 | import { omniboxStore } from './stores.svelte.ts'; | 7 | import { omniboxStore } from './stores.svelte.ts'; | ||
| 8 | - | import { debouncedQuery, debouncedFind, omniboxBridge } from './messaging'; | 8 | + | import { debouncedQuery, debouncedFind, debouncedPrefixQuery, omniboxBridge } from './messaging'; |
| 9 | import { findInPage } from './find'; | 9 | import { findInPage } from './find'; | ||
| 10 | 10 | ||||
| 11 | // Reactive state | 11 | // Reactive state | ||
| @@ -17,6 +17,11 @@ | |||||
| 17 | let favorites = $derived(omniboxStore.favorites); | 17 | let favorites = $derived(omniboxStore.favorites); | ||
| 18 | let matches = $derived(omniboxStore.matches); | 18 | let matches = $derived(omniboxStore.matches); | ||
| 19 | let searchShortcuts = $derived(omniboxStore.searchShortcuts); | 19 | let searchShortcuts = $derived(omniboxStore.searchShortcuts); | ||
| 20 | + | let inlineCompletion = $derived(omniboxStore.inlineCompletion); | |||
| 21 | + | let inlineSuggestion = $derived(omniboxStore.inlineSuggestion); | |||
| 22 | + | ||||
| 23 | + | // Cursor tracking for ghost text visibility | |||
| 24 | + | let cursorAtEnd = $state(true); | |||
| 20 | 25 | ||||
| 21 | // Input element ref and responsive styles (props for parent to bind) | 26 | // Input element ref and responsive styles (props for parent to bind) | ||
| 22 | interface Props { | 27 | interface Props { | ||
| @@ -84,6 +89,14 @@ | |||||
| 84 | } | 89 | } | ||
| 85 | 90 | ||||
| 86 | let activeBadge = $state<CommandBadge | null>(null); | 91 | let activeBadge = $state<CommandBadge | null>(null); | ||
| 92 | + | // Ghost text visibility - only show when cursor is at end and no command badge active | |||
| 93 | + | let showGhostText = $derived( | |||
| 94 | + | mode === 'omnibox' && | |||
| 95 | + | inlineCompletion && | |||
| 96 | + | cursorAtEnd && | |||
| 97 | + | inputValue.length > 0 && | |||
| 98 | + | !activeBadge | |||
| 99 | + | ); | |||
| 87 | let basePaddingSegments = $state<[string, string, string, string]>(DEFAULT_PADDING); | 100 | let basePaddingSegments = $state<[string, string, string, string]>(DEFAULT_PADDING); | ||
| 88 | let inputPadding = $state(DEFAULT_PADDING.join(' ')); | 101 | let inputPadding = $state(DEFAULT_PADDING.join(' ')); | ||
| 89 | let baseLeftPadding = $state(DEFAULT_PADDING[3]); | 102 | let baseLeftPadding = $state(DEFAULT_PADDING[3]); | ||
| @@ -97,6 +110,19 @@ | |||||
| 97 | basePaddingSegments = normalizePadding(responsiveStyles?.inputPadding); | 110 | basePaddingSegments = normalizePadding(responsiveStyles?.inputPadding); | ||
| 98 | }); | 111 | }); | ||
| 99 | 112 | ||||
| 113 | + | // Debug: log when inline suggestion state changes | |||
| 114 | + | $effect(() => { | |||
| 115 | + | console.log('[INLINE] State changed:', { | |||
| 116 | + | inlineCompletion, | |||
| 117 | + | inlineSuggestion, | |||
| 118 | + | showGhostText, | |||
| 119 | + | cursorAtEnd, | |||
| 120 | + | mode, | |||
| 121 | + | inputValueLength: inputValue?.length, | |||
| 122 | + | activeBadge: !!activeBadge | |||
| 123 | + | }); | |||
| 124 | + | }); | |||
| 125 | + | ||||
| 100 | $effect(() => { | 126 | $effect(() => { | ||
| 101 | const value = inputValue || ''; | 127 | const value = inputValue || ''; | ||
| 102 | const trimmedValue = value.trimStart(); | 128 | const trimmedValue = value.trimStart(); | ||
| @@ -110,6 +136,57 @@ | |||||
| 110 | inputPadding = `${top} ${right} ${bottom} ${leftPadding}`; | 136 | inputPadding = `${top} ${right} ${bottom} ${leftPadding}`; | ||
| 111 | }); | 137 | }); | ||
| 112 | 138 | ||||
| 139 | + | // Track cursor position for ghost text visibility | |||
| 140 | + | function updateCursorPosition() { | |||
| 141 | + | if (inputElement) { | |||
| 142 | + | cursorAtEnd = inputElement.selectionStart === inputValue.length && | |||
| 143 | + | inputElement.selectionEnd === inputValue.length; | |||
| 144 | + | } | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | // Accept inline suggestion (full or word-by-word) | |||
| 148 | + | function acceptInlineSuggestion(acceptMode: 'full' | 'word') { | |||
| 149 | + | if (!inlineSuggestion || !inlineCompletion) return; | |||
| 150 | + | ||||
| 151 | + | switch (acceptMode) { | |||
| 152 | + | case 'full': | |||
| 153 | + | // Accept entire suggestion | |||
| 154 | + | omniboxStore.setInputValue(inlineSuggestion); | |||
| 155 | + | omniboxStore.clearInlineSuggestion(); | |||
| 156 | + | // Query for next prefix match | |||
| 157 | + | debouncedPrefixQuery(inlineSuggestion); | |||
| 158 | + | // Also update the search results | |||
| 159 | + | debouncedQuery(inlineSuggestion); | |||
| 160 | + | break; | |||
| 161 | + | ||||
| 162 | + | case 'word': | |||
| 163 | + | // Accept next word at cursor position | |||
| 164 | + | const cursorPos = inputElement?.selectionStart ?? inputValue.length; | |||
| 165 | + | const remainingCompletion = inlineSuggestion.slice(cursorPos); | |||
| 166 | + | ||||
| 167 | + | // Find next word boundary (handles /, ., -, _ as boundaries) | |||
| 168 | + | const wordMatch = remainingCompletion.match(/^([^\s\/\.\-\_]+[\s\/\.\-\_]?)/); | |||
| 169 | + | if (wordMatch && wordMatch[1]) { | |||
| 170 | + | const wordToInsert = wordMatch[1]; | |||
| 171 | + | const newValue = inputValue.slice(0, cursorPos) + wordToInsert + inputValue.slice(cursorPos); | |||
| 172 | + | const newCursorPos = cursorPos + wordToInsert.length; | |||
| 173 | + | ||||
| 174 | + | omniboxStore.setInputValue(newValue); | |||
| 175 | + | ||||
| 176 | + | requestAnimationFrame(() => { | |||
| 177 | + | if (inputElement) { | |||
| 178 | + | inputElement.setSelectionRange(newCursorPos, newCursorPos); | |||
| 179 | + | updateCursorPosition(); | |||
| 180 | + | } | |||
| 181 | + | }); | |||
| 182 | + | ||||
| 183 | + | debouncedPrefixQuery(newValue); | |||
| 184 | + | debouncedQuery(newValue); | |||
| 185 | + | } | |||
| 186 | + | break; | |||
| 187 | + | } | |||
| 188 | + | } | |||
| 189 | + | ||||
| 113 | // Handle input changes | 190 | // Handle input changes | ||
| 114 | function handleInput(event: Event) { | 191 | function handleInput(event: Event) { | ||
| 115 | const target = event.target as HTMLInputElement; | 192 | const target = event.target as HTMLInputElement; | ||
| @@ -117,6 +194,7 @@ | |||||
| 117 | 194 | ||||
| 118 | omniboxStore.setInputValue(value); | 195 | omniboxStore.setInputValue(value); | ||
| 119 | omniboxStore.setFaded(false); | 196 | omniboxStore.setFaded(false); | ||
| 197 | + | updateCursorPosition(); | |||
| 120 | 198 | ||||
| 121 | if (mode === 'omnibox') { | 199 | if (mode === 'omnibox') { | ||
| 122 | // Only query backend if we're in history view | 200 | // Only query backend if we're in history view | ||
| @@ -125,9 +203,12 @@ | |||||
| 125 | if (value === '') { | 203 | if (value === '') { | ||
| 126 | // Input is empty - fetch initial history based on config | 204 | // Input is empty - fetch initial history based on config | ||
| 127 | omniboxBridge.fetchInitialHistory(); | 205 | omniboxBridge.fetchInitialHistory(); | ||
| 206 | + | omniboxStore.clearInlineSuggestion(); | |||
| 128 | } else { | 207 | } else { | ||
| 129 | // Input has content - perform search | 208 | // Input has content - perform search | ||
| 130 | debouncedQuery(value); | 209 | debouncedQuery(value); | ||
| 210 | + | // Also query for inline suggestion (fish-style ghost text) | |||
| 211 | + | debouncedPrefixQuery(value); | |||
| 131 | } | 212 | } | ||
| 132 | } | 213 | } | ||
| 133 | } else if (mode === 'find') { | 214 | } else if (mode === 'find') { | ||
| @@ -190,6 +271,33 @@ | |||||
| 190 | handleArrowKeys(event.key); | 271 | handleArrowKeys(event.key); | ||
| 191 | break; | 272 | break; | ||
| 192 | 273 | ||||
| 274 | + | case 'ArrowRight': | |||
| 275 | + | // Handle inline suggestion acceptance (fish-style) | |||
| 276 | + | if (mode === 'omnibox' && inlineCompletion) { | |||
| 277 | + | if (event.ctrlKey || event.metaKey) { | |||
| 278 | + | // Ctrl+Right: accept next word | |||
| 279 | + | event.preventDefault(); | |||
| 280 | + | event.stopPropagation(); | |||
| 281 | + | acceptInlineSuggestion('word'); | |||
| 282 | + | } else if (cursorAtEnd) { | |||
| 283 | + | // Right at end: accept full suggestion | |||
| 284 | + | event.preventDefault(); | |||
| 285 | + | event.stopPropagation(); | |||
| 286 | + | acceptInlineSuggestion('full'); | |||
| 287 | + | } | |||
| 288 | + | // If cursor not at end and no modifier, let default behavior happen | |||
| 289 | + | } | |||
| 290 | + | break; | |||
| 291 | + | ||||
| 292 | + | case 'y': | |||
| 293 | + | // Ctrl+Y: accept full inline suggestion (alternative to Right Arrow) | |||
| 294 | + | if (event.ctrlKey && mode === 'omnibox' && inlineCompletion) { | |||
| 295 | + | event.preventDefault(); | |||
| 296 | + | event.stopPropagation(); | |||
| 297 | + | acceptInlineSuggestion('full'); | |||
| 298 | + | } | |||
| 299 | + | break; | |||
| 300 | + | ||||
| 193 | default: | 301 | default: | ||
| 194 | // Handle Ctrl+1 through Ctrl+0 for quick navigation to first 10 results | 302 | // Handle Ctrl+1 through Ctrl+0 for quick navigation to first 10 results | ||
| 195 | // Use event.code for keyboard layout independence (QWERTY, AZERTY, QWERTZ, etc.) | 303 | // Use event.code for keyboard layout independence (QWERTY, AZERTY, QWERTZ, etc.) | ||
| @@ -365,10 +473,6 @@ | |||||
| 365 | omniboxStore.setFaded(false); | 473 | omniboxStore.setFaded(false); | ||
| 366 | } | 474 | } | ||
| 367 | 475 | ||||
| 368 | - | function handleClick() { | |||
| 369 | - | omniboxStore.setFaded(false); | |||
| 370 | - | } | |||
| 371 | - | ||||
| 372 | function handleFocus() { | 476 | function handleFocus() { | ||
| 373 | omniboxStore.setFaded(false); | 477 | omniboxStore.setFaded(false); | ||
| 374 | if (inputElement) { | 478 | if (inputElement) { | ||
| @@ -394,6 +498,17 @@ | |||||
| 394 | } | 498 | } | ||
| 395 | } | 499 | } | ||
| 396 | } | 500 | } | ||
| 501 | + | ||||
| 502 | + | // Handle selection changes (for cursor tracking) | |||
| 503 | + | function handleSelect() { | |||
| 504 | + | updateCursorPosition(); | |||
| 505 | + | } | |||
| 506 | + | ||||
| 507 | + | // Handle click with cursor tracking | |||
| 508 | + | function handleClickWithCursor() { | |||
| 509 | + | omniboxStore.setFaded(false); | |||
| 510 | + | updateCursorPosition(); | |||
| 511 | + | } | |||
| 397 | </script> | 512 | </script> | ||
| 398 | 513 | ||||
| 399 | <div class="omnibox-input-wrapper"> | 514 | <div class="omnibox-input-wrapper"> | ||
| @@ -405,17 +520,30 @@ | |||||
| 405 | >{activeBadge.label}</span> | 520 | >{activeBadge.label}</span> | ||
| 406 | {/if} | 521 | {/if} | ||
| 407 | 522 | ||||
| 523 | + | <!-- Ghost text overlay (fish-style inline suggestion) --> | |||
| 524 | + | {#if showGhostText} | |||
| 525 | + | <div | |||
| 526 | + | class="omnibox-ghost-text" | |||
| 527 | + | aria-hidden="true" | |||
| 528 | + | style={`padding: ${inputPadding}; font-size: ${inputFontSize};`} | |||
| 529 | + | > | |||
| 530 | + | <span class="ghost-spacer">{inputValue}</span><span class="ghost-completion">{inlineCompletion}</span> | |||
| 531 | + | </div> | |||
| 532 | + | {/if} | |||
| 533 | + | ||||
| 408 | <input | 534 | <input | ||
| 409 | bind:this={inputElement} | 535 | bind:this={inputElement} | ||
| 410 | type="text" | 536 | type="text" | ||
| 411 | {placeholder} | 537 | {placeholder} | ||
| 412 | value={inputValue} | 538 | value={inputValue} | ||
| 413 | class="w-full box-border omnibox-input omnibox-input-field focus:outline-none" | 539 | class="w-full box-border omnibox-input omnibox-input-field focus:outline-none" | ||
| 540 | + | class:has-ghost-text={showGhostText} | |||
| 414 | style={`padding: ${inputPadding}; font-size: ${inputFontSize}; box-sizing: border-box;`} | 541 | style={`padding: ${inputPadding}; font-size: ${inputFontSize}; box-sizing: border-box;`} | ||
| 415 | oninput={handleInput} | 542 | oninput={handleInput} | ||
| 416 | onkeydown={handleKeyDown} | 543 | onkeydown={handleKeyDown} | ||
| 417 | onmousedown={handleMouseDown} | 544 | onmousedown={handleMouseDown} | ||
| 418 | - | onclick={handleClick} | 545 | + | onclick={handleClickWithCursor} |
| 546 | + | onselect={handleSelect} | |||
| 419 | onfocus={handleFocus} | 547 | onfocus={handleFocus} | ||
| 420 | onblur={handleBlur} | 548 | onblur={handleBlur} | ||
| 421 | onmouseenter={handleMouseEnter} | 549 | onmouseenter={handleMouseEnter} | ||
| @@ -481,4 +609,50 @@ | |||||
| 481 | box-shadow: 0 0 0 1px var(--dynamic-border, transparent); | 609 | box-shadow: 0 0 0 1px var(--dynamic-border, transparent); | ||
| 482 | z-index: 1; | 610 | z-index: 1; | ||
| 483 | } | 611 | } | ||
| 612 | + | ||||
| 613 | + | /* Ghost text overlay for fish-style inline suggestions */ | |||
| 614 | + | .omnibox-ghost-text { | |||
| 615 | + | position: absolute; | |||
| 616 | + | top: 0; | |||
| 617 | + | left: 0; | |||
| 618 | + | right: 0; | |||
| 619 | + | bottom: 0; | |||
| 620 | + | pointer-events: none; | |||
| 621 | + | display: flex; | |||
| 622 | + | align-items: center; | |||
| 623 | + | white-space: nowrap; | |||
| 624 | + | overflow: hidden; | |||
| 625 | + | font-family: 'Fira Sans', system-ui, -apple-system, 'Segoe UI', 'Ubuntu', 'Cantarell', sans-serif; | |||
| 626 | + | letter-spacing: normal; | |||
| 627 | + | z-index: 0; | |||
| 628 | + | box-sizing: border-box; | |||
| 629 | + | border: 1px solid transparent; | |||
| 630 | + | } | |||
| 631 | + | ||||
| 632 | + | .ghost-spacer { | |||
| 633 | + | visibility: hidden; | |||
| 634 | + | white-space: pre; | |||
| 635 | + | } | |||
| 636 | + | ||||
| 637 | + | .ghost-completion { | |||
| 638 | + | color: var(--dynamic-muted); | |||
| 639 | + | opacity: 0.5; | |||
| 640 | + | white-space: pre; | |||
| 641 | + | transition: opacity 100ms ease-out; | |||
| 642 | + | } | |||
| 643 | + | ||||
| 644 | + | .omnibox-ghost-text { | |||
| 645 | + | transition: opacity 80ms ease-out; | |||
| 646 | + | } | |||
| 647 | + | ||||
| 648 | + | /* Make input transparent when showing ghost text */ | |||
| 649 | + | .omnibox-input-field.has-ghost-text { | |||
| 650 | + | background: transparent !important; | |||
| 651 | + | } | |||
| 652 | + | ||||
| 653 | + | /* Ensure wrapper provides background when ghost text is active */ | |||
| 654 | + | .omnibox-input-wrapper:has(.has-ghost-text) { | |||
| 655 | + | background: var(--dumber-input-bg, var(--dynamic-bg)); | |||
| 656 | + | border-radius: 2px; | |||
| 657 | + | } | |||
| 484 | </style> | 658 | </style> | ||
M gui/src/components/omnibox/messaging.ts +50 -0
| @@ -185,6 +185,28 @@ export class OmniboxBridge implements OmniboxMessageBridge { | |||
| 185 | 185 | this.postMessage({ type: "toggle_favorite", url, title, faviconURL }); | |
| 186 | 186 | } | |
| 187 | 187 | ||
| 188 | + | /** | |
| 189 | + | * Handle inline suggestion from backend (fish-style ghost text) | |
| 190 | + | */ | |
| 191 | + | setInlineSuggestion(url: string | null): void { | |
| 192 | + | console.log('[INLINE] bridge.setInlineSuggestion called:', url); | |
| 193 | + | const inputValue = omniboxStore.inputValue; | |
| 194 | + | omniboxStore.setInlineSuggestion(url, inputValue); | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | /** | |
| 198 | + | * Query for prefix-matching URL (for inline suggestions) | |
| 199 | + | */ | |
| 200 | + | prefixQuery(prefix: string): void { | |
| 201 | + | console.log('[INLINE] prefixQuery called:', prefix); | |
| 202 | + | if (!prefix || prefix.trim().length < MIN_SEARCH_LENGTH) { | |
| 203 | + | omniboxStore.clearInlineSuggestion(); | |
| 204 | + | return; | |
| 205 | + | } | |
| 206 | + | this.postMessage({ type: "prefix_query", q: prefix }); | |
| 207 | + | console.log('[INLINE] prefix_query message sent'); | |
| 208 | + | } | |
| 209 | + | ||
| 188 | 210 | // Suggestions are returned via setSuggestions() when native handler responds | |
| 189 | 211 | } | |
| 190 | 212 | ||
| @@ -246,6 +268,33 @@ export function debouncedFind(query: string, findFn: (q: string) => void): void | |||
| 246 | 268 | createDebouncedSearch("find", findFn, () => findFn(""))(query); | |
| 247 | 269 | } | |
| 248 | 270 | ||
| 271 | + | // Debounce for inline suggestions - balanced between responsiveness and smoothness | |
| 272 | + | const PREFIX_DEBOUNCE_MS = 80; | |
| 273 | + | let prefixDebounceTimer = 0; | |
| 274 | + | ||
| 275 | + | /** | |
| 276 | + | * Debounced prefix query for inline suggestions (fish-style ghost text) | |
| 277 | + | */ | |
| 278 | + | export function debouncedPrefixQuery(query: string): void { | |
| 279 | + | if (prefixDebounceTimer) { | |
| 280 | + | clearTimeout(prefixDebounceTimer); | |
| 281 | + | prefixDebounceTimer = 0; | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | const trimmed = (query || "").trim(); | |
| 285 | + | if (trimmed.length < MIN_SEARCH_LENGTH) { | |
| 286 | + | // Clear after a short delay to avoid flicker | |
| 287 | + | prefixDebounceTimer = window.setTimeout(() => { | |
| 288 | + | omniboxStore.clearInlineSuggestion(); | |
| 289 | + | }, 50); | |
| 290 | + | return; | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | prefixDebounceTimer = window.setTimeout(() => { | |
| 294 | + | omniboxBridge.prefixQuery(trimmed); | |
| 295 | + | }, PREFIX_DEBOUNCE_MS); | |
| 296 | + | } | |
| 297 | + | ||
| 249 | 298 | // Extend global window interface for Go bridge compatibility | |
| 250 | 299 | declare global { | |
| 251 | 300 | interface Window { | |
| @@ -259,6 +308,7 @@ declare global { | |||
| 259 | 308 | // Omnibox API for Go bridge | |
| 260 | 309 | __dumber_omnibox?: { | |
| 261 | 310 | setSuggestions: (suggestions: Suggestion[]) => void; | |
| 311 | + | setInlineSuggestion: (url: string | null) => void; | |
| 262 | 312 | toggle: () => void; | |
| 263 | 313 | open: (mode?: string, query?: string) => void; | |
| 264 | 314 | close: () => void; | |
| @@ -185,6 +185,28 @@ export class OmniboxBridge implements OmniboxMessageBridge { | |||||
| 185 | this.postMessage({ type: "toggle_favorite", url, title, faviconURL }); | 185 | this.postMessage({ type: "toggle_favorite", url, title, faviconURL }); | ||
| 186 | } | 186 | } | ||
| 187 | 187 | ||||
| 188 | + | /** | |||
| 189 | + | * Handle inline suggestion from backend (fish-style ghost text) | |||
| 190 | + | */ | |||
| 191 | + | setInlineSuggestion(url: string | null): void { | |||
| 192 | + | console.log('[INLINE] bridge.setInlineSuggestion called:', url); | |||
| 193 | + | const inputValue = omniboxStore.inputValue; | |||
| 194 | + | omniboxStore.setInlineSuggestion(url, inputValue); | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | /** | |||
| 198 | + | * Query for prefix-matching URL (for inline suggestions) | |||
| 199 | + | */ | |||
| 200 | + | prefixQuery(prefix: string): void { | |||
| 201 | + | console.log('[INLINE] prefixQuery called:', prefix); | |||
| 202 | + | if (!prefix || prefix.trim().length < MIN_SEARCH_LENGTH) { | |||
| 203 | + | omniboxStore.clearInlineSuggestion(); | |||
| 204 | + | return; | |||
| 205 | + | } | |||
| 206 | + | this.postMessage({ type: "prefix_query", q: prefix }); | |||
| 207 | + | console.log('[INLINE] prefix_query message sent'); | |||
| 208 | + | } | |||
| 209 | + | ||||
| 188 | // Suggestions are returned via setSuggestions() when native handler responds | 210 | // Suggestions are returned via setSuggestions() when native handler responds | ||
| 189 | } | 211 | } | ||
| 190 | 212 | ||||
| @@ -246,6 +268,33 @@ export function debouncedFind(query: string, findFn: (q: string) => void): void | |||||
| 246 | createDebouncedSearch("find", findFn, () => findFn(""))(query); | 268 | createDebouncedSearch("find", findFn, () => findFn(""))(query); | ||
| 247 | } | 269 | } | ||
| 248 | 270 | ||||
| 271 | + | // Debounce for inline suggestions - balanced between responsiveness and smoothness | |||
| 272 | + | const PREFIX_DEBOUNCE_MS = 80; | |||
| 273 | + | let prefixDebounceTimer = 0; | |||
| 274 | + | ||||
| 275 | + | /** | |||
| 276 | + | * Debounced prefix query for inline suggestions (fish-style ghost text) | |||
| 277 | + | */ | |||
| 278 | + | export function debouncedPrefixQuery(query: string): void { | |||
| 279 | + | if (prefixDebounceTimer) { | |||
| 280 | + | clearTimeout(prefixDebounceTimer); | |||
| 281 | + | prefixDebounceTimer = 0; | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | const trimmed = (query || "").trim(); | |||
| 285 | + | if (trimmed.length < MIN_SEARCH_LENGTH) { | |||
| 286 | + | // Clear after a short delay to avoid flicker | |||
| 287 | + | prefixDebounceTimer = window.setTimeout(() => { | |||
| 288 | + | omniboxStore.clearInlineSuggestion(); | |||
| 289 | + | }, 50); | |||
| 290 | + | return; | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | prefixDebounceTimer = window.setTimeout(() => { | |||
| 294 | + | omniboxBridge.prefixQuery(trimmed); | |||
| 295 | + | }, PREFIX_DEBOUNCE_MS); | |||
| 296 | + | } | |||
| 297 | + | ||||
| 249 | // Extend global window interface for Go bridge compatibility | 298 | // Extend global window interface for Go bridge compatibility | ||
| 250 | declare global { | 299 | declare global { | ||
| 251 | interface Window { | 300 | interface Window { | ||
| @@ -259,6 +308,7 @@ declare global { | |||||
| 259 | // Omnibox API for Go bridge | 308 | // Omnibox API for Go bridge | ||
| 260 | __dumber_omnibox?: { | 309 | __dumber_omnibox?: { | ||
| 261 | setSuggestions: (suggestions: Suggestion[]) => void; | 310 | setSuggestions: (suggestions: Suggestion[]) => void; | ||
| 311 | + | setInlineSuggestion: (url: string | null) => void; | |||
| 262 | toggle: () => void; | 312 | toggle: () => void; | ||
| 263 | open: (mode?: string, query?: string) => void; | 313 | open: (mode?: string, query?: string) => void; | ||
| 264 | close: () => void; | 314 | close: () => void; | ||
M gui/src/components/omnibox/stores.svelte.ts +43 -0
| @@ -51,6 +51,10 @@ let prevOverflow = $state(""); | |||
| 51 | 51 | let debounceTimer = $state(0); | |
| 52 | 52 | let searchShortcuts = $state<Record<string, SearchShortcut>>({}); | |
| 53 | 53 | ||
| 54 | + | // Inline suggestion state (fish-style ghost text) | |
| 55 | + | let inlineSuggestion = $state<string | null>(null); | |
| 56 | + | let inlineCompletion = $state<string | null>(null); | |
| 57 | + | ||
| 54 | 58 | // Configuration | |
| 55 | 59 | let config = $state<Required<OmniboxConfig>>(DEFAULT_CONFIG); | |
| 56 | 60 | ||
| @@ -98,6 +102,12 @@ export const omniboxStore = { | |||
| 98 | 102 | get searchShortcuts() { | |
| 99 | 103 | return searchShortcuts; | |
| 100 | 104 | }, | |
| 105 | + | get inlineSuggestion() { | |
| 106 | + | return inlineSuggestion; | |
| 107 | + | }, | |
| 108 | + | get inlineCompletion() { | |
| 109 | + | return inlineCompletion; | |
| 110 | + | }, | |
| 101 | 111 | ||
| 102 | 112 | // Computed getters | |
| 103 | 113 | get hasContent() { | |
| @@ -294,6 +304,37 @@ export const omniboxStore = { | |||
| 294 | 304 | } | |
| 295 | 305 | }, | |
| 296 | 306 | ||
| 307 | + | setInlineSuggestion(url: string | null, currentInput: string) { | |
| 308 | + | console.log('[INLINE] setInlineSuggestion called:', { url, currentInput }); | |
| 309 | + | inlineSuggestion = url; | |
| 310 | + | if (!url || !currentInput) { | |
| 311 | + | inlineCompletion = null; | |
| 312 | + | console.log('[INLINE] Cleared - missing url or input'); | |
| 313 | + | return; | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | // Normalize URL by stripping protocol and www for matching | |
| 317 | + | let normalizedUrl = url.toLowerCase(); | |
| 318 | + | normalizedUrl = normalizedUrl.replace(/^https?:\/\//, ''); | |
| 319 | + | normalizedUrl = normalizedUrl.replace(/^www\./, ''); | |
| 320 | + | ||
| 321 | + | const normalizedInput = currentInput.toLowerCase(); | |
| 322 | + | ||
| 323 | + | if (normalizedUrl.startsWith(normalizedInput)) { | |
| 324 | + | // Show the completion (rest of normalized URL after what user typed) | |
| 325 | + | inlineCompletion = normalizedUrl.slice(normalizedInput.length); | |
| 326 | + | console.log('[INLINE] Set completion:', inlineCompletion); | |
| 327 | + | } else { | |
| 328 | + | inlineCompletion = null; | |
| 329 | + | console.log('[INLINE] Cleared - no prefix match after normalization'); | |
| 330 | + | } | |
| 331 | + | }, | |
| 332 | + | ||
| 333 | + | clearInlineSuggestion() { | |
| 334 | + | inlineSuggestion = null; | |
| 335 | + | inlineCompletion = null; | |
| 336 | + | }, | |
| 337 | + | ||
| 297 | 338 | reset() { | |
| 298 | 339 | visible = false; | |
| 299 | 340 | mode = "omnibox"; | |
| @@ -306,6 +347,8 @@ export const omniboxStore = { | |||
| 306 | 347 | faded = false; | |
| 307 | 348 | inputValue = ""; | |
| 308 | 349 | prevOverflow = ""; | |
| 350 | + | inlineSuggestion = null; | |
| 351 | + | inlineCompletion = null; | |
| 309 | 352 | this.clearHighlights(); | |
| 310 | 353 | this.clearDebounceTimer(); | |
| 311 | 354 | }, | |
| @@ -51,6 +51,10 @@ let prevOverflow = $state(""); | |||||
| 51 | let debounceTimer = $state(0); | 51 | let debounceTimer = $state(0); | ||
| 52 | let searchShortcuts = $state<Record<string, SearchShortcut>>({}); | 52 | let searchShortcuts = $state<Record<string, SearchShortcut>>({}); | ||
| 53 | 53 | ||||
| 54 | + | // Inline suggestion state (fish-style ghost text) | |||
| 55 | + | let inlineSuggestion = $state<string | null>(null); | |||
| 56 | + | let inlineCompletion = $state<string | null>(null); | |||
| 57 | + | ||||
| 54 | // Configuration | 58 | // Configuration | ||
| 55 | let config = $state<Required<OmniboxConfig>>(DEFAULT_CONFIG); | 59 | let config = $state<Required<OmniboxConfig>>(DEFAULT_CONFIG); | ||
| 56 | 60 | ||||
| @@ -98,6 +102,12 @@ export const omniboxStore = { | |||||
| 98 | get searchShortcuts() { | 102 | get searchShortcuts() { | ||
| 99 | return searchShortcuts; | 103 | return searchShortcuts; | ||
| 100 | }, | 104 | }, | ||
| 105 | + | get inlineSuggestion() { | |||
| 106 | + | return inlineSuggestion; | |||
| 107 | + | }, | |||
| 108 | + | get inlineCompletion() { | |||
| 109 | + | return inlineCompletion; | |||
| 110 | + | }, | |||
| 101 | 111 | ||||
| 102 | // Computed getters | 112 | // Computed getters | ||
| 103 | get hasContent() { | 113 | get hasContent() { | ||
| @@ -294,6 +304,37 @@ export const omniboxStore = { | |||||
| 294 | } | 304 | } | ||
| 295 | }, | 305 | }, | ||
| 296 | 306 | ||||
| 307 | + | setInlineSuggestion(url: string | null, currentInput: string) { | |||
| 308 | + | console.log('[INLINE] setInlineSuggestion called:', { url, currentInput }); | |||
| 309 | + | inlineSuggestion = url; | |||
| 310 | + | if (!url || !currentInput) { | |||
| 311 | + | inlineCompletion = null; | |||
| 312 | + | console.log('[INLINE] Cleared - missing url or input'); | |||
| 313 | + | return; | |||
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | // Normalize URL by stripping protocol and www for matching | |||
| 317 | + | let normalizedUrl = url.toLowerCase(); | |||
| 318 | + | normalizedUrl = normalizedUrl.replace(/^https?:\/\//, ''); | |||
| 319 | + | normalizedUrl = normalizedUrl.replace(/^www\./, ''); | |||
| 320 | + | ||||
| 321 | + | const normalizedInput = currentInput.toLowerCase(); | |||
| 322 | + | ||||
| 323 | + | if (normalizedUrl.startsWith(normalizedInput)) { | |||
| 324 | + | // Show the completion (rest of normalized URL after what user typed) | |||
| 325 | + | inlineCompletion = normalizedUrl.slice(normalizedInput.length); | |||
| 326 | + | console.log('[INLINE] Set completion:', inlineCompletion); | |||
| 327 | + | } else { | |||
| 328 | + | inlineCompletion = null; | |||
| 329 | + | console.log('[INLINE] Cleared - no prefix match after normalization'); | |||
| 330 | + | } | |||
| 331 | + | }, | |||
| 332 | + | ||||
| 333 | + | clearInlineSuggestion() { | |||
| 334 | + | inlineSuggestion = null; | |||
| 335 | + | inlineCompletion = null; | |||
| 336 | + | }, | |||
| 337 | + | ||||
| 297 | reset() { | 338 | reset() { | ||
| 298 | visible = false; | 339 | visible = false; | ||
| 299 | mode = "omnibox"; | 340 | mode = "omnibox"; | ||
| @@ -306,6 +347,8 @@ export const omniboxStore = { | |||||
| 306 | faded = false; | 347 | faded = false; | ||
| 307 | inputValue = ""; | 348 | inputValue = ""; | ||
| 308 | prevOverflow = ""; | 349 | prevOverflow = ""; | ||
| 350 | + | inlineSuggestion = null; | |||
| 351 | + | inlineCompletion = null; | |||
| 309 | this.clearHighlights(); | 352 | this.clearHighlights(); | ||
| 310 | this.clearDebounceTimer(); | 353 | this.clearDebounceTimer(); | ||
| 311 | }, | 354 | }, | ||
M gui/src/components/omnibox/types.ts +1 -1
| @@ -23,7 +23,7 @@ export interface SearchShortcut { | |||
| 23 | 23 | } | |
| 24 | 24 | ||
| 25 | 25 | export interface OmniboxMessage { | |
| 26 | - | type: "navigate" | "query" | "omnibox_initial_history" | "get_search_shortcuts" | "get_favorites" | "toggle_favorite" | "is_favorite"; | |
| 26 | + | type: "navigate" | "query" | "omnibox_initial_history" | "get_search_shortcuts" | "get_favorites" | "toggle_favorite" | "is_favorite" | "prefix_query"; | |
| 27 | 27 | url?: string; | |
| 28 | 28 | q?: string; | |
| 29 | 29 | limit?: number; | |
| @@ -23,7 +23,7 @@ export interface SearchShortcut { | |||||
| 23 | } | 23 | } | ||
| 24 | 24 | ||||
| 25 | export interface OmniboxMessage { | 25 | export interface OmniboxMessage { | ||
| 26 | - | type: "navigate" | "query" | "omnibox_initial_history" | "get_search_shortcuts" | "get_favorites" | "toggle_favorite" | "is_favorite"; | 26 | + | type: "navigate" | "query" | "omnibox_initial_history" | "get_search_shortcuts" | "get_favorites" | "toggle_favorite" | "is_favorite" | "prefix_query"; |
| 27 | url?: string; | 27 | url?: string; | ||
| 28 | q?: string; | 28 | q?: string; | ||
| 29 | limit?: number; | 29 | limit?: number; | ||
M gui/src/injected/bootstrap.ts +1 -0
| @@ -27,6 +27,7 @@ declare global { | |||
| 27 | 27 | __dumber_keyboard?: KeyboardService; | |
| 28 | 28 | __dumber_omnibox?: { | |
| 29 | 29 | setSuggestions: (suggestions: Suggestion[]) => void; | |
| 30 | + | setInlineSuggestion: (url: string | null) => void; | |
| 30 | 31 | toggle: () => void; | |
| 31 | 32 | open: (mode?: string, query?: string) => void; | |
| 32 | 33 | close: () => void; | |
| @@ -27,6 +27,7 @@ declare global { | |||||
| 27 | __dumber_keyboard?: KeyboardService; | 27 | __dumber_keyboard?: KeyboardService; | ||
| 28 | __dumber_omnibox?: { | 28 | __dumber_omnibox?: { | ||
| 29 | setSuggestions: (suggestions: Suggestion[]) => void; | 29 | setSuggestions: (suggestions: Suggestion[]) => void; | ||
| 30 | + | setInlineSuggestion: (url: string | null) => void; | |||
| 30 | toggle: () => void; | 31 | toggle: () => void; | ||
| 31 | open: (mode?: string, query?: string) => void; | 32 | open: (mode?: string, query?: string) => void; | ||
| 32 | close: () => void; | 33 | close: () => void; | ||
M gui/src/injected/main-world.ts +7 -0
| @@ -32,6 +32,7 @@ declare global { | |||
| 32 | 32 | ) => number | void; | |
| 33 | 33 | __dumber_showZoomToast?: (level: number) => void; | |
| 34 | 34 | __dumber_omnibox_suggestions?: (suggestions: Suggestion[]) => void; | |
| 35 | + | __dumber_omnibox_inline_suggestion?: (url: string | null) => void; | |
| 35 | 36 | __dumber_favorites?: (favorites: unknown[]) => void; | |
| 36 | 37 | __dumber_webview_id?: string | number; | |
| 37 | 38 | __dumber_is_active?: boolean; | |
| @@ -373,6 +374,12 @@ interface DumberAPI { | |||
| 373 | 374 | } | |
| 374 | 375 | }; | |
| 375 | 376 | ||
| 377 | + | // Inline suggestion bridge (fish-style ghost text) | |
| 378 | + | window.__dumber_omnibox_inline_suggestion = (url: string | null) => { | |
| 379 | + | console.log("[INLINE] main-world received inline suggestion:", url); | |
| 380 | + | bridgeDispatch("dumber:omnibox:inline-suggestion", { url }); | |
| 381 | + | }; | |
| 382 | + | ||
| 376 | 383 | // Favorites bridge | |
| 377 | 384 | window.__dumber_favorites = (favorites: unknown[]) => { | |
| 378 | 385 | bridgeDispatch("dumber:favorites", { favorites }); | |
| @@ -32,6 +32,7 @@ declare global { | |||||
| 32 | ) => number | void; | 32 | ) => number | void; | ||
| 33 | __dumber_showZoomToast?: (level: number) => void; | 33 | __dumber_showZoomToast?: (level: number) => void; | ||
| 34 | __dumber_omnibox_suggestions?: (suggestions: Suggestion[]) => void; | 34 | __dumber_omnibox_suggestions?: (suggestions: Suggestion[]) => void; | ||
| 35 | + | __dumber_omnibox_inline_suggestion?: (url: string | null) => void; | |||
| 35 | __dumber_favorites?: (favorites: unknown[]) => void; | 36 | __dumber_favorites?: (favorites: unknown[]) => void; | ||
| 36 | __dumber_webview_id?: string | number; | 37 | __dumber_webview_id?: string | number; | ||
| 37 | __dumber_is_active?: boolean; | 38 | __dumber_is_active?: boolean; | ||
| @@ -373,6 +374,12 @@ interface DumberAPI { | |||||
| 373 | } | 374 | } | ||
| 374 | }; | 375 | }; | ||
| 375 | 376 | ||||
| 377 | + | // Inline suggestion bridge (fish-style ghost text) | |||
| 378 | + | window.__dumber_omnibox_inline_suggestion = (url: string | null) => { | |||
| 379 | + | console.log("[INLINE] main-world received inline suggestion:", url); | |||
| 380 | + | bridgeDispatch("dumber:omnibox:inline-suggestion", { url }); | |||
| 381 | + | }; | |||
| 382 | + | ||||
| 376 | // Favorites bridge | 383 | // Favorites bridge | ||
| 377 | window.__dumber_favorites = (favorites: unknown[]) => { | 384 | window.__dumber_favorites = (favorites: unknown[]) => { | ||
| 378 | bridgeDispatch("dumber:favorites", { favorites }); | 385 | bridgeDispatch("dumber:favorites", { favorites }); | ||
M gui/src/injected/modules/omnibox.ts +22 -0
| @@ -132,6 +132,28 @@ export async function initializeOmnibox( | |||
| 132 | 132 | handleSuggestionsEvent, | |
| 133 | 133 | false, | |
| 134 | 134 | ); | |
| 135 | + | ||
| 136 | + | // Bridge: listen for inline suggestion events (fish-style ghost text) | |
| 137 | + | const handleInlineSuggestionEvent = (e: Event) => { | |
| 138 | + | try { | |
| 139 | + | const detail = (e as CustomEvent).detail; | |
| 140 | + | const url = detail?.url ?? null; | |
| 141 | + | console.log("[INLINE] omnibox module received event:", url); | |
| 142 | + | if (window.__dumber_omnibox?.setInlineSuggestion) { | |
| 143 | + | window.__dumber_omnibox.setInlineSuggestion(url); | |
| 144 | + | } else { | |
| 145 | + | console.warn("[INLINE] __dumber_omnibox.setInlineSuggestion not available"); | |
| 146 | + | } | |
| 147 | + | } catch (err) { | |
| 148 | + | console.warn("[Bridge] Failed handling inline suggestion event:", err); | |
| 149 | + | } | |
| 150 | + | }; | |
| 151 | + | ||
| 152 | + | document.addEventListener( | |
| 153 | + | "dumber:omnibox:inline-suggestion", | |
| 154 | + | handleInlineSuggestionEvent, | |
| 155 | + | false, | |
| 156 | + | ); | |
| 135 | 157 | } catch (error) { | |
| 136 | 158 | console.error("❌ Failed to initialize omnibox component:", error); | |
| 137 | 159 | throw error; | |
| @@ -132,6 +132,28 @@ export async function initializeOmnibox( | |||||
| 132 | handleSuggestionsEvent, | 132 | handleSuggestionsEvent, | ||
| 133 | false, | 133 | false, | ||
| 134 | ); | 134 | ); | ||
| 135 | + | ||||
| 136 | + | // Bridge: listen for inline suggestion events (fish-style ghost text) | |||
| 137 | + | const handleInlineSuggestionEvent = (e: Event) => { | |||
| 138 | + | try { | |||
| 139 | + | const detail = (e as CustomEvent).detail; | |||
| 140 | + | const url = detail?.url ?? null; | |||
| 141 | + | console.log("[INLINE] omnibox module received event:", url); | |||
| 142 | + | if (window.__dumber_omnibox?.setInlineSuggestion) { | |||
| 143 | + | window.__dumber_omnibox.setInlineSuggestion(url); | |||
| 144 | + | } else { | |||
| 145 | + | console.warn("[INLINE] __dumber_omnibox.setInlineSuggestion not available"); | |||
| 146 | + | } | |||
| 147 | + | } catch (err) { | |||
| 148 | + | console.warn("[Bridge] Failed handling inline suggestion event:", err); | |||
| 149 | + | } | |||
| 150 | + | }; | |||
| 151 | + | ||||
| 152 | + | document.addEventListener( | |||
| 153 | + | "dumber:omnibox:inline-suggestion", | |||
| 154 | + | handleInlineSuggestionEvent, | |||
| 155 | + | false, | |||
| 156 | + | ); | |||
| 135 | } catch (error) { | 157 | } catch (error) { | ||
| 136 | console.error("❌ Failed to initialize omnibox component:", error); | 158 | console.error("❌ Failed to initialize omnibox component:", error); | ||
| 137 | throw error; | 159 | throw error; | ||
M internal/app/messaging/handler.go +30 -0
| @@ -127,6 +127,8 @@ func (h *Handler) Handle(payload string) { | |||
| 127 | 127 | h.handleIsFavorite(msg) | |
| 128 | 128 | case "keyboard_blocking": | |
| 129 | 129 | h.handleKeyboardBlocking(msg) | |
| 130 | + | case "prefix_query": | |
| 131 | + | h.handlePrefixQuery(msg) | |
| 130 | 132 | } | |
| 131 | 133 | } | |
| 132 | 134 | ||
| @@ -871,3 +873,31 @@ func (h *Handler) handleKeyboardBlocking(msg Message) { | |||
| 871 | 873 | logging.Error(fmt.Sprintf("[ERROR] Failed to inject keyboard blocking script: %v", err)) | |
| 872 | 874 | } | |
| 873 | 875 | } | |
| 876 | + | ||
| 877 | + | // handlePrefixQuery returns the best prefix-matching URL for inline suggestions | |
| 878 | + | func (h *Handler) handlePrefixQuery(msg Message) { | |
| 879 | + | if h.webView == nil || h.browserService == nil { | |
| 880 | + | return | |
| 881 | + | } | |
| 882 | + | ||
| 883 | + | query := msg.Q | |
| 884 | + | if query == "" { | |
| 885 | + | _ = h.webView.InjectScript("window.__dumber_omnibox_inline_suggestion && window.__dumber_omnibox_inline_suggestion(null)") | |
| 886 | + | return | |
| 887 | + | } | |
| 888 | + | ||
| 889 | + | ctx := context.Background() | |
| 890 | + | prefixURL := h.browserService.GetBestPrefixMatch(ctx, query) | |
| 891 | + | ||
| 892 | + | if prefixURL != "" { | |
| 893 | + | // Escape the URL for JavaScript string | |
| 894 | + | escaped, err := json.Marshal(prefixURL) | |
| 895 | + | if err != nil { | |
| 896 | + | _ = h.webView.InjectScript("window.__dumber_omnibox_inline_suggestion && window.__dumber_omnibox_inline_suggestion(null)") | |
| 897 | + | return | |
| 898 | + | } | |
| 899 | + | _ = h.webView.InjectScript("window.__dumber_omnibox_inline_suggestion && window.__dumber_omnibox_inline_suggestion(" + string(escaped) + ")") | |
| 900 | + | } else { | |
| 901 | + | _ = h.webView.InjectScript("window.__dumber_omnibox_inline_suggestion && window.__dumber_omnibox_inline_suggestion(null)") | |
| 902 | + | } | |
| 903 | + | } | |
| @@ -127,6 +127,8 @@ func (h *Handler) Handle(payload string) { | |||||
| 127 | h.handleIsFavorite(msg) | 127 | h.handleIsFavorite(msg) | ||
| 128 | case "keyboard_blocking": | 128 | case "keyboard_blocking": | ||
| 129 | h.handleKeyboardBlocking(msg) | 129 | h.handleKeyboardBlocking(msg) | ||
| 130 | + | case "prefix_query": | |||
| 131 | + | h.handlePrefixQuery(msg) | |||
| 130 | } | 132 | } | ||
| 131 | } | 133 | } | ||
| 132 | 134 | ||||
| @@ -871,3 +873,31 @@ func (h *Handler) handleKeyboardBlocking(msg Message) { | |||||
| 871 | logging.Error(fmt.Sprintf("[ERROR] Failed to inject keyboard blocking script: %v", err)) | 873 | logging.Error(fmt.Sprintf("[ERROR] Failed to inject keyboard blocking script: %v", err)) | ||
| 872 | } | 874 | } | ||
| 873 | } | 875 | } | ||
| 876 | + | ||||
| 877 | + | // handlePrefixQuery returns the best prefix-matching URL for inline suggestions | |||
| 878 | + | func (h *Handler) handlePrefixQuery(msg Message) { | |||
| 879 | + | if h.webView == nil || h.browserService == nil { | |||
| 880 | + | return | |||
| 881 | + | } | |||
| 882 | + | ||||
| 883 | + | query := msg.Q | |||
| 884 | + | if query == "" { | |||
| 885 | + | _ = h.webView.InjectScript("window.__dumber_omnibox_inline_suggestion && window.__dumber_omnibox_inline_suggestion(null)") | |||
| 886 | + | return | |||
| 887 | + | } | |||
| 888 | + | ||||
| 889 | + | ctx := context.Background() | |||
| 890 | + | prefixURL := h.browserService.GetBestPrefixMatch(ctx, query) | |||
| 891 | + | ||||
| 892 | + | if prefixURL != "" { | |||
| 893 | + | // Escape the URL for JavaScript string | |||
| 894 | + | escaped, err := json.Marshal(prefixURL) | |||
| 895 | + | if err != nil { | |||
| 896 | + | _ = h.webView.InjectScript("window.__dumber_omnibox_inline_suggestion && window.__dumber_omnibox_inline_suggestion(null)") | |||
| 897 | + | return | |||
| 898 | + | } | |||
| 899 | + | _ = h.webView.InjectScript("window.__dumber_omnibox_inline_suggestion && window.__dumber_omnibox_inline_suggestion(" + string(escaped) + ")") | |||
| 900 | + | } else { | |||
| 901 | + | _ = h.webView.InjectScript("window.__dumber_omnibox_inline_suggestion && window.__dumber_omnibox_inline_suggestion(null)") | |||
| 902 | + | } | |||
| 903 | + | } | |||
M internal/cache/fuzzy_cache.go +11 -0
| @@ -322,6 +322,17 @@ func (cm *CacheManager) GetTopEntries(ctx context.Context) (*FuzzyResult, error) | |||
| 322 | 322 | return cache.getTopEntries(cm.config), nil | |
| 323 | 323 | } | |
| 324 | 324 | ||
| 325 | + | // GetBestPrefixMatch returns the highest-scoring URL that starts with the given prefix. | |
| 326 | + | // Used for fish-style inline autosuggestions. | |
| 327 | + | func (cm *CacheManager) GetBestPrefixMatch(ctx context.Context, prefix string) string { | |
| 328 | + | cache, err := cm.GetCache(ctx) | |
| 329 | + | if err != nil || prefix == "" { | |
| 330 | + | return "" | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | return cache.getBestPrefixMatch(prefix) | |
| 334 | + | } | |
| 335 | + | ||
| 325 | 336 | // Stats returns cache statistics. | |
| 326 | 337 | func (cm *CacheManager) Stats() CacheStats { | |
| 327 | 338 | cm.mu.RLock() | |
| @@ -322,6 +322,17 @@ func (cm *CacheManager) GetTopEntries(ctx context.Context) (*FuzzyResult, error) | |||||
| 322 | return cache.getTopEntries(cm.config), nil | 322 | return cache.getTopEntries(cm.config), nil | ||
| 323 | } | 323 | } | ||
| 324 | 324 | ||||
| 325 | + | // GetBestPrefixMatch returns the highest-scoring URL that starts with the given prefix. | |||
| 326 | + | // Used for fish-style inline autosuggestions. | |||
| 327 | + | func (cm *CacheManager) GetBestPrefixMatch(ctx context.Context, prefix string) string { | |||
| 328 | + | cache, err := cm.GetCache(ctx) | |||
| 329 | + | if err != nil || prefix == "" { | |||
| 330 | + | return "" | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | return cache.getBestPrefixMatch(prefix) | |||
| 334 | + | } | |||
| 335 | + | ||||
| 325 | // Stats returns cache statistics. | 336 | // Stats returns cache statistics. | ||
| 326 | func (cm *CacheManager) Stats() CacheStats { | 337 | func (cm *CacheManager) Stats() CacheStats { | ||
| 327 | cm.mu.RLock() | 338 | cm.mu.RLock() | ||
M internal/cache/fuzzy_cache_test.go +146 -0
| @@ -632,6 +632,152 @@ func BenchmarkGetTopEntries(b *testing.B) { | |||
| 632 | 632 | } | |
| 633 | 633 | } | |
| 634 | 634 | ||
| 635 | + | func TestGetBestPrefixMatch(t *testing.T) { | |
| 636 | + | ctrl := gomock.NewController(t) | |
| 637 | + | defer ctrl.Finish() | |
| 638 | + | ||
| 639 | + | mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl) | |
| 640 | + | history := createTestHistory() | |
| 641 | + | ||
| 642 | + | mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(history, nil).AnyTimes() | |
| 643 | + | ||
| 644 | + | tempDir, err := os.MkdirTemp("", "dumber_prefix_test") | |
| 645 | + | if err != nil { | |
| 646 | + | t.Fatalf("Failed to create temp dir: %v", err) | |
| 647 | + | } | |
| 648 | + | defer func() { | |
| 649 | + | if err := os.RemoveAll(tempDir); err != nil { | |
| 650 | + | t.Logf("Warning: failed to remove temp dir %s: %v", tempDir, err) | |
| 651 | + | } | |
| 652 | + | }() | |
| 653 | + | ||
| 654 | + | config := DefaultCacheConfig() | |
| 655 | + | config.CacheFile = filepath.Join(tempDir, "prefix_cache.bin") | |
| 656 | + | ||
| 657 | + | manager := NewCacheManager(mockQuerier, config) | |
| 658 | + | ctx := context.Background() | |
| 659 | + | ||
| 660 | + | // Build cache first | |
| 661 | + | _, err = manager.GetCache(ctx) | |
| 662 | + | if err != nil { | |
| 663 | + | t.Fatalf("Failed to get cache: %v", err) | |
| 664 | + | } | |
| 665 | + | ||
| 666 | + | tests := []struct { | |
| 667 | + | name string | |
| 668 | + | prefix string | |
| 669 | + | expectURL string | |
| 670 | + | expectEmpty bool | |
| 671 | + | }{ | |
| 672 | + | { | |
| 673 | + | name: "exact prefix match - github", | |
| 674 | + | prefix: "https://github", | |
| 675 | + | expectURL: "https://github.com", | |
| 676 | + | }, | |
| 677 | + | { | |
| 678 | + | name: "partial prefix match - you", | |
| 679 | + | prefix: "https://you", | |
| 680 | + | expectURL: "https://youtube.com", | |
| 681 | + | }, | |
| 682 | + | { | |
| 683 | + | name: "partial prefix match - stack", | |
| 684 | + | prefix: "https://stack", | |
| 685 | + | expectURL: "https://stackoverflow.com", | |
| 686 | + | }, | |
| 687 | + | { | |
| 688 | + | name: "no match", | |
| 689 | + | prefix: "https://nonexistent", | |
| 690 | + | expectEmpty: true, | |
| 691 | + | }, | |
| 692 | + | { | |
| 693 | + | name: "empty prefix", | |
| 694 | + | prefix: "", | |
| 695 | + | expectEmpty: true, | |
| 696 | + | }, | |
| 697 | + | { | |
| 698 | + | name: "case insensitive match", | |
| 699 | + | prefix: "https://GITHUB", | |
| 700 | + | expectURL: "https://github.com", | |
| 701 | + | }, | |
| 702 | + | } | |
| 703 | + | ||
| 704 | + | for _, tt := range tests { | |
| 705 | + | t.Run(tt.name, func(t *testing.T) { | |
| 706 | + | result := manager.GetBestPrefixMatch(ctx, tt.prefix) | |
| 707 | + | ||
| 708 | + | if tt.expectEmpty { | |
| 709 | + | if result != "" { | |
| 710 | + | t.Errorf("Expected empty result, got %q", result) | |
| 711 | + | } | |
| 712 | + | return | |
| 713 | + | } | |
| 714 | + | ||
| 715 | + | if result != tt.expectURL { | |
| 716 | + | t.Errorf("Expected %q, got %q", tt.expectURL, result) | |
| 717 | + | } | |
| 718 | + | }) | |
| 719 | + | } | |
| 720 | + | } | |
| 721 | + | ||
| 722 | + | func TestGetBestPrefixMatch_ReturnsHighestScore(t *testing.T) { | |
| 723 | + | ctrl := gomock.NewController(t) | |
| 724 | + | defer ctrl.Finish() | |
| 725 | + | ||
| 726 | + | mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl) | |
| 727 | + | ||
| 728 | + | // Create history with multiple entries sharing the same prefix | |
| 729 | + | // "https://go" matches both golang.org and google.com | |
| 730 | + | now := time.Now() | |
| 731 | + | history := []db.History{ | |
| 732 | + | { | |
| 733 | + | ID: 1, | |
| 734 | + | Url: "https://golang.org", | |
| 735 | + | Title: sql.NullString{String: "Go Programming Language", Valid: true}, | |
| 736 | + | VisitCount: sql.NullInt64{Int64: 5, Valid: true}, | |
| 737 | + | LastVisited: sql.NullTime{Time: now.Add(-5 * time.Hour), Valid: true}, | |
| 738 | + | }, | |
| 739 | + | { | |
| 740 | + | ID: 2, | |
| 741 | + | Url: "https://google.com", | |
| 742 | + | Title: sql.NullString{String: "Google", Valid: true}, | |
| 743 | + | VisitCount: sql.NullInt64{Int64: 100, Valid: true}, // Much higher visits | |
| 744 | + | LastVisited: sql.NullTime{Time: now.Add(-1 * time.Hour), Valid: true}, // More recent | |
| 745 | + | }, | |
| 746 | + | } | |
| 747 | + | ||
| 748 | + | mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(history, nil).AnyTimes() | |
| 749 | + | ||
| 750 | + | tempDir, err := os.MkdirTemp("", "dumber_prefix_score_test") | |
| 751 | + | if err != nil { | |
| 752 | + | t.Fatalf("Failed to create temp dir: %v", err) | |
| 753 | + | } | |
| 754 | + | defer func() { | |
| 755 | + | if err := os.RemoveAll(tempDir); err != nil { | |
| 756 | + | t.Logf("Warning: failed to remove temp dir %s: %v", tempDir, err) | |
| 757 | + | } | |
| 758 | + | }() | |
| 759 | + | ||
| 760 | + | config := DefaultCacheConfig() | |
| 761 | + | config.CacheFile = filepath.Join(tempDir, "prefix_score_cache.bin") | |
| 762 | + | ||
| 763 | + | manager := NewCacheManager(mockQuerier, config) | |
| 764 | + | ctx := context.Background() | |
| 765 | + | ||
| 766 | + | // Build cache first | |
| 767 | + | _, err = manager.GetCache(ctx) | |
| 768 | + | if err != nil { | |
| 769 | + | t.Fatalf("Failed to get cache: %v", err) | |
| 770 | + | } | |
| 771 | + | ||
| 772 | + | // Query with prefix that matches both | |
| 773 | + | result := manager.GetBestPrefixMatch(ctx, "https://go") | |
| 774 | + | ||
| 775 | + | // Should return google.com because it has higher score (more visits, more recent) | |
| 776 | + | if result != "https://google.com" { | |
| 777 | + | t.Errorf("Expected https://google.com (highest score), got %q", result) | |
| 778 | + | } | |
| 779 | + | } | |
| 780 | + | ||
| 635 | 781 | // BenchmarkGetTopEntriesParallel benchmarks concurrent access performance | |
| 636 | 782 | func BenchmarkGetTopEntriesParallel(b *testing.B) { | |
| 637 | 783 | ctrl := gomock.NewController(b) | |
| @@ -632,6 +632,152 @@ func BenchmarkGetTopEntries(b *testing.B) { | |||||
| 632 | } | 632 | } | ||
| 633 | } | 633 | } | ||
| 634 | 634 | ||||
| 635 | + | func TestGetBestPrefixMatch(t *testing.T) { | |||
| 636 | + | ctrl := gomock.NewController(t) | |||
| 637 | + | defer ctrl.Finish() | |||
| 638 | + | ||||
| 639 | + | mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl) | |||
| 640 | + | history := createTestHistory() | |||
| 641 | + | ||||
| 642 | + | mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(history, nil).AnyTimes() | |||
| 643 | + | ||||
| 644 | + | tempDir, err := os.MkdirTemp("", "dumber_prefix_test") | |||
| 645 | + | if err != nil { | |||
| 646 | + | t.Fatalf("Failed to create temp dir: %v", err) | |||
| 647 | + | } | |||
| 648 | + | defer func() { | |||
| 649 | + | if err := os.RemoveAll(tempDir); err != nil { | |||
| 650 | + | t.Logf("Warning: failed to remove temp dir %s: %v", tempDir, err) | |||
| 651 | + | } | |||
| 652 | + | }() | |||
| 653 | + | ||||
| 654 | + | config := DefaultCacheConfig() | |||
| 655 | + | config.CacheFile = filepath.Join(tempDir, "prefix_cache.bin") | |||
| 656 | + | ||||
| 657 | + | manager := NewCacheManager(mockQuerier, config) | |||
| 658 | + | ctx := context.Background() | |||
| 659 | + | ||||
| 660 | + | // Build cache first | |||
| 661 | + | _, err = manager.GetCache(ctx) | |||
| 662 | + | if err != nil { | |||
| 663 | + | t.Fatalf("Failed to get cache: %v", err) | |||
| 664 | + | } | |||
| 665 | + | ||||
| 666 | + | tests := []struct { | |||
| 667 | + | name string | |||
| 668 | + | prefix string | |||
| 669 | + | expectURL string | |||
| 670 | + | expectEmpty bool | |||
| 671 | + | }{ | |||
| 672 | + | { | |||
| 673 | + | name: "exact prefix match - github", | |||
| 674 | + | prefix: "https://github", | |||
| 675 | + | expectURL: "https://github.com", | |||
| 676 | + | }, | |||
| 677 | + | { | |||
| 678 | + | name: "partial prefix match - you", | |||
| 679 | + | prefix: "https://you", | |||
| 680 | + | expectURL: "https://youtube.com", | |||
| 681 | + | }, | |||
| 682 | + | { | |||
| 683 | + | name: "partial prefix match - stack", | |||
| 684 | + | prefix: "https://stack", | |||
| 685 | + | expectURL: "https://stackoverflow.com", | |||
| 686 | + | }, | |||
| 687 | + | { | |||
| 688 | + | name: "no match", | |||
| 689 | + | prefix: "https://nonexistent", | |||
| 690 | + | expectEmpty: true, | |||
| 691 | + | }, | |||
| 692 | + | { | |||
| 693 | + | name: "empty prefix", | |||
| 694 | + | prefix: "", | |||
| 695 | + | expectEmpty: true, | |||
| 696 | + | }, | |||
| 697 | + | { | |||
| 698 | + | name: "case insensitive match", | |||
| 699 | + | prefix: "https://GITHUB", | |||
| 700 | + | expectURL: "https://github.com", | |||
| 701 | + | }, | |||
| 702 | + | } | |||
| 703 | + | ||||
| 704 | + | for _, tt := range tests { | |||
| 705 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 706 | + | result := manager.GetBestPrefixMatch(ctx, tt.prefix) | |||
| 707 | + | ||||
| 708 | + | if tt.expectEmpty { | |||
| 709 | + | if result != "" { | |||
| 710 | + | t.Errorf("Expected empty result, got %q", result) | |||
| 711 | + | } | |||
| 712 | + | return | |||
| 713 | + | } | |||
| 714 | + | ||||
| 715 | + | if result != tt.expectURL { | |||
| 716 | + | t.Errorf("Expected %q, got %q", tt.expectURL, result) | |||
| 717 | + | } | |||
| 718 | + | }) | |||
| 719 | + | } | |||
| 720 | + | } | |||
| 721 | + | ||||
| 722 | + | func TestGetBestPrefixMatch_ReturnsHighestScore(t *testing.T) { | |||
| 723 | + | ctrl := gomock.NewController(t) | |||
| 724 | + | defer ctrl.Finish() | |||
| 725 | + | ||||
| 726 | + | mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl) | |||
| 727 | + | ||||
| 728 | + | // Create history with multiple entries sharing the same prefix | |||
| 729 | + | // "https://go" matches both golang.org and google.com | |||
| 730 | + | now := time.Now() | |||
| 731 | + | history := []db.History{ | |||
| 732 | + | { | |||
| 733 | + | ID: 1, | |||
| 734 | + | Url: "https://golang.org", | |||
| 735 | + | Title: sql.NullString{String: "Go Programming Language", Valid: true}, | |||
| 736 | + | VisitCount: sql.NullInt64{Int64: 5, Valid: true}, | |||
| 737 | + | LastVisited: sql.NullTime{Time: now.Add(-5 * time.Hour), Valid: true}, | |||
| 738 | + | }, | |||
| 739 | + | { | |||
| 740 | + | ID: 2, | |||
| 741 | + | Url: "https://google.com", | |||
| 742 | + | Title: sql.NullString{String: "Google", Valid: true}, | |||
| 743 | + | VisitCount: sql.NullInt64{Int64: 100, Valid: true}, // Much higher visits | |||
| 744 | + | LastVisited: sql.NullTime{Time: now.Add(-1 * time.Hour), Valid: true}, // More recent | |||
| 745 | + | }, | |||
| 746 | + | } | |||
| 747 | + | ||||
| 748 | + | mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(history, nil).AnyTimes() | |||
| 749 | + | ||||
| 750 | + | tempDir, err := os.MkdirTemp("", "dumber_prefix_score_test") | |||
| 751 | + | if err != nil { | |||
| 752 | + | t.Fatalf("Failed to create temp dir: %v", err) | |||
| 753 | + | } | |||
| 754 | + | defer func() { | |||
| 755 | + | if err := os.RemoveAll(tempDir); err != nil { | |||
| 756 | + | t.Logf("Warning: failed to remove temp dir %s: %v", tempDir, err) | |||
| 757 | + | } | |||
| 758 | + | }() | |||
| 759 | + | ||||
| 760 | + | config := DefaultCacheConfig() | |||
| 761 | + | config.CacheFile = filepath.Join(tempDir, "prefix_score_cache.bin") | |||
| 762 | + | ||||
| 763 | + | manager := NewCacheManager(mockQuerier, config) | |||
| 764 | + | ctx := context.Background() | |||
| 765 | + | ||||
| 766 | + | // Build cache first | |||
| 767 | + | _, err = manager.GetCache(ctx) | |||
| 768 | + | if err != nil { | |||
| 769 | + | t.Fatalf("Failed to get cache: %v", err) | |||
| 770 | + | } | |||
| 771 | + | ||||
| 772 | + | // Query with prefix that matches both | |||
| 773 | + | result := manager.GetBestPrefixMatch(ctx, "https://go") | |||
| 774 | + | ||||
| 775 | + | // Should return google.com because it has higher score (more visits, more recent) | |||
| 776 | + | if result != "https://google.com" { | |||
| 777 | + | t.Errorf("Expected https://google.com (highest score), got %q", result) | |||
| 778 | + | } | |||
| 779 | + | } | |||
| 780 | + | ||||
| 635 | // BenchmarkGetTopEntriesParallel benchmarks concurrent access performance | 781 | // BenchmarkGetTopEntriesParallel benchmarks concurrent access performance | ||
| 636 | func BenchmarkGetTopEntriesParallel(b *testing.B) { | 782 | func BenchmarkGetTopEntriesParallel(b *testing.B) { | ||
| 637 | ctrl := gomock.NewController(b) | 783 | ctrl := gomock.NewController(b) | ||
M internal/cache/trie.go +40 -0
| @@ -329,3 +329,43 @@ func (c *DmenuFuzzyCache) buildSortedIndex() { | |||
| 329 | 329 | return c.entries[c.sortedIndex[i]].Score > c.entries[c.sortedIndex[j]].Score | |
| 330 | 330 | }) | |
| 331 | 331 | } | |
| 332 | + | ||
| 333 | + | // getBestPrefixMatch returns the highest-scoring URL that starts with the given prefix. | |
| 334 | + | // Used for fish-style inline autosuggestions. | |
| 335 | + | func (c *DmenuFuzzyCache) getBestPrefixMatch(prefix string) string { | |
| 336 | + | c.mu.RLock() | |
| 337 | + | defer c.mu.RUnlock() | |
| 338 | + | ||
| 339 | + | if c.prefixTrie == nil || prefix == "" { | |
| 340 | + | return "" | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | normalizedPrefix := normalizeText(prefix) | |
| 344 | + | candidates := c.prefixTrie.PrefixSearch(normalizedPrefix) | |
| 345 | + | if len(candidates) == 0 { | |
| 346 | + | return "" | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | var bestURL string | |
| 350 | + | var bestScore uint16 | |
| 351 | + | ||
| 352 | + | for _, entryID := range candidates { | |
| 353 | + | if int(entryID) >= len(c.entries) { | |
| 354 | + | continue | |
| 355 | + | } | |
| 356 | + | entry := &c.entries[entryID] | |
| 357 | + | ||
| 358 | + | // Only match if URL actually starts with the prefix (case-insensitive) | |
| 359 | + | normalizedURL := normalizeText(entry.URL) | |
| 360 | + | if !strings.HasPrefix(normalizedURL, normalizedPrefix) { | |
| 361 | + | continue | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | if entry.Score > bestScore { | |
| 365 | + | bestScore = entry.Score | |
| 366 | + | bestURL = entry.URL | |
| 367 | + | } | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | return bestURL | |
| 371 | + | } | |
| @@ -329,3 +329,43 @@ func (c *DmenuFuzzyCache) buildSortedIndex() { | |||||
| 329 | return c.entries[c.sortedIndex[i]].Score > c.entries[c.sortedIndex[j]].Score | 329 | return c.entries[c.sortedIndex[i]].Score > c.entries[c.sortedIndex[j]].Score | ||
| 330 | }) | 330 | }) | ||
| 331 | } | 331 | } | ||
| 332 | + | ||||
| 333 | + | // getBestPrefixMatch returns the highest-scoring URL that starts with the given prefix. | |||
| 334 | + | // Used for fish-style inline autosuggestions. | |||
| 335 | + | func (c *DmenuFuzzyCache) getBestPrefixMatch(prefix string) string { | |||
| 336 | + | c.mu.RLock() | |||
| 337 | + | defer c.mu.RUnlock() | |||
| 338 | + | ||||
| 339 | + | if c.prefixTrie == nil || prefix == "" { | |||
| 340 | + | return "" | |||
| 341 | + | } | |||
| 342 | + | ||||
| 343 | + | normalizedPrefix := normalizeText(prefix) | |||
| 344 | + | candidates := c.prefixTrie.PrefixSearch(normalizedPrefix) | |||
| 345 | + | if len(candidates) == 0 { | |||
| 346 | + | return "" | |||
| 347 | + | } | |||
| 348 | + | ||||
| 349 | + | var bestURL string | |||
| 350 | + | var bestScore uint16 | |||
| 351 | + | ||||
| 352 | + | for _, entryID := range candidates { | |||
| 353 | + | if int(entryID) >= len(c.entries) { | |||
| 354 | + | continue | |||
| 355 | + | } | |||
| 356 | + | entry := &c.entries[entryID] | |||
| 357 | + | ||||
| 358 | + | // Only match if URL actually starts with the prefix (case-insensitive) | |||
| 359 | + | normalizedURL := normalizeText(entry.URL) | |||
| 360 | + | if !strings.HasPrefix(normalizedURL, normalizedPrefix) { | |||
| 361 | + | continue | |||
| 362 | + | } | |||
| 363 | + | ||||
| 364 | + | if entry.Score > bestScore { | |||
| 365 | + | bestScore = entry.Score | |||
| 366 | + | bestURL = entry.URL | |||
| 367 | + | } | |||
| 368 | + | } | |||
| 369 | + | ||||
| 370 | + | return bestURL | |||
| 371 | + | } | |||
M internal/services/browser.go +9 -0
| @@ -366,6 +366,15 @@ func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit | |||
| 366 | 366 | return result, nil | |
| 367 | 367 | } | |
| 368 | 368 | ||
| 369 | + | // GetBestPrefixMatch returns the highest-scoring URL that starts with the given prefix. | |
| 370 | + | // Used for fish-style inline autosuggestions in the omnibox. | |
| 371 | + | func (s *BrowserService) GetBestPrefixMatch(ctx context.Context, prefix string) string { | |
| 372 | + | if s.fuzzyCache == nil || prefix == "" { | |
| 373 | + | return "" | |
| 374 | + | } | |
| 375 | + | return s.fuzzyCache.GetBestPrefixMatch(ctx, prefix) | |
| 376 | + | } | |
| 377 | + | ||
| 369 | 378 | // DeleteHistoryEntry removes a specific history entry. | |
| 370 | 379 | func (s *BrowserService) DeleteHistoryEntry(ctx context.Context, id int64) error { | |
| 371 | 380 | return s.dbQueries.DeleteHistory(ctx, id) | |
| @@ -366,6 +366,15 @@ func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit | |||||
| 366 | return result, nil | 366 | return result, nil | ||
| 367 | } | 367 | } | ||
| 368 | 368 | ||||
| 369 | + | // GetBestPrefixMatch returns the highest-scoring URL that starts with the given prefix. | |||
| 370 | + | // Used for fish-style inline autosuggestions in the omnibox. | |||
| 371 | + | func (s *BrowserService) GetBestPrefixMatch(ctx context.Context, prefix string) string { | |||
| 372 | + | if s.fuzzyCache == nil || prefix == "" { | |||
| 373 | + | return "" | |||
| 374 | + | } | |||
| 375 | + | return s.fuzzyCache.GetBestPrefixMatch(ctx, prefix) | |||
| 376 | + | } | |||
| 377 | + | ||||
| 369 | // DeleteHistoryEntry removes a specific history entry. | 378 | // DeleteHistoryEntry removes a specific history entry. | ||
| 370 | func (s *BrowserService) DeleteHistoryEntry(ctx context.Context, id int64) error { | 379 | func (s *BrowserService) DeleteHistoryEntry(ctx context.Context, id int64) error { | ||
| 371 | return s.dbQueries.DeleteHistory(ctx, id) | 380 | return s.dbQueries.DeleteHistory(ctx, id) | ||