detached c1cf747

fix(omnibox): reconcil entry buffer

commit
c1cf747102010fde52c9329348893b4f64bfc0ba
author
brice · · 2026-07-02 18:17 UTC
parent
d64caed
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +118 -20
M internal/ui/component/omnibox.go +29 -20
@@ -748,7 +748,6 @@ func (o *Omnibox) initList() error {
748748 o.listBox.SetSelectionMode(gtk.SelectionSingleValue)
749749
750750 rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {
751- o.restoreEntryToRealInput()
752751 row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
753752 if row == nil {
754753 o.mu.Lock()
@@ -1295,41 +1294,51 @@ func (o *Omnibox) updateGhostFromURL(userInput, targetURL string) bool {
12951294
12961295 // updateGhostFromSelection updates ghost text based on the currently selected row.
12971296 func (o *Omnibox) updateGhostFromSelection() {
1297+ o.restoreEntryToRealInput()
1298+
12981299 o.mu.RLock()
1299- realInput := o.realInput
1300- hasGhost := o.ghostSuffix != ""
1300+ query := o.realInput
13011301 o.mu.RUnlock()
13021302
1303- entryText := ""
1304- if o.entry != nil {
1305- entryText = o.entry.GetText()
1306- }
1307-
1308- query := effectiveSearchQuery(entryText, realInput, hasGhost)
1309- o.restoreEntryToRealInput()
13101303 o.updateGhostFromSelectionWithInput(query)
13111304 }
13121305
1306+// reconcileEntryState decides how to resync the entry buffer with the
1307+// realInput/ghostSuffix shadow state. The buffer is the source of truth:
1308+// search-changed is debounced, so realInput lags behind fresh keystrokes.
1309+// The buffer is only ever rewritten to strip a still-intact ghost suffix —
1310+// never to restore older text, which would delete characters the user just
1311+// typed.
1312+func reconcileEntryState(buffer, realInput, ghostSuffix string) (newRealInput string, rewrite bool) {
1313+ if ghostSuffix != "" && buffer == realInput+ghostSuffix {
1314+ return realInput, true
1315+ }
1316+ return buffer, false
1317+}
1318+
1319+// restoreEntryToRealInput strips any intact ghost suffix from the entry buffer
1320+// and resyncs realInput from the buffer. It never writes stale realInput over
1321+// the buffer (see reconcileEntryState).
13131322 func (o *Omnibox) restoreEntryToRealInput() {
13141323 if o.entry == nil {
13151324 return
13161325 }
13171326
1318- // Clear ghost text first (removes suffix from buffer with guard).
1319- o.clearGhostText()
1327+ buffer := o.entry.GetText()
13201328
1321- // If the buffer still doesn't match realInput, force-set it.
1322- o.mu.RLock()
1323- realInput := o.realInput
1324- o.mu.RUnlock()
1329+ o.mu.Lock()
1330+ newRealInput, rewrite := reconcileEntryState(buffer, o.realInput, o.ghostSuffix)
1331+ o.realInput = newRealInput
1332+ o.ghostSuffix = ""
1333+ o.mu.Unlock()
13251334
1326- if o.entry.GetText() != realInput {
1335+ if rewrite {
13271336 o.withGhostGuard(func() {
1328- o.entry.SetText(realInput)
1337+ o.entry.SetText(newRealInput)
1338+ o.entry.SelectRegion(-1, -1)
1339+ o.entry.SetPosition(-1)
13291340 })
13301341 }
1331- o.entry.SelectRegion(-1, -1)
1332- o.entry.SetPosition(-1)
13331342 }
13341343
13351344 // updateGhostFromSelectionWithInput updates ghost text based on selected row and input.
@@ -748,7 +748,6 @@ func (o *Omnibox) initList() error {
748 o.listBox.SetSelectionMode(gtk.SelectionSingleValue)748 o.listBox.SetSelectionMode(gtk.SelectionSingleValue)
749 749
750 rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {750 rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {
751- o.restoreEntryToRealInput()
752 row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)751 row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
753 if row == nil {752 if row == nil {
754 o.mu.Lock()753 o.mu.Lock()
@@ -1295,41 +1294,51 @@ func (o *Omnibox) updateGhostFromURL(userInput, targetURL string) bool {
1295 1294
1296 // updateGhostFromSelection updates ghost text based on the currently selected row.1295 // updateGhostFromSelection updates ghost text based on the currently selected row.
1297 func (o *Omnibox) updateGhostFromSelection() {1296 func (o *Omnibox) updateGhostFromSelection() {
1297+ o.restoreEntryToRealInput()
1298+
1298 o.mu.RLock()1299 o.mu.RLock()
1299- realInput := o.realInput1300+ query := o.realInput
1300- hasGhost := o.ghostSuffix != ""
1301 o.mu.RUnlock()1301 o.mu.RUnlock()
1302 1302
1303- entryText := ""
1304- if o.entry != nil {
1305- entryText = o.entry.GetText()
1306- }
1307-
1308- query := effectiveSearchQuery(entryText, realInput, hasGhost)
1309- o.restoreEntryToRealInput()
1310 o.updateGhostFromSelectionWithInput(query)1303 o.updateGhostFromSelectionWithInput(query)
1311 }1304 }
1312 1305
1306+// reconcileEntryState decides how to resync the entry buffer with the
1307+// realInput/ghostSuffix shadow state. The buffer is the source of truth:
1308+// search-changed is debounced, so realInput lags behind fresh keystrokes.
1309+// The buffer is only ever rewritten to strip a still-intact ghost suffix —
1310+// never to restore older text, which would delete characters the user just
1311+// typed.
1312+func reconcileEntryState(buffer, realInput, ghostSuffix string) (newRealInput string, rewrite bool) {
1313+ if ghostSuffix != "" && buffer == realInput+ghostSuffix {
1314+ return realInput, true
1315+ }
1316+ return buffer, false
1317+}
1318+
1319+// restoreEntryToRealInput strips any intact ghost suffix from the entry buffer
1320+// and resyncs realInput from the buffer. It never writes stale realInput over
1321+// the buffer (see reconcileEntryState).
1313 func (o *Omnibox) restoreEntryToRealInput() {1322 func (o *Omnibox) restoreEntryToRealInput() {
1314 if o.entry == nil {1323 if o.entry == nil {
1315 return1324 return
1316 }1325 }
1317 1326
1318- // Clear ghost text first (removes suffix from buffer with guard).1327+ buffer := o.entry.GetText()
1319- o.clearGhostText()
1320 1328
1321- // If the buffer still doesn't match realInput, force-set it.1329+ o.mu.Lock()
1322- o.mu.RLock()1330+ newRealInput, rewrite := reconcileEntryState(buffer, o.realInput, o.ghostSuffix)
1323- realInput := o.realInput1331+ o.realInput = newRealInput
1324- o.mu.RUnlock()1332+ o.ghostSuffix = ""
1333+ o.mu.Unlock()
1325 1334
1326- if o.entry.GetText() != realInput {1335+ if rewrite {
1327 o.withGhostGuard(func() {1336 o.withGhostGuard(func() {
1328- o.entry.SetText(realInput)1337+ o.entry.SetText(newRealInput)
1338+ o.entry.SelectRegion(-1, -1)
1339+ o.entry.SetPosition(-1)
1329 })1340 })
1330 }1341 }
1331- o.entry.SelectRegion(-1, -1)
1332- o.entry.SetPosition(-1)
1333 }1342 }
1334 1343
1335 // updateGhostFromSelectionWithInput updates ghost text based on selected row and input.1344 // updateGhostFromSelectionWithInput updates ghost text based on selected row and input.
A internal/ui/component/omnibox_entry_reconcile_test.go +89 -0
@@ -0,0 +1,89 @@
1+package component
2+
3+import "testing"
4+
5+// reconcileEntryState decides how row-selected handlers resync the entry
6+// buffer with the realInput/ghostSuffix shadow. The entry buffer is the source
7+// of truth: search-changed is debounced (~150ms), so realInput lags behind
8+// what the user actually typed. The old force-restore behavior rewrote the
9+// buffer to the stale realInput, deleting characters typed during the
10+// debounce window (fast typing dropped letters).
11+func TestReconcileEntryState(t *testing.T) {
12+ tests := []struct {
13+ name string
14+ buffer string
15+ realInput string
16+ ghostSuffix string
17+ wantRealInput string
18+ wantRewrite bool
19+ }{
20+ {
21+ name: "ghost visible and untouched: strip it from the buffer",
22+ buffer: "google.com",
23+ realInput: "goo",
24+ ghostSuffix: "gle.com",
25+ wantRealInput: "goo",
26+ wantRewrite: true,
27+ },
28+ {
29+ name: "buffer in sync, no ghost: nothing to do",
30+ buffer: "goo",
31+ realInput: "goo",
32+ ghostSuffix: "",
33+ wantRealInput: "goo",
34+ wantRewrite: false,
35+ },
36+ {
37+ name: "user typed ahead of debounced realInput: buffer wins, never rewrite",
38+ buffer: "bne",
39+ realInput: "bn",
40+ ghostSuffix: "",
41+ wantRealInput: "bne",
42+ wantRewrite: false,
43+ },
44+ {
45+ name: "user typed over the selected ghost suffix: buffer wins",
46+ buffer: "goog",
47+ realInput: "goo",
48+ ghostSuffix: "gle.com",
49+ wantRealInput: "goog",
50+ wantRewrite: false,
51+ },
52+ {
53+ name: "stale ghost flag but buffer already shows real input only",
54+ buffer: "goo",
55+ realInput: "goo",
56+ ghostSuffix: "gle.com",
57+ wantRealInput: "goo",
58+ wantRewrite: false,
59+ },
60+ {
61+ name: "user cleared the entry: buffer wins",
62+ buffer: "",
63+ realInput: "goo",
64+ ghostSuffix: "",
65+ wantRealInput: "",
66+ wantRewrite: false,
67+ },
68+ {
69+ name: "empty ghost with empty input stays empty",
70+ buffer: "",
71+ realInput: "",
72+ ghostSuffix: "",
73+ wantRealInput: "",
74+ wantRewrite: false,
75+ },
76+ }
77+
78+ for _, tt := range tests {
79+ t.Run(tt.name, func(t *testing.T) {
80+ gotRealInput, gotRewrite := reconcileEntryState(tt.buffer, tt.realInput, tt.ghostSuffix)
81+ if gotRealInput != tt.wantRealInput {
82+ t.Errorf("realInput = %q, want %q", gotRealInput, tt.wantRealInput)
83+ }
84+ if gotRewrite != tt.wantRewrite {
85+ t.Errorf("rewrite = %v, want %v", gotRewrite, tt.wantRewrite)
86+ }
87+ })
88+ }
89+}
@@ -0,0 +1,89 @@
1+package component
2+
3+import "testing"
4+
5+// reconcileEntryState decides how row-selected handlers resync the entry
6+// buffer with the realInput/ghostSuffix shadow. The entry buffer is the source
7+// of truth: search-changed is debounced (~150ms), so realInput lags behind
8+// what the user actually typed. The old force-restore behavior rewrote the
9+// buffer to the stale realInput, deleting characters typed during the
10+// debounce window (fast typing dropped letters).
11+func TestReconcileEntryState(t *testing.T) {
12+ tests := []struct {
13+ name string
14+ buffer string
15+ realInput string
16+ ghostSuffix string
17+ wantRealInput string
18+ wantRewrite bool
19+ }{
20+ {
21+ name: "ghost visible and untouched: strip it from the buffer",
22+ buffer: "google.com",
23+ realInput: "goo",
24+ ghostSuffix: "gle.com",
25+ wantRealInput: "goo",
26+ wantRewrite: true,
27+ },
28+ {
29+ name: "buffer in sync, no ghost: nothing to do",
30+ buffer: "goo",
31+ realInput: "goo",
32+ ghostSuffix: "",
33+ wantRealInput: "goo",
34+ wantRewrite: false,
35+ },
36+ {
37+ name: "user typed ahead of debounced realInput: buffer wins, never rewrite",
38+ buffer: "bne",
39+ realInput: "bn",
40+ ghostSuffix: "",
41+ wantRealInput: "bne",
42+ wantRewrite: false,
43+ },
44+ {
45+ name: "user typed over the selected ghost suffix: buffer wins",
46+ buffer: "goog",
47+ realInput: "goo",
48+ ghostSuffix: "gle.com",
49+ wantRealInput: "goog",
50+ wantRewrite: false,
51+ },
52+ {
53+ name: "stale ghost flag but buffer already shows real input only",
54+ buffer: "goo",
55+ realInput: "goo",
56+ ghostSuffix: "gle.com",
57+ wantRealInput: "goo",
58+ wantRewrite: false,
59+ },
60+ {
61+ name: "user cleared the entry: buffer wins",
62+ buffer: "",
63+ realInput: "goo",
64+ ghostSuffix: "",
65+ wantRealInput: "",
66+ wantRewrite: false,
67+ },
68+ {
69+ name: "empty ghost with empty input stays empty",
70+ buffer: "",
71+ realInput: "",
72+ ghostSuffix: "",
73+ wantRealInput: "",
74+ wantRewrite: false,
75+ },
76+ }
77+
78+ for _, tt := range tests {
79+ t.Run(tt.name, func(t *testing.T) {
80+ gotRealInput, gotRewrite := reconcileEntryState(tt.buffer, tt.realInput, tt.ghostSuffix)
81+ if gotRealInput != tt.wantRealInput {
82+ t.Errorf("realInput = %q, want %q", gotRealInput, tt.wantRealInput)
83+ }
84+ if gotRewrite != tt.wantRewrite {
85+ t.Errorf("rewrite = %v, want %v", gotRewrite, tt.wantRewrite)
86+ }
87+ })
88+ }
89+}

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