detached 1d5172c

fix(browser): add focus debouncing to prevent rapid oscillation crashes

- Add 150ms focus debouncing to SetActivePane for all sources
- Prevent rapid focus changes that cause GTK segmentation faults
- Preserve system events priority (bypass debouncing for SourceSystem)
- Track last focus time and target to avoid redundant requests
- Fixes crashes during rapid mouse movements between panes
commit
1d5172ca7dccc8d212875dd4a94e28e4890e47cc
author
Brice · · 2025-09-27 21:47 UTC
parent
0533835
signature
unsigned
1 file · +20 -2
M internal/app/browser/workspace_manager.go +20 -2
@@ -50,6 +50,11 @@ type WorkspaceManager struct {
5050 // Widget operation synchronization to prevent GTK race conditions
5151 widgetMutex sync.Mutex
5252 widgetRegistry *WidgetRegistry
53+
54+ // Focus debouncing to prevent rapid oscillation from any source
55+ lastFocusTime time.Time
56+ lastFocusTarget *paneNode
57+ focusDebounce time.Duration
5358 }
5459
5560 // Workspace navigation shortcuts are now handled globally by WindowShortcutHandler
@@ -64,6 +69,7 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag
6469 lastExitMsg: make(map[*webkit.WebView]time.Time),
6570 paneDeduplicator: messaging.NewPaneRequestDeduplicator(), // NEW: Initialize deduplicator
6671 widgetRegistry: NewWidgetRegistry(), // Initialize widget registry
72+ focusDebounce: 150 * time.Millisecond, // 150ms focus debouncing for all sources
6773 }
6874
6975 // Initialize specialized managers
@@ -88,7 +94,7 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag
8894 }
8995 return manager.app.createPaneForView(view)
9096 }
91- manager.ensureStackedPaneStyles()
97+ manager.ensureWorkspaceStyles()
9298
9399 rootContainer := rootPane.webView.RootWidget()
94100 root := &paneNode{
@@ -338,8 +344,20 @@ func (wm *WorkspaceManager) GetActiveNode() *paneNode {
338344 return wm.focusStateMachine.GetActivePane()
339345 }
340346
341-// SetActivePane requests focus change through the focus state machine
347+// SetActivePane requests focus change through the focus state machine with debouncing
342348 func (wm *WorkspaceManager) SetActivePane(node *paneNode, source FocusSource) {
349+ now := time.Now()
350+
351+ // Check if we should debounce this focus request (except for urgent system events)
352+ if source != SourceSystem && now.Sub(wm.lastFocusTime) < wm.focusDebounce && wm.lastFocusTarget == node {
353+ // Too soon and same target - ignore this request to prevent rapid oscillation
354+ return
355+ }
356+
357+ // Update tracking and proceed with focus change
358+ wm.lastFocusTime = now
359+ wm.lastFocusTarget = node
360+
343361 if err := wm.focusStateMachine.RequestFocus(node, source); err != nil {
344362 log.Printf("[workspace] Focus request failed: %v", err)
345363 }
@@ -50,6 +50,11 @@ type WorkspaceManager struct {
50 // Widget operation synchronization to prevent GTK race conditions50 // Widget operation synchronization to prevent GTK race conditions
51 widgetMutex sync.Mutex51 widgetMutex sync.Mutex
52 widgetRegistry *WidgetRegistry52 widgetRegistry *WidgetRegistry
53+
54+ // Focus debouncing to prevent rapid oscillation from any source
55+ lastFocusTime time.Time
56+ lastFocusTarget *paneNode
57+ focusDebounce time.Duration
53 }58 }
54 59
55 // Workspace navigation shortcuts are now handled globally by WindowShortcutHandler60 // Workspace navigation shortcuts are now handled globally by WindowShortcutHandler
@@ -64,6 +69,7 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag
64 lastExitMsg: make(map[*webkit.WebView]time.Time),69 lastExitMsg: make(map[*webkit.WebView]time.Time),
65 paneDeduplicator: messaging.NewPaneRequestDeduplicator(), // NEW: Initialize deduplicator70 paneDeduplicator: messaging.NewPaneRequestDeduplicator(), // NEW: Initialize deduplicator
66 widgetRegistry: NewWidgetRegistry(), // Initialize widget registry71 widgetRegistry: NewWidgetRegistry(), // Initialize widget registry
72+ focusDebounce: 150 * time.Millisecond, // 150ms focus debouncing for all sources
67 }73 }
68 74
69 // Initialize specialized managers75 // Initialize specialized managers
@@ -88,7 +94,7 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag
88 }94 }
89 return manager.app.createPaneForView(view)95 return manager.app.createPaneForView(view)
90 }96 }
91- manager.ensureStackedPaneStyles()97+ manager.ensureWorkspaceStyles()
92 98
93 rootContainer := rootPane.webView.RootWidget()99 rootContainer := rootPane.webView.RootWidget()
94 root := &paneNode{100 root := &paneNode{
@@ -338,8 +344,20 @@ func (wm *WorkspaceManager) GetActiveNode() *paneNode {
338 return wm.focusStateMachine.GetActivePane()344 return wm.focusStateMachine.GetActivePane()
339 }345 }
340 346
341-// SetActivePane requests focus change through the focus state machine347+// SetActivePane requests focus change through the focus state machine with debouncing
342 func (wm *WorkspaceManager) SetActivePane(node *paneNode, source FocusSource) {348 func (wm *WorkspaceManager) SetActivePane(node *paneNode, source FocusSource) {
349+ now := time.Now()
350+
351+ // Check if we should debounce this focus request (except for urgent system events)
352+ if source != SourceSystem && now.Sub(wm.lastFocusTime) < wm.focusDebounce && wm.lastFocusTarget == node {
353+ // Too soon and same target - ignore this request to prevent rapid oscillation
354+ return
355+ }
356+
357+ // Update tracking and proceed with focus change
358+ wm.lastFocusTime = now
359+ wm.lastFocusTarget = node
360+
343 if err := wm.focusStateMachine.RequestFocus(node, source); err != nil {361 if err := wm.focusStateMachine.RequestFocus(node, source); err != nil {
344 log.Printf("[workspace] Focus request failed: %v", err)362 log.Printf("[workspace] Focus request failed: %v", err)
345 }363 }

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