detached 26fd7de

Merge pull request #206 from bnema/refactor/omnibox-ghostwriter

fix: rewrite omnibox ghostwriter to eliminate letter-eating on fast typing
commit
26fd7de610c6f1a44ecfa6ef70a1ecc88b7c1816
author
bnema · · 2026-03-27 20:19 UTC
committer
GitHub · · 2026-03-27 20:19 UTC
merge of
459eca0 + 57a5850 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
9 files · +296 -398
M go.mod +1 -1
@@ -4,7 +4,7 @@ go 1.26.1
44
55 require (
66 github.com/bnema/purego-sqlite v0.1.1
7- github.com/bnema/puregotk v0.2.0
7+ github.com/bnema/puregotk v0.2.1
88 github.com/bnema/puregotk-webkit v0.0.15
99 github.com/charmbracelet/bubbles v1.0.0
1010 github.com/charmbracelet/bubbletea v1.3.10
@@ -4,7 +4,7 @@ go 1.26.1
4 4
5 require (5 require (
6 github.com/bnema/purego-sqlite v0.1.16 github.com/bnema/purego-sqlite v0.1.1
7- github.com/bnema/puregotk v0.2.07+ github.com/bnema/puregotk v0.2.1
8 github.com/bnema/puregotk-webkit v0.0.158 github.com/bnema/puregotk-webkit v0.0.15
9 github.com/charmbracelet/bubbles v1.0.09 github.com/charmbracelet/bubbles v1.0.0
10 github.com/charmbracelet/bubbletea v1.3.1010 github.com/charmbracelet/bubbletea v1.3.10
M go.sum +2 -2 generated file: collapsed
M internal/application/usecase/handle_permission.go +46 -0
@@ -9,6 +9,7 @@ import (
99
1010 "github.com/bnema/dumber/internal/application/port"
1111 "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/bnema/dumber/internal/logging"
1213 "github.com/rs/zerolog"
1314 )
1415
@@ -57,6 +58,33 @@ func (uc *HandlePermissionUseCase) logger(ctx context.Context) *zerolog.Logger {
5758 return log
5859 }
5960
61+const maxPermissionLogURLLen = 96
62+
63+func isWebsiteDataAccessPermission(permTypes []entity.PermissionType) bool {
64+ for _, permType := range permTypes {
65+ if permType == entity.PermissionTypeWebsiteDataAccess {
66+ return true
67+ }
68+ }
69+ return false
70+}
71+
72+func permissionLoggerWithMetadata(log *zerolog.Logger, metadata entity.PermissionMetadata) *zerolog.Logger {
73+ if log == nil {
74+ nop := zerolog.Nop()
75+ return &nop
76+ }
77+ ctx := log.With()
78+ if requesting := metadata[entity.PermissionMetadataKeyRequestingDomain]; requesting != "" {
79+ ctx = ctx.Str("requesting_domain", logging.TruncateURL(requesting, maxPermissionLogURLLen))
80+ }
81+ if current := metadata[entity.PermissionMetadataKeyCurrentDomain]; current != "" {
82+ ctx = ctx.Str("current_domain", logging.TruncateURL(current, maxPermissionLogURLLen))
83+ }
84+ enriched := ctx.Logger()
85+ return &enriched
86+}
87+
6088 // SetDialogPresenter sets the dialog presenter. This can be called after
6189 // initialization when the UI window is available.
6290 func (uc *HandlePermissionUseCase) SetDialogPresenter(dialog port.PermissionDialogPresenter) {
@@ -127,10 +155,18 @@ func (uc *HandlePermissionUseCase) HandlePermissionRequest(
127155 decision := uc.checkStoredPermissions(ctx, origin, permTypes)
128156 switch decision {
129157 case entity.PermissionGranted:
158+ if isWebsiteDataAccessPermission(permTypes) {
159+ permissionLoggerWithMetadata(&log, metadata).Info().
160+ Msg("using stored website data access permission: granted")
161+ }
130162 log.Debug().Msg("using stored permission: granted")
131163 callback.Allow()
132164 return
133165 case entity.PermissionDenied:
166+ if isWebsiteDataAccessPermission(permTypes) {
167+ permissionLoggerWithMetadata(&log, metadata).Info().
168+ Msg("using stored website data access permission: denied")
169+ }
134170 log.Debug().Msg("using stored permission: denied")
135171 callback.Deny()
136172 return
@@ -269,6 +305,11 @@ func (uc *HandlePermissionUseCase) showPermissionDialog(
269305
270306 dialog.ShowPermissionDialog(ctx, origin, permTypes, metadata, func(result port.PermissionDialogResult) {
271307 if result.Allowed {
308+ if isWebsiteDataAccessPermission(permTypes) {
309+ permissionLoggerWithMetadata(log, metadata).Info().
310+ Bool("persistent", result.Persistent).
311+ Msg("website data access permission allowed")
312+ }
272313 log.Debug().Bool("persistent", result.Persistent).Msg("user allowed permission")
273314 callback.Allow()
274315
@@ -277,6 +318,11 @@ func (uc *HandlePermissionUseCase) showPermissionDialog(
277318 uc.persistPermission(ctx, origin, permTypes, entity.PermissionGranted)
278319 }
279320 } else {
321+ if isWebsiteDataAccessPermission(permTypes) {
322+ permissionLoggerWithMetadata(log, metadata).Info().
323+ Bool("persistent", result.Persistent).
324+ Msg("website data access permission denied")
325+ }
280326 log.Debug().Msg("user denied permission")
281327 callback.Deny()
282328
@@ -9,6 +9,7 @@ import (
9 9
10 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
11 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/bnema/dumber/internal/logging"
12 "github.com/rs/zerolog"13 "github.com/rs/zerolog"
13 )14 )
14 15
@@ -57,6 +58,33 @@ func (uc *HandlePermissionUseCase) logger(ctx context.Context) *zerolog.Logger {
57 return log58 return log
58 }59 }
59 60
61+const maxPermissionLogURLLen = 96
62+
63+func isWebsiteDataAccessPermission(permTypes []entity.PermissionType) bool {
64+ for _, permType := range permTypes {
65+ if permType == entity.PermissionTypeWebsiteDataAccess {
66+ return true
67+ }
68+ }
69+ return false
70+}
71+
72+func permissionLoggerWithMetadata(log *zerolog.Logger, metadata entity.PermissionMetadata) *zerolog.Logger {
73+ if log == nil {
74+ nop := zerolog.Nop()
75+ return &nop
76+ }
77+ ctx := log.With()
78+ if requesting := metadata[entity.PermissionMetadataKeyRequestingDomain]; requesting != "" {
79+ ctx = ctx.Str("requesting_domain", logging.TruncateURL(requesting, maxPermissionLogURLLen))
80+ }
81+ if current := metadata[entity.PermissionMetadataKeyCurrentDomain]; current != "" {
82+ ctx = ctx.Str("current_domain", logging.TruncateURL(current, maxPermissionLogURLLen))
83+ }
84+ enriched := ctx.Logger()
85+ return &enriched
86+}
87+
60 // SetDialogPresenter sets the dialog presenter. This can be called after88 // SetDialogPresenter sets the dialog presenter. This can be called after
61 // initialization when the UI window is available.89 // initialization when the UI window is available.
62 func (uc *HandlePermissionUseCase) SetDialogPresenter(dialog port.PermissionDialogPresenter) {90 func (uc *HandlePermissionUseCase) SetDialogPresenter(dialog port.PermissionDialogPresenter) {
@@ -127,10 +155,18 @@ func (uc *HandlePermissionUseCase) HandlePermissionRequest(
127 decision := uc.checkStoredPermissions(ctx, origin, permTypes)155 decision := uc.checkStoredPermissions(ctx, origin, permTypes)
128 switch decision {156 switch decision {
129 case entity.PermissionGranted:157 case entity.PermissionGranted:
158+ if isWebsiteDataAccessPermission(permTypes) {
159+ permissionLoggerWithMetadata(&log, metadata).Info().
160+ Msg("using stored website data access permission: granted")
161+ }
130 log.Debug().Msg("using stored permission: granted")162 log.Debug().Msg("using stored permission: granted")
131 callback.Allow()163 callback.Allow()
132 return164 return
133 case entity.PermissionDenied:165 case entity.PermissionDenied:
166+ if isWebsiteDataAccessPermission(permTypes) {
167+ permissionLoggerWithMetadata(&log, metadata).Info().
168+ Msg("using stored website data access permission: denied")
169+ }
134 log.Debug().Msg("using stored permission: denied")170 log.Debug().Msg("using stored permission: denied")
135 callback.Deny()171 callback.Deny()
136 return172 return
@@ -269,6 +305,11 @@ func (uc *HandlePermissionUseCase) showPermissionDialog(
269 305
270 dialog.ShowPermissionDialog(ctx, origin, permTypes, metadata, func(result port.PermissionDialogResult) {306 dialog.ShowPermissionDialog(ctx, origin, permTypes, metadata, func(result port.PermissionDialogResult) {
271 if result.Allowed {307 if result.Allowed {
308+ if isWebsiteDataAccessPermission(permTypes) {
309+ permissionLoggerWithMetadata(log, metadata).Info().
310+ Bool("persistent", result.Persistent).
311+ Msg("website data access permission allowed")
312+ }
272 log.Debug().Bool("persistent", result.Persistent).Msg("user allowed permission")313 log.Debug().Bool("persistent", result.Persistent).Msg("user allowed permission")
273 callback.Allow()314 callback.Allow()
274 315
@@ -277,6 +318,11 @@ func (uc *HandlePermissionUseCase) showPermissionDialog(
277 uc.persistPermission(ctx, origin, permTypes, entity.PermissionGranted)318 uc.persistPermission(ctx, origin, permTypes, entity.PermissionGranted)
278 }319 }
279 } else {320 } else {
321+ if isWebsiteDataAccessPermission(permTypes) {
322+ permissionLoggerWithMetadata(log, metadata).Info().
323+ Bool("persistent", result.Persistent).
324+ Msg("website data access permission denied")
325+ }
280 log.Debug().Msg("user denied permission")326 log.Debug().Msg("user denied permission")
281 callback.Deny()327 callback.Deny()
282 328
M internal/infrastructure/webkit/webview.go +31 -0
@@ -419,6 +419,7 @@ func (wv *WebView) connectSignals() {
419419 wv.connectFaviconSignal()
420420 wv.connectProgressSignal()
421421 wv.connectLoadFailedSignal()
422+ wv.connectWebProcessResponsiveSignal()
422423 wv.connectDecidePolicySignal()
423424 wv.connectEnterFullscreenSignal()
424425 wv.connectLeaveFullscreenSignal()
@@ -480,6 +481,28 @@ func (wv *WebView) connectLoadFailedSignal() {
480481 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
481482 }
482483
484+func (wv *WebView) connectWebProcessResponsiveSignal() {
485+ responsiveCb := func() {
486+ responsive := wv.inner.GetPropertyIsWebProcessResponsive()
487+ event := wv.logger.With().
488+ Uint64("id", uint64(wv.id)).
489+ Str("uri", wv.inner.GetUri()).
490+ Bool("responsive", responsive).
491+ Logger()
492+ if responsive {
493+ event.Info().Msg("web process responsiveness changed")
494+ return
495+ }
496+ event.Warn().Msg("web process became unresponsive")
497+ }
498+ sigID := gobject.SignalConnect(
499+ wv.inner.GoPointer(),
500+ "notify::is-web-process-responsive",
501+ glib.NewCallback(&responsiveCb),
502+ )
503+ wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
504+}
505+
483506 func (wv *WebView) connectCloseSignal() {
484507 closeCb := func(_ webkit.WebView) {
485508 if wv.OnClose != nil {
@@ -586,6 +609,7 @@ func (wv *WebView) connectFaviconSignal() {
586609
587610 // progressThrottleInterval limits progress callbacks to ~60fps to reduce UI overhead.
588611 const progressThrottleInterval = 16 * time.Millisecond
612+const maxPermissionLogURLLen = 96
589613
590614 func (wv *WebView) connectProgressSignal() {
591615 progressCb := func() {
@@ -1024,6 +1048,13 @@ func (wv *WebView) connectPermissionRequestSignal() {
10241048 wv.logger.Warn().Msg("permission request with unknown type, denying")
10251049 return false
10261050 }
1051+ if len(permTypes) == 1 && permTypes[0] == "website_data_access" {
1052+ wv.logger.Info().
1053+ Str("origin", origin).
1054+ Str("requesting_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyRequestingDomain], maxPermissionLogURLLen)).
1055+ Str("current_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyCurrentDomain], maxPermissionLogURLLen)).
1056+ Msg("website data access permission requested")
1057+ }
10271058
10281059 // Ref the request object to prevent use-after-free
10291060 // The request may outlive the signal handler if we show a dialog
@@ -419,6 +419,7 @@ func (wv *WebView) connectSignals() {
419 wv.connectFaviconSignal()419 wv.connectFaviconSignal()
420 wv.connectProgressSignal()420 wv.connectProgressSignal()
421 wv.connectLoadFailedSignal()421 wv.connectLoadFailedSignal()
422+ wv.connectWebProcessResponsiveSignal()
422 wv.connectDecidePolicySignal()423 wv.connectDecidePolicySignal()
423 wv.connectEnterFullscreenSignal()424 wv.connectEnterFullscreenSignal()
424 wv.connectLeaveFullscreenSignal()425 wv.connectLeaveFullscreenSignal()
@@ -480,6 +481,28 @@ func (wv *WebView) connectLoadFailedSignal() {
480 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))481 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
481 }482 }
482 483
484+func (wv *WebView) connectWebProcessResponsiveSignal() {
485+ responsiveCb := func() {
486+ responsive := wv.inner.GetPropertyIsWebProcessResponsive()
487+ event := wv.logger.With().
488+ Uint64("id", uint64(wv.id)).
489+ Str("uri", wv.inner.GetUri()).
490+ Bool("responsive", responsive).
491+ Logger()
492+ if responsive {
493+ event.Info().Msg("web process responsiveness changed")
494+ return
495+ }
496+ event.Warn().Msg("web process became unresponsive")
497+ }
498+ sigID := gobject.SignalConnect(
499+ wv.inner.GoPointer(),
500+ "notify::is-web-process-responsive",
501+ glib.NewCallback(&responsiveCb),
502+ )
503+ wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
504+}
505+
483 func (wv *WebView) connectCloseSignal() {506 func (wv *WebView) connectCloseSignal() {
484 closeCb := func(_ webkit.WebView) {507 closeCb := func(_ webkit.WebView) {
485 if wv.OnClose != nil {508 if wv.OnClose != nil {
@@ -586,6 +609,7 @@ func (wv *WebView) connectFaviconSignal() {
586 609
587 // progressThrottleInterval limits progress callbacks to ~60fps to reduce UI overhead.610 // progressThrottleInterval limits progress callbacks to ~60fps to reduce UI overhead.
588 const progressThrottleInterval = 16 * time.Millisecond611 const progressThrottleInterval = 16 * time.Millisecond
612+const maxPermissionLogURLLen = 96
589 613
590 func (wv *WebView) connectProgressSignal() {614 func (wv *WebView) connectProgressSignal() {
591 progressCb := func() {615 progressCb := func() {
@@ -1024,6 +1048,13 @@ func (wv *WebView) connectPermissionRequestSignal() {
1024 wv.logger.Warn().Msg("permission request with unknown type, denying")1048 wv.logger.Warn().Msg("permission request with unknown type, denying")
1025 return false1049 return false
1026 }1050 }
1051+ if len(permTypes) == 1 && permTypes[0] == "website_data_access" {
1052+ wv.logger.Info().
1053+ Str("origin", origin).
1054+ Str("requesting_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyRequestingDomain], maxPermissionLogURLLen)).
1055+ Str("current_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyCurrentDomain], maxPermissionLogURLLen)).
1056+ Msg("website data access permission requested")
1057+ }
1027 1058
1028 // Ref the request object to prevent use-after-free1059 // Ref the request object to prevent use-after-free
1029 // The request may outlive the signal handler if we show a dialog1060 // The request may outlive the signal handler if we show a dialog
M internal/ui/component/omnibox.go +185 -219
@@ -95,11 +95,9 @@ type Omnibox struct {
9595
9696 // Ghost text state
9797 realInput string // What user actually typed (without ghost suffix)
98- ghostSuffix string // Completion suffix to display
99- ghostFullText string // Full text that ghost completes to
100- hasGhostText bool // Quick check for Tab behavior
101- isAcceptingGhost bool // Prevent re-trigger during acceptance
102- suppressGhost bool // Temporarily disable ghost completion after deletion keys
98+ ghostSuffix string // Completion suffix currently displayed
99+ insertCompletion bool // True on character insert, false on delete — gates ghost completion
100+ isSettingGhost bool // Guard flag: skip onEntryChanged during programmatic SetText
103101
104102 // Dependencies
105103 historyUC *usecase.SearchHistoryUseCase
@@ -123,7 +121,6 @@ type Omnibox struct {
123121 debounceMu sync.Mutex
124122 lastQuery string // Prevent duplicate searches
125123 searchToken uint64
126- ghostToken uint64
127124
128125 // Scaling
129126 uiScale float64
@@ -638,7 +635,6 @@ func (o *Omnibox) assembleWidgets() {
638635
639636 // setupEntryChanged wires entry text changes to debounced search.
640637 func (o *Omnibox) setupEntryChanged() {
641- // SearchEntry has built-in debouncing via search-changed signal
642638 changedCb := func(_ gtk.SearchEntry) {
643639 o.onEntryChanged()
644640 }
@@ -681,7 +677,7 @@ func (o *Omnibox) setupFocusCallbacks(onFocusIn func(*gtk.SearchEntry), onFocusO
681677 func (o *Omnibox) hasGhost() bool {
682678 o.mu.RLock()
683679 defer o.mu.RUnlock()
684- return o.hasGhostText
680+ return o.ghostSuffix != ""
685681 }
686682
687683 // hasUserNavigated returns whether user has navigated with arrow keys.
@@ -694,7 +690,7 @@ func (o *Omnibox) hasUserNavigated() bool {
694690 // handleKeyPress processes keyboard events.
695691 // Returns true if the event was handled.
696692 func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {
697- o.updateGhostSuppressionFromKey(keyval)
693+ o.updateInsertCompletionFromKey(keyval)
698694
699695 ctrl := state&gdk.ControlMaskValue != 0
700696
@@ -767,16 +763,6 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b
767763 return false // Let entry handle the key
768764 }
769765
770-func nextGhostSuppressionState(keyval uint) bool {
771- return isDeletionKey(keyval)
772-}
773-
774-func (o *Omnibox) updateGhostSuppressionFromKey(keyval uint) {
775- o.mu.Lock()
776- o.suppressGhost = nextGhostSuppressionState(keyval)
777- o.mu.Unlock()
778-}
779-
780766 func isDeletionKey(keyval uint) bool {
781767 switch keyval {
782768 case uint(gdk.KEY_BackSpace), uint(gdk.KEY_Delete), uint(gdk.KEY_KP_Delete):
@@ -786,6 +772,28 @@ func isDeletionKey(keyval uint) bool {
786772 }
787773 }
788774
775+// updateInsertCompletionFromKey sets whether the next text change should trigger
776+// ghost completion. Character insertion enables it; deletion disables it.
777+func (o *Omnibox) updateInsertCompletionFromKey(keyval uint) {
778+ o.mu.Lock()
779+ o.insertCompletion = !isDeletionKey(keyval)
780+ o.mu.Unlock()
781+}
782+
783+// withGhostGuard executes fn while the isSettingGhost flag is set,
784+// so that onEntryChanged skips during programmatic text changes.
785+func (o *Omnibox) withGhostGuard(fn func()) {
786+ o.mu.Lock()
787+ o.isSettingGhost = true
788+ o.mu.Unlock()
789+
790+ fn()
791+
792+ o.mu.Lock()
793+ o.isSettingGhost = false
794+ o.mu.Unlock()
795+}
796+
789797 // handleCtrlNumberShortcut handles Ctrl+1-9 and Ctrl+0 for quick navigation.
790798 // Uses hardware keycode as fallback for non-QWERTY keyboards (AZERTY, QWERTZ, etc.).
791799 func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool {
@@ -823,42 +831,60 @@ func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool
823831 }
824832
825833 // onEntryChanged handles text input changes with debouncing.
834+// When this fires, GTK has already processed the keystroke — if ghost text was
835+// selected and the user typed, the selection was replaced naturally by GTK.
836+// We must NOT call clearGhostText() here (that would delete real user text).
826837 func (o *Omnibox) onEntryChanged() {
827- // Skip if we're programmatically accepting ghost text
828838 o.mu.RLock()
829- isAccepting := o.isAcceptingGhost
839+ isSettingGhost := o.isSettingGhost
830840 o.mu.RUnlock()
831- if isAccepting {
841+ if isSettingGhost {
832842 return
833843 }
834844
835845 entryText := o.entry.GetText()
836- if trimmed := url.TrimLeadingSpacesIfURL(entryText); trimmed != entryText {
837- o.entry.SetText(trimmed)
838- o.entry.SetPosition(-1)
846+
847+ // Detect debounced echo from our own SetText in setGhostText.
848+ // search-changed is debounced by GtkSearchEntry, so it fires AFTER
849+ // the isSettingGhost guard is already off.
850+ o.mu.RLock()
851+ if o.ghostSuffix != "" && entryText == o.realInput+o.ghostSuffix {
852+ o.mu.RUnlock()
839853 return
840854 }
855+ o.mu.RUnlock()
841856
842- if o.isGhostProgrammaticEcho(entryText) {
843- return
857+ log := logging.FromContext(o.ctx)
858+ if trimmed := url.TrimLeadingSpacesIfURL(entryText); trimmed != entryText {
859+ o.withGhostGuard(func() {
860+ o.entry.SetText(trimmed)
861+ o.entry.SetPosition(-1)
862+ })
863+ entryText = trimmed
844864 }
845865
846- // Reset navigation state when user types - space should type, not toggle favorite
866+ // Reset ghost and selection state (the buffer is already correct — GTK handled it).
847867 o.mu.Lock()
848- previousInput := o.realInput
868+ oldGhost := o.ghostSuffix
849869 o.hasNavigated = false
850- // Update realInput with what user actually typed
870+ o.selectedIndex = -1
871+ o.ghostSuffix = ""
851872 o.realInput = entryText
873+ shouldComplete := o.insertCompletion
852874 o.mu.Unlock()
853875
854- // Clear ghost text when user types (it will be recalculated after search)
855- o.clearGhostText()
856- // Recompute immediately from currently visible rows to avoid transient gaps
857- // where suggestions are visible but ghost text is missing until async search returns.
858- if shouldUpdateGhostImmediately(previousInput, entryText) {
876+ log.Debug().
877+ Str("entryText", entryText).
878+ Str("oldGhost", oldGhost).
879+ Bool("insertCompletion", shouldComplete).
880+ Msg("ghost: onEntryChanged")
881+
882+ // Compute ghost completion synchronously if this was a character insertion.
883+ if shouldComplete {
859884 o.updateGhostFromSelectionWithInput(entryText)
860885 }
861886
887+ // Schedule async search for suggestion list updates.
862888 o.debounceMu.Lock()
863889 if o.debounceTimer != nil {
864890 o.debounceTimer.Stop()
@@ -869,131 +895,93 @@ func (o *Omnibox) onEntryChanged() {
869895 o.debounceMu.Unlock()
870896 }
871897
872-func shouldUpdateGhostImmediately(previousInput, entryText string) bool {
873- return utf8.RuneCountInString(entryText) >= utf8.RuneCountInString(previousInput)
874-}
898+// setGhostText applies inline completion by setting the full text (input + suffix)
899+// and selecting the suffix portion. Uses guard flag to prevent re-entrant onEntryChanged.
900+// Skips if the user has typed since this ghost was computed (stale input check).
901+func (o *Omnibox) setGhostText(originalInput, suffix string) {
902+ log := logging.FromContext(o.ctx)
903+ if o.entry == nil || suffix == "" {
904+ o.clearGhostText()
905+ return
906+ }
875907
876-func (o *Omnibox) isGhostProgrammaticEcho(entryText string) bool {
908+ // Verify the buffer hasn't been modified by user typing since this ghost
909+ // was computed. We check the actual buffer — not realInput, which may be
910+ // stale due to debounced search-changed signal.
911+ currentBuffer := o.entry.GetText()
877912 o.mu.RLock()
878- hasGhost := o.hasGhostText
879- realInput := o.realInput
880- ghostFullText := o.ghostFullText
913+ existingGhost := o.ghostSuffix
881914 o.mu.RUnlock()
882-
883- if !hasGhost || ghostFullText == "" || entryText != ghostFullText {
884- return false
885- }
886-
887- selectionStart := 0
888- selectionEnd := 0
889- hasSelection := o.entry.GetSelectionBounds(&selectionStart, &selectionEnd)
890- return isInlineGhostSelection(realInput, ghostFullText, selectionStart, selectionEnd, hasSelection)
891-}
892-
893-func isInlineGhostSelection(realInput, ghostFullText string, selectionStart, selectionEnd int, hasSelection bool) bool {
894- if !hasSelection {
895- return false
896- }
897- prefixRunes := utf8.RuneCountInString(realInput)
898- fullRunes := utf8.RuneCountInString(ghostFullText)
899- if fullRunes <= prefixRunes {
900- return false
901- }
902- return selectionStart == prefixRunes && selectionEnd == fullRunes
903-}
904-
905-// setGhostText applies inline completion using entry selection.
906-// It writes full text and selects [len(originalInput):end] as the ghost suffix.
907-func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {
908- if o.entry == nil || suffix == "" {
909- o.clearGhostText()
915+ expectedWithGhost := originalInput + existingGhost
916+ if currentBuffer != originalInput && currentBuffer != expectedWithGhost {
917+ log.Debug().
918+ Str("originalInput", originalInput).
919+ Str("currentBuffer", currentBuffer).
920+ Str("suffix", suffix).
921+ Msg("ghost: setGhostText skipped (buffer modified by user)")
910922 return
911923 }
912924
913925 displayText := originalInput + suffix
914- if fullText != "" {
915- displayText = fullText
916- }
917- selectionStart := utf8.RuneCountInString(originalInput)
918- selectionEnd := utf8.RuneCountInString(displayText)
926+ cursorPos := utf8.RuneCountInString(originalInput)
927+ endPos := utf8.RuneCountInString(displayText)
928+
929+ log.Debug().
930+ Str("input", originalInput).
931+ Str("suffix", suffix).
932+ Int("cursorPos", cursorPos).
933+ Int("endPos", endPos).
934+ Msg("ghost: setGhostText")
919935
920936 o.mu.Lock()
921937 o.realInput = originalInput
922938 o.ghostSuffix = suffix
923- o.ghostFullText = fullText
924- o.hasGhostText = true
925- capturedToken := o.ghostToken
926- o.mu.Unlock()
927-
928- o.idleCoalescer.Post("omnibox-ghost-set", func() {
929- if o.entry == nil {
930- return
931- }
932939
933- o.mu.RLock()
934- currentInput := o.realInput
935- currentToken := o.ghostToken
936- o.mu.RUnlock()
937- if currentInput != originalInput {
938- return
939- }
940- if capturedToken != currentToken {
941- return
942- }
943- o.mu.Lock()
944- o.isAcceptingGhost = true
945- o.mu.Unlock()
940+ o.mu.Unlock()
946941
942+ o.withGhostGuard(func() {
947943 o.entry.SetText(displayText)
948- o.entry.SetPosition(selectionStart)
949- o.entry.SelectRegion(selectionStart, selectionEnd)
950-
951- o.mu.Lock()
952- o.isAcceptingGhost = false
953- o.mu.Unlock()
944+ o.entry.SetPosition(cursorPos)
945+ o.entry.SelectRegion(cursorPos, endPos)
954946 })
955947 }
956948
957-// clearGhostText hides the ghost completion text.
949+// clearGhostText removes any ghost suffix from the entry buffer.
950+// Uses guard flag to prevent re-entrant onEntryChanged.
958951 func (o *Omnibox) clearGhostText() {
952+ log := logging.FromContext(o.ctx)
959953 o.mu.Lock()
954+ hadGhost := o.ghostSuffix != ""
955+ ghostSuffix := o.ghostSuffix
960956 realInput := o.realInput
961- ghostFullText := o.ghostFullText
962- hadGhost := o.hasGhostText || o.ghostSuffix != "" || o.ghostFullText != ""
963- o.ghostToken++
964- capturedToken := o.ghostToken
965957 o.ghostSuffix = ""
966- o.ghostFullText = ""
967- o.hasGhostText = false
958+
968959 o.mu.Unlock()
969960
970- if !hadGhost || o.entry == nil {
961+ if !hadGhost || ghostSuffix == "" || o.entry == nil {
971962 return
972963 }
973964
974- o.idleCoalescer.Post("omnibox-ghost-clear", func() {
975- if o.entry == nil {
976- return
977- }
978- o.mu.RLock()
979- currentToken := o.ghostToken
980- o.mu.RUnlock()
981- if capturedToken != currentToken {
982- return
983- }
984- o.mu.Lock()
985- o.isAcceptingGhost = true
986- o.mu.Unlock()
987- // Only revert text when the entry still shows the previous ghost completion.
988- // If the user typed since scheduling this idle callback, never overwrite it.
989- if o.entry.GetText() == ghostFullText && o.entry.GetText() != realInput {
990- o.entry.SetText(realInput)
991- }
965+ // Only touch the buffer if it still shows the expected ghost text.
966+ expectedText := realInput + ghostSuffix
967+ currentText := o.entry.GetText()
968+ if currentText != expectedText {
969+ log.Debug().
970+ Str("expected", expectedText).
971+ Str("current", currentText).
972+ Msg("ghost: clearGhostText skipped (buffer changed)")
973+ return
974+ }
975+
976+ log.Debug().
977+ Str("realInput", realInput).
978+ Str("ghostSuffix", ghostSuffix).
979+ Msg("ghost: clearGhostText")
980+
981+ o.withGhostGuard(func() {
982+ o.entry.SetText(realInput)
992983 o.entry.SelectRegion(-1, -1)
993984 o.entry.SetPosition(-1)
994- o.mu.Lock()
995- o.isAcceptingGhost = false
996- o.mu.Unlock()
997985 })
998986 }
999987
@@ -1009,45 +997,40 @@ func (o *Omnibox) clearGhostTextIfInput(expectedInput string) {
1009997 o.clearGhostText()
1010998 }
1011999
1012-// acceptGhostCompletion accepts the ghost text and fills the input.
1000+// acceptGhostCompletion accepts the ghost text — the suffix is already in the
1001+// buffer, so we just deselect and update realInput to include it.
10131002 func (o *Omnibox) acceptGhostCompletion() {
1003+ log := logging.FromContext(o.ctx)
10141004 o.mu.Lock()
1015- fullText, accepted := acceptedGhostInput(o.hasGhostText, o.ghostFullText)
1016- if !accepted {
1005+ if o.ghostSuffix == "" {
10171006 o.mu.Unlock()
1007+ log.Debug().Msg("ghost: acceptGhostCompletion — no ghost to accept")
10181008 return
10191009 }
1020- o.isAcceptingGhost = true
1021- o.realInput = fullText
1022- o.selectedIndex = -1
1023- o.hasNavigated = false
1024- o.ghostToken++
1010+ suffix := o.ghostSuffix
1011+
10251012 o.ghostSuffix = ""
1026- o.ghostFullText = ""
1027- o.hasGhostText = false
10281013 o.mu.Unlock()
1014+ log.Debug().Str("suffix", suffix).Msg("ghost: acceptGhostCompletion")
10291015
10301016 if o.entry != nil {
1031- if o.entry.GetText() != fullText {
1032- o.entry.SetText(fullText)
1033- }
1034- o.entry.SelectRegion(-1, -1)
1035- o.entry.SetPosition(-1)
1017+ // The suffix is already in the buffer as selected text.
1018+ // Just deselect and move cursor to end.
1019+ fullText := o.entry.GetText()
1020+ o.mu.Lock()
1021+ o.realInput = fullText
1022+ o.selectedIndex = -1
1023+ o.hasNavigated = false
1024+ o.mu.Unlock()
1025+
1026+ o.withGhostGuard(func() {
1027+ o.entry.SelectRegion(-1, -1)
1028+ o.entry.SetPosition(-1)
1029+ })
10361030 }
10371031 if o.listBox != nil {
10381032 o.listBox.SelectRow(nil)
10391033 }
1040-
1041- o.mu.Lock()
1042- o.isAcceptingGhost = false
1043- o.mu.Unlock()
1044-}
1045-
1046-func acceptedGhostInput(hasGhost bool, ghostFullText string) (string, bool) {
1047- if !hasGhost || ghostFullText == "" {
1048- return "", false
1049- }
1050- return ghostFullText, true
10511034 }
10521035
10531036 // updateGhostFromURL updates ghost text based on a specific URL (from row selection).
@@ -1064,13 +1047,13 @@ func (o *Omnibox) updateGhostFromURL(userInput, targetURL string) bool {
10641047
10651048 // Only show full URL as ghost text when input is empty (initial state)
10661049 if userInput == "" {
1067- o.setGhostText("", displayURL, displayURL)
1050+ o.setGhostText("", displayURL)
10681051 return true
10691052 }
10701053
10711054 // Try to compute proper suffix if input is a prefix of the URL
1072- if suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(userInput, targetURL); ok {
1073- o.setGhostText(userInput, suffix, matchedURL)
1055+ if suffix, _, ok := autocomplete.ComputeURLCompletionSuffix(userInput, targetURL); ok {
1056+ o.setGhostText(userInput, suffix)
10741057 return true
10751058 }
10761059
@@ -1081,7 +1064,7 @@ func (o *Omnibox) updateGhostFromURL(userInput, targetURL string) bool {
10811064 func (o *Omnibox) updateGhostFromSelection() {
10821065 o.mu.RLock()
10831066 realInput := o.realInput
1084- hasGhost := o.hasGhostText
1067+ hasGhost := o.ghostSuffix != ""
10851068 o.mu.RUnlock()
10861069
10871070 entryText := ""
@@ -1099,44 +1082,39 @@ func (o *Omnibox) restoreEntryToRealInput() {
10991082 return
11001083 }
11011084
1085+ // Clear ghost text first (removes suffix from buffer with guard).
1086+ o.clearGhostText()
1087+
1088+ // If the buffer still doesn't match realInput, force-set it.
11021089 o.mu.RLock()
11031090 realInput := o.realInput
1104- hadGhost := o.hasGhostText || o.ghostSuffix != "" || o.ghostFullText != ""
11051091 o.mu.RUnlock()
11061092
1107- o.mu.Lock()
1108- o.isAcceptingGhost = true
1109- o.ghostToken++
1110- o.ghostSuffix = ""
1111- o.ghostFullText = ""
1112- o.hasGhostText = false
1113- o.mu.Unlock()
1114-
1115- if hadGhost || o.entry.GetText() != realInput {
1116- o.entry.SetText(realInput)
1093+ if o.entry.GetText() != realInput {
1094+ o.withGhostGuard(func() {
1095+ o.entry.SetText(realInput)
1096+ })
11171097 }
11181098 o.entry.SelectRegion(-1, -1)
11191099 o.entry.SetPosition(-1)
1120-
1121- o.mu.Lock()
1122- o.isAcceptingGhost = false
1123- o.mu.Unlock()
11241100 }
11251101
11261102 // updateGhostFromSelectionWithInput updates ghost text based on selected row and input.
11271103 func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) {
1104+ log := logging.FromContext(o.ctx)
11281105 o.mu.RLock()
11291106 idx := o.selectedIndex
11301107 mode := o.viewMode
11311108 bangMode := o.bangMode
11321109 hasNavigated := o.hasNavigated
1133- suppressGhost := o.suppressGhost
1110+ shouldComplete := o.insertCompletion
11341111 suggestions := o.suggestions
11351112 favorites := o.favorites
11361113 o.mu.RUnlock()
11371114
1138- if suppressGhost {
1139- o.clearGhostTextIfInput(entryText)
1115+ // No ghost text after deletion (backspace/delete key).
1116+ if !shouldComplete {
1117+ log.Debug().Str("entryText", entryText).Msg("ghost: updateGhost skipped (deletion key)")
11401118 return
11411119 }
11421120
@@ -1170,6 +1148,8 @@ func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) {
11701148 o.clearGhostTextIfInput(entryText)
11711149 }
11721150
1151+// resolveGhostCompletion computes and applies ghost text synchronously.
1152+// All computation is pure string matching — no I/O, no goroutines needed.
11731153 func (o *Omnibox) resolveGhostCompletion(
11741154 entryText, selectedURL string,
11751155 hasExplicitSelection bool,
@@ -1177,33 +1157,29 @@ func (o *Omnibox) resolveGhostCompletion(
11771157 suggestions []Suggestion,
11781158 favorites []Favorite,
11791159 ) {
1180- leadingWhitespace, completionInput, ok := ghostCompletionInput(entryText)
1160+ log := logging.FromContext(o.ctx)
1161+ _, completionInput, ok := ghostCompletionInput(entryText)
11811162 if !ok {
1182- o.clearGhostTextIfInput(entryText)
1163+ log.Debug().Str("entryText", entryText).Msg("ghost: resolveGhost — no completion input")
11831164 return
11841165 }
11851166
1186- o.mu.Lock()
1187- o.ghostToken++
1188- ghostToken := o.ghostToken
1189- o.mu.Unlock()
1190-
1191- go func() {
1192- fullText, suffix, found := visibleGhostSuggestion(completionInput, selectedURL, hasExplicitSelection, mode, suggestions, favorites)
1193- if !o.isGhostTokenCurrent(ghostToken, entryText) {
1194- return
1195- }
1196- if !found {
1197- o.clearGhostTextIfInput(entryText)
1198- return
1199- }
1200- fullText, suffix = normalizeGhostSuggestion(completionInput, fullText, suffix)
1201- if fullText == "" || suffix == "" {
1202- o.clearGhostTextIfInput(entryText)
1203- return
1204- }
1205- o.setGhostText(entryText, suffix, leadingWhitespace+fullText)
1206- }()
1167+ fullText, suffix, found := visibleGhostSuggestion(completionInput, selectedURL, hasExplicitSelection, mode, suggestions, favorites)
1168+ if !found {
1169+ log.Debug().Str("input", completionInput).Msg("ghost: resolveGhost — no match found")
1170+ return
1171+ }
1172+ fullText, suffix = normalizeGhostSuggestion(completionInput, fullText, suffix)
1173+ if fullText == "" || suffix == "" {
1174+ log.Debug().Str("input", completionInput).Msg("ghost: resolveGhost — empty after normalize")
1175+ return
1176+ }
1177+ log.Debug().
1178+ Str("input", completionInput).
1179+ Str("fullText", fullText).
1180+ Str("suffix", suffix).
1181+ Msg("ghost: resolveGhost — applying")
1182+ o.setGhostText(entryText, suffix)
12071183 }
12081184
12091185 func ghostCompletionInput(entryText string) (leadingWhitespace, completionInput string, ok bool) {
@@ -1300,21 +1276,13 @@ func selectedTargetURL(mode ViewMode, idx int, suggestions []Suggestion, favorit
13001276 return resolveTargetURLForSelection(mode, idx, suggestions, favorites), true
13011277 }
13021278
1303-func (o *Omnibox) isGhostTokenCurrent(ghostToken uint64, query string) bool {
1304- o.mu.RLock()
1305- defer o.mu.RUnlock()
1306- return o.visible &&
1307- o.ghostToken == ghostToken &&
1308- o.realInput == query
1309-}
1310-
13111279 // performSearch executes the search based on current view mode and query.
13121280 func (o *Omnibox) performSearch() {
13131281 o.mu.RLock()
13141282 visible := o.visible
13151283 mode := o.viewMode
13161284 realInput := o.realInput
1317- hasGhost := o.hasGhostText
1285+ hasGhost := o.ghostSuffix != ""
13181286 o.mu.RUnlock()
13191287
13201288 // Skip search if omnibox is hidden
@@ -1876,7 +1844,7 @@ func (o *Omnibox) attachRowHoverSelection(row *gtk.ListBoxRow, index int) {
18761844 enterCb := func(_ gtk.EventControllerMotion, _ float64, _ float64) {
18771845 o.mu.RLock()
18781846 realInput := o.realInput
1879- hasGhostText := o.hasGhostText
1847+ hasGhostText := o.ghostSuffix != ""
18801848 hasNavigated := o.hasNavigated
18811849 o.mu.RUnlock()
18821850
@@ -2341,10 +2309,8 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
23412309 // Initialize ghost text state
23422310 o.realInput = query
23432311 o.ghostSuffix = ""
2344- o.ghostFullText = ""
2345- o.hasGhostText = false
2346- o.isAcceptingGhost = false
2347- o.suppressGhost = false
2312+
2313+ o.insertCompletion = false
23482314 o.mu.Unlock()
23492315
23502316 // Set initial query
@@ -2413,10 +2379,10 @@ func (o *Omnibox) Hide(ctx context.Context) {
24132379 o.entry.SetText("")
24142380 o.listBox.RemoveAll()
24152381
2416- // Reset realInput
2382+ // Reset state
24172383 o.mu.Lock()
24182384 o.realInput = ""
2419- o.suppressGhost = false
2385+ o.insertCompletion = false
24202386 o.mu.Unlock()
24212387 o.resetSearchSessionState()
24222388
@@ -95,11 +95,9 @@ type Omnibox struct {
95 95
96 // Ghost text state96 // Ghost text state
97 realInput string // What user actually typed (without ghost suffix)97 realInput string // What user actually typed (without ghost suffix)
98- ghostSuffix string // Completion suffix to display98+ ghostSuffix string // Completion suffix currently displayed
99- ghostFullText string // Full text that ghost completes to99+ insertCompletion bool // True on character insert, false on delete — gates ghost completion
100- hasGhostText bool // Quick check for Tab behavior100+ isSettingGhost bool // Guard flag: skip onEntryChanged during programmatic SetText
101- isAcceptingGhost bool // Prevent re-trigger during acceptance
102- suppressGhost bool // Temporarily disable ghost completion after deletion keys
103 101
104 // Dependencies102 // Dependencies
105 historyUC *usecase.SearchHistoryUseCase103 historyUC *usecase.SearchHistoryUseCase
@@ -123,7 +121,6 @@ type Omnibox struct {
123 debounceMu sync.Mutex121 debounceMu sync.Mutex
124 lastQuery string // Prevent duplicate searches122 lastQuery string // Prevent duplicate searches
125 searchToken uint64123 searchToken uint64
126- ghostToken uint64
127 124
128 // Scaling125 // Scaling
129 uiScale float64126 uiScale float64
@@ -638,7 +635,6 @@ func (o *Omnibox) assembleWidgets() {
638 635
639 // setupEntryChanged wires entry text changes to debounced search.636 // setupEntryChanged wires entry text changes to debounced search.
640 func (o *Omnibox) setupEntryChanged() {637 func (o *Omnibox) setupEntryChanged() {
641- // SearchEntry has built-in debouncing via search-changed signal
642 changedCb := func(_ gtk.SearchEntry) {638 changedCb := func(_ gtk.SearchEntry) {
643 o.onEntryChanged()639 o.onEntryChanged()
644 }640 }
@@ -681,7 +677,7 @@ func (o *Omnibox) setupFocusCallbacks(onFocusIn func(*gtk.SearchEntry), onFocusO
681 func (o *Omnibox) hasGhost() bool {677 func (o *Omnibox) hasGhost() bool {
682 o.mu.RLock()678 o.mu.RLock()
683 defer o.mu.RUnlock()679 defer o.mu.RUnlock()
684- return o.hasGhostText680+ return o.ghostSuffix != ""
685 }681 }
686 682
687 // hasUserNavigated returns whether user has navigated with arrow keys.683 // hasUserNavigated returns whether user has navigated with arrow keys.
@@ -694,7 +690,7 @@ func (o *Omnibox) hasUserNavigated() bool {
694 // handleKeyPress processes keyboard events.690 // handleKeyPress processes keyboard events.
695 // Returns true if the event was handled.691 // Returns true if the event was handled.
696 func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {692 func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {
697- o.updateGhostSuppressionFromKey(keyval)693+ o.updateInsertCompletionFromKey(keyval)
698 694
699 ctrl := state&gdk.ControlMaskValue != 0695 ctrl := state&gdk.ControlMaskValue != 0
700 696
@@ -767,16 +763,6 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b
767 return false // Let entry handle the key763 return false // Let entry handle the key
768 }764 }
769 765
770-func nextGhostSuppressionState(keyval uint) bool {
771- return isDeletionKey(keyval)
772-}
773-
774-func (o *Omnibox) updateGhostSuppressionFromKey(keyval uint) {
775- o.mu.Lock()
776- o.suppressGhost = nextGhostSuppressionState(keyval)
777- o.mu.Unlock()
778-}
779-
780 func isDeletionKey(keyval uint) bool {766 func isDeletionKey(keyval uint) bool {
781 switch keyval {767 switch keyval {
782 case uint(gdk.KEY_BackSpace), uint(gdk.KEY_Delete), uint(gdk.KEY_KP_Delete):768 case uint(gdk.KEY_BackSpace), uint(gdk.KEY_Delete), uint(gdk.KEY_KP_Delete):
@@ -786,6 +772,28 @@ func isDeletionKey(keyval uint) bool {
786 }772 }
787 }773 }
788 774
775+// updateInsertCompletionFromKey sets whether the next text change should trigger
776+// ghost completion. Character insertion enables it; deletion disables it.
777+func (o *Omnibox) updateInsertCompletionFromKey(keyval uint) {
778+ o.mu.Lock()
779+ o.insertCompletion = !isDeletionKey(keyval)
780+ o.mu.Unlock()
781+}
782+
783+// withGhostGuard executes fn while the isSettingGhost flag is set,
784+// so that onEntryChanged skips during programmatic text changes.
785+func (o *Omnibox) withGhostGuard(fn func()) {
786+ o.mu.Lock()
787+ o.isSettingGhost = true
788+ o.mu.Unlock()
789+
790+ fn()
791+
792+ o.mu.Lock()
793+ o.isSettingGhost = false
794+ o.mu.Unlock()
795+}
796+
789 // handleCtrlNumberShortcut handles Ctrl+1-9 and Ctrl+0 for quick navigation.797 // handleCtrlNumberShortcut handles Ctrl+1-9 and Ctrl+0 for quick navigation.
790 // Uses hardware keycode as fallback for non-QWERTY keyboards (AZERTY, QWERTZ, etc.).798 // Uses hardware keycode as fallback for non-QWERTY keyboards (AZERTY, QWERTZ, etc.).
791 func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool {799 func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool {
@@ -823,42 +831,60 @@ func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool
823 }831 }
824 832
825 // onEntryChanged handles text input changes with debouncing.833 // onEntryChanged handles text input changes with debouncing.
834+// When this fires, GTK has already processed the keystroke — if ghost text was
835+// selected and the user typed, the selection was replaced naturally by GTK.
836+// We must NOT call clearGhostText() here (that would delete real user text).
826 func (o *Omnibox) onEntryChanged() {837 func (o *Omnibox) onEntryChanged() {
827- // Skip if we're programmatically accepting ghost text
828 o.mu.RLock()838 o.mu.RLock()
829- isAccepting := o.isAcceptingGhost839+ isSettingGhost := o.isSettingGhost
830 o.mu.RUnlock()840 o.mu.RUnlock()
831- if isAccepting {841+ if isSettingGhost {
832 return842 return
833 }843 }
834 844
835 entryText := o.entry.GetText()845 entryText := o.entry.GetText()
836- if trimmed := url.TrimLeadingSpacesIfURL(entryText); trimmed != entryText {846+
837- o.entry.SetText(trimmed)847+ // Detect debounced echo from our own SetText in setGhostText.
838- o.entry.SetPosition(-1)848+ // search-changed is debounced by GtkSearchEntry, so it fires AFTER
849+ // the isSettingGhost guard is already off.
850+ o.mu.RLock()
851+ if o.ghostSuffix != "" && entryText == o.realInput+o.ghostSuffix {
852+ o.mu.RUnlock()
839 return853 return
840 }854 }
855+ o.mu.RUnlock()
841 856
842- if o.isGhostProgrammaticEcho(entryText) {857+ log := logging.FromContext(o.ctx)
843- return858+ if trimmed := url.TrimLeadingSpacesIfURL(entryText); trimmed != entryText {
859+ o.withGhostGuard(func() {
860+ o.entry.SetText(trimmed)
861+ o.entry.SetPosition(-1)
862+ })
863+ entryText = trimmed
844 }864 }
845 865
846- // Reset navigation state when user types - space should type, not toggle favorite866+ // Reset ghost and selection state (the buffer is already correct — GTK handled it).
847 o.mu.Lock()867 o.mu.Lock()
848- previousInput := o.realInput868+ oldGhost := o.ghostSuffix
849 o.hasNavigated = false869 o.hasNavigated = false
850- // Update realInput with what user actually typed870+ o.selectedIndex = -1
871+ o.ghostSuffix = ""
851 o.realInput = entryText872 o.realInput = entryText
873+ shouldComplete := o.insertCompletion
852 o.mu.Unlock()874 o.mu.Unlock()
853 875
854- // Clear ghost text when user types (it will be recalculated after search)876+ log.Debug().
855- o.clearGhostText()877+ Str("entryText", entryText).
856- // Recompute immediately from currently visible rows to avoid transient gaps878+ Str("oldGhost", oldGhost).
857- // where suggestions are visible but ghost text is missing until async search returns.879+ Bool("insertCompletion", shouldComplete).
858- if shouldUpdateGhostImmediately(previousInput, entryText) {880+ Msg("ghost: onEntryChanged")
881+
882+ // Compute ghost completion synchronously if this was a character insertion.
883+ if shouldComplete {
859 o.updateGhostFromSelectionWithInput(entryText)884 o.updateGhostFromSelectionWithInput(entryText)
860 }885 }
861 886
887+ // Schedule async search for suggestion list updates.
862 o.debounceMu.Lock()888 o.debounceMu.Lock()
863 if o.debounceTimer != nil {889 if o.debounceTimer != nil {
864 o.debounceTimer.Stop()890 o.debounceTimer.Stop()
@@ -869,131 +895,93 @@ func (o *Omnibox) onEntryChanged() {
869 o.debounceMu.Unlock()895 o.debounceMu.Unlock()
870 }896 }
871 897
872-func shouldUpdateGhostImmediately(previousInput, entryText string) bool {898+// setGhostText applies inline completion by setting the full text (input + suffix)
873- return utf8.RuneCountInString(entryText) >= utf8.RuneCountInString(previousInput)899+// and selecting the suffix portion. Uses guard flag to prevent re-entrant onEntryChanged.
874-}900+// Skips if the user has typed since this ghost was computed (stale input check).
901+func (o *Omnibox) setGhostText(originalInput, suffix string) {
902+ log := logging.FromContext(o.ctx)
903+ if o.entry == nil || suffix == "" {
904+ o.clearGhostText()
905+ return
906+ }
875 907
876-func (o *Omnibox) isGhostProgrammaticEcho(entryText string) bool {908+ // Verify the buffer hasn't been modified by user typing since this ghost
909+ // was computed. We check the actual buffer — not realInput, which may be
910+ // stale due to debounced search-changed signal.
911+ currentBuffer := o.entry.GetText()
877 o.mu.RLock()912 o.mu.RLock()
878- hasGhost := o.hasGhostText913+ existingGhost := o.ghostSuffix
879- realInput := o.realInput
880- ghostFullText := o.ghostFullText
881 o.mu.RUnlock()914 o.mu.RUnlock()
882-915+ expectedWithGhost := originalInput + existingGhost
883- if !hasGhost || ghostFullText == "" || entryText != ghostFullText {916+ if currentBuffer != originalInput && currentBuffer != expectedWithGhost {
884- return false917+ log.Debug().
885- }918+ Str("originalInput", originalInput).
886-919+ Str("currentBuffer", currentBuffer).
887- selectionStart := 0920+ Str("suffix", suffix).
888- selectionEnd := 0921+ Msg("ghost: setGhostText skipped (buffer modified by user)")
889- hasSelection := o.entry.GetSelectionBounds(&selectionStart, &selectionEnd)
890- return isInlineGhostSelection(realInput, ghostFullText, selectionStart, selectionEnd, hasSelection)
891-}
892-
893-func isInlineGhostSelection(realInput, ghostFullText string, selectionStart, selectionEnd int, hasSelection bool) bool {
894- if !hasSelection {
895- return false
896- }
897- prefixRunes := utf8.RuneCountInString(realInput)
898- fullRunes := utf8.RuneCountInString(ghostFullText)
899- if fullRunes <= prefixRunes {
900- return false
901- }
902- return selectionStart == prefixRunes && selectionEnd == fullRunes
903-}
904-
905-// setGhostText applies inline completion using entry selection.
906-// It writes full text and selects [len(originalInput):end] as the ghost suffix.
907-func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {
908- if o.entry == nil || suffix == "" {
909- o.clearGhostText()
910 return922 return
911 }923 }
912 924
913 displayText := originalInput + suffix925 displayText := originalInput + suffix
914- if fullText != "" {926+ cursorPos := utf8.RuneCountInString(originalInput)
915- displayText = fullText927+ endPos := utf8.RuneCountInString(displayText)
916- }928+
917- selectionStart := utf8.RuneCountInString(originalInput)929+ log.Debug().
918- selectionEnd := utf8.RuneCountInString(displayText)930+ Str("input", originalInput).
931+ Str("suffix", suffix).
932+ Int("cursorPos", cursorPos).
933+ Int("endPos", endPos).
934+ Msg("ghost: setGhostText")
919 935
920 o.mu.Lock()936 o.mu.Lock()
921 o.realInput = originalInput937 o.realInput = originalInput
922 o.ghostSuffix = suffix938 o.ghostSuffix = suffix
923- o.ghostFullText = fullText
924- o.hasGhostText = true
925- capturedToken := o.ghostToken
926- o.mu.Unlock()
927-
928- o.idleCoalescer.Post("omnibox-ghost-set", func() {
929- if o.entry == nil {
930- return
931- }
932 939
933- o.mu.RLock()940+ o.mu.Unlock()
934- currentInput := o.realInput
935- currentToken := o.ghostToken
936- o.mu.RUnlock()
937- if currentInput != originalInput {
938- return
939- }
940- if capturedToken != currentToken {
941- return
942- }
943- o.mu.Lock()
944- o.isAcceptingGhost = true
945- o.mu.Unlock()
946 941
942+ o.withGhostGuard(func() {
947 o.entry.SetText(displayText)943 o.entry.SetText(displayText)
948- o.entry.SetPosition(selectionStart)944+ o.entry.SetPosition(cursorPos)
949- o.entry.SelectRegion(selectionStart, selectionEnd)945+ o.entry.SelectRegion(cursorPos, endPos)
950-
951- o.mu.Lock()
952- o.isAcceptingGhost = false
953- o.mu.Unlock()
954 })946 })
955 }947 }
956 948
957-// clearGhostText hides the ghost completion text.949+// clearGhostText removes any ghost suffix from the entry buffer.
950+// Uses guard flag to prevent re-entrant onEntryChanged.
958 func (o *Omnibox) clearGhostText() {951 func (o *Omnibox) clearGhostText() {
952+ log := logging.FromContext(o.ctx)
959 o.mu.Lock()953 o.mu.Lock()
954+ hadGhost := o.ghostSuffix != ""
955+ ghostSuffix := o.ghostSuffix
960 realInput := o.realInput956 realInput := o.realInput
961- ghostFullText := o.ghostFullText
962- hadGhost := o.hasGhostText || o.ghostSuffix != "" || o.ghostFullText != ""
963- o.ghostToken++
964- capturedToken := o.ghostToken
965 o.ghostSuffix = ""957 o.ghostSuffix = ""
966- o.ghostFullText = ""958+
967- o.hasGhostText = false
968 o.mu.Unlock()959 o.mu.Unlock()
969 960
970- if !hadGhost || o.entry == nil {961+ if !hadGhost || ghostSuffix == "" || o.entry == nil {
971 return962 return
972 }963 }
973 964
974- o.idleCoalescer.Post("omnibox-ghost-clear", func() {965+ // Only touch the buffer if it still shows the expected ghost text.
975- if o.entry == nil {966+ expectedText := realInput + ghostSuffix
976- return967+ currentText := o.entry.GetText()
977- }968+ if currentText != expectedText {
978- o.mu.RLock()969+ log.Debug().
979- currentToken := o.ghostToken970+ Str("expected", expectedText).
980- o.mu.RUnlock()971+ Str("current", currentText).
981- if capturedToken != currentToken {972+ Msg("ghost: clearGhostText skipped (buffer changed)")
982- return973+ return
983- }974+ }
984- o.mu.Lock()975+
985- o.isAcceptingGhost = true976+ log.Debug().
986- o.mu.Unlock()977+ Str("realInput", realInput).
987- // Only revert text when the entry still shows the previous ghost completion.978+ Str("ghostSuffix", ghostSuffix).
988- // If the user typed since scheduling this idle callback, never overwrite it.979+ Msg("ghost: clearGhostText")
989- if o.entry.GetText() == ghostFullText && o.entry.GetText() != realInput {980+
990- o.entry.SetText(realInput)981+ o.withGhostGuard(func() {
991- }982+ o.entry.SetText(realInput)
992 o.entry.SelectRegion(-1, -1)983 o.entry.SelectRegion(-1, -1)
993 o.entry.SetPosition(-1)984 o.entry.SetPosition(-1)
994- o.mu.Lock()
995- o.isAcceptingGhost = false
996- o.mu.Unlock()
997 })985 })
998 }986 }
999 987
@@ -1009,45 +997,40 @@ func (o *Omnibox) clearGhostTextIfInput(expectedInput string) {
1009 o.clearGhostText()997 o.clearGhostText()
1010 }998 }
1011 999
1012-// acceptGhostCompletion accepts the ghost text and fills the input.1000+// acceptGhostCompletion accepts the ghost text — the suffix is already in the
1001+// buffer, so we just deselect and update realInput to include it.
1013 func (o *Omnibox) acceptGhostCompletion() {1002 func (o *Omnibox) acceptGhostCompletion() {
1003+ log := logging.FromContext(o.ctx)
1014 o.mu.Lock()1004 o.mu.Lock()
1015- fullText, accepted := acceptedGhostInput(o.hasGhostText, o.ghostFullText)1005+ if o.ghostSuffix == "" {
1016- if !accepted {
1017 o.mu.Unlock()1006 o.mu.Unlock()
1007+ log.Debug().Msg("ghost: acceptGhostCompletion — no ghost to accept")
1018 return1008 return
1019 }1009 }
1020- o.isAcceptingGhost = true1010+ suffix := o.ghostSuffix
1021- o.realInput = fullText1011+
1022- o.selectedIndex = -1
1023- o.hasNavigated = false
1024- o.ghostToken++
1025 o.ghostSuffix = ""1012 o.ghostSuffix = ""
1026- o.ghostFullText = ""
1027- o.hasGhostText = false
1028 o.mu.Unlock()1013 o.mu.Unlock()
1014+ log.Debug().Str("suffix", suffix).Msg("ghost: acceptGhostCompletion")
1029 1015
1030 if o.entry != nil {1016 if o.entry != nil {
1031- if o.entry.GetText() != fullText {1017+ // The suffix is already in the buffer as selected text.
1032- o.entry.SetText(fullText)1018+ // Just deselect and move cursor to end.
1033- }1019+ fullText := o.entry.GetText()
1034- o.entry.SelectRegion(-1, -1)1020+ o.mu.Lock()
1035- o.entry.SetPosition(-1)1021+ o.realInput = fullText
1022+ o.selectedIndex = -1
1023+ o.hasNavigated = false
1024+ o.mu.Unlock()
1025+
1026+ o.withGhostGuard(func() {
1027+ o.entry.SelectRegion(-1, -1)
1028+ o.entry.SetPosition(-1)
1029+ })
1036 }1030 }
1037 if o.listBox != nil {1031 if o.listBox != nil {
1038 o.listBox.SelectRow(nil)1032 o.listBox.SelectRow(nil)
1039 }1033 }
1040-
1041- o.mu.Lock()
1042- o.isAcceptingGhost = false
1043- o.mu.Unlock()
1044-}
1045-
1046-func acceptedGhostInput(hasGhost bool, ghostFullText string) (string, bool) {
1047- if !hasGhost || ghostFullText == "" {
1048- return "", false
1049- }
1050- return ghostFullText, true
1051 }1034 }
1052 1035
1053 // updateGhostFromURL updates ghost text based on a specific URL (from row selection).1036 // updateGhostFromURL updates ghost text based on a specific URL (from row selection).
@@ -1064,13 +1047,13 @@ func (o *Omnibox) updateGhostFromURL(userInput, targetURL string) bool {
1064 1047
1065 // Only show full URL as ghost text when input is empty (initial state)1048 // Only show full URL as ghost text when input is empty (initial state)
1066 if userInput == "" {1049 if userInput == "" {
1067- o.setGhostText("", displayURL, displayURL)1050+ o.setGhostText("", displayURL)
1068 return true1051 return true
1069 }1052 }
1070 1053
1071 // Try to compute proper suffix if input is a prefix of the URL1054 // Try to compute proper suffix if input is a prefix of the URL
1072- if suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(userInput, targetURL); ok {1055+ if suffix, _, ok := autocomplete.ComputeURLCompletionSuffix(userInput, targetURL); ok {
1073- o.setGhostText(userInput, suffix, matchedURL)1056+ o.setGhostText(userInput, suffix)
1074 return true1057 return true
1075 }1058 }
1076 1059
@@ -1081,7 +1064,7 @@ func (o *Omnibox) updateGhostFromURL(userInput, targetURL string) bool {
1081 func (o *Omnibox) updateGhostFromSelection() {1064 func (o *Omnibox) updateGhostFromSelection() {
1082 o.mu.RLock()1065 o.mu.RLock()
1083 realInput := o.realInput1066 realInput := o.realInput
1084- hasGhost := o.hasGhostText1067+ hasGhost := o.ghostSuffix != ""
1085 o.mu.RUnlock()1068 o.mu.RUnlock()
1086 1069
1087 entryText := ""1070 entryText := ""
@@ -1099,44 +1082,39 @@ func (o *Omnibox) restoreEntryToRealInput() {
1099 return1082 return
1100 }1083 }
1101 1084
1085+ // Clear ghost text first (removes suffix from buffer with guard).
1086+ o.clearGhostText()
1087+
1088+ // If the buffer still doesn't match realInput, force-set it.
1102 o.mu.RLock()1089 o.mu.RLock()
1103 realInput := o.realInput1090 realInput := o.realInput
1104- hadGhost := o.hasGhostText || o.ghostSuffix != "" || o.ghostFullText != ""
1105 o.mu.RUnlock()1091 o.mu.RUnlock()
1106 1092
1107- o.mu.Lock()1093+ if o.entry.GetText() != realInput {
1108- o.isAcceptingGhost = true1094+ o.withGhostGuard(func() {
1109- o.ghostToken++1095+ o.entry.SetText(realInput)
1110- o.ghostSuffix = ""1096+ })
1111- o.ghostFullText = ""
1112- o.hasGhostText = false
1113- o.mu.Unlock()
1114-
1115- if hadGhost || o.entry.GetText() != realInput {
1116- o.entry.SetText(realInput)
1117 }1097 }
1118 o.entry.SelectRegion(-1, -1)1098 o.entry.SelectRegion(-1, -1)
1119 o.entry.SetPosition(-1)1099 o.entry.SetPosition(-1)
1120-
1121- o.mu.Lock()
1122- o.isAcceptingGhost = false
1123- o.mu.Unlock()
1124 }1100 }
1125 1101
1126 // updateGhostFromSelectionWithInput updates ghost text based on selected row and input.1102 // updateGhostFromSelectionWithInput updates ghost text based on selected row and input.
1127 func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) {1103 func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) {
1104+ log := logging.FromContext(o.ctx)
1128 o.mu.RLock()1105 o.mu.RLock()
1129 idx := o.selectedIndex1106 idx := o.selectedIndex
1130 mode := o.viewMode1107 mode := o.viewMode
1131 bangMode := o.bangMode1108 bangMode := o.bangMode
1132 hasNavigated := o.hasNavigated1109 hasNavigated := o.hasNavigated
1133- suppressGhost := o.suppressGhost1110+ shouldComplete := o.insertCompletion
1134 suggestions := o.suggestions1111 suggestions := o.suggestions
1135 favorites := o.favorites1112 favorites := o.favorites
1136 o.mu.RUnlock()1113 o.mu.RUnlock()
1137 1114
1138- if suppressGhost {1115+ // No ghost text after deletion (backspace/delete key).
1139- o.clearGhostTextIfInput(entryText)1116+ if !shouldComplete {
1117+ log.Debug().Str("entryText", entryText).Msg("ghost: updateGhost skipped (deletion key)")
1140 return1118 return
1141 }1119 }
1142 1120
@@ -1170,6 +1148,8 @@ func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) {
1170 o.clearGhostTextIfInput(entryText)1148 o.clearGhostTextIfInput(entryText)
1171 }1149 }
1172 1150
1151+// resolveGhostCompletion computes and applies ghost text synchronously.
1152+// All computation is pure string matching — no I/O, no goroutines needed.
1173 func (o *Omnibox) resolveGhostCompletion(1153 func (o *Omnibox) resolveGhostCompletion(
1174 entryText, selectedURL string,1154 entryText, selectedURL string,
1175 hasExplicitSelection bool,1155 hasExplicitSelection bool,
@@ -1177,33 +1157,29 @@ func (o *Omnibox) resolveGhostCompletion(
1177 suggestions []Suggestion,1157 suggestions []Suggestion,
1178 favorites []Favorite,1158 favorites []Favorite,
1179 ) {1159 ) {
1180- leadingWhitespace, completionInput, ok := ghostCompletionInput(entryText)1160+ log := logging.FromContext(o.ctx)
1161+ _, completionInput, ok := ghostCompletionInput(entryText)
1181 if !ok {1162 if !ok {
1182- o.clearGhostTextIfInput(entryText)1163+ log.Debug().Str("entryText", entryText).Msg("ghost: resolveGhost — no completion input")
1183 return1164 return
1184 }1165 }
1185 1166
1186- o.mu.Lock()1167+ fullText, suffix, found := visibleGhostSuggestion(completionInput, selectedURL, hasExplicitSelection, mode, suggestions, favorites)
1187- o.ghostToken++1168+ if !found {
1188- ghostToken := o.ghostToken1169+ log.Debug().Str("input", completionInput).Msg("ghost: resolveGhost — no match found")
1189- o.mu.Unlock()1170+ return
1190-1171+ }
1191- go func() {1172+ fullText, suffix = normalizeGhostSuggestion(completionInput, fullText, suffix)
1192- fullText, suffix, found := visibleGhostSuggestion(completionInput, selectedURL, hasExplicitSelection, mode, suggestions, favorites)1173+ if fullText == "" || suffix == "" {
1193- if !o.isGhostTokenCurrent(ghostToken, entryText) {1174+ log.Debug().Str("input", completionInput).Msg("ghost: resolveGhost — empty after normalize")
1194- return1175+ return
1195- }1176+ }
1196- if !found {1177+ log.Debug().
1197- o.clearGhostTextIfInput(entryText)1178+ Str("input", completionInput).
1198- return1179+ Str("fullText", fullText).
1199- }1180+ Str("suffix", suffix).
1200- fullText, suffix = normalizeGhostSuggestion(completionInput, fullText, suffix)1181+ Msg("ghost: resolveGhost — applying")
1201- if fullText == "" || suffix == "" {1182+ o.setGhostText(entryText, suffix)
1202- o.clearGhostTextIfInput(entryText)
1203- return
1204- }
1205- o.setGhostText(entryText, suffix, leadingWhitespace+fullText)
1206- }()
1207 }1183 }
1208 1184
1209 func ghostCompletionInput(entryText string) (leadingWhitespace, completionInput string, ok bool) {1185 func ghostCompletionInput(entryText string) (leadingWhitespace, completionInput string, ok bool) {
@@ -1300,21 +1276,13 @@ func selectedTargetURL(mode ViewMode, idx int, suggestions []Suggestion, favorit
1300 return resolveTargetURLForSelection(mode, idx, suggestions, favorites), true1276 return resolveTargetURLForSelection(mode, idx, suggestions, favorites), true
1301 }1277 }
1302 1278
1303-func (o *Omnibox) isGhostTokenCurrent(ghostToken uint64, query string) bool {
1304- o.mu.RLock()
1305- defer o.mu.RUnlock()
1306- return o.visible &&
1307- o.ghostToken == ghostToken &&
1308- o.realInput == query
1309-}
1310-
1311 // performSearch executes the search based on current view mode and query.1279 // performSearch executes the search based on current view mode and query.
1312 func (o *Omnibox) performSearch() {1280 func (o *Omnibox) performSearch() {
1313 o.mu.RLock()1281 o.mu.RLock()
1314 visible := o.visible1282 visible := o.visible
1315 mode := o.viewMode1283 mode := o.viewMode
1316 realInput := o.realInput1284 realInput := o.realInput
1317- hasGhost := o.hasGhostText1285+ hasGhost := o.ghostSuffix != ""
1318 o.mu.RUnlock()1286 o.mu.RUnlock()
1319 1287
1320 // Skip search if omnibox is hidden1288 // Skip search if omnibox is hidden
@@ -1876,7 +1844,7 @@ func (o *Omnibox) attachRowHoverSelection(row *gtk.ListBoxRow, index int) {
1876 enterCb := func(_ gtk.EventControllerMotion, _ float64, _ float64) {1844 enterCb := func(_ gtk.EventControllerMotion, _ float64, _ float64) {
1877 o.mu.RLock()1845 o.mu.RLock()
1878 realInput := o.realInput1846 realInput := o.realInput
1879- hasGhostText := o.hasGhostText1847+ hasGhostText := o.ghostSuffix != ""
1880 hasNavigated := o.hasNavigated1848 hasNavigated := o.hasNavigated
1881 o.mu.RUnlock()1849 o.mu.RUnlock()
1882 1850
@@ -2341,10 +2309,8 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
2341 // Initialize ghost text state2309 // Initialize ghost text state
2342 o.realInput = query2310 o.realInput = query
2343 o.ghostSuffix = ""2311 o.ghostSuffix = ""
2344- o.ghostFullText = ""2312+
2345- o.hasGhostText = false2313+ o.insertCompletion = false
2346- o.isAcceptingGhost = false
2347- o.suppressGhost = false
2348 o.mu.Unlock()2314 o.mu.Unlock()
2349 2315
2350 // Set initial query2316 // Set initial query
@@ -2413,10 +2379,10 @@ func (o *Omnibox) Hide(ctx context.Context) {
2413 o.entry.SetText("")2379 o.entry.SetText("")
2414 o.listBox.RemoveAll()2380 o.listBox.RemoveAll()
2415 2381
2416- // Reset realInput2382+ // Reset state
2417 o.mu.Lock()2383 o.mu.Lock()
2418 o.realInput = ""2384 o.realInput = ""
2419- o.suppressGhost = false2385+ o.insertCompletion = false
2420 o.mu.Unlock()2386 o.mu.Unlock()
2421 o.resetSearchSessionState()2387 o.resetSearchSessionState()
2422 2388
M internal/ui/component/omnibox_async_guard_test.go +0 -25
@@ -20,31 +20,6 @@ func TestOmnibox_IsSearchTokenCurrent(t *testing.T) {
2020 }
2121 }
2222
23-func TestOmnibox_IsGhostTokenCurrent(t *testing.T) {
24- o := &Omnibox{}
25- o.visible = true
26- o.ghostToken = 5
27- o.realInput = "git"
28-
29- if !o.isGhostTokenCurrent(5, "git") {
30- t.Fatalf("expected ghost token to be current")
31- }
32- if o.isGhostTokenCurrent(4, "git") {
33- t.Fatalf("expected stale ghost token to be rejected")
34- }
35- if o.isGhostTokenCurrent(5, "github") {
36- t.Fatalf("expected query mismatch to be rejected")
37- }
38-
39- o.visible = false
40- if o.isGhostTokenCurrent(5, "git") {
41- t.Fatalf("expected hidden omnibox to reject matching ghost token")
42- }
43- if o.isGhostTokenCurrent(4, "github") {
44- t.Fatalf("expected hidden omnibox to reject stale ghost token and query")
45- }
46-}
47-
4823 func TestOmnibox_IsBangUpdateCurrent(t *testing.T) {
4924 o := &Omnibox{}
5025 o.visible = true
@@ -20,31 +20,6 @@ func TestOmnibox_IsSearchTokenCurrent(t *testing.T) {
20 }20 }
21 }21 }
22 22
23-func TestOmnibox_IsGhostTokenCurrent(t *testing.T) {
24- o := &Omnibox{}
25- o.visible = true
26- o.ghostToken = 5
27- o.realInput = "git"
28-
29- if !o.isGhostTokenCurrent(5, "git") {
30- t.Fatalf("expected ghost token to be current")
31- }
32- if o.isGhostTokenCurrent(4, "git") {
33- t.Fatalf("expected stale ghost token to be rejected")
34- }
35- if o.isGhostTokenCurrent(5, "github") {
36- t.Fatalf("expected query mismatch to be rejected")
37- }
38-
39- o.visible = false
40- if o.isGhostTokenCurrent(5, "git") {
41- t.Fatalf("expected hidden omnibox to reject matching ghost token")
42- }
43- if o.isGhostTokenCurrent(4, "github") {
44- t.Fatalf("expected hidden omnibox to reject stale ghost token and query")
45- }
46-}
47-
48 func TestOmnibox_IsBangUpdateCurrent(t *testing.T) {23 func TestOmnibox_IsBangUpdateCurrent(t *testing.T) {
49 o := &Omnibox{}24 o := &Omnibox{}
50 o.visible = true25 o.visible = true
D internal/ui/component/omnibox_ghost_state_test.go +0 -111
@@ -1,111 +0,0 @@
1-package component
2-
3-import "testing"
4-
5-func TestInlineGhostSelection(t *testing.T) {
6- tests := []struct {
7- name string
8- realInput string
9- ghostFullText string
10- selectionStart int
11- selectionEnd int
12- hasSelection bool
13- want bool
14- }{
15- {
16- name: "selected suffix matches typed prefix",
17- realInput: "goo",
18- ghostFullText: "google.com",
19- selectionStart: 3,
20- selectionEnd: 10,
21- hasSelection: true,
22- want: true,
23- },
24- {
25- name: "no selection means no ghost suffix",
26- realInput: "goo",
27- ghostFullText: "google.com",
28- selectionStart: 10,
29- selectionEnd: 10,
30- hasSelection: false,
31- want: false,
32- },
33- {
34- name: "collapsed selection should not be treated as ghost",
35- realInput: "goo",
36- ghostFullText: "google.com",
37- selectionStart: 10,
38- selectionEnd: 10,
39- hasSelection: true,
40- want: false,
41- },
42- {
43- name: "wrong selection range should not be treated as ghost",
44- realInput: "goo",
45- ghostFullText: "google.com",
46- selectionStart: 1,
47- selectionEnd: 10,
48- hasSelection: true,
49- want: false,
50- },
51- }
52-
53- for _, tt := range tests {
54- t.Run(tt.name, func(t *testing.T) {
55- got := isInlineGhostSelection(
56- tt.realInput,
57- tt.ghostFullText,
58- tt.selectionStart,
59- tt.selectionEnd,
60- tt.hasSelection,
61- )
62- if got != tt.want {
63- t.Fatalf("isInlineGhostSelection() = %v, want %v", got, tt.want)
64- }
65- })
66- }
67-}
68-
69-func TestAcceptedGhostInput(t *testing.T) {
70- tests := []struct {
71- name string
72- hasGhost bool
73- ghostFullText string
74- wantText string
75- wantAccepted bool
76- }{
77- {
78- name: "accepts explicit ghost",
79- hasGhost: true,
80- ghostFullText: "google.com",
81- wantText: "google.com",
82- wantAccepted: true,
83- },
84- {
85- name: "rejects without ghost",
86- hasGhost: false,
87- ghostFullText: "google.com",
88- wantText: "",
89- wantAccepted: false,
90- },
91- {
92- name: "rejects empty ghost text",
93- hasGhost: true,
94- ghostFullText: "",
95- wantText: "",
96- wantAccepted: false,
97- },
98- }
99-
100- for _, tt := range tests {
101- t.Run(tt.name, func(t *testing.T) {
102- gotText, gotAccepted := acceptedGhostInput(tt.hasGhost, tt.ghostFullText)
103- if gotText != tt.wantText {
104- t.Fatalf("acceptedGhostInput() text = %q, want %q", gotText, tt.wantText)
105- }
106- if gotAccepted != tt.wantAccepted {
107- t.Fatalf("acceptedGhostInput() accepted = %v, want %v", gotAccepted, tt.wantAccepted)
108- }
109- })
110- }
111-}
@@ -1,111 +0,0 @@
1-package component
2-
3-import "testing"
4-
5-func TestInlineGhostSelection(t *testing.T) {
6- tests := []struct {
7- name string
8- realInput string
9- ghostFullText string
10- selectionStart int
11- selectionEnd int
12- hasSelection bool
13- want bool
14- }{
15- {
16- name: "selected suffix matches typed prefix",
17- realInput: "goo",
18- ghostFullText: "google.com",
19- selectionStart: 3,
20- selectionEnd: 10,
21- hasSelection: true,
22- want: true,
23- },
24- {
25- name: "no selection means no ghost suffix",
26- realInput: "goo",
27- ghostFullText: "google.com",
28- selectionStart: 10,
29- selectionEnd: 10,
30- hasSelection: false,
31- want: false,
32- },
33- {
34- name: "collapsed selection should not be treated as ghost",
35- realInput: "goo",
36- ghostFullText: "google.com",
37- selectionStart: 10,
38- selectionEnd: 10,
39- hasSelection: true,
40- want: false,
41- },
42- {
43- name: "wrong selection range should not be treated as ghost",
44- realInput: "goo",
45- ghostFullText: "google.com",
46- selectionStart: 1,
47- selectionEnd: 10,
48- hasSelection: true,
49- want: false,
50- },
51- }
52-
53- for _, tt := range tests {
54- t.Run(tt.name, func(t *testing.T) {
55- got := isInlineGhostSelection(
56- tt.realInput,
57- tt.ghostFullText,
58- tt.selectionStart,
59- tt.selectionEnd,
60- tt.hasSelection,
61- )
62- if got != tt.want {
63- t.Fatalf("isInlineGhostSelection() = %v, want %v", got, tt.want)
64- }
65- })
66- }
67-}
68-
69-func TestAcceptedGhostInput(t *testing.T) {
70- tests := []struct {
71- name string
72- hasGhost bool
73- ghostFullText string
74- wantText string
75- wantAccepted bool
76- }{
77- {
78- name: "accepts explicit ghost",
79- hasGhost: true,
80- ghostFullText: "google.com",
81- wantText: "google.com",
82- wantAccepted: true,
83- },
84- {
85- name: "rejects without ghost",
86- hasGhost: false,
87- ghostFullText: "google.com",
88- wantText: "",
89- wantAccepted: false,
90- },
91- {
92- name: "rejects empty ghost text",
93- hasGhost: true,
94- ghostFullText: "",
95- wantText: "",
96- wantAccepted: false,
97- },
98- }
99-
100- for _, tt := range tests {
101- t.Run(tt.name, func(t *testing.T) {
102- gotText, gotAccepted := acceptedGhostInput(tt.hasGhost, tt.ghostFullText)
103- if gotText != tt.wantText {
104- t.Fatalf("acceptedGhostInput() text = %q, want %q", gotText, tt.wantText)
105- }
106- if gotAccepted != tt.wantAccepted {
107- t.Fatalf("acceptedGhostInput() accepted = %v, want %v", gotAccepted, tt.wantAccepted)
108- }
109- })
110- }
111-}
M internal/ui/component/omnibox_navigation_state_test.go +10 -39
@@ -44,51 +44,22 @@ func TestResetSearchSessionState(t *testing.T) {
4444 }
4545 }
4646
47-func TestShouldUpdateGhostImmediately(t *testing.T) {
47+func TestIsDeletionKey(t *testing.T) {
4848 tests := []struct {
49- name string
50- previousInput string
51- entryText string
52- want bool
49+ name string
50+ key uint
51+ want bool
5352 }{
54- {name: "typing forward", previousInput: "goo", entryText: "goog", want: true},
55- {name: "same length replace", previousInput: "goo", entryText: "gaa", want: true},
56- {name: "backspace", previousInput: "goog", entryText: "goo", want: false},
57- {name: "clear input", previousInput: "goo", entryText: "", want: false},
53+ {name: "backspace is deletion", key: uint(gdk.KEY_BackSpace), want: true},
54+ {name: "delete is deletion", key: uint(gdk.KEY_Delete), want: true},
55+ {name: "typing key is not deletion", key: uint(gdk.KEY_g), want: false},
56+ {name: "arrow key is not deletion", key: uint(gdk.KEY_Left), want: false},
5857 }
5958
6059 for _, tt := range tests {
6160 t.Run(tt.name, func(t *testing.T) {
62- if got := shouldUpdateGhostImmediately(tt.previousInput, tt.entryText); got != tt.want {
63- t.Fatalf(
64- "shouldUpdateGhostImmediately(%q, %q) = %v, want %v",
65- tt.previousInput,
66- tt.entryText,
67- got,
68- tt.want,
69- )
70- }
71- })
72- }
73-}
74-
75-func TestNextGhostSuppressionState(t *testing.T) {
76- tests := []struct {
77- name string
78- current bool
79- key uint
80- want bool
81- }{
82- {name: "backspace enables suppression", current: false, key: uint(gdk.KEY_BackSpace), want: true},
83- {name: "delete enables suppression", current: false, key: uint(gdk.KEY_Delete), want: true},
84- {name: "typing key disables suppression", current: true, key: uint(gdk.KEY_g), want: false},
85- {name: "arrow key disables suppression", current: true, key: uint(gdk.KEY_Left), want: false},
86- }
87-
88- for _, tt := range tests {
89- t.Run(tt.name, func(t *testing.T) {
90- if got := nextGhostSuppressionState(tt.key); got != tt.want {
91- t.Fatalf("nextGhostSuppressionState(%v, %d) = %v, want %v", tt.current, tt.key, got, tt.want)
61+ if got := isDeletionKey(tt.key); got != tt.want {
62+ t.Fatalf("isDeletionKey(%d) = %v, want %v", tt.key, got, tt.want)
9263 }
9364 })
9465 }
@@ -44,51 +44,22 @@ func TestResetSearchSessionState(t *testing.T) {
44 }44 }
45 }45 }
46 46
47-func TestShouldUpdateGhostImmediately(t *testing.T) {47+func TestIsDeletionKey(t *testing.T) {
48 tests := []struct {48 tests := []struct {
49- name string49+ name string
50- previousInput string50+ key uint
51- entryText string51+ want bool
52- want bool
53 }{52 }{
54- {name: "typing forward", previousInput: "goo", entryText: "goog", want: true},53+ {name: "backspace is deletion", key: uint(gdk.KEY_BackSpace), want: true},
55- {name: "same length replace", previousInput: "goo", entryText: "gaa", want: true},54+ {name: "delete is deletion", key: uint(gdk.KEY_Delete), want: true},
56- {name: "backspace", previousInput: "goog", entryText: "goo", want: false},55+ {name: "typing key is not deletion", key: uint(gdk.KEY_g), want: false},
57- {name: "clear input", previousInput: "goo", entryText: "", want: false},56+ {name: "arrow key is not deletion", key: uint(gdk.KEY_Left), want: false},
58 }57 }
59 58
60 for _, tt := range tests {59 for _, tt := range tests {
61 t.Run(tt.name, func(t *testing.T) {60 t.Run(tt.name, func(t *testing.T) {
62- if got := shouldUpdateGhostImmediately(tt.previousInput, tt.entryText); got != tt.want {61+ if got := isDeletionKey(tt.key); got != tt.want {
63- t.Fatalf(62+ t.Fatalf("isDeletionKey(%d) = %v, want %v", tt.key, got, tt.want)
64- "shouldUpdateGhostImmediately(%q, %q) = %v, want %v",
65- tt.previousInput,
66- tt.entryText,
67- got,
68- tt.want,
69- )
70- }
71- })
72- }
73-}
74-
75-func TestNextGhostSuppressionState(t *testing.T) {
76- tests := []struct {
77- name string
78- current bool
79- key uint
80- want bool
81- }{
82- {name: "backspace enables suppression", current: false, key: uint(gdk.KEY_BackSpace), want: true},
83- {name: "delete enables suppression", current: false, key: uint(gdk.KEY_Delete), want: true},
84- {name: "typing key disables suppression", current: true, key: uint(gdk.KEY_g), want: false},
85- {name: "arrow key disables suppression", current: true, key: uint(gdk.KEY_Left), want: false},
86- }
87-
88- for _, tt := range tests {
89- t.Run(tt.name, func(t *testing.T) {
90- if got := nextGhostSuppressionState(tt.key); got != tt.want {
91- t.Fatalf("nextGhostSuppressionState(%v, %d) = %v, want %v", tt.current, tt.key, got, tt.want)
92 }63 }
93 })64 })
94 }65 }
M internal/ui/dialog/permission.go +21 -1
@@ -13,6 +13,8 @@ import (
1313 "github.com/bnema/dumber/internal/ui/component"
1414 )
1515
16+const maxPermissionLogURLLen = 96
17+
1618 type permissionPopup interface {
1719 Show(ctx context.Context, heading, body string, callback func(allowed, persistent bool))
1820 }
@@ -84,6 +86,7 @@ func (d *PermissionDialog) showRequest(req permissionDialogRequest) {
8486 ctx := req.ctx
8587 origin := req.origin
8688 permTypes := req.permTypes
89+ metadata := req.metadata
8790 callback := req.callback
8891
8992 log := logging.FromContext(ctx)
@@ -97,9 +100,26 @@ func (d *PermissionDialog) showRequest(req permissionDialogRequest) {
97100
98101 // Build dialog text
99102 heading := d.buildHeading(permTypes)
100- body := d.buildBody(origin, permTypes, req.metadata)
103+ body := d.buildBody(origin, permTypes, metadata)
104+
105+ if parsePermFlags(permTypes).dataAccess {
106+ log.Info().
107+ Str("origin", origin).
108+ Str("requesting_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyRequestingDomain], maxPermissionLogURLLen)).
109+ Str("current_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyCurrentDomain], maxPermissionLogURLLen)).
110+ Msg("showing website data access permission dialog")
111+ }
101112
102113 d.popup.Show(ctx, heading, body, func(allowed, persistent bool) {
114+ if parsePermFlags(permTypes).dataAccess {
115+ log.Info().
116+ Str("origin", origin).
117+ Str("requesting_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyRequestingDomain], maxPermissionLogURLLen)).
118+ Str("current_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyCurrentDomain], maxPermissionLogURLLen)).
119+ Bool("allowed", allowed).
120+ Bool("persistent", persistent).
121+ Msg("website data access permission dialog response")
122+ }
103123 log.Debug().
104124 Bool("allowed", allowed).
105125 Bool("persistent", persistent).
@@ -13,6 +13,8 @@ import (
13 "github.com/bnema/dumber/internal/ui/component"13 "github.com/bnema/dumber/internal/ui/component"
14 )14 )
15 15
16+const maxPermissionLogURLLen = 96
17+
16 type permissionPopup interface {18 type permissionPopup interface {
17 Show(ctx context.Context, heading, body string, callback func(allowed, persistent bool))19 Show(ctx context.Context, heading, body string, callback func(allowed, persistent bool))
18 }20 }
@@ -84,6 +86,7 @@ func (d *PermissionDialog) showRequest(req permissionDialogRequest) {
84 ctx := req.ctx86 ctx := req.ctx
85 origin := req.origin87 origin := req.origin
86 permTypes := req.permTypes88 permTypes := req.permTypes
89+ metadata := req.metadata
87 callback := req.callback90 callback := req.callback
88 91
89 log := logging.FromContext(ctx)92 log := logging.FromContext(ctx)
@@ -97,9 +100,26 @@ func (d *PermissionDialog) showRequest(req permissionDialogRequest) {
97 100
98 // Build dialog text101 // Build dialog text
99 heading := d.buildHeading(permTypes)102 heading := d.buildHeading(permTypes)
100- body := d.buildBody(origin, permTypes, req.metadata)103+ body := d.buildBody(origin, permTypes, metadata)
104+
105+ if parsePermFlags(permTypes).dataAccess {
106+ log.Info().
107+ Str("origin", origin).
108+ Str("requesting_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyRequestingDomain], maxPermissionLogURLLen)).
109+ Str("current_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyCurrentDomain], maxPermissionLogURLLen)).
110+ Msg("showing website data access permission dialog")
111+ }
101 112
102 d.popup.Show(ctx, heading, body, func(allowed, persistent bool) {113 d.popup.Show(ctx, heading, body, func(allowed, persistent bool) {
114+ if parsePermFlags(permTypes).dataAccess {
115+ log.Info().
116+ Str("origin", origin).
117+ Str("requesting_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyRequestingDomain], maxPermissionLogURLLen)).
118+ Str("current_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyCurrentDomain], maxPermissionLogURLLen)).
119+ Bool("allowed", allowed).
120+ Bool("persistent", persistent).
121+ Msg("website data access permission dialog response")
122+ }
103 log.Debug().123 log.Debug().
104 Bool("allowed", allowed).124 Bool("allowed", allowed).
105 Bool("persistent", persistent).125 Bool("persistent", persistent).

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