detached b7b10b9

fix(omnibox): preserve submit during async paste (#430)

* fix(omnibox): preserve submit during async paste

* fix(omnibox): honor the deferred submit when a paste keeps the same text

pasteSubmissionState.textChanged inferred paste completion by comparing the
entry text with the text captured at the paste shortcut. GTK emits the change
notification for an identical replacement too, so the comparison returned early,
left the paste active, and kept a pending deferredEnter armed: the Enter pressed
during the clipboard read was never replayed, and the stale state could later
fire a submission on an unrelated edit.

Completion is now decided by the entry change notification itself, never by the
text value, and the deferred Enter is consumed exactly once. The ghost-text echo
detection runs before the paste state update so our own SetText notifications
cannot clear a paste that is still in flight.

* fix(omnibox): replay the deferred enter when a paste keeps the ghost suffix

The ghost echo early return in onEntryChanged ran before the paste state was
updated, so a paste that replaced the selected ghost suffix with the same text
looked exactly like a self-generated notification: the entry change was dropped,
the deferred Enter was never replayed, and the omnibox stayed open.

The paste state is now consumed first. A self-generated echo still returns early
and keeps the ghost state untouched, but a pending deferred Enter takes priority,
because the two notifications cannot be told apart from the text alone; the ghost
state is then rebuilt from the pasted text and the submission is replayed.

The GTK integration test drives the real entry through Ctrl+V with clipboard
content equal to the displayed completion, defers Enter, then delivers the entry
change and asserts a single navigateToSelected call.
commit
b7b10b9eab86ed09dd767c4ee97d33c0bc16e7d2
author
bnema · · 2026-09-12 09:15 UTC
committer
GitHub · · 2026-09-12 09:15 UTC
parent
3cee55e
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
2 files · +246 -5
M internal/ui/component/omnibox.go +104 -5
@@ -52,6 +52,56 @@ type favoriteToggleResultUpdate struct {
5252 IsFavorite bool
5353 }
5454
55+// pasteSubmissionState tracks a paste whose clipboard read is still in flight so
56+// that an Enter pressed in the meantime is replayed once the text lands.
57+type pasteSubmissionState struct {
58+ active bool
59+ initialText string
60+ deferredEnter bool
61+}
62+
63+// beginPaste arms the state with the entry text seen when the user pressed the
64+// paste shortcut.
65+func (s *pasteSubmissionState) beginPaste(text string) {
66+ s.active = true
67+ s.initialText = text
68+ s.deferredEnter = false
69+}
70+
71+// requestSubmit reports whether Enter submits now and whether the pasted text is
72+// already in the entry. Enter pressed while the entry still holds the pre-paste
73+// text is deferred instead.
74+func (s *pasteSubmissionState) requestSubmit(text string) (submit, pastedTextReady bool) {
75+ if !s.active {
76+ return true, false
77+ }
78+ if text == s.initialText {
79+ s.deferredEnter = true
80+ return false, false
81+ }
82+ s.active = false
83+ return true, true
84+}
85+
86+// textChanged reports whether an Enter pressed during the paste must be replayed.
87+// The first entry change after a paste shortcut completes the paste operation,
88+// even when the pasted text matches what the entry already contained, so
89+// completion is never inferred from the text itself.
90+func (s *pasteSubmissionState) textChanged() bool {
91+ if !s.active {
92+ return false
93+ }
94+ s.active = false
95+ deferred := s.deferredEnter
96+ s.deferredEnter = false
97+ return deferred
98+}
99+
100+// reset drops any paste state, discarding a deferred submission.
101+func (s *pasteSubmissionState) reset() {
102+ *s = pasteSubmissionState{}
103+}
104+
55105 // ViewMode distinguishes history search from favorites display.
56106 type ViewMode string
57107
@@ -119,6 +169,7 @@ type Omnibox struct {
119169 ghostSuffix string // Completion suffix currently displayed
120170 insertCompletion bool // True on character insert, false on delete — gates ghost completion
121171 isSettingGhost bool // Guard flag: skip onEntryChanged during programmatic SetText
172+ pasteSubmit pasteSubmissionState
122173
123174 // Dependencies
124175 historyUC *usecase.SearchHistoryUseCase
@@ -648,6 +699,16 @@ func (o *Omnibox) setupKeyboardHandling() {
648699 controller.SetPropagationPhase(gtk.PhaseCaptureValue)
649700
650701 keyPressedCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool {
702+ if isPasteShortcut(keyval, state) && o.entry != nil {
703+ entryText := o.entry.GetText()
704+ o.mu.Lock()
705+ o.pasteSubmit.beginPaste(entryText)
706+ o.mu.Unlock()
707+ } else if keyval != uint(gdk.KEY_Return) && keyval != uint(gdk.KEY_KP_Enter) {
708+ o.mu.Lock()
709+ o.pasteSubmit.reset()
710+ o.mu.Unlock()
711+ }
651712 if o.handleKeyPress(keyval, keycode, state) {
652713 return true
653714 }
@@ -985,7 +1046,7 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b
9851046 }
9861047
9871048 case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
988- o.navigateToSelected()
1049+ o.handleSubmitKeyPress()
9891050 return true
9901051
9911052 case uint(gdk.KEY_Up):
@@ -1036,6 +1097,30 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b
10361097 return false // Let entry handle the key
10371098 }
10381099
1100+func (o *Omnibox) handleSubmitKeyPress() {
1101+ if o.entry == nil {
1102+ return
1103+ }
1104+ entryText := o.entry.GetText()
1105+ o.mu.Lock()
1106+ shouldSubmit, pastedTextReady := o.pasteSubmit.requestSubmit(entryText)
1107+ if pastedTextReady {
1108+ o.selectedIndex = -1
1109+ o.hasNavigated = false
1110+ }
1111+ o.mu.Unlock()
1112+ if shouldSubmit {
1113+ o.navigateToSelected()
1114+ }
1115+}
1116+
1117+func isPasteShortcut(keyval uint, state gdk.ModifierType) bool {
1118+ ctrl := state&gdk.ControlMaskValue != 0
1119+ shift := state&gdk.ShiftMaskValue != 0
1120+ return (ctrl && (keyval == uint(gdk.KEY_v) || keyval == uint(gdk.KEY_V))) ||
1121+ (shift && keyval == uint(gdk.KEY_Insert))
1122+}
1123+
10391124 func isDeletionKey(keyval uint) bool {
10401125 switch keyval {
10411126 case uint(gdk.KEY_BackSpace), uint(gdk.KEY_Delete), uint(gdk.KEY_KP_Delete):
@@ -1117,15 +1202,22 @@ func (o *Omnibox) onEntryChanged() {
11171202
11181203 entryText := o.entry.GetText()
11191204
1205+ o.mu.Lock()
1206+ deferredSubmit := o.pasteSubmit.textChanged()
1207+ o.mu.Unlock()
1208+
11201209 // Detect debounced echo from our own SetText in setGhostText.
11211210 // search-changed is debounced by GtkSearchEntry, so it fires AFTER
1122- // the isSettingGhost guard is already off.
1211+ // the isSettingGhost guard is already off. A self-inflicted notification
1212+ // must leave the ghost state untouched, but it cannot be told apart from a
1213+ // paste that replaced the selected ghost suffix with the same text, so a
1214+ // deferred Enter always wins and the ghost state is rebuilt from the paste.
11231215 o.mu.RLock()
1124- if o.ghostSuffix != "" && entryText == o.realInput+o.ghostSuffix {
1125- o.mu.RUnlock()
1216+ ghostEcho := o.ghostSuffix != "" && entryText == o.realInput+o.ghostSuffix
1217+ o.mu.RUnlock()
1218+ if ghostEcho && !deferredSubmit {
11261219 return
11271220 }
1128- o.mu.RUnlock()
11291221
11301222 log := logging.FromContext(o.ctx)
11311223 if trimmed := url.TrimLeadingSpacesIfURL(entryText); trimmed != entryText {
@@ -1146,6 +1238,11 @@ func (o *Omnibox) onEntryChanged() {
11461238 shouldComplete := o.insertCompletion
11471239 o.mu.Unlock()
11481240
1241+ if deferredSubmit {
1242+ o.navigateToSelected()
1243+ return
1244+ }
1245+
11491246 log.Debug().
11501247 Str("entryText", entryText).
11511248 Str("oldGhost", oldGhost).
@@ -2803,6 +2900,7 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
28032900 o.ghostSuffix = ""
28042901
28052902 o.insertCompletion = false
2903+ o.pasteSubmit.reset()
28062904 o.mu.Unlock()
28072905
28082906 // Set initial query
@@ -2888,6 +2986,7 @@ func (o *Omnibox) Hide(ctx context.Context) {
28882986 o.mu.Lock()
28892987 o.realInput = ""
28902988 o.insertCompletion = false
2989+ o.pasteSubmit.reset()
28912990 o.mu.Unlock()
28922991 o.resetSearchSessionState()
28932992
@@ -52,6 +52,56 @@ type favoriteToggleResultUpdate struct {
52 IsFavorite bool52 IsFavorite bool
53 }53 }
54 54
55+// pasteSubmissionState tracks a paste whose clipboard read is still in flight so
56+// that an Enter pressed in the meantime is replayed once the text lands.
57+type pasteSubmissionState struct {
58+ active bool
59+ initialText string
60+ deferredEnter bool
61+}
62+
63+// beginPaste arms the state with the entry text seen when the user pressed the
64+// paste shortcut.
65+func (s *pasteSubmissionState) beginPaste(text string) {
66+ s.active = true
67+ s.initialText = text
68+ s.deferredEnter = false
69+}
70+
71+// requestSubmit reports whether Enter submits now and whether the pasted text is
72+// already in the entry. Enter pressed while the entry still holds the pre-paste
73+// text is deferred instead.
74+func (s *pasteSubmissionState) requestSubmit(text string) (submit, pastedTextReady bool) {
75+ if !s.active {
76+ return true, false
77+ }
78+ if text == s.initialText {
79+ s.deferredEnter = true
80+ return false, false
81+ }
82+ s.active = false
83+ return true, true
84+}
85+
86+// textChanged reports whether an Enter pressed during the paste must be replayed.
87+// The first entry change after a paste shortcut completes the paste operation,
88+// even when the pasted text matches what the entry already contained, so
89+// completion is never inferred from the text itself.
90+func (s *pasteSubmissionState) textChanged() bool {
91+ if !s.active {
92+ return false
93+ }
94+ s.active = false
95+ deferred := s.deferredEnter
96+ s.deferredEnter = false
97+ return deferred
98+}
99+
100+// reset drops any paste state, discarding a deferred submission.
101+func (s *pasteSubmissionState) reset() {
102+ *s = pasteSubmissionState{}
103+}
104+
55 // ViewMode distinguishes history search from favorites display.105 // ViewMode distinguishes history search from favorites display.
56 type ViewMode string106 type ViewMode string
57 107
@@ -119,6 +169,7 @@ type Omnibox struct {
119 ghostSuffix string // Completion suffix currently displayed169 ghostSuffix string // Completion suffix currently displayed
120 insertCompletion bool // True on character insert, false on delete — gates ghost completion170 insertCompletion bool // True on character insert, false on delete — gates ghost completion
121 isSettingGhost bool // Guard flag: skip onEntryChanged during programmatic SetText171 isSettingGhost bool // Guard flag: skip onEntryChanged during programmatic SetText
172+ pasteSubmit pasteSubmissionState
122 173
123 // Dependencies174 // Dependencies
124 historyUC *usecase.SearchHistoryUseCase175 historyUC *usecase.SearchHistoryUseCase
@@ -648,6 +699,16 @@ func (o *Omnibox) setupKeyboardHandling() {
648 controller.SetPropagationPhase(gtk.PhaseCaptureValue)699 controller.SetPropagationPhase(gtk.PhaseCaptureValue)
649 700
650 keyPressedCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool {701 keyPressedCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool {
702+ if isPasteShortcut(keyval, state) && o.entry != nil {
703+ entryText := o.entry.GetText()
704+ o.mu.Lock()
705+ o.pasteSubmit.beginPaste(entryText)
706+ o.mu.Unlock()
707+ } else if keyval != uint(gdk.KEY_Return) && keyval != uint(gdk.KEY_KP_Enter) {
708+ o.mu.Lock()
709+ o.pasteSubmit.reset()
710+ o.mu.Unlock()
711+ }
651 if o.handleKeyPress(keyval, keycode, state) {712 if o.handleKeyPress(keyval, keycode, state) {
652 return true713 return true
653 }714 }
@@ -985,7 +1046,7 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b
985 }1046 }
986 1047
987 case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):1048 case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
988- o.navigateToSelected()1049+ o.handleSubmitKeyPress()
989 return true1050 return true
990 1051
991 case uint(gdk.KEY_Up):1052 case uint(gdk.KEY_Up):
@@ -1036,6 +1097,30 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b
1036 return false // Let entry handle the key1097 return false // Let entry handle the key
1037 }1098 }
1038 1099
1100+func (o *Omnibox) handleSubmitKeyPress() {
1101+ if o.entry == nil {
1102+ return
1103+ }
1104+ entryText := o.entry.GetText()
1105+ o.mu.Lock()
1106+ shouldSubmit, pastedTextReady := o.pasteSubmit.requestSubmit(entryText)
1107+ if pastedTextReady {
1108+ o.selectedIndex = -1
1109+ o.hasNavigated = false
1110+ }
1111+ o.mu.Unlock()
1112+ if shouldSubmit {
1113+ o.navigateToSelected()
1114+ }
1115+}
1116+
1117+func isPasteShortcut(keyval uint, state gdk.ModifierType) bool {
1118+ ctrl := state&gdk.ControlMaskValue != 0
1119+ shift := state&gdk.ShiftMaskValue != 0
1120+ return (ctrl && (keyval == uint(gdk.KEY_v) || keyval == uint(gdk.KEY_V))) ||
1121+ (shift && keyval == uint(gdk.KEY_Insert))
1122+}
1123+
1039 func isDeletionKey(keyval uint) bool {1124 func isDeletionKey(keyval uint) bool {
1040 switch keyval {1125 switch keyval {
1041 case uint(gdk.KEY_BackSpace), uint(gdk.KEY_Delete), uint(gdk.KEY_KP_Delete):1126 case uint(gdk.KEY_BackSpace), uint(gdk.KEY_Delete), uint(gdk.KEY_KP_Delete):
@@ -1117,15 +1202,22 @@ func (o *Omnibox) onEntryChanged() {
1117 1202
1118 entryText := o.entry.GetText()1203 entryText := o.entry.GetText()
1119 1204
1205+ o.mu.Lock()
1206+ deferredSubmit := o.pasteSubmit.textChanged()
1207+ o.mu.Unlock()
1208+
1120 // Detect debounced echo from our own SetText in setGhostText.1209 // Detect debounced echo from our own SetText in setGhostText.
1121 // search-changed is debounced by GtkSearchEntry, so it fires AFTER1210 // search-changed is debounced by GtkSearchEntry, so it fires AFTER
1122- // the isSettingGhost guard is already off.1211+ // the isSettingGhost guard is already off. A self-inflicted notification
1212+ // must leave the ghost state untouched, but it cannot be told apart from a
1213+ // paste that replaced the selected ghost suffix with the same text, so a
1214+ // deferred Enter always wins and the ghost state is rebuilt from the paste.
1123 o.mu.RLock()1215 o.mu.RLock()
1124- if o.ghostSuffix != "" && entryText == o.realInput+o.ghostSuffix {1216+ ghostEcho := o.ghostSuffix != "" && entryText == o.realInput+o.ghostSuffix
1125- o.mu.RUnlock()1217+ o.mu.RUnlock()
1218+ if ghostEcho && !deferredSubmit {
1126 return1219 return
1127 }1220 }
1128- o.mu.RUnlock()
1129 1221
1130 log := logging.FromContext(o.ctx)1222 log := logging.FromContext(o.ctx)
1131 if trimmed := url.TrimLeadingSpacesIfURL(entryText); trimmed != entryText {1223 if trimmed := url.TrimLeadingSpacesIfURL(entryText); trimmed != entryText {
@@ -1146,6 +1238,11 @@ func (o *Omnibox) onEntryChanged() {
1146 shouldComplete := o.insertCompletion1238 shouldComplete := o.insertCompletion
1147 o.mu.Unlock()1239 o.mu.Unlock()
1148 1240
1241+ if deferredSubmit {
1242+ o.navigateToSelected()
1243+ return
1244+ }
1245+
1149 log.Debug().1246 log.Debug().
1150 Str("entryText", entryText).1247 Str("entryText", entryText).
1151 Str("oldGhost", oldGhost).1248 Str("oldGhost", oldGhost).
@@ -2803,6 +2900,7 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
2803 o.ghostSuffix = ""2900 o.ghostSuffix = ""
2804 2901
2805 o.insertCompletion = false2902 o.insertCompletion = false
2903+ o.pasteSubmit.reset()
2806 o.mu.Unlock()2904 o.mu.Unlock()
2807 2905
2808 // Set initial query2906 // Set initial query
@@ -2888,6 +2986,7 @@ func (o *Omnibox) Hide(ctx context.Context) {
2888 o.mu.Lock()2986 o.mu.Lock()
2889 o.realInput = ""2987 o.realInput = ""
2890 o.insertCompletion = false2988 o.insertCompletion = false
2989+ o.pasteSubmit.reset()
2891 o.mu.Unlock()2990 o.mu.Unlock()
2892 o.resetSearchSessionState()2991 o.resetSearchSessionState()
2893 2992
M internal/ui/component/omnibox_navigation_state_test.go +142 -0
@@ -1,9 +1,11 @@
11 package component
22
33 import (
4+ "context"
45 "testing"
56
67 "github.com/bnema/puregotk/v4/gdk"
8+ "github.com/bnema/puregotk/v4/gtk"
79 )
810
911 func TestShouldPreferTypedURLNavigation(t *testing.T) {
@@ -33,6 +35,146 @@ func TestShouldPreferTypedURLNavigation(t *testing.T) {
3335 }
3436 }
3537
38+func TestPasteSubmissionStateDefersEnterUntilTextArrives(t *testing.T) {
39+ state := pasteSubmissionState{}
40+ state.beginPaste("")
41+
42+ if submit, _ := state.requestSubmit(""); submit {
43+ t.Fatal("Enter must not submit while the paste buffer is still empty")
44+ }
45+ if !state.textChanged() {
46+ t.Fatal("the completed paste must consume the deferred submission")
47+ }
48+ if state.textChanged() {
49+ t.Fatal("the deferred submission must be consumed exactly once")
50+ }
51+}
52+
53+func TestPasteSubmissionStateCompletesWhenPasteKeepsTheSameText(t *testing.T) {
54+ state := pasteSubmissionState{}
55+ state.beginPaste("https://example.com")
56+
57+ if submit, _ := state.requestSubmit("https://example.com"); submit {
58+ t.Fatal("Enter must be deferred while the clipboard read is still in flight")
59+ }
60+ if !state.textChanged() {
61+ t.Fatal("an identical replacement must consume the deferred submission")
62+ }
63+ if state.textChanged() {
64+ t.Fatal("the deferred submission must be consumed exactly once")
65+ }
66+}
67+
68+func TestPasteSubmissionStateRecognizesPasteThatArrivesBeforeEnter(t *testing.T) {
69+ state := pasteSubmissionState{}
70+ state.beginPaste("old")
71+
72+ submit, pastedTextReady := state.requestSubmit("new")
73+ if !submit || !pastedTextReady {
74+ t.Fatalf("completed paste should submit immediately, got submit=%v ready=%v", submit, pastedTextReady)
75+ }
76+}
77+
78+func TestPasteSubmissionStateResetCancelsDeferredEnter(t *testing.T) {
79+ state := pasteSubmissionState{}
80+ state.beginPaste("")
81+ state.requestSubmit("")
82+ state.reset()
83+
84+ if state.textChanged() {
85+ t.Fatal("a later edit must not consume a canceled paste submission")
86+ }
87+}
88+
89+// omniboxPasteHarness drives the omnibox paste path against a real GTK entry.
90+type omniboxPasteHarness struct {
91+ omnibox *Omnibox
92+ navigated []string
93+}
94+
95+func newOmniboxPasteHarness(t *testing.T) *omniboxPasteHarness {
96+ t.Helper()
97+ if !gtk.InitCheck() {
98+ t.Skip("GTK native display prerequisite unavailable (gtk.InitCheck returned false)")
99+ }
100+ entry := gtk.NewSearchEntry()
101+ if entry == nil {
102+ t.Fatal("failed to create a GTK search entry")
103+ }
104+ harness := &omniboxPasteHarness{}
105+ harness.omnibox = &Omnibox{
106+ ctx: context.Background(),
107+ entry: entry,
108+ onNavigate: func(_ context.Context, targetURL string) error {
109+ harness.navigated = append(harness.navigated, targetURL)
110+ return nil
111+ },
112+ }
113+ return harness
114+}
115+
116+// pasteShortcut reproduces what the capture-phase key controller does on Ctrl+V.
117+func (h *omniboxPasteHarness) pasteShortcut() {
118+ h.omnibox.mu.Lock()
119+ defer h.omnibox.mu.Unlock()
120+ h.omnibox.pasteSubmit.beginPaste(h.omnibox.entry.GetText())
121+}
122+
123+// showGhostSuffix displays input+suffix with the suffix selected, as the ghost
124+// completion does, without emitting entry notifications.
125+func (h *omniboxPasteHarness) showGhostSuffix(input, suffix string) {
126+ h.omnibox.entry.SetText(input + suffix)
127+ h.omnibox.mu.Lock()
128+ defer h.omnibox.mu.Unlock()
129+ h.omnibox.realInput = input
130+ h.omnibox.ghostSuffix = suffix
131+ h.omnibox.selectedIndex = -1
132+}
133+
134+func TestOmniboxReplaysDeferredEnterWhenPasteKeepsTheGhostSuffixText(t *testing.T) {
135+ harness := newOmniboxPasteHarness(t)
136+ harness.showGhostSuffix("https://example.com/gu", "ide")
137+
138+ // Ctrl+V with clipboard content equal to the whole entry text, then Enter
139+ // before GTK delivers the pasted text.
140+ harness.pasteShortcut()
141+ harness.omnibox.handleSubmitKeyPress()
142+ if len(harness.navigated) != 0 {
143+ t.Fatalf("Enter must be deferred while the clipboard read is in flight, got %v", harness.navigated)
144+ }
145+
146+ // GTK reports the replacement. The text is unchanged, so it also matches the
147+ // ghost echo pattern; the pasted text still has to win.
148+ harness.omnibox.onEntryChanged()
149+
150+ if len(harness.navigated) != 1 || harness.navigated[0] != "https://example.com/guide" {
151+ t.Fatalf("the deferred Enter must navigate once the paste lands, got %v", harness.navigated)
152+ }
153+ if harness.omnibox.hasGhost() {
154+ t.Fatal("the pasted text must replace the ghost suffix")
155+ }
156+}
157+
158+func TestOmniboxGhostEchoAloneKeepsItsBehavior(t *testing.T) {
159+ harness := newOmniboxPasteHarness(t)
160+ harness.showGhostSuffix("https://example.com/gu", "ide")
161+
162+ harness.omnibox.onEntryChanged()
163+
164+ if len(harness.navigated) != 0 {
165+ t.Fatalf("a self-generated ghost echo must not navigate, got %v", harness.navigated)
166+ }
167+ harness.omnibox.mu.RLock()
168+ defer harness.omnibox.mu.RUnlock()
169+ if harness.omnibox.realInput != "https://example.com/gu" || harness.omnibox.ghostSuffix != "ide" {
170+ t.Fatalf(
171+ "a ghost echo must keep the ghost state, got input=%q suffix=%q",
172+ harness.omnibox.realInput,
173+ harness.omnibox.ghostSuffix,
174+ )
175+ }
176+}
177+
36178 func TestResetSearchSessionState(t *testing.T) {
37179 o := &Omnibox{}
38180 o.lastQuery = "github"
@@ -1,9 +1,11 @@
1 package component1 package component
2 2
3 import (3 import (
4+ "context"
4 "testing"5 "testing"
5 6
6 "github.com/bnema/puregotk/v4/gdk"7 "github.com/bnema/puregotk/v4/gdk"
8+ "github.com/bnema/puregotk/v4/gtk"
7 )9 )
8 10
9 func TestShouldPreferTypedURLNavigation(t *testing.T) {11 func TestShouldPreferTypedURLNavigation(t *testing.T) {
@@ -33,6 +35,146 @@ func TestShouldPreferTypedURLNavigation(t *testing.T) {
33 }35 }
34 }36 }
35 37
38+func TestPasteSubmissionStateDefersEnterUntilTextArrives(t *testing.T) {
39+ state := pasteSubmissionState{}
40+ state.beginPaste("")
41+
42+ if submit, _ := state.requestSubmit(""); submit {
43+ t.Fatal("Enter must not submit while the paste buffer is still empty")
44+ }
45+ if !state.textChanged() {
46+ t.Fatal("the completed paste must consume the deferred submission")
47+ }
48+ if state.textChanged() {
49+ t.Fatal("the deferred submission must be consumed exactly once")
50+ }
51+}
52+
53+func TestPasteSubmissionStateCompletesWhenPasteKeepsTheSameText(t *testing.T) {
54+ state := pasteSubmissionState{}
55+ state.beginPaste("https://example.com")
56+
57+ if submit, _ := state.requestSubmit("https://example.com"); submit {
58+ t.Fatal("Enter must be deferred while the clipboard read is still in flight")
59+ }
60+ if !state.textChanged() {
61+ t.Fatal("an identical replacement must consume the deferred submission")
62+ }
63+ if state.textChanged() {
64+ t.Fatal("the deferred submission must be consumed exactly once")
65+ }
66+}
67+
68+func TestPasteSubmissionStateRecognizesPasteThatArrivesBeforeEnter(t *testing.T) {
69+ state := pasteSubmissionState{}
70+ state.beginPaste("old")
71+
72+ submit, pastedTextReady := state.requestSubmit("new")
73+ if !submit || !pastedTextReady {
74+ t.Fatalf("completed paste should submit immediately, got submit=%v ready=%v", submit, pastedTextReady)
75+ }
76+}
77+
78+func TestPasteSubmissionStateResetCancelsDeferredEnter(t *testing.T) {
79+ state := pasteSubmissionState{}
80+ state.beginPaste("")
81+ state.requestSubmit("")
82+ state.reset()
83+
84+ if state.textChanged() {
85+ t.Fatal("a later edit must not consume a canceled paste submission")
86+ }
87+}
88+
89+// omniboxPasteHarness drives the omnibox paste path against a real GTK entry.
90+type omniboxPasteHarness struct {
91+ omnibox *Omnibox
92+ navigated []string
93+}
94+
95+func newOmniboxPasteHarness(t *testing.T) *omniboxPasteHarness {
96+ t.Helper()
97+ if !gtk.InitCheck() {
98+ t.Skip("GTK native display prerequisite unavailable (gtk.InitCheck returned false)")
99+ }
100+ entry := gtk.NewSearchEntry()
101+ if entry == nil {
102+ t.Fatal("failed to create a GTK search entry")
103+ }
104+ harness := &omniboxPasteHarness{}
105+ harness.omnibox = &Omnibox{
106+ ctx: context.Background(),
107+ entry: entry,
108+ onNavigate: func(_ context.Context, targetURL string) error {
109+ harness.navigated = append(harness.navigated, targetURL)
110+ return nil
111+ },
112+ }
113+ return harness
114+}
115+
116+// pasteShortcut reproduces what the capture-phase key controller does on Ctrl+V.
117+func (h *omniboxPasteHarness) pasteShortcut() {
118+ h.omnibox.mu.Lock()
119+ defer h.omnibox.mu.Unlock()
120+ h.omnibox.pasteSubmit.beginPaste(h.omnibox.entry.GetText())
121+}
122+
123+// showGhostSuffix displays input+suffix with the suffix selected, as the ghost
124+// completion does, without emitting entry notifications.
125+func (h *omniboxPasteHarness) showGhostSuffix(input, suffix string) {
126+ h.omnibox.entry.SetText(input + suffix)
127+ h.omnibox.mu.Lock()
128+ defer h.omnibox.mu.Unlock()
129+ h.omnibox.realInput = input
130+ h.omnibox.ghostSuffix = suffix
131+ h.omnibox.selectedIndex = -1
132+}
133+
134+func TestOmniboxReplaysDeferredEnterWhenPasteKeepsTheGhostSuffixText(t *testing.T) {
135+ harness := newOmniboxPasteHarness(t)
136+ harness.showGhostSuffix("https://example.com/gu", "ide")
137+
138+ // Ctrl+V with clipboard content equal to the whole entry text, then Enter
139+ // before GTK delivers the pasted text.
140+ harness.pasteShortcut()
141+ harness.omnibox.handleSubmitKeyPress()
142+ if len(harness.navigated) != 0 {
143+ t.Fatalf("Enter must be deferred while the clipboard read is in flight, got %v", harness.navigated)
144+ }
145+
146+ // GTK reports the replacement. The text is unchanged, so it also matches the
147+ // ghost echo pattern; the pasted text still has to win.
148+ harness.omnibox.onEntryChanged()
149+
150+ if len(harness.navigated) != 1 || harness.navigated[0] != "https://example.com/guide" {
151+ t.Fatalf("the deferred Enter must navigate once the paste lands, got %v", harness.navigated)
152+ }
153+ if harness.omnibox.hasGhost() {
154+ t.Fatal("the pasted text must replace the ghost suffix")
155+ }
156+}
157+
158+func TestOmniboxGhostEchoAloneKeepsItsBehavior(t *testing.T) {
159+ harness := newOmniboxPasteHarness(t)
160+ harness.showGhostSuffix("https://example.com/gu", "ide")
161+
162+ harness.omnibox.onEntryChanged()
163+
164+ if len(harness.navigated) != 0 {
165+ t.Fatalf("a self-generated ghost echo must not navigate, got %v", harness.navigated)
166+ }
167+ harness.omnibox.mu.RLock()
168+ defer harness.omnibox.mu.RUnlock()
169+ if harness.omnibox.realInput != "https://example.com/gu" || harness.omnibox.ghostSuffix != "ide" {
170+ t.Fatalf(
171+ "a ghost echo must keep the ghost state, got input=%q suffix=%q",
172+ harness.omnibox.realInput,
173+ harness.omnibox.ghostSuffix,
174+ )
175+ }
176+}
177+
36 func TestResetSearchSessionState(t *testing.T) {178 func TestResetSearchSessionState(t *testing.T) {
37 o := &Omnibox{}179 o := &Omnibox{}
38 o.lastQuery = "github"180 o.lastQuery = "github"

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