detached ff5eb4f

fix: make setControllerPhase synchronous, dispatch modal timeouts via GTK main thread

The async glib.IdleAdd in setControllerPhase caused a race: after Ctrl+P
entered pane mode, the phase switch to capture was deferred to the next
idle cycle, so the follow-up 's' key still reached the WebView in bubble
phase. Fix by making setControllerPhase synchronous and instead dispatching
the timer's ExitMode call to the GTK thread via an injectable scheduler.

Also DRY-refactors modal.go: extracts shared enterMode() helper,
startTimeoutLocked() for glib dispatch, and SetMainThreadScheduler()
for testability (tests use direct execution, app wires glib.IdleAdd).
commit
ff5eb4fa041996fab7d4faa9c010a3217ee12520
author
brice · · 2026-03-13 06:50 UTC
parent
9c71032
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +64 -116
M internal/ui/input/keyboard.go +19 -9
@@ -134,19 +134,18 @@ func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) {
134134 }
135135
136136 // setControllerPhase changes the propagation phase of the key controller.
137-// Uses glib.IdleAdd because this may be called from timer goroutines
138-// (modal timeout) which are not on the GTK main thread.
137+// Capture phase during modal modes so plain keys reach the handler before WebView.
138+// Bubble phase in normal mode for WebKit IM/dead-key support.
139+//
140+// This is called synchronously from onModeChange so the phase switch takes
141+// effect before the next key event. The caller must ensure GTK thread safety;
142+// modal timeouts dispatch to the GTK main thread via glib.IdleAdd before
143+// calling ExitMode, so the onModeChange callback always runs on the GTK thread.
139144 func (h *KeyboardHandler) setControllerPhase(phase gtk.PropagationPhase) {
140145 if h.controller == nil {
141146 return
142147 }
143- cb := glib.SourceFunc(func(_ uintptr) bool {
144- if h.controller != nil {
145- h.controller.SetPropagationPhase(phase)
146- }
147- return false
148- })
149- glib.IdleAdd(&cb, 0)
148+ h.controller.SetPropagationPhase(phase)
150149 }
151150
152151 // SetRouteKey sets the callback that determines how each key event should be routed.
@@ -205,6 +204,17 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
205204 // so app shortcuts still work.
206205 h.controller.SetPropagationPhase(gtk.PhaseBubbleValue)
207206
207+ // Wire GTK main thread scheduler for modal timeouts. Timer goroutines
208+ // must dispatch ExitMode to the GTK thread because onModeChange may
209+ // call setControllerPhase (a GTK operation).
210+ h.modal.SetMainThreadScheduler(func(fn func()) {
211+ cb := glib.SourceFunc(func(_ uintptr) bool {
212+ fn()
213+ return false
214+ })
215+ glib.IdleAdd(&cb, 0)
216+ })
217+
208218 // Connect key pressed handler (retain callback to prevent GC).
209219 // The callback receives: keyval (translated key), keycode (hardware key position), state (modifiers)
210220 h.keyPressedCb = func(_ gtk.EventControllerKey, keyval uint, keycode uint, state gdk.ModifierType) bool {
@@ -134,19 +134,18 @@ func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) {
134 }134 }
135 135
136 // setControllerPhase changes the propagation phase of the key controller.136 // setControllerPhase changes the propagation phase of the key controller.
137-// Uses glib.IdleAdd because this may be called from timer goroutines137+// Capture phase during modal modes so plain keys reach the handler before WebView.
138-// (modal timeout) which are not on the GTK main thread.138+// Bubble phase in normal mode for WebKit IM/dead-key support.
139+//
140+// This is called synchronously from onModeChange so the phase switch takes
141+// effect before the next key event. The caller must ensure GTK thread safety;
142+// modal timeouts dispatch to the GTK main thread via glib.IdleAdd before
143+// calling ExitMode, so the onModeChange callback always runs on the GTK thread.
139 func (h *KeyboardHandler) setControllerPhase(phase gtk.PropagationPhase) {144 func (h *KeyboardHandler) setControllerPhase(phase gtk.PropagationPhase) {
140 if h.controller == nil {145 if h.controller == nil {
141 return146 return
142 }147 }
143- cb := glib.SourceFunc(func(_ uintptr) bool {148+ h.controller.SetPropagationPhase(phase)
144- if h.controller != nil {
145- h.controller.SetPropagationPhase(phase)
146- }
147- return false
148- })
149- glib.IdleAdd(&cb, 0)
150 }149 }
151 150
152 // SetRouteKey sets the callback that determines how each key event should be routed.151 // SetRouteKey sets the callback that determines how each key event should be routed.
@@ -205,6 +204,17 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
205 // so app shortcuts still work.204 // so app shortcuts still work.
206 h.controller.SetPropagationPhase(gtk.PhaseBubbleValue)205 h.controller.SetPropagationPhase(gtk.PhaseBubbleValue)
207 206
207+ // Wire GTK main thread scheduler for modal timeouts. Timer goroutines
208+ // must dispatch ExitMode to the GTK thread because onModeChange may
209+ // call setControllerPhase (a GTK operation).
210+ h.modal.SetMainThreadScheduler(func(fn func()) {
211+ cb := glib.SourceFunc(func(_ uintptr) bool {
212+ fn()
213+ return false
214+ })
215+ glib.IdleAdd(&cb, 0)
216+ })
217+
208 // Connect key pressed handler (retain callback to prevent GC).218 // Connect key pressed handler (retain callback to prevent GC).
209 // The callback receives: keyval (translated key), keycode (hardware key position), state (modifiers)219 // The callback receives: keyval (translated key), keycode (hardware key position), state (modifiers)
210 h.keyPressedCb = func(_ gtk.EventControllerKey, keyval uint, keycode uint, state gdk.ModifierType) bool {220 h.keyPressedCb = func(_ gtk.EventControllerKey, keyval uint, keycode uint, state gdk.ModifierType) bool {
M internal/ui/input/modal.go +45 -107
@@ -67,6 +67,13 @@ type ModalState struct {
6767 // Callback for mode changes (called synchronously under lock).
6868 onModeChange func(from, to Mode)
6969
70+ // scheduleOnMainThread dispatches a function to the GTK main thread.
71+ // Timer callbacks use this so that onModeChange (which may make GTK
72+ // calls like switching controller phase) always runs on the GTK thread.
73+ // Defaults to direct execution (suitable for tests without a GTK loop).
74+ // The app sets this to a glib.IdleAdd wrapper via SetMainThreadScheduler.
75+ scheduleOnMainThread func(fn func())
76+
7077 mu sync.RWMutex
7178 }
7279
@@ -75,10 +82,20 @@ func NewModalState(ctx context.Context) *ModalState {
7582 log := logging.FromContext(ctx)
7683 log.Debug().Msg("creating modal state")
7784 return &ModalState{
78- mode: ModeNormal,
85+ mode: ModeNormal,
86+ scheduleOnMainThread: func(fn func()) { fn() },
7987 }
8088 }
8189
90+// SetMainThreadScheduler sets the function used to dispatch timer callbacks
91+// to the GTK main thread. In the real app this should be a glib.IdleAdd
92+// wrapper. In tests, the default (direct execution) is used.
93+func (m *ModalState) SetMainThreadScheduler(fn func(func())) {
94+ m.mu.Lock()
95+ defer m.mu.Unlock()
96+ m.scheduleOnMainThread = fn
97+}
98+
8299 // Mode returns the current mode (thread-safe).
83100 func (m *ModalState) Mode() Mode {
84101 m.mu.RLock()
@@ -87,142 +104,54 @@ func (m *ModalState) Mode() Mode {
87104 }
88105
89106 // EnterTabMode switches to tab mode with an optional timeout.
90-// If timeout is 0, the mode stays until explicitly exited.
91107 func (m *ModalState) EnterTabMode(ctx context.Context, timeout time.Duration) {
92- log := logging.FromContext(ctx)
93- m.mu.Lock()
94- defer m.mu.Unlock()
95-
96- if m.mode == ModeTab {
97- // Already in tab mode, just reset timeout
98- m.resetTimeoutLocked(ctx, timeout)
99- return
100- }
101-
102- m.cancelTimerLocked()
103- oldMode := m.mode
104- m.mode = ModeTab
105- m.timeout = timeout
106-
107- if timeout > 0 {
108- m.timer = time.AfterFunc(timeout, func() {
109- m.ExitMode(ctx)
110- })
111- }
112-
113- log.Debug().
114- Str("from", oldMode.String()).
115- Str("to", m.mode.String()).
116- Dur("timeout", timeout).
117- Msg("entered tab mode")
118-
119- if m.onModeChange != nil {
120- m.onModeChange(oldMode, m.mode)
121- }
108+ m.enterMode(ctx, ModeTab, timeout)
122109 }
123110
124111 // EnterPaneMode switches to pane mode with an optional timeout.
125-// If timeout is 0, the mode stays until explicitly exited.
126112 func (m *ModalState) EnterPaneMode(ctx context.Context, timeout time.Duration) {
127- log := logging.FromContext(ctx)
128- m.mu.Lock()
129- defer m.mu.Unlock()
130-
131- if m.mode == ModePane {
132- // Already in pane mode, just reset timeout
133- m.resetTimeoutLocked(ctx, timeout)
134- return
135- }
136-
137- m.cancelTimerLocked()
138- oldMode := m.mode
139- m.mode = ModePane
140- m.timeout = timeout
141-
142- if timeout > 0 {
143- m.timer = time.AfterFunc(timeout, func() {
144- m.ExitMode(ctx)
145- })
146- }
147-
148- log.Debug().
149- Str("from", oldMode.String()).
150- Str("to", m.mode.String()).
151- Dur("timeout", timeout).
152- Msg("entered pane mode")
153-
154- if m.onModeChange != nil {
155- m.onModeChange(oldMode, m.mode)
156- }
113+ m.enterMode(ctx, ModePane, timeout)
157114 }
158115
159116 // EnterSessionMode switches to session mode with an optional timeout.
160-// If timeout is 0, the mode stays until explicitly exited.
161117 func (m *ModalState) EnterSessionMode(ctx context.Context, timeout time.Duration) {
162- log := logging.FromContext(ctx)
163- m.mu.Lock()
164- defer m.mu.Unlock()
165-
166- if m.mode == ModeSession {
167- // Already in session mode, just reset timeout
168- m.resetTimeoutLocked(ctx, timeout)
169- return
170- }
171-
172- m.cancelTimerLocked()
173- oldMode := m.mode
174- m.mode = ModeSession
175- m.timeout = timeout
176-
177- if timeout > 0 {
178- m.timer = time.AfterFunc(timeout, func() {
179- m.ExitMode(ctx)
180- })
181- }
182-
183- log.Debug().
184- Str("from", oldMode.String()).
185- Str("to", m.mode.String()).
186- Dur("timeout", timeout).
187- Msg("entered session mode")
188-
189- if m.onModeChange != nil {
190- m.onModeChange(oldMode, m.mode)
191- }
118+ m.enterMode(ctx, ModeSession, timeout)
192119 }
193120
194121 // EnterResizeMode switches to resize mode with an optional timeout.
195-// If timeout is 0, the mode stays until explicitly exited.
196122 func (m *ModalState) EnterResizeMode(ctx context.Context, timeout time.Duration) {
123+ m.enterMode(ctx, ModeResize, timeout)
124+}
125+
126+// enterMode is the shared implementation for all mode-enter methods.
127+// If already in the target mode, it resets the timeout instead.
128+func (m *ModalState) enterMode(ctx context.Context, target Mode, timeout time.Duration) {
197129 log := logging.FromContext(ctx)
198130 m.mu.Lock()
199131 defer m.mu.Unlock()
200132
201- if m.mode == ModeResize {
202- // Already in resize mode, just reset timeout
133+ if m.mode == target {
203134 m.resetTimeoutLocked(ctx, timeout)
204135 return
205136 }
206137
207138 m.cancelTimerLocked()
208139 oldMode := m.mode
209- m.mode = ModeResize
140+ m.mode = target
210141 m.timeout = timeout
211142
212143 if timeout > 0 {
213- m.timer = time.AfterFunc(timeout, func() {
214- m.ExitMode(ctx)
215- })
144+ m.startTimeoutLocked(ctx, timeout)
216145 }
217146
218147 log.Debug().
219148 Str("from", oldMode.String()).
220- Str("to", m.mode.String()).
149+ Str("to", target.String()).
221150 Dur("timeout", timeout).
222- Msg("entered resize mode")
151+ Msg("entered " + target.String() + " mode")
223152
224153 if m.onModeChange != nil {
225- m.onModeChange(oldMode, m.mode)
154+ m.onModeChange(oldMode, target)
226155 }
227156 }
228157
@@ -281,14 +210,23 @@ func (m *ModalState) cancelTimerLocked() {
281210 }
282211 }
283212
213+// startTimeoutLocked starts a new timeout timer that exits the mode via the
214+// main thread scheduler. Timer goroutines must not call ExitMode directly
215+// because onModeChange may make GTK calls (e.g., switching controller phase).
216+// Must be called with m.mu held.
217+func (m *ModalState) startTimeoutLocked(ctx context.Context, timeout time.Duration) {
218+ schedule := m.scheduleOnMainThread
219+ m.timer = time.AfterFunc(timeout, func() {
220+ schedule(func() { m.ExitMode(ctx) })
221+ })
222+}
223+
284224 // resetTimeoutLocked resets the timeout timer.
285225 // Must be called with m.mu held.
286226 func (m *ModalState) resetTimeoutLocked(ctx context.Context, timeout time.Duration) {
287227 m.cancelTimerLocked()
288228 m.timeout = timeout
289229 if timeout > 0 {
290- m.timer = time.AfterFunc(timeout, func() {
291- m.ExitMode(ctx)
292- })
230+ m.startTimeoutLocked(ctx, timeout)
293231 }
294232 }
@@ -67,6 +67,13 @@ type ModalState struct {
67 // Callback for mode changes (called synchronously under lock).67 // Callback for mode changes (called synchronously under lock).
68 onModeChange func(from, to Mode)68 onModeChange func(from, to Mode)
69 69
70+ // scheduleOnMainThread dispatches a function to the GTK main thread.
71+ // Timer callbacks use this so that onModeChange (which may make GTK
72+ // calls like switching controller phase) always runs on the GTK thread.
73+ // Defaults to direct execution (suitable for tests without a GTK loop).
74+ // The app sets this to a glib.IdleAdd wrapper via SetMainThreadScheduler.
75+ scheduleOnMainThread func(fn func())
76+
70 mu sync.RWMutex77 mu sync.RWMutex
71 }78 }
72 79
@@ -75,10 +82,20 @@ func NewModalState(ctx context.Context) *ModalState {
75 log := logging.FromContext(ctx)82 log := logging.FromContext(ctx)
76 log.Debug().Msg("creating modal state")83 log.Debug().Msg("creating modal state")
77 return &ModalState{84 return &ModalState{
78- mode: ModeNormal,85+ mode: ModeNormal,
86+ scheduleOnMainThread: func(fn func()) { fn() },
79 }87 }
80 }88 }
81 89
90+// SetMainThreadScheduler sets the function used to dispatch timer callbacks
91+// to the GTK main thread. In the real app this should be a glib.IdleAdd
92+// wrapper. In tests, the default (direct execution) is used.
93+func (m *ModalState) SetMainThreadScheduler(fn func(func())) {
94+ m.mu.Lock()
95+ defer m.mu.Unlock()
96+ m.scheduleOnMainThread = fn
97+}
98+
82 // Mode returns the current mode (thread-safe).99 // Mode returns the current mode (thread-safe).
83 func (m *ModalState) Mode() Mode {100 func (m *ModalState) Mode() Mode {
84 m.mu.RLock()101 m.mu.RLock()
@@ -87,142 +104,54 @@ func (m *ModalState) Mode() Mode {
87 }104 }
88 105
89 // EnterTabMode switches to tab mode with an optional timeout.106 // EnterTabMode switches to tab mode with an optional timeout.
90-// If timeout is 0, the mode stays until explicitly exited.
91 func (m *ModalState) EnterTabMode(ctx context.Context, timeout time.Duration) {107 func (m *ModalState) EnterTabMode(ctx context.Context, timeout time.Duration) {
92- log := logging.FromContext(ctx)108+ m.enterMode(ctx, ModeTab, timeout)
93- m.mu.Lock()
94- defer m.mu.Unlock()
95-
96- if m.mode == ModeTab {
97- // Already in tab mode, just reset timeout
98- m.resetTimeoutLocked(ctx, timeout)
99- return
100- }
101-
102- m.cancelTimerLocked()
103- oldMode := m.mode
104- m.mode = ModeTab
105- m.timeout = timeout
106-
107- if timeout > 0 {
108- m.timer = time.AfterFunc(timeout, func() {
109- m.ExitMode(ctx)
110- })
111- }
112-
113- log.Debug().
114- Str("from", oldMode.String()).
115- Str("to", m.mode.String()).
116- Dur("timeout", timeout).
117- Msg("entered tab mode")
118-
119- if m.onModeChange != nil {
120- m.onModeChange(oldMode, m.mode)
121- }
122 }109 }
123 110
124 // EnterPaneMode switches to pane mode with an optional timeout.111 // EnterPaneMode switches to pane mode with an optional timeout.
125-// If timeout is 0, the mode stays until explicitly exited.
126 func (m *ModalState) EnterPaneMode(ctx context.Context, timeout time.Duration) {112 func (m *ModalState) EnterPaneMode(ctx context.Context, timeout time.Duration) {
127- log := logging.FromContext(ctx)113+ m.enterMode(ctx, ModePane, timeout)
128- m.mu.Lock()
129- defer m.mu.Unlock()
130-
131- if m.mode == ModePane {
132- // Already in pane mode, just reset timeout
133- m.resetTimeoutLocked(ctx, timeout)
134- return
135- }
136-
137- m.cancelTimerLocked()
138- oldMode := m.mode
139- m.mode = ModePane
140- m.timeout = timeout
141-
142- if timeout > 0 {
143- m.timer = time.AfterFunc(timeout, func() {
144- m.ExitMode(ctx)
145- })
146- }
147-
148- log.Debug().
149- Str("from", oldMode.String()).
150- Str("to", m.mode.String()).
151- Dur("timeout", timeout).
152- Msg("entered pane mode")
153-
154- if m.onModeChange != nil {
155- m.onModeChange(oldMode, m.mode)
156- }
157 }114 }
158 115
159 // EnterSessionMode switches to session mode with an optional timeout.116 // EnterSessionMode switches to session mode with an optional timeout.
160-// If timeout is 0, the mode stays until explicitly exited.
161 func (m *ModalState) EnterSessionMode(ctx context.Context, timeout time.Duration) {117 func (m *ModalState) EnterSessionMode(ctx context.Context, timeout time.Duration) {
162- log := logging.FromContext(ctx)118+ m.enterMode(ctx, ModeSession, timeout)
163- m.mu.Lock()
164- defer m.mu.Unlock()
165-
166- if m.mode == ModeSession {
167- // Already in session mode, just reset timeout
168- m.resetTimeoutLocked(ctx, timeout)
169- return
170- }
171-
172- m.cancelTimerLocked()
173- oldMode := m.mode
174- m.mode = ModeSession
175- m.timeout = timeout
176-
177- if timeout > 0 {
178- m.timer = time.AfterFunc(timeout, func() {
179- m.ExitMode(ctx)
180- })
181- }
182-
183- log.Debug().
184- Str("from", oldMode.String()).
185- Str("to", m.mode.String()).
186- Dur("timeout", timeout).
187- Msg("entered session mode")
188-
189- if m.onModeChange != nil {
190- m.onModeChange(oldMode, m.mode)
191- }
192 }119 }
193 120
194 // EnterResizeMode switches to resize mode with an optional timeout.121 // EnterResizeMode switches to resize mode with an optional timeout.
195-// If timeout is 0, the mode stays until explicitly exited.
196 func (m *ModalState) EnterResizeMode(ctx context.Context, timeout time.Duration) {122 func (m *ModalState) EnterResizeMode(ctx context.Context, timeout time.Duration) {
123+ m.enterMode(ctx, ModeResize, timeout)
124+}
125+
126+// enterMode is the shared implementation for all mode-enter methods.
127+// If already in the target mode, it resets the timeout instead.
128+func (m *ModalState) enterMode(ctx context.Context, target Mode, timeout time.Duration) {
197 log := logging.FromContext(ctx)129 log := logging.FromContext(ctx)
198 m.mu.Lock()130 m.mu.Lock()
199 defer m.mu.Unlock()131 defer m.mu.Unlock()
200 132
201- if m.mode == ModeResize {133+ if m.mode == target {
202- // Already in resize mode, just reset timeout
203 m.resetTimeoutLocked(ctx, timeout)134 m.resetTimeoutLocked(ctx, timeout)
204 return135 return
205 }136 }
206 137
207 m.cancelTimerLocked()138 m.cancelTimerLocked()
208 oldMode := m.mode139 oldMode := m.mode
209- m.mode = ModeResize140+ m.mode = target
210 m.timeout = timeout141 m.timeout = timeout
211 142
212 if timeout > 0 {143 if timeout > 0 {
213- m.timer = time.AfterFunc(timeout, func() {144+ m.startTimeoutLocked(ctx, timeout)
214- m.ExitMode(ctx)
215- })
216 }145 }
217 146
218 log.Debug().147 log.Debug().
219 Str("from", oldMode.String()).148 Str("from", oldMode.String()).
220- Str("to", m.mode.String()).149+ Str("to", target.String()).
221 Dur("timeout", timeout).150 Dur("timeout", timeout).
222- Msg("entered resize mode")151+ Msg("entered " + target.String() + " mode")
223 152
224 if m.onModeChange != nil {153 if m.onModeChange != nil {
225- m.onModeChange(oldMode, m.mode)154+ m.onModeChange(oldMode, target)
226 }155 }
227 }156 }
228 157
@@ -281,14 +210,23 @@ func (m *ModalState) cancelTimerLocked() {
281 }210 }
282 }211 }
283 212
213+// startTimeoutLocked starts a new timeout timer that exits the mode via the
214+// main thread scheduler. Timer goroutines must not call ExitMode directly
215+// because onModeChange may make GTK calls (e.g., switching controller phase).
216+// Must be called with m.mu held.
217+func (m *ModalState) startTimeoutLocked(ctx context.Context, timeout time.Duration) {
218+ schedule := m.scheduleOnMainThread
219+ m.timer = time.AfterFunc(timeout, func() {
220+ schedule(func() { m.ExitMode(ctx) })
221+ })
222+}
223+
284 // resetTimeoutLocked resets the timeout timer.224 // resetTimeoutLocked resets the timeout timer.
285 // Must be called with m.mu held.225 // Must be called with m.mu held.
286 func (m *ModalState) resetTimeoutLocked(ctx context.Context, timeout time.Duration) {226 func (m *ModalState) resetTimeoutLocked(ctx context.Context, timeout time.Duration) {
287 m.cancelTimerLocked()227 m.cancelTimerLocked()
288 m.timeout = timeout228 m.timeout = timeout
289 if timeout > 0 {229 if timeout > 0 {
290- m.timer = time.AfterFunc(timeout, func() {230+ m.startTimeoutLocked(ctx, timeout)
291- m.ExitMode(ctx)
292- })
293 }231 }
294 }232 }

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