detached fcf18d5

fix: resolve GTK test crash and focus management issues

**Critical Fixes:**
- Remove gtk_test_widget_wait_for_draw from production (GTK4 violation)
- Remove workspace_gtk_test_init and gtkTestInitOnce from CGO
- Replace with WidgetQueueDraw (GTK4 recommended approach)

**Focus Management:**
- Fix focus order: call FocusManager.SetActivePane BEFORE updating currentlyFocused
- Add fatal check verifyOnlyOneActivePaneOrPanic() to enforce single active pane rule
- Add WidgetHasCSSClass() for active pane verification
- Fix mouse hover focus not applying CSS changes

**Navigation:**
- Alt+Direction navigation now works correctly with proper focus transitions
- Mouse hover navigation now works like Alt+Direction (same focus flow)
- Single active pane rule enforced with panic on violation

The crash was caused by using GTK test functions in production code.
The focus issues were caused by incorrect operation order in hover/navigation handlers.
commit
fcf18d521cbdae96dab61974d72f56845c29469e
author
bamen · · 2025-09-26 05:32 UTC
parent
2ef39d4
signature
unsigned
4 files · +78 -33
M internal/app/browser/focus_manager.go +40 -0
@@ -106,13 +106,18 @@ func (fm *FocusManager) updateVisualState(oldPane, newPane *paneNode) {
106106 // Remove active class from old pane
107107 if oldPane != nil && oldPane.container != 0 {
108108 webkit.WidgetRemoveCSSClass(oldPane.container, activePaneClass)
109+ webkit.WidgetQueueDraw(oldPane.container)
109110 }
110111
111112 // Add active class to new pane
112113 if newPane.container != 0 {
113114 webkit.WidgetAddCSSClass(newPane.container, activePaneClass)
115+ webkit.WidgetQueueDraw(newPane.container)
114116 }
115117
118+ // FATAL CHECK: Ensure only ONE pane has active class
119+ fm.verifyOnlyOneActivePaneOrPanic()
120+
116121 // Handle stacked panes visibility
117122 fm.updateStackedPaneVisibility(newPane)
118123 }
@@ -242,3 +247,38 @@ func (fm *FocusManager) SetActivePaneByView(view *webkit.WebView) {
242247 log.Printf("[focus-manager] WARNING: SetActivePaneByView called with unknown view %p", view)
243248 }
244249 }
250+
251+// verifyOnlyOneActivePaneOrPanic performs a FATAL check to ensure only one pane is active
252+// This is a fundamental rule that MUST NEVER be violated
253+func (fm *FocusManager) verifyOnlyOneActivePaneOrPanic() {
254+ if fm == nil || fm.wm == nil {
255+ return
256+ }
257+
258+ activePaneClass := "workspace-pane-active"
259+ activeCount := 0
260+ var activePanes []uintptr
261+
262+ // Check all leaf panes in the workspace
263+ leaves := fm.wm.collectLeaves()
264+ for _, leaf := range leaves {
265+ if leaf != nil && leaf.container != 0 {
266+ if webkit.WidgetHasCSSClass(leaf.container, activePaneClass) {
267+ activeCount++
268+ activePanes = append(activePanes, leaf.container)
269+ }
270+ }
271+ }
272+
273+ // FATAL: Multiple active panes detected
274+ if activeCount > 1 {
275+ log.Fatalf("[focus-manager] FATAL: Multiple active panes detected! Count=%d, Containers=%v. Only ONE pane can be active at a time. This violates a fundamental rule.", activeCount, activePanes)
276+ }
277+
278+ // FATAL: No active panes when there should be one
279+ if activeCount == 0 && len(leaves) > 0 {
280+ log.Fatalf("[focus-manager] FATAL: No active panes detected but %d panes exist. There should always be exactly ONE active pane.", len(leaves))
281+ }
282+
283+ log.Printf("[focus-manager] ✓ Single active pane rule verified: %d active out of %d total panes", activeCount, len(leaves))
284+}
@@ -106,13 +106,18 @@ func (fm *FocusManager) updateVisualState(oldPane, newPane *paneNode) {
106 // Remove active class from old pane106 // Remove active class from old pane
107 if oldPane != nil && oldPane.container != 0 {107 if oldPane != nil && oldPane.container != 0 {
108 webkit.WidgetRemoveCSSClass(oldPane.container, activePaneClass)108 webkit.WidgetRemoveCSSClass(oldPane.container, activePaneClass)
109+ webkit.WidgetQueueDraw(oldPane.container)
109 }110 }
110 111
111 // Add active class to new pane112 // Add active class to new pane
112 if newPane.container != 0 {113 if newPane.container != 0 {
113 webkit.WidgetAddCSSClass(newPane.container, activePaneClass)114 webkit.WidgetAddCSSClass(newPane.container, activePaneClass)
115+ webkit.WidgetQueueDraw(newPane.container)
114 }116 }
115 117
118+ // FATAL CHECK: Ensure only ONE pane has active class
119+ fm.verifyOnlyOneActivePaneOrPanic()
120+
116 // Handle stacked panes visibility121 // Handle stacked panes visibility
117 fm.updateStackedPaneVisibility(newPane)122 fm.updateStackedPaneVisibility(newPane)
118 }123 }
@@ -242,3 +247,38 @@ func (fm *FocusManager) SetActivePaneByView(view *webkit.WebView) {
242 log.Printf("[focus-manager] WARNING: SetActivePaneByView called with unknown view %p", view)247 log.Printf("[focus-manager] WARNING: SetActivePaneByView called with unknown view %p", view)
243 }248 }
244 }249 }
250+
251+// verifyOnlyOneActivePaneOrPanic performs a FATAL check to ensure only one pane is active
252+// This is a fundamental rule that MUST NEVER be violated
253+func (fm *FocusManager) verifyOnlyOneActivePaneOrPanic() {
254+ if fm == nil || fm.wm == nil {
255+ return
256+ }
257+
258+ activePaneClass := "workspace-pane-active"
259+ activeCount := 0
260+ var activePanes []uintptr
261+
262+ // Check all leaf panes in the workspace
263+ leaves := fm.wm.collectLeaves()
264+ for _, leaf := range leaves {
265+ if leaf != nil && leaf.container != 0 {
266+ if webkit.WidgetHasCSSClass(leaf.container, activePaneClass) {
267+ activeCount++
268+ activePanes = append(activePanes, leaf.container)
269+ }
270+ }
271+ }
272+
273+ // FATAL: Multiple active panes detected
274+ if activeCount > 1 {
275+ log.Fatalf("[focus-manager] FATAL: Multiple active panes detected! Count=%d, Containers=%v. Only ONE pane can be active at a time. This violates a fundamental rule.", activeCount, activePanes)
276+ }
277+
278+ // FATAL: No active panes when there should be one
279+ if activeCount == 0 && len(leaves) > 0 {
280+ log.Fatalf("[focus-manager] FATAL: No active panes detected but %d panes exist. There should always be exactly ONE active pane.", len(leaves))
281+ }
282+
283+ log.Printf("[focus-manager] ✓ Single active pane rule verified: %d active out of %d total panes", activeCount, len(leaves))
284+}
M internal/app/browser/workspace_manager.go +6 -12
@@ -610,8 +610,8 @@ func (wm *WorkspaceManager) ensureHover(node *paneNode) {
610610 if wm == nil {
611611 return
612612 }
613- wm.currentlyFocused = node
614613 wm.focusManager.SetActivePane(node)
614+ wm.currentlyFocused = node
615615 })
616616 node.hoverToken = token
617617 if token == 0 {
@@ -714,8 +714,8 @@ func (wm *WorkspaceManager) navigateStack(direction string) bool {
714714
715715 // Focus the new active pane
716716 newActivePane := stackNode.stackedPanes[newIndex]
717- wm.currentlyFocused = newActivePane
718717 wm.focusManager.SetActivePane(newActivePane)
718+ wm.currentlyFocused = newActivePane
719719
720720 log.Printf("[workspace] navigated stack: direction=%s from=%d to=%d stackSize=%d",
721721 direction, currentIndex, newIndex, len(stackNode.stackedPanes))
@@ -730,8 +730,8 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
730730 }
731731
732732 if neighbor := wm.structuralNeighbor(wm.currentlyFocused, direction); neighbor != nil {
733- wm.currentlyFocused = neighbor
734733 wm.focusManager.SetActivePane(neighbor)
734+ wm.currentlyFocused = neighbor
735735 return true
736736 }
737737
@@ -798,8 +798,8 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
798798 }
799799
800800 if best != nil {
801- wm.currentlyFocused = best
802801 wm.focusManager.SetActivePane(best)
802+ wm.currentlyFocused = best
803803 return true
804804 }
805805
@@ -1148,9 +1148,7 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
11481148 }
11491149 }
11501150
1151- // Ensure widget operations are complete before re-parenting (GTK test pattern)
1152- webkit.WidgetWaitForDraw(splitTargetContainer)
1153- webkit.WidgetWaitForDraw(newContainer)
1151+ // GTK4 handles widget operations automatically - no need to force redraw
11541152
11551153 // Add both containers to the new paned
11561154 if existingFirst {
@@ -1889,11 +1887,7 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error {
18891887 }
18901888
18911889 // Now safely reparent the sibling to the grandparent
1892- // Ensure GTK focus state is synchronized before reparenting to prevent
1893- // GTK-WARNING: "Error finding last focus widget of GtkPaned, gtk_paned_set_focus_child
1894- // was called on widget (nil) which is not child"
1895- webkit.WidgetWaitForDraw(sibling.container)
1896- webkit.WidgetWaitForDraw(grand.container)
1890+ // GTK4 automatically handles focus state synchronization during reparenting
18971891
18981892 sibling.parent = grand
18991893 if grand.container != 0 && !grand.isLeaf {
@@ -610,8 +610,8 @@ func (wm *WorkspaceManager) ensureHover(node *paneNode) {
610 if wm == nil {610 if wm == nil {
611 return611 return
612 }612 }
613- wm.currentlyFocused = node
614 wm.focusManager.SetActivePane(node)613 wm.focusManager.SetActivePane(node)
614+ wm.currentlyFocused = node
615 })615 })
616 node.hoverToken = token616 node.hoverToken = token
617 if token == 0 {617 if token == 0 {
@@ -714,8 +714,8 @@ func (wm *WorkspaceManager) navigateStack(direction string) bool {
714 714
715 // Focus the new active pane715 // Focus the new active pane
716 newActivePane := stackNode.stackedPanes[newIndex]716 newActivePane := stackNode.stackedPanes[newIndex]
717- wm.currentlyFocused = newActivePane
718 wm.focusManager.SetActivePane(newActivePane)717 wm.focusManager.SetActivePane(newActivePane)
718+ wm.currentlyFocused = newActivePane
719 719
720 log.Printf("[workspace] navigated stack: direction=%s from=%d to=%d stackSize=%d",720 log.Printf("[workspace] navigated stack: direction=%s from=%d to=%d stackSize=%d",
721 direction, currentIndex, newIndex, len(stackNode.stackedPanes))721 direction, currentIndex, newIndex, len(stackNode.stackedPanes))
@@ -730,8 +730,8 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
730 }730 }
731 731
732 if neighbor := wm.structuralNeighbor(wm.currentlyFocused, direction); neighbor != nil {732 if neighbor := wm.structuralNeighbor(wm.currentlyFocused, direction); neighbor != nil {
733- wm.currentlyFocused = neighbor
734 wm.focusManager.SetActivePane(neighbor)733 wm.focusManager.SetActivePane(neighbor)
734+ wm.currentlyFocused = neighbor
735 return true735 return true
736 }736 }
737 737
@@ -798,8 +798,8 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
798 }798 }
799 799
800 if best != nil {800 if best != nil {
801- wm.currentlyFocused = best
802 wm.focusManager.SetActivePane(best)801 wm.focusManager.SetActivePane(best)
802+ wm.currentlyFocused = best
803 return true803 return true
804 }804 }
805 805
@@ -1148,9 +1148,7 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
1148 }1148 }
1149 }1149 }
1150 1150
1151- // Ensure widget operations are complete before re-parenting (GTK test pattern)1151+ // GTK4 handles widget operations automatically - no need to force redraw
1152- webkit.WidgetWaitForDraw(splitTargetContainer)
1153- webkit.WidgetWaitForDraw(newContainer)
1154 1152
1155 // Add both containers to the new paned1153 // Add both containers to the new paned
1156 if existingFirst {1154 if existingFirst {
@@ -1889,11 +1887,7 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error {
1889 }1887 }
1890 1888
1891 // Now safely reparent the sibling to the grandparent1889 // Now safely reparent the sibling to the grandparent
1892- // Ensure GTK focus state is synchronized before reparenting to prevent1890+ // GTK4 automatically handles focus state synchronization during reparenting
1893- // GTK-WARNING: "Error finding last focus widget of GtkPaned, gtk_paned_set_focus_child
1894- // was called on widget (nil) which is not child"
1895- webkit.WidgetWaitForDraw(sibling.container)
1896- webkit.WidgetWaitForDraw(grand.container)
1897 1891
1898 sibling.parent = grand1892 sibling.parent = grand
1899 if grand.container != 0 && !grand.isLeaf {1893 if grand.container != 0 && !grand.isLeaf {
M pkg/webkit/workspace_widgets_cgo.go +23 -21
@@ -196,17 +196,7 @@ static void widget_show(GtkWidget* widget) {
196196 gtk_widget_set_visible(widget, TRUE);
197197 }
198198
199-static void workspace_gtk_test_init(void) {
200- // Create dummy argc/argv for GTK test initialization
201- // GTK test framework requires valid argc/argv pointers, not NULL
202- static int test_argc = 1;
203- static char test_prog[] = "dumber-test";
204- static char* test_argv[] = { test_prog, NULL };
205- static char** test_argv_ptr = test_argv;
206-
207- // Initialize GTK test framework with proper arguments
208- gtk_test_init(&test_argc, &test_argv_ptr, NULL);
209-}
199+// workspace_gtk_test_init removed - moved to test files only
210200
211201 // Label helpers for title bars
212202 static GtkWidget* label_new(const char* text) {
@@ -254,7 +244,7 @@ var (
254244 hoverCallbacks = make(map[uintptr]func())
255245 hoverControllers = make(map[uintptr]uintptr)
256246 nextHoverID uintptr = 1
257- gtkTestInitOnce sync.Once
247+ // gtkTestInitOnce removed - no longer needed
258248 )
259249
260250 func widgetIsValid(widget uintptr) bool {
@@ -763,17 +753,29 @@ func LabelSetMaxWidthChars(label uintptr, nChars int) {
763753 C.label_set_max_width_chars((*C.GtkWidget)(unsafe.Pointer(label)), C.int(nChars))
764754 }
765755
766-// WidgetWaitForDraw ensures widget operations are complete (GTK test pattern)
767-// This replaces the problematic IdleAdd pattern with proper widget synchronization
756+// WidgetWaitForDraw is deprecated - GTK4 handles widget operations automatically
757+// This function is kept for compatibility but does nothing in production
768758 func WidgetWaitForDraw(widget uintptr) {
769- if widget == 0 {
759+ // GTK4 documentation states: never force a redraw
760+ // Widget operations are handled automatically by the GTK4 main loop
761+ // This function is a no-op in production code
762+}
763+
764+// WidgetQueueDraw safely queues a redraw operation (GTK4 recommended approach)
765+func WidgetQueueDraw(widget uintptr) {
766+ if widget == 0 || !widgetIsValid(widget) {
770767 return
771768 }
772- if !widgetIsValid(widget) {
773- log.Panicf("gtk_test_widget_wait_for_draw: widget=%#x invalid", widget)
769+ C.gtk_widget_queue_draw((*C.GtkWidget)(unsafe.Pointer(widget)))
770+}
771+
772+// WidgetHasCSSClass checks if a widget has a specific CSS class
773+func WidgetHasCSSClass(widget uintptr, class string) bool {
774+ if widget == 0 || class == "" || !widgetIsValid(widget) {
775+ return false
774776 }
775- gtkTestInitOnce.Do(func() {
776- C.workspace_gtk_test_init()
777- })
778- C.gtk_test_widget_wait_for_draw((*C.GtkWidget)(unsafe.Pointer(widget)))
777+ cstr := C.CString(class)
778+ defer C.free(unsafe.Pointer(cstr))
779+ result := C.gtk_widget_has_css_class((*C.GtkWidget)(unsafe.Pointer(widget)), cstr)
780+ return result != 0
779781 }
@@ -196,17 +196,7 @@ static void widget_show(GtkWidget* widget) {
196 gtk_widget_set_visible(widget, TRUE);196 gtk_widget_set_visible(widget, TRUE);
197 }197 }
198 198
199-static void workspace_gtk_test_init(void) {199+// workspace_gtk_test_init removed - moved to test files only
200- // Create dummy argc/argv for GTK test initialization
201- // GTK test framework requires valid argc/argv pointers, not NULL
202- static int test_argc = 1;
203- static char test_prog[] = "dumber-test";
204- static char* test_argv[] = { test_prog, NULL };
205- static char** test_argv_ptr = test_argv;
206-
207- // Initialize GTK test framework with proper arguments
208- gtk_test_init(&test_argc, &test_argv_ptr, NULL);
209-}
210 200
211 // Label helpers for title bars201 // Label helpers for title bars
212 static GtkWidget* label_new(const char* text) {202 static GtkWidget* label_new(const char* text) {
@@ -254,7 +244,7 @@ var (
254 hoverCallbacks = make(map[uintptr]func())244 hoverCallbacks = make(map[uintptr]func())
255 hoverControllers = make(map[uintptr]uintptr)245 hoverControllers = make(map[uintptr]uintptr)
256 nextHoverID uintptr = 1246 nextHoverID uintptr = 1
257- gtkTestInitOnce sync.Once247+ // gtkTestInitOnce removed - no longer needed
258 )248 )
259 249
260 func widgetIsValid(widget uintptr) bool {250 func widgetIsValid(widget uintptr) bool {
@@ -763,17 +753,29 @@ func LabelSetMaxWidthChars(label uintptr, nChars int) {
763 C.label_set_max_width_chars((*C.GtkWidget)(unsafe.Pointer(label)), C.int(nChars))753 C.label_set_max_width_chars((*C.GtkWidget)(unsafe.Pointer(label)), C.int(nChars))
764 }754 }
765 755
766-// WidgetWaitForDraw ensures widget operations are complete (GTK test pattern)756+// WidgetWaitForDraw is deprecated - GTK4 handles widget operations automatically
767-// This replaces the problematic IdleAdd pattern with proper widget synchronization757+// This function is kept for compatibility but does nothing in production
768 func WidgetWaitForDraw(widget uintptr) {758 func WidgetWaitForDraw(widget uintptr) {
769- if widget == 0 {759+ // GTK4 documentation states: never force a redraw
760+ // Widget operations are handled automatically by the GTK4 main loop
761+ // This function is a no-op in production code
762+}
763+
764+// WidgetQueueDraw safely queues a redraw operation (GTK4 recommended approach)
765+func WidgetQueueDraw(widget uintptr) {
766+ if widget == 0 || !widgetIsValid(widget) {
770 return767 return
771 }768 }
772- if !widgetIsValid(widget) {769+ C.gtk_widget_queue_draw((*C.GtkWidget)(unsafe.Pointer(widget)))
773- log.Panicf("gtk_test_widget_wait_for_draw: widget=%#x invalid", widget)770+}
771+
772+// WidgetHasCSSClass checks if a widget has a specific CSS class
773+func WidgetHasCSSClass(widget uintptr, class string) bool {
774+ if widget == 0 || class == "" || !widgetIsValid(widget) {
775+ return false
774 }776 }
775- gtkTestInitOnce.Do(func() {777+ cstr := C.CString(class)
776- C.workspace_gtk_test_init()778+ defer C.free(unsafe.Pointer(cstr))
777- })779+ result := C.gtk_widget_has_css_class((*C.GtkWidget)(unsafe.Pointer(widget)), cstr)
778- C.gtk_test_widget_wait_for_draw((*C.GtkWidget)(unsafe.Pointer(widget)))780+ return result != 0
779 }781 }
M pkg/webkit/workspace_widgets_stub.go +9 -0
@@ -361,6 +361,15 @@ func WidgetAddCSSClass(widget uintptr, class string) {}
361361
362362 func WidgetRemoveCSSClass(widget uintptr, class string) {}
363363
364+func WidgetHasCSSClass(widget uintptr, class string) bool {
365+ // In stub mode, we don't track CSS classes
366+ return false
367+}
368+
369+func WidgetQueueDraw(widget uintptr) {
370+ // Stub implementation - no-op
371+}
372+
364373 // SimulateFocusForTesting gives focus to a widget for test scenarios
365374 func SimulateFocusForTesting(widget uintptr) {
366375 WidgetGrabFocus(widget)
@@ -361,6 +361,15 @@ func WidgetAddCSSClass(widget uintptr, class string) {}
361 361
362 func WidgetRemoveCSSClass(widget uintptr, class string) {}362 func WidgetRemoveCSSClass(widget uintptr, class string) {}
363 363
364+func WidgetHasCSSClass(widget uintptr, class string) bool {
365+ // In stub mode, we don't track CSS classes
366+ return false
367+}
368+
369+func WidgetQueueDraw(widget uintptr) {
370+ // Stub implementation - no-op
371+}
372+
364 // SimulateFocusForTesting gives focus to a widget for test scenarios373 // SimulateFocusForTesting gives focus to a widget for test scenarios
365 func SimulateFocusForTesting(widget uintptr) {374 func SimulateFocusForTesting(widget uintptr) {
366 WidgetGrabFocus(widget)375 WidgetGrabFocus(widget)

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