detached 3a4e710

fix: rewrite omnibox ghostwriter to eliminate letter-eating on fast typing

The ghost text system had race conditions where SetText calls from async
search results would overwrite characters typed between search start and
result delivery. Replaced goroutine+token+coalescer machinery with
synchronous ghost computation, a guard flag for programmatic SetText,
debounced echo detection, and stale input checks.

Also bumps puregotk to v0.2.1 (fixes InsertText int* parameter and
GError signal callback typing).
commit
3a4e710331b5d60ff69ab6214dad16fa29632864
author
brice · · 2026-03-27 19:28 UTC
parent
459eca0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +285 -391
M go.mod +3 -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
@@ -25,6 +25,8 @@ require (
2525
2626 replace github.com/ebitengine/purego => github.com/bnema/purego v0.0.0-20260323095903-91aa177a2704
2727
28+replace github.com/bnema/puregotk => /home/brice/dev/projects/puregotk
29+
2830 require (
2931 github.com/atotto/clipboard v0.1.4 // indirect
3032 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
@@ -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
@@ -25,6 +25,8 @@ require (
25 25
26 replace github.com/ebitengine/purego => github.com/bnema/purego v0.0.0-20260323095903-91aa177a270426 replace github.com/ebitengine/purego => github.com/bnema/purego v0.0.0-20260323095903-91aa177a2704
27 27
28+replace github.com/bnema/puregotk => /home/brice/dev/projects/puregotk
29+
28 require (30 require (
29 github.com/atotto/clipboard v0.1.4 // indirect31 github.com/atotto/clipboard v0.1.4 // indirect
30 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect32 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
M go.sum +0 -2 generated file: collapsed
M internal/application/usecase/handle_permission.go +44 -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,31 @@ func (uc *HandlePermissionUseCase) logger(ctx context.Context) *zerolog.Logger {
5758 return log
5859 }
5960
61+func containsPermissionType(permTypes []entity.PermissionType, target entity.PermissionType) bool {
62+ for _, permType := range permTypes {
63+ if permType == target {
64+ return true
65+ }
66+ }
67+ return false
68+}
69+
70+func permissionLoggerWithMetadata(log *zerolog.Logger, metadata entity.PermissionMetadata) *zerolog.Logger {
71+ if log == nil {
72+ nop := zerolog.Nop()
73+ return &nop
74+ }
75+ ctx := log.With()
76+ if requesting := metadata[entity.PermissionMetadataKeyRequestingDomain]; requesting != "" {
77+ ctx = ctx.Str("requesting_domain", logging.TruncateURL(requesting, 96))
78+ }
79+ if current := metadata[entity.PermissionMetadataKeyCurrentDomain]; current != "" {
80+ ctx = ctx.Str("current_domain", logging.TruncateURL(current, 96))
81+ }
82+ enriched := ctx.Logger()
83+ return &enriched
84+}
85+
6086 // SetDialogPresenter sets the dialog presenter. This can be called after
6187 // initialization when the UI window is available.
6288 func (uc *HandlePermissionUseCase) SetDialogPresenter(dialog port.PermissionDialogPresenter) {
@@ -127,10 +153,18 @@ func (uc *HandlePermissionUseCase) HandlePermissionRequest(
127153 decision := uc.checkStoredPermissions(ctx, origin, permTypes)
128154 switch decision {
129155 case entity.PermissionGranted:
156+ if containsPermissionType(permTypes, entity.PermissionTypeWebsiteDataAccess) {
157+ permissionLoggerWithMetadata(&log, metadata).Info().
158+ Msg("using stored website data access permission: granted")
159+ }
130160 log.Debug().Msg("using stored permission: granted")
131161 callback.Allow()
132162 return
133163 case entity.PermissionDenied:
164+ if containsPermissionType(permTypes, entity.PermissionTypeWebsiteDataAccess) {
165+ permissionLoggerWithMetadata(&log, metadata).Info().
166+ Msg("using stored website data access permission: denied")
167+ }
134168 log.Debug().Msg("using stored permission: denied")
135169 callback.Deny()
136170 return
@@ -269,6 +303,11 @@ func (uc *HandlePermissionUseCase) showPermissionDialog(
269303
270304 dialog.ShowPermissionDialog(ctx, origin, permTypes, metadata, func(result port.PermissionDialogResult) {
271305 if result.Allowed {
306+ if containsPermissionType(permTypes, entity.PermissionTypeWebsiteDataAccess) {
307+ permissionLoggerWithMetadata(log, metadata).Info().
308+ Bool("persistent", result.Persistent).
309+ Msg("website data access permission allowed")
310+ }
272311 log.Debug().Bool("persistent", result.Persistent).Msg("user allowed permission")
273312 callback.Allow()
274313
@@ -277,6 +316,11 @@ func (uc *HandlePermissionUseCase) showPermissionDialog(
277316 uc.persistPermission(ctx, origin, permTypes, entity.PermissionGranted)
278317 }
279318 } else {
319+ if containsPermissionType(permTypes, entity.PermissionTypeWebsiteDataAccess) {
320+ permissionLoggerWithMetadata(log, metadata).Info().
321+ Bool("persistent", result.Persistent).
322+ Msg("website data access permission denied")
323+ }
280324 log.Debug().Msg("user denied permission")
281325 callback.Deny()
282326
@@ -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,31 @@ func (uc *HandlePermissionUseCase) logger(ctx context.Context) *zerolog.Logger {
57 return log58 return log
58 }59 }
59 60
61+func containsPermissionType(permTypes []entity.PermissionType, target entity.PermissionType) bool {
62+ for _, permType := range permTypes {
63+ if permType == target {
64+ return true
65+ }
66+ }
67+ return false
68+}
69+
70+func permissionLoggerWithMetadata(log *zerolog.Logger, metadata entity.PermissionMetadata) *zerolog.Logger {
71+ if log == nil {
72+ nop := zerolog.Nop()
73+ return &nop
74+ }
75+ ctx := log.With()
76+ if requesting := metadata[entity.PermissionMetadataKeyRequestingDomain]; requesting != "" {
77+ ctx = ctx.Str("requesting_domain", logging.TruncateURL(requesting, 96))
78+ }
79+ if current := metadata[entity.PermissionMetadataKeyCurrentDomain]; current != "" {
80+ ctx = ctx.Str("current_domain", logging.TruncateURL(current, 96))
81+ }
82+ enriched := ctx.Logger()
83+ return &enriched
84+}
85+
60 // SetDialogPresenter sets the dialog presenter. This can be called after86 // SetDialogPresenter sets the dialog presenter. This can be called after
61 // initialization when the UI window is available.87 // initialization when the UI window is available.
62 func (uc *HandlePermissionUseCase) SetDialogPresenter(dialog port.PermissionDialogPresenter) {88 func (uc *HandlePermissionUseCase) SetDialogPresenter(dialog port.PermissionDialogPresenter) {
@@ -127,10 +153,18 @@ func (uc *HandlePermissionUseCase) HandlePermissionRequest(
127 decision := uc.checkStoredPermissions(ctx, origin, permTypes)153 decision := uc.checkStoredPermissions(ctx, origin, permTypes)
128 switch decision {154 switch decision {
129 case entity.PermissionGranted:155 case entity.PermissionGranted:
156+ if containsPermissionType(permTypes, entity.PermissionTypeWebsiteDataAccess) {
157+ permissionLoggerWithMetadata(&log, metadata).Info().
158+ Msg("using stored website data access permission: granted")
159+ }
130 log.Debug().Msg("using stored permission: granted")160 log.Debug().Msg("using stored permission: granted")
131 callback.Allow()161 callback.Allow()
132 return162 return
133 case entity.PermissionDenied:163 case entity.PermissionDenied:
164+ if containsPermissionType(permTypes, entity.PermissionTypeWebsiteDataAccess) {
165+ permissionLoggerWithMetadata(&log, metadata).Info().
166+ Msg("using stored website data access permission: denied")
167+ }
134 log.Debug().Msg("using stored permission: denied")168 log.Debug().Msg("using stored permission: denied")
135 callback.Deny()169 callback.Deny()
136 return170 return
@@ -269,6 +303,11 @@ func (uc *HandlePermissionUseCase) showPermissionDialog(
269 303
270 dialog.ShowPermissionDialog(ctx, origin, permTypes, metadata, func(result port.PermissionDialogResult) {304 dialog.ShowPermissionDialog(ctx, origin, permTypes, metadata, func(result port.PermissionDialogResult) {
271 if result.Allowed {305 if result.Allowed {
306+ if containsPermissionType(permTypes, entity.PermissionTypeWebsiteDataAccess) {
307+ permissionLoggerWithMetadata(log, metadata).Info().
308+ Bool("persistent", result.Persistent).
309+ Msg("website data access permission allowed")
310+ }
272 log.Debug().Bool("persistent", result.Persistent).Msg("user allowed permission")311 log.Debug().Bool("persistent", result.Persistent).Msg("user allowed permission")
273 callback.Allow()312 callback.Allow()
274 313
@@ -277,6 +316,11 @@ func (uc *HandlePermissionUseCase) showPermissionDialog(
277 uc.persistPermission(ctx, origin, permTypes, entity.PermissionGranted)316 uc.persistPermission(ctx, origin, permTypes, entity.PermissionGranted)
278 }317 }
279 } else {318 } else {
319+ if containsPermissionType(permTypes, entity.PermissionTypeWebsiteDataAccess) {
320+ permissionLoggerWithMetadata(log, metadata).Info().
321+ Bool("persistent", result.Persistent).
322+ Msg("website data access permission denied")
323+ }
280 log.Debug().Msg("user denied permission")324 log.Debug().Msg("user denied permission")
281 callback.Deny()325 callback.Deny()
282 326
M internal/infrastructure/webkit/webview.go +30 -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 {
@@ -1024,6 +1047,13 @@ func (wv *WebView) connectPermissionRequestSignal() {
10241047 wv.logger.Warn().Msg("permission request with unknown type, denying")
10251048 return false
10261049 }
1050+ if len(permTypes) == 1 && permTypes[0] == "website_data_access" {
1051+ wv.logger.Info().
1052+ Str("origin", origin).
1053+ Str("requesting_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyRequestingDomain], 96)).
1054+ Str("current_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyCurrentDomain], 96)).
1055+ Msg("website data access permission requested")
1056+ }
10271057
10281058 // Ref the request object to prevent use-after-free
10291059 // 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 {
@@ -1024,6 +1047,13 @@ func (wv *WebView) connectPermissionRequestSignal() {
1024 wv.logger.Warn().Msg("permission request with unknown type, denying")1047 wv.logger.Warn().Msg("permission request with unknown type, denying")
1025 return false1048 return false
1026 }1049 }
1050+ if len(permTypes) == 1 && permTypes[0] == "website_data_access" {
1051+ wv.logger.Info().
1052+ Str("origin", origin).
1053+ Str("requesting_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyRequestingDomain], 96)).
1054+ Str("current_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyCurrentDomain], 96)).
1055+ Msg("website data access permission requested")
1056+ }
1027 1057
1028 // Ref the request object to prevent use-after-free1058 // Ref the request object to prevent use-after-free
1029 // The request may outlive the signal handler if we show a dialog1059 // The request may outlive the signal handler if we show a dialog
M internal/ui/component/omnibox.go +179 -212
@@ -95,11 +95,10 @@ 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
98+ ghostSuffix string // Completion suffix currently displayed
10099 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
100+ insertCompletion bool // True on character insert, false on delete — gates ghost completion
101+ isSettingGhost bool // Guard flag: skip onEntryChanged during programmatic SetText
103102
104103 // Dependencies
105104 historyUC *usecase.SearchHistoryUseCase
@@ -123,7 +122,6 @@ type Omnibox struct {
123122 debounceMu sync.Mutex
124123 lastQuery string // Prevent duplicate searches
125124 searchToken uint64
126- ghostToken uint64
127125
128126 // Scaling
129127 uiScale float64
@@ -638,7 +636,6 @@ func (o *Omnibox) assembleWidgets() {
638636
639637 // setupEntryChanged wires entry text changes to debounced search.
640638 func (o *Omnibox) setupEntryChanged() {
641- // SearchEntry has built-in debouncing via search-changed signal
642639 changedCb := func(_ gtk.SearchEntry) {
643640 o.onEntryChanged()
644641 }
@@ -694,7 +691,7 @@ func (o *Omnibox) hasUserNavigated() bool {
694691 // handleKeyPress processes keyboard events.
695692 // Returns true if the event was handled.
696693 func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {
697- o.updateGhostSuppressionFromKey(keyval)
694+ o.updateInsertCompletionFromKey(keyval)
698695
699696 ctrl := state&gdk.ControlMaskValue != 0
700697
@@ -767,16 +764,6 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b
767764 return false // Let entry handle the key
768765 }
769766
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-
780767 func isDeletionKey(keyval uint) bool {
781768 switch keyval {
782769 case uint(gdk.KEY_BackSpace), uint(gdk.KEY_Delete), uint(gdk.KEY_KP_Delete):
@@ -786,6 +773,28 @@ func isDeletionKey(keyval uint) bool {
786773 }
787774 }
788775
776+// updateInsertCompletionFromKey sets whether the next text change should trigger
777+// ghost completion. Character insertion enables it; deletion disables it.
778+func (o *Omnibox) updateInsertCompletionFromKey(keyval uint) {
779+ o.mu.Lock()
780+ o.insertCompletion = !isDeletionKey(keyval)
781+ o.mu.Unlock()
782+}
783+
784+// withGhostGuard executes fn while the isSettingGhost flag is set,
785+// so that onEntryChanged skips during programmatic text changes.
786+func (o *Omnibox) withGhostGuard(fn func()) {
787+ o.mu.Lock()
788+ o.isSettingGhost = true
789+ o.mu.Unlock()
790+
791+ fn()
792+
793+ o.mu.Lock()
794+ o.isSettingGhost = false
795+ o.mu.Unlock()
796+}
797+
789798 // handleCtrlNumberShortcut handles Ctrl+1-9 and Ctrl+0 for quick navigation.
790799 // Uses hardware keycode as fallback for non-QWERTY keyboards (AZERTY, QWERTZ, etc.).
791800 func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool {
@@ -823,42 +832,64 @@ func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool
823832 }
824833
825834 // onEntryChanged handles text input changes with debouncing.
835+// When this fires, GTK has already processed the keystroke — if ghost text was
836+// selected and the user typed, the selection was replaced naturally by GTK.
837+// We must NOT call clearGhostText() here (that would delete real user text).
826838 func (o *Omnibox) onEntryChanged() {
827- // Skip if we're programmatically accepting ghost text
839+ log := logging.FromContext(o.ctx)
840+
841+ // Skip if we're programmatically setting ghost text (synchronous guard).
828842 o.mu.RLock()
829- isAccepting := o.isAcceptingGhost
843+ isSettingGhost := o.isSettingGhost
830844 o.mu.RUnlock()
831- if isAccepting {
845+ if isSettingGhost {
846+ log.Debug().Msg("ghost: onEntryChanged skipped (isSettingGhost)")
832847 return
833848 }
834849
835850 entryText := o.entry.GetText()
836- if trimmed := url.TrimLeadingSpacesIfURL(entryText); trimmed != entryText {
837- o.entry.SetText(trimmed)
838- o.entry.SetPosition(-1)
839- return
840- }
841851
842- if o.isGhostProgrammaticEcho(entryText) {
852+ // Detect debounced echo from our own SetText in setGhostText.
853+ // search-changed is debounced by GtkSearchEntry, so it fires AFTER
854+ // the isSettingGhost guard is already off. Recognize it by checking
855+ // if the entry text matches realInput + ghostSuffix exactly.
856+ o.mu.RLock()
857+ if o.hasGhostText && o.ghostSuffix != "" && entryText == o.realInput+o.ghostSuffix {
858+ o.mu.RUnlock()
859+ log.Debug().Str("entryText", entryText).Msg("ghost: onEntryChanged skipped (debounced echo)")
843860 return
844861 }
862+ o.mu.RUnlock()
863+ if trimmed := url.TrimLeadingSpacesIfURL(entryText); trimmed != entryText {
864+ o.withGhostGuard(func() {
865+ o.entry.SetText(trimmed)
866+ o.entry.SetPosition(-1)
867+ })
868+ entryText = trimmed
869+ }
845870
846- // Reset navigation state when user types - space should type, not toggle favorite
871+ // Reset ghost state (the buffer is already correct — GTK handled it).
847872 o.mu.Lock()
848- previousInput := o.realInput
873+ oldGhost := o.ghostSuffix
849874 o.hasNavigated = false
850- // Update realInput with what user actually typed
875+ o.hasGhostText = false
876+ o.ghostSuffix = ""
851877 o.realInput = entryText
878+ shouldComplete := o.insertCompletion
852879 o.mu.Unlock()
853880
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) {
881+ log.Debug().
882+ Str("entryText", entryText).
883+ Str("oldGhost", oldGhost).
884+ Bool("insertCompletion", shouldComplete).
885+ Msg("ghost: onEntryChanged")
886+
887+ // Compute ghost completion synchronously if this was a character insertion.
888+ if shouldComplete {
859889 o.updateGhostFromSelectionWithInput(entryText)
860890 }
861891
892+ // Schedule async search for suggestion list updates.
862893 o.debounceMu.Lock()
863894 if o.debounceTimer != nil {
864895 o.debounceTimer.Stop()
@@ -869,131 +900,89 @@ func (o *Omnibox) onEntryChanged() {
869900 o.debounceMu.Unlock()
870901 }
871902
872-func shouldUpdateGhostImmediately(previousInput, entryText string) bool {
873- return utf8.RuneCountInString(entryText) >= utf8.RuneCountInString(previousInput)
874-}
903+// setGhostText applies inline completion by setting the full text (input + suffix)
904+// and selecting the suffix portion. Uses guard flag to prevent re-entrant onEntryChanged.
905+// Skips if the user has typed since this ghost was computed (stale input check).
906+func (o *Omnibox) setGhostText(originalInput, suffix string) {
907+ log := logging.FromContext(o.ctx)
908+ if o.entry == nil || suffix == "" {
909+ o.clearGhostText()
910+ return
911+ }
875912
876-func (o *Omnibox) isGhostProgrammaticEcho(entryText string) bool {
913+ // Verify the user hasn't typed since this ghost was computed.
877914 o.mu.RLock()
878- hasGhost := o.hasGhostText
879- realInput := o.realInput
880- ghostFullText := o.ghostFullText
915+ currentInput := o.realInput
881916 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()
917+ if currentInput != originalInput {
918+ log.Debug().
919+ Str("staleInput", originalInput).
920+ Str("currentInput", currentInput).
921+ Str("suffix", suffix).
922+ Msg("ghost: setGhostText skipped (stale input)")
910923 return
911924 }
912925
913926 displayText := originalInput + suffix
914- if fullText != "" {
915- displayText = fullText
916- }
917- selectionStart := utf8.RuneCountInString(originalInput)
918- selectionEnd := utf8.RuneCountInString(displayText)
927+ cursorPos := utf8.RuneCountInString(originalInput)
928+ endPos := utf8.RuneCountInString(displayText)
929+
930+ log.Debug().
931+ Str("input", originalInput).
932+ Str("suffix", suffix).
933+ Int("cursorPos", cursorPos).
934+ Int("endPos", endPos).
935+ Msg("ghost: setGhostText")
919936
920937 o.mu.Lock()
921938 o.realInput = originalInput
922939 o.ghostSuffix = suffix
923- o.ghostFullText = fullText
924940 o.hasGhostText = true
925- capturedToken := o.ghostToken
926941 o.mu.Unlock()
927942
928- o.idleCoalescer.Post("omnibox-ghost-set", func() {
929- if o.entry == nil {
930- return
931- }
932-
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()
946-
943+ o.withGhostGuard(func() {
947944 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()
945+ o.entry.SetPosition(cursorPos)
946+ o.entry.SelectRegion(cursorPos, endPos)
954947 })
955948 }
956949
957-// clearGhostText hides the ghost completion text.
950+// clearGhostText removes any ghost suffix from the entry buffer.
951+// Uses guard flag to prevent re-entrant onEntryChanged.
958952 func (o *Omnibox) clearGhostText() {
953+ log := logging.FromContext(o.ctx)
959954 o.mu.Lock()
955+ hadGhost := o.hasGhostText
956+ ghostSuffix := o.ghostSuffix
960957 realInput := o.realInput
961- ghostFullText := o.ghostFullText
962- hadGhost := o.hasGhostText || o.ghostSuffix != "" || o.ghostFullText != ""
963- o.ghostToken++
964- capturedToken := o.ghostToken
965958 o.ghostSuffix = ""
966- o.ghostFullText = ""
967959 o.hasGhostText = false
968960 o.mu.Unlock()
969961
970- if !hadGhost || o.entry == nil {
962+ if !hadGhost || ghostSuffix == "" || o.entry == nil {
971963 return
972964 }
973965
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- }
966+ // Only touch the buffer if it still shows the expected ghost text.
967+ expectedText := realInput + ghostSuffix
968+ currentText := o.entry.GetText()
969+ if currentText != expectedText {
970+ log.Debug().
971+ Str("expected", expectedText).
972+ Str("current", currentText).
973+ Msg("ghost: clearGhostText skipped (buffer changed)")
974+ return
975+ }
976+
977+ log.Debug().
978+ Str("realInput", realInput).
979+ Str("ghostSuffix", ghostSuffix).
980+ Msg("ghost: clearGhostText")
981+
982+ o.withGhostGuard(func() {
983+ o.entry.SetText(realInput)
992984 o.entry.SelectRegion(-1, -1)
993985 o.entry.SetPosition(-1)
994- o.mu.Lock()
995- o.isAcceptingGhost = false
996- o.mu.Unlock()
997986 })
998987 }
999988
@@ -1009,45 +998,40 @@ func (o *Omnibox) clearGhostTextIfInput(expectedInput string) {
1009998 o.clearGhostText()
1010999 }
10111000
1012-// acceptGhostCompletion accepts the ghost text and fills the input.
1001+// acceptGhostCompletion accepts the ghost text — the suffix is already in the
1002+// buffer, so we just deselect and update realInput to include it.
10131003 func (o *Omnibox) acceptGhostCompletion() {
1004+ log := logging.FromContext(o.ctx)
10141005 o.mu.Lock()
1015- fullText, accepted := acceptedGhostInput(o.hasGhostText, o.ghostFullText)
1016- if !accepted {
1006+ if !o.hasGhostText || o.ghostSuffix == "" {
10171007 o.mu.Unlock()
1008+ log.Debug().Msg("ghost: acceptGhostCompletion — no ghost to accept")
10181009 return
10191010 }
1020- o.isAcceptingGhost = true
1021- o.realInput = fullText
1022- o.selectedIndex = -1
1023- o.hasNavigated = false
1024- o.ghostToken++
1025- o.ghostSuffix = ""
1026- o.ghostFullText = ""
1011+ suffix := o.ghostSuffix
10271012 o.hasGhostText = false
1013+ o.ghostSuffix = ""
10281014 o.mu.Unlock()
1015+ log.Debug().Str("suffix", suffix).Msg("ghost: acceptGhostCompletion")
10291016
10301017 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)
1018+ // The suffix is already in the buffer as selected text.
1019+ // Just deselect and move cursor to end.
1020+ fullText := o.entry.GetText()
1021+ o.mu.Lock()
1022+ o.realInput = fullText
1023+ o.selectedIndex = -1
1024+ o.hasNavigated = false
1025+ o.mu.Unlock()
1026+
1027+ o.withGhostGuard(func() {
1028+ o.entry.SelectRegion(-1, -1)
1029+ o.entry.SetPosition(-1)
1030+ })
10361031 }
10371032 if o.listBox != nil {
10381033 o.listBox.SelectRow(nil)
10391034 }
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
10511035 }
10521036
10531037 // updateGhostFromURL updates ghost text based on a specific URL (from row selection).
@@ -1064,13 +1048,13 @@ func (o *Omnibox) updateGhostFromURL(userInput, targetURL string) bool {
10641048
10651049 // Only show full URL as ghost text when input is empty (initial state)
10661050 if userInput == "" {
1067- o.setGhostText("", displayURL, displayURL)
1051+ o.setGhostText("", displayURL)
10681052 return true
10691053 }
10701054
10711055 // 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)
1056+ if suffix, _, ok := autocomplete.ComputeURLCompletionSuffix(userInput, targetURL); ok {
1057+ o.setGhostText(userInput, suffix)
10741058 return true
10751059 }
10761060
@@ -1099,44 +1083,39 @@ func (o *Omnibox) restoreEntryToRealInput() {
10991083 return
11001084 }
11011085
1086+ // Clear ghost text first (removes suffix from buffer with guard).
1087+ o.clearGhostText()
1088+
1089+ // If the buffer still doesn't match realInput, force-set it.
11021090 o.mu.RLock()
11031091 realInput := o.realInput
1104- hadGhost := o.hasGhostText || o.ghostSuffix != "" || o.ghostFullText != ""
11051092 o.mu.RUnlock()
11061093
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)
1094+ if o.entry.GetText() != realInput {
1095+ o.withGhostGuard(func() {
1096+ o.entry.SetText(realInput)
1097+ })
11171098 }
11181099 o.entry.SelectRegion(-1, -1)
11191100 o.entry.SetPosition(-1)
1120-
1121- o.mu.Lock()
1122- o.isAcceptingGhost = false
1123- o.mu.Unlock()
11241101 }
11251102
11261103 // updateGhostFromSelectionWithInput updates ghost text based on selected row and input.
11271104 func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) {
1105+ log := logging.FromContext(o.ctx)
11281106 o.mu.RLock()
11291107 idx := o.selectedIndex
11301108 mode := o.viewMode
11311109 bangMode := o.bangMode
11321110 hasNavigated := o.hasNavigated
1133- suppressGhost := o.suppressGhost
1111+ shouldComplete := o.insertCompletion
11341112 suggestions := o.suggestions
11351113 favorites := o.favorites
11361114 o.mu.RUnlock()
11371115
1138- if suppressGhost {
1139- o.clearGhostTextIfInput(entryText)
1116+ // No ghost text after deletion (backspace/delete key).
1117+ if !shouldComplete {
1118+ log.Debug().Str("entryText", entryText).Msg("ghost: updateGhost skipped (deletion key)")
11401119 return
11411120 }
11421121
@@ -1170,6 +1149,8 @@ func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) {
11701149 o.clearGhostTextIfInput(entryText)
11711150 }
11721151
1152+// resolveGhostCompletion computes and applies ghost text synchronously.
1153+// All computation is pure string matching — no I/O, no goroutines needed.
11731154 func (o *Omnibox) resolveGhostCompletion(
11741155 entryText, selectedURL string,
11751156 hasExplicitSelection bool,
@@ -1177,33 +1158,29 @@ func (o *Omnibox) resolveGhostCompletion(
11771158 suggestions []Suggestion,
11781159 favorites []Favorite,
11791160 ) {
1180- leadingWhitespace, completionInput, ok := ghostCompletionInput(entryText)
1161+ log := logging.FromContext(o.ctx)
1162+ _, completionInput, ok := ghostCompletionInput(entryText)
11811163 if !ok {
1182- o.clearGhostTextIfInput(entryText)
1164+ log.Debug().Str("entryText", entryText).Msg("ghost: resolveGhost — no completion input")
11831165 return
11841166 }
11851167
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- }()
1168+ fullText, suffix, found := visibleGhostSuggestion(completionInput, selectedURL, hasExplicitSelection, mode, suggestions, favorites)
1169+ if !found {
1170+ log.Debug().Str("input", completionInput).Msg("ghost: resolveGhost — no match found")
1171+ return
1172+ }
1173+ fullText, suffix = normalizeGhostSuggestion(completionInput, fullText, suffix)
1174+ if fullText == "" || suffix == "" {
1175+ log.Debug().Str("input", completionInput).Msg("ghost: resolveGhost — empty after normalize")
1176+ return
1177+ }
1178+ log.Debug().
1179+ Str("input", completionInput).
1180+ Str("fullText", fullText).
1181+ Str("suffix", suffix).
1182+ Msg("ghost: resolveGhost — applying")
1183+ o.setGhostText(entryText, suffix)
12071184 }
12081185
12091186 func ghostCompletionInput(entryText string) (leadingWhitespace, completionInput string, ok bool) {
@@ -1300,14 +1277,6 @@ func selectedTargetURL(mode ViewMode, idx int, suggestions []Suggestion, favorit
13001277 return resolveTargetURLForSelection(mode, idx, suggestions, favorites), true
13011278 }
13021279
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-
13111280 // performSearch executes the search based on current view mode and query.
13121281 func (o *Omnibox) performSearch() {
13131282 o.mu.RLock()
@@ -2341,10 +2310,8 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
23412310 // Initialize ghost text state
23422311 o.realInput = query
23432312 o.ghostSuffix = ""
2344- o.ghostFullText = ""
23452313 o.hasGhostText = false
2346- o.isAcceptingGhost = false
2347- o.suppressGhost = false
2314+ o.insertCompletion = false
23482315 o.mu.Unlock()
23492316
23502317 // Set initial query
@@ -2413,10 +2380,10 @@ func (o *Omnibox) Hide(ctx context.Context) {
24132380 o.entry.SetText("")
24142381 o.listBox.RemoveAll()
24152382
2416- // Reset realInput
2383+ // Reset state
24172384 o.mu.Lock()
24182385 o.realInput = ""
2419- o.suppressGhost = false
2386+ o.insertCompletion = false
24202387 o.mu.Unlock()
24212388 o.resetSearchSessionState()
24222389
@@ -95,11 +95,10 @@ 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 to
100 hasGhostText bool // Quick check for Tab behavior99 hasGhostText bool // Quick check for Tab behavior
101- isAcceptingGhost bool // Prevent re-trigger during acceptance100+ insertCompletion bool // True on character insert, false on delete — gates ghost completion
102- suppressGhost bool // Temporarily disable ghost completion after deletion keys101+ isSettingGhost bool // Guard flag: skip onEntryChanged during programmatic SetText
103 102
104 // Dependencies103 // Dependencies
105 historyUC *usecase.SearchHistoryUseCase104 historyUC *usecase.SearchHistoryUseCase
@@ -123,7 +122,6 @@ type Omnibox struct {
123 debounceMu sync.Mutex122 debounceMu sync.Mutex
124 lastQuery string // Prevent duplicate searches123 lastQuery string // Prevent duplicate searches
125 searchToken uint64124 searchToken uint64
126- ghostToken uint64
127 125
128 // Scaling126 // Scaling
129 uiScale float64127 uiScale float64
@@ -638,7 +636,6 @@ func (o *Omnibox) assembleWidgets() {
638 636
639 // setupEntryChanged wires entry text changes to debounced search.637 // setupEntryChanged wires entry text changes to debounced search.
640 func (o *Omnibox) setupEntryChanged() {638 func (o *Omnibox) setupEntryChanged() {
641- // SearchEntry has built-in debouncing via search-changed signal
642 changedCb := func(_ gtk.SearchEntry) {639 changedCb := func(_ gtk.SearchEntry) {
643 o.onEntryChanged()640 o.onEntryChanged()
644 }641 }
@@ -694,7 +691,7 @@ func (o *Omnibox) hasUserNavigated() bool {
694 // handleKeyPress processes keyboard events.691 // handleKeyPress processes keyboard events.
695 // Returns true if the event was handled.692 // Returns true if the event was handled.
696 func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {693 func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {
697- o.updateGhostSuppressionFromKey(keyval)694+ o.updateInsertCompletionFromKey(keyval)
698 695
699 ctrl := state&gdk.ControlMaskValue != 0696 ctrl := state&gdk.ControlMaskValue != 0
700 697
@@ -767,16 +764,6 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b
767 return false // Let entry handle the key764 return false // Let entry handle the key
768 }765 }
769 766
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 {767 func isDeletionKey(keyval uint) bool {
781 switch keyval {768 switch keyval {
782 case uint(gdk.KEY_BackSpace), uint(gdk.KEY_Delete), uint(gdk.KEY_KP_Delete):769 case uint(gdk.KEY_BackSpace), uint(gdk.KEY_Delete), uint(gdk.KEY_KP_Delete):
@@ -786,6 +773,28 @@ func isDeletionKey(keyval uint) bool {
786 }773 }
787 }774 }
788 775
776+// updateInsertCompletionFromKey sets whether the next text change should trigger
777+// ghost completion. Character insertion enables it; deletion disables it.
778+func (o *Omnibox) updateInsertCompletionFromKey(keyval uint) {
779+ o.mu.Lock()
780+ o.insertCompletion = !isDeletionKey(keyval)
781+ o.mu.Unlock()
782+}
783+
784+// withGhostGuard executes fn while the isSettingGhost flag is set,
785+// so that onEntryChanged skips during programmatic text changes.
786+func (o *Omnibox) withGhostGuard(fn func()) {
787+ o.mu.Lock()
788+ o.isSettingGhost = true
789+ o.mu.Unlock()
790+
791+ fn()
792+
793+ o.mu.Lock()
794+ o.isSettingGhost = false
795+ o.mu.Unlock()
796+}
797+
789 // handleCtrlNumberShortcut handles Ctrl+1-9 and Ctrl+0 for quick navigation.798 // handleCtrlNumberShortcut handles Ctrl+1-9 and Ctrl+0 for quick navigation.
790 // Uses hardware keycode as fallback for non-QWERTY keyboards (AZERTY, QWERTZ, etc.).799 // Uses hardware keycode as fallback for non-QWERTY keyboards (AZERTY, QWERTZ, etc.).
791 func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool {800 func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool {
@@ -823,42 +832,64 @@ func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool
823 }832 }
824 833
825 // onEntryChanged handles text input changes with debouncing.834 // onEntryChanged handles text input changes with debouncing.
835+// When this fires, GTK has already processed the keystroke — if ghost text was
836+// selected and the user typed, the selection was replaced naturally by GTK.
837+// We must NOT call clearGhostText() here (that would delete real user text).
826 func (o *Omnibox) onEntryChanged() {838 func (o *Omnibox) onEntryChanged() {
827- // Skip if we're programmatically accepting ghost text839+ log := logging.FromContext(o.ctx)
840+
841+ // Skip if we're programmatically setting ghost text (synchronous guard).
828 o.mu.RLock()842 o.mu.RLock()
829- isAccepting := o.isAcceptingGhost843+ isSettingGhost := o.isSettingGhost
830 o.mu.RUnlock()844 o.mu.RUnlock()
831- if isAccepting {845+ if isSettingGhost {
846+ log.Debug().Msg("ghost: onEntryChanged skipped (isSettingGhost)")
832 return847 return
833 }848 }
834 849
835 entryText := o.entry.GetText()850 entryText := o.entry.GetText()
836- if trimmed := url.TrimLeadingSpacesIfURL(entryText); trimmed != entryText {
837- o.entry.SetText(trimmed)
838- o.entry.SetPosition(-1)
839- return
840- }
841 851
842- if o.isGhostProgrammaticEcho(entryText) {852+ // Detect debounced echo from our own SetText in setGhostText.
853+ // search-changed is debounced by GtkSearchEntry, so it fires AFTER
854+ // the isSettingGhost guard is already off. Recognize it by checking
855+ // if the entry text matches realInput + ghostSuffix exactly.
856+ o.mu.RLock()
857+ if o.hasGhostText && o.ghostSuffix != "" && entryText == o.realInput+o.ghostSuffix {
858+ o.mu.RUnlock()
859+ log.Debug().Str("entryText", entryText).Msg("ghost: onEntryChanged skipped (debounced echo)")
843 return860 return
844 }861 }
862+ o.mu.RUnlock()
863+ if trimmed := url.TrimLeadingSpacesIfURL(entryText); trimmed != entryText {
864+ o.withGhostGuard(func() {
865+ o.entry.SetText(trimmed)
866+ o.entry.SetPosition(-1)
867+ })
868+ entryText = trimmed
869+ }
845 870
846- // Reset navigation state when user types - space should type, not toggle favorite871+ // Reset ghost state (the buffer is already correct — GTK handled it).
847 o.mu.Lock()872 o.mu.Lock()
848- previousInput := o.realInput873+ oldGhost := o.ghostSuffix
849 o.hasNavigated = false874 o.hasNavigated = false
850- // Update realInput with what user actually typed875+ o.hasGhostText = false
876+ o.ghostSuffix = ""
851 o.realInput = entryText877 o.realInput = entryText
878+ shouldComplete := o.insertCompletion
852 o.mu.Unlock()879 o.mu.Unlock()
853 880
854- // Clear ghost text when user types (it will be recalculated after search)881+ log.Debug().
855- o.clearGhostText()882+ Str("entryText", entryText).
856- // Recompute immediately from currently visible rows to avoid transient gaps883+ Str("oldGhost", oldGhost).
857- // where suggestions are visible but ghost text is missing until async search returns.884+ Bool("insertCompletion", shouldComplete).
858- if shouldUpdateGhostImmediately(previousInput, entryText) {885+ Msg("ghost: onEntryChanged")
886+
887+ // Compute ghost completion synchronously if this was a character insertion.
888+ if shouldComplete {
859 o.updateGhostFromSelectionWithInput(entryText)889 o.updateGhostFromSelectionWithInput(entryText)
860 }890 }
861 891
892+ // Schedule async search for suggestion list updates.
862 o.debounceMu.Lock()893 o.debounceMu.Lock()
863 if o.debounceTimer != nil {894 if o.debounceTimer != nil {
864 o.debounceTimer.Stop()895 o.debounceTimer.Stop()
@@ -869,131 +900,89 @@ func (o *Omnibox) onEntryChanged() {
869 o.debounceMu.Unlock()900 o.debounceMu.Unlock()
870 }901 }
871 902
872-func shouldUpdateGhostImmediately(previousInput, entryText string) bool {903+// setGhostText applies inline completion by setting the full text (input + suffix)
873- return utf8.RuneCountInString(entryText) >= utf8.RuneCountInString(previousInput)904+// and selecting the suffix portion. Uses guard flag to prevent re-entrant onEntryChanged.
874-}905+// Skips if the user has typed since this ghost was computed (stale input check).
906+func (o *Omnibox) setGhostText(originalInput, suffix string) {
907+ log := logging.FromContext(o.ctx)
908+ if o.entry == nil || suffix == "" {
909+ o.clearGhostText()
910+ return
911+ }
875 912
876-func (o *Omnibox) isGhostProgrammaticEcho(entryText string) bool {913+ // Verify the user hasn't typed since this ghost was computed.
877 o.mu.RLock()914 o.mu.RLock()
878- hasGhost := o.hasGhostText915+ currentInput := o.realInput
879- realInput := o.realInput
880- ghostFullText := o.ghostFullText
881 o.mu.RUnlock()916 o.mu.RUnlock()
882-917+ if currentInput != originalInput {
883- if !hasGhost || ghostFullText == "" || entryText != ghostFullText {918+ log.Debug().
884- return false919+ Str("staleInput", originalInput).
885- }920+ Str("currentInput", currentInput).
886-921+ Str("suffix", suffix).
887- selectionStart := 0922+ Msg("ghost: setGhostText skipped (stale input)")
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()
910 return923 return
911 }924 }
912 925
913 displayText := originalInput + suffix926 displayText := originalInput + suffix
914- if fullText != "" {927+ cursorPos := utf8.RuneCountInString(originalInput)
915- displayText = fullText928+ endPos := utf8.RuneCountInString(displayText)
916- }929+
917- selectionStart := utf8.RuneCountInString(originalInput)930+ log.Debug().
918- selectionEnd := utf8.RuneCountInString(displayText)931+ Str("input", originalInput).
932+ Str("suffix", suffix).
933+ Int("cursorPos", cursorPos).
934+ Int("endPos", endPos).
935+ Msg("ghost: setGhostText")
919 936
920 o.mu.Lock()937 o.mu.Lock()
921 o.realInput = originalInput938 o.realInput = originalInput
922 o.ghostSuffix = suffix939 o.ghostSuffix = suffix
923- o.ghostFullText = fullText
924 o.hasGhostText = true940 o.hasGhostText = true
925- capturedToken := o.ghostToken
926 o.mu.Unlock()941 o.mu.Unlock()
927 942
928- o.idleCoalescer.Post("omnibox-ghost-set", func() {943+ o.withGhostGuard(func() {
929- if o.entry == nil {
930- return
931- }
932-
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()
946-
947 o.entry.SetText(displayText)944 o.entry.SetText(displayText)
948- o.entry.SetPosition(selectionStart)945+ o.entry.SetPosition(cursorPos)
949- o.entry.SelectRegion(selectionStart, selectionEnd)946+ o.entry.SelectRegion(cursorPos, endPos)
950-
951- o.mu.Lock()
952- o.isAcceptingGhost = false
953- o.mu.Unlock()
954 })947 })
955 }948 }
956 949
957-// clearGhostText hides the ghost completion text.950+// clearGhostText removes any ghost suffix from the entry buffer.
951+// Uses guard flag to prevent re-entrant onEntryChanged.
958 func (o *Omnibox) clearGhostText() {952 func (o *Omnibox) clearGhostText() {
953+ log := logging.FromContext(o.ctx)
959 o.mu.Lock()954 o.mu.Lock()
955+ hadGhost := o.hasGhostText
956+ ghostSuffix := o.ghostSuffix
960 realInput := o.realInput957 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 = ""958 o.ghostSuffix = ""
966- o.ghostFullText = ""
967 o.hasGhostText = false959 o.hasGhostText = false
968 o.mu.Unlock()960 o.mu.Unlock()
969 961
970- if !hadGhost || o.entry == nil {962+ if !hadGhost || ghostSuffix == "" || o.entry == nil {
971 return963 return
972 }964 }
973 965
974- o.idleCoalescer.Post("omnibox-ghost-clear", func() {966+ // Only touch the buffer if it still shows the expected ghost text.
975- if o.entry == nil {967+ expectedText := realInput + ghostSuffix
976- return968+ currentText := o.entry.GetText()
977- }969+ if currentText != expectedText {
978- o.mu.RLock()970+ log.Debug().
979- currentToken := o.ghostToken971+ Str("expected", expectedText).
980- o.mu.RUnlock()972+ Str("current", currentText).
981- if capturedToken != currentToken {973+ Msg("ghost: clearGhostText skipped (buffer changed)")
982- return974+ return
983- }975+ }
984- o.mu.Lock()976+
985- o.isAcceptingGhost = true977+ log.Debug().
986- o.mu.Unlock()978+ Str("realInput", realInput).
987- // Only revert text when the entry still shows the previous ghost completion.979+ Str("ghostSuffix", ghostSuffix).
988- // If the user typed since scheduling this idle callback, never overwrite it.980+ Msg("ghost: clearGhostText")
989- if o.entry.GetText() == ghostFullText && o.entry.GetText() != realInput {981+
990- o.entry.SetText(realInput)982+ o.withGhostGuard(func() {
991- }983+ o.entry.SetText(realInput)
992 o.entry.SelectRegion(-1, -1)984 o.entry.SelectRegion(-1, -1)
993 o.entry.SetPosition(-1)985 o.entry.SetPosition(-1)
994- o.mu.Lock()
995- o.isAcceptingGhost = false
996- o.mu.Unlock()
997 })986 })
998 }987 }
999 988
@@ -1009,45 +998,40 @@ func (o *Omnibox) clearGhostTextIfInput(expectedInput string) {
1009 o.clearGhostText()998 o.clearGhostText()
1010 }999 }
1011 1000
1012-// acceptGhostCompletion accepts the ghost text and fills the input.1001+// acceptGhostCompletion accepts the ghost text — the suffix is already in the
1002+// buffer, so we just deselect and update realInput to include it.
1013 func (o *Omnibox) acceptGhostCompletion() {1003 func (o *Omnibox) acceptGhostCompletion() {
1004+ log := logging.FromContext(o.ctx)
1014 o.mu.Lock()1005 o.mu.Lock()
1015- fullText, accepted := acceptedGhostInput(o.hasGhostText, o.ghostFullText)1006+ if !o.hasGhostText || o.ghostSuffix == "" {
1016- if !accepted {
1017 o.mu.Unlock()1007 o.mu.Unlock()
1008+ log.Debug().Msg("ghost: acceptGhostCompletion — no ghost to accept")
1018 return1009 return
1019 }1010 }
1020- o.isAcceptingGhost = true1011+ suffix := o.ghostSuffix
1021- o.realInput = fullText
1022- o.selectedIndex = -1
1023- o.hasNavigated = false
1024- o.ghostToken++
1025- o.ghostSuffix = ""
1026- o.ghostFullText = ""
1027 o.hasGhostText = false1012 o.hasGhostText = false
1013+ o.ghostSuffix = ""
1028 o.mu.Unlock()1014 o.mu.Unlock()
1015+ log.Debug().Str("suffix", suffix).Msg("ghost: acceptGhostCompletion")
1029 1016
1030 if o.entry != nil {1017 if o.entry != nil {
1031- if o.entry.GetText() != fullText {1018+ // The suffix is already in the buffer as selected text.
1032- o.entry.SetText(fullText)1019+ // Just deselect and move cursor to end.
1033- }1020+ fullText := o.entry.GetText()
1034- o.entry.SelectRegion(-1, -1)1021+ o.mu.Lock()
1035- o.entry.SetPosition(-1)1022+ o.realInput = fullText
1023+ o.selectedIndex = -1
1024+ o.hasNavigated = false
1025+ o.mu.Unlock()
1026+
1027+ o.withGhostGuard(func() {
1028+ o.entry.SelectRegion(-1, -1)
1029+ o.entry.SetPosition(-1)
1030+ })
1036 }1031 }
1037 if o.listBox != nil {1032 if o.listBox != nil {
1038 o.listBox.SelectRow(nil)1033 o.listBox.SelectRow(nil)
1039 }1034 }
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 }1035 }
1052 1036
1053 // updateGhostFromURL updates ghost text based on a specific URL (from row selection).1037 // updateGhostFromURL updates ghost text based on a specific URL (from row selection).
@@ -1064,13 +1048,13 @@ func (o *Omnibox) updateGhostFromURL(userInput, targetURL string) bool {
1064 1048
1065 // Only show full URL as ghost text when input is empty (initial state)1049 // Only show full URL as ghost text when input is empty (initial state)
1066 if userInput == "" {1050 if userInput == "" {
1067- o.setGhostText("", displayURL, displayURL)1051+ o.setGhostText("", displayURL)
1068 return true1052 return true
1069 }1053 }
1070 1054
1071 // Try to compute proper suffix if input is a prefix of the URL1055 // Try to compute proper suffix if input is a prefix of the URL
1072- if suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(userInput, targetURL); ok {1056+ if suffix, _, ok := autocomplete.ComputeURLCompletionSuffix(userInput, targetURL); ok {
1073- o.setGhostText(userInput, suffix, matchedURL)1057+ o.setGhostText(userInput, suffix)
1074 return true1058 return true
1075 }1059 }
1076 1060
@@ -1099,44 +1083,39 @@ func (o *Omnibox) restoreEntryToRealInput() {
1099 return1083 return
1100 }1084 }
1101 1085
1086+ // Clear ghost text first (removes suffix from buffer with guard).
1087+ o.clearGhostText()
1088+
1089+ // If the buffer still doesn't match realInput, force-set it.
1102 o.mu.RLock()1090 o.mu.RLock()
1103 realInput := o.realInput1091 realInput := o.realInput
1104- hadGhost := o.hasGhostText || o.ghostSuffix != "" || o.ghostFullText != ""
1105 o.mu.RUnlock()1092 o.mu.RUnlock()
1106 1093
1107- o.mu.Lock()1094+ if o.entry.GetText() != realInput {
1108- o.isAcceptingGhost = true1095+ o.withGhostGuard(func() {
1109- o.ghostToken++1096+ o.entry.SetText(realInput)
1110- o.ghostSuffix = ""1097+ })
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 }1098 }
1118 o.entry.SelectRegion(-1, -1)1099 o.entry.SelectRegion(-1, -1)
1119 o.entry.SetPosition(-1)1100 o.entry.SetPosition(-1)
1120-
1121- o.mu.Lock()
1122- o.isAcceptingGhost = false
1123- o.mu.Unlock()
1124 }1101 }
1125 1102
1126 // updateGhostFromSelectionWithInput updates ghost text based on selected row and input.1103 // updateGhostFromSelectionWithInput updates ghost text based on selected row and input.
1127 func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) {1104 func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) {
1105+ log := logging.FromContext(o.ctx)
1128 o.mu.RLock()1106 o.mu.RLock()
1129 idx := o.selectedIndex1107 idx := o.selectedIndex
1130 mode := o.viewMode1108 mode := o.viewMode
1131 bangMode := o.bangMode1109 bangMode := o.bangMode
1132 hasNavigated := o.hasNavigated1110 hasNavigated := o.hasNavigated
1133- suppressGhost := o.suppressGhost1111+ shouldComplete := o.insertCompletion
1134 suggestions := o.suggestions1112 suggestions := o.suggestions
1135 favorites := o.favorites1113 favorites := o.favorites
1136 o.mu.RUnlock()1114 o.mu.RUnlock()
1137 1115
1138- if suppressGhost {1116+ // No ghost text after deletion (backspace/delete key).
1139- o.clearGhostTextIfInput(entryText)1117+ if !shouldComplete {
1118+ log.Debug().Str("entryText", entryText).Msg("ghost: updateGhost skipped (deletion key)")
1140 return1119 return
1141 }1120 }
1142 1121
@@ -1170,6 +1149,8 @@ func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) {
1170 o.clearGhostTextIfInput(entryText)1149 o.clearGhostTextIfInput(entryText)
1171 }1150 }
1172 1151
1152+// resolveGhostCompletion computes and applies ghost text synchronously.
1153+// All computation is pure string matching — no I/O, no goroutines needed.
1173 func (o *Omnibox) resolveGhostCompletion(1154 func (o *Omnibox) resolveGhostCompletion(
1174 entryText, selectedURL string,1155 entryText, selectedURL string,
1175 hasExplicitSelection bool,1156 hasExplicitSelection bool,
@@ -1177,33 +1158,29 @@ func (o *Omnibox) resolveGhostCompletion(
1177 suggestions []Suggestion,1158 suggestions []Suggestion,
1178 favorites []Favorite,1159 favorites []Favorite,
1179 ) {1160 ) {
1180- leadingWhitespace, completionInput, ok := ghostCompletionInput(entryText)1161+ log := logging.FromContext(o.ctx)
1162+ _, completionInput, ok := ghostCompletionInput(entryText)
1181 if !ok {1163 if !ok {
1182- o.clearGhostTextIfInput(entryText)1164+ log.Debug().Str("entryText", entryText).Msg("ghost: resolveGhost — no completion input")
1183 return1165 return
1184 }1166 }
1185 1167
1186- o.mu.Lock()1168+ fullText, suffix, found := visibleGhostSuggestion(completionInput, selectedURL, hasExplicitSelection, mode, suggestions, favorites)
1187- o.ghostToken++1169+ if !found {
1188- ghostToken := o.ghostToken1170+ log.Debug().Str("input", completionInput).Msg("ghost: resolveGhost — no match found")
1189- o.mu.Unlock()1171+ return
1190-1172+ }
1191- go func() {1173+ fullText, suffix = normalizeGhostSuggestion(completionInput, fullText, suffix)
1192- fullText, suffix, found := visibleGhostSuggestion(completionInput, selectedURL, hasExplicitSelection, mode, suggestions, favorites)1174+ if fullText == "" || suffix == "" {
1193- if !o.isGhostTokenCurrent(ghostToken, entryText) {1175+ log.Debug().Str("input", completionInput).Msg("ghost: resolveGhost — empty after normalize")
1194- return1176+ return
1195- }1177+ }
1196- if !found {1178+ log.Debug().
1197- o.clearGhostTextIfInput(entryText)1179+ Str("input", completionInput).
1198- return1180+ Str("fullText", fullText).
1199- }1181+ Str("suffix", suffix).
1200- fullText, suffix = normalizeGhostSuggestion(completionInput, fullText, suffix)1182+ Msg("ghost: resolveGhost — applying")
1201- if fullText == "" || suffix == "" {1183+ o.setGhostText(entryText, suffix)
1202- o.clearGhostTextIfInput(entryText)
1203- return
1204- }
1205- o.setGhostText(entryText, suffix, leadingWhitespace+fullText)
1206- }()
1207 }1184 }
1208 1185
1209 func ghostCompletionInput(entryText string) (leadingWhitespace, completionInput string, ok bool) {1186 func ghostCompletionInput(entryText string) (leadingWhitespace, completionInput string, ok bool) {
@@ -1300,14 +1277,6 @@ func selectedTargetURL(mode ViewMode, idx int, suggestions []Suggestion, favorit
1300 return resolveTargetURLForSelection(mode, idx, suggestions, favorites), true1277 return resolveTargetURLForSelection(mode, idx, suggestions, favorites), true
1301 }1278 }
1302 1279
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.1280 // performSearch executes the search based on current view mode and query.
1312 func (o *Omnibox) performSearch() {1281 func (o *Omnibox) performSearch() {
1313 o.mu.RLock()1282 o.mu.RLock()
@@ -2341,10 +2310,8 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
2341 // Initialize ghost text state2310 // Initialize ghost text state
2342 o.realInput = query2311 o.realInput = query
2343 o.ghostSuffix = ""2312 o.ghostSuffix = ""
2344- o.ghostFullText = ""
2345 o.hasGhostText = false2313 o.hasGhostText = false
2346- o.isAcceptingGhost = false2314+ o.insertCompletion = false
2347- o.suppressGhost = false
2348 o.mu.Unlock()2315 o.mu.Unlock()
2349 2316
2350 // Set initial query2317 // Set initial query
@@ -2413,10 +2380,10 @@ func (o *Omnibox) Hide(ctx context.Context) {
2413 o.entry.SetText("")2380 o.entry.SetText("")
2414 o.listBox.RemoveAll()2381 o.listBox.RemoveAll()
2415 2382
2416- // Reset realInput2383+ // Reset state
2417 o.mu.Lock()2384 o.mu.Lock()
2418 o.realInput = ""2385 o.realInput = ""
2419- o.suppressGhost = false2386+ o.insertCompletion = false
2420 o.mu.Unlock()2387 o.mu.Unlock()
2421 o.resetSearchSessionState()2388 o.resetSearchSessionState()
2422 2389
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 +19 -1
@@ -84,6 +84,7 @@ func (d *PermissionDialog) showRequest(req permissionDialogRequest) {
8484 ctx := req.ctx
8585 origin := req.origin
8686 permTypes := req.permTypes
87+ metadata := req.metadata
8788 callback := req.callback
8889
8990 log := logging.FromContext(ctx)
@@ -97,9 +98,26 @@ func (d *PermissionDialog) showRequest(req permissionDialogRequest) {
9798
9899 // Build dialog text
99100 heading := d.buildHeading(permTypes)
100- body := d.buildBody(origin, permTypes, req.metadata)
101+ body := d.buildBody(origin, permTypes, metadata)
102+
103+ if parsePermFlags(permTypes).dataAccess {
104+ log.Info().
105+ Str("origin", origin).
106+ Str("requesting_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyRequestingDomain], 96)).
107+ Str("current_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyCurrentDomain], 96)).
108+ Msg("showing website data access permission dialog")
109+ }
101110
102111 d.popup.Show(ctx, heading, body, func(allowed, persistent bool) {
112+ if parsePermFlags(permTypes).dataAccess {
113+ log.Info().
114+ Str("origin", origin).
115+ Str("requesting_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyRequestingDomain], 96)).
116+ Str("current_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyCurrentDomain], 96)).
117+ Bool("allowed", allowed).
118+ Bool("persistent", persistent).
119+ Msg("website data access permission dialog response")
120+ }
103121 log.Debug().
104122 Bool("allowed", allowed).
105123 Bool("persistent", persistent).
@@ -84,6 +84,7 @@ func (d *PermissionDialog) showRequest(req permissionDialogRequest) {
84 ctx := req.ctx84 ctx := req.ctx
85 origin := req.origin85 origin := req.origin
86 permTypes := req.permTypes86 permTypes := req.permTypes
87+ metadata := req.metadata
87 callback := req.callback88 callback := req.callback
88 89
89 log := logging.FromContext(ctx)90 log := logging.FromContext(ctx)
@@ -97,9 +98,26 @@ func (d *PermissionDialog) showRequest(req permissionDialogRequest) {
97 98
98 // Build dialog text99 // Build dialog text
99 heading := d.buildHeading(permTypes)100 heading := d.buildHeading(permTypes)
100- body := d.buildBody(origin, permTypes, req.metadata)101+ body := d.buildBody(origin, permTypes, metadata)
102+
103+ if parsePermFlags(permTypes).dataAccess {
104+ log.Info().
105+ Str("origin", origin).
106+ Str("requesting_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyRequestingDomain], 96)).
107+ Str("current_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyCurrentDomain], 96)).
108+ Msg("showing website data access permission dialog")
109+ }
101 110
102 d.popup.Show(ctx, heading, body, func(allowed, persistent bool) {111 d.popup.Show(ctx, heading, body, func(allowed, persistent bool) {
112+ if parsePermFlags(permTypes).dataAccess {
113+ log.Info().
114+ Str("origin", origin).
115+ Str("requesting_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyRequestingDomain], 96)).
116+ Str("current_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyCurrentDomain], 96)).
117+ Bool("allowed", allowed).
118+ Bool("persistent", persistent).
119+ Msg("website data access permission dialog response")
120+ }
103 log.Debug().121 log.Debug().
104 Bool("allowed", allowed).122 Bool("allowed", allowed).
105 Bool("persistent", persistent).123 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