detached 084157a

Merge pull request #1 from bnema/feat/stacked-panes

feat(workspace): implement Zellij-style stacked panes
commit
084157a7577d8dbb7486f0aaf37071c59893baee
author
bnema · · 2025-10-05 15:56 UTC
committer
GitHub · · 2025-10-05 15:56 UTC
merge of
a85e2e5 + d11d103 · diff against the first parent
signature
unsigned
49 files · +8405 -3919
M .gitignore +4 -0
@@ -66,13 +66,17 @@ assets/gui
66 66
67 # Node/TypeScript related67 # Node/TypeScript related
68 node_modules/68 node_modules/
69+.pnpm-store/
70+
69 71
70 # Development/tooling files72 # Development/tooling files
71 CLAUDE.md73 CLAUDE.md
72 .claude/74 .claude/
73 memory/75 memory/
76+notes/
74 scripts/77 scripts/
75 !scripts/generate-types.js78 !scripts/generate-types.js
76 specs/79 specs/
77 templates/80 templates/
78 .serena/81 .serena/
82+config.yaml
M CHANGELOG.md +7 -0
@@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.
6 6
7 ### Added7 ### Added
8 - **Pin/favorite sites**: Pin websites from history or quick access to prioritize them in "Jump back in" section. Uses localStorage persistence with star icons for pin/unpin actions8 - **Pin/favorite sites**: Pin websites from history or quick access to prioritize them in "Jump back in" section. Uses localStorage persistence with star icons for pin/unpin actions
9+- **Zellij-style stacked panes**: Added Ctrl+P → 's' to stack panes instead of splitting, with Alt+Up/Down navigation between stacked panes. Features collapsed page title bars showing for inactive panes and full WebView interaction for the active pane. Includes GTK4 CSS styling for proper visual feedback
9 10
10 ### Changed11 ### Changed
11 - **History item layout**: Removed "• domain" from titles, now highlight domain within the full URL using same color as title text12 - **History item layout**: Removed "• domain" from titles, now highlight domain within the full URL using same color as title text
@@ -15,6 +16,12 @@ All notable changes to this project will be documented in this file.
15 ### Fixed16 ### Fixed
16 - **Mobile URL overflow**: Fixed long URLs breaking layout on narrow screens by adding proper width constraints and CSS fixes17 - **Mobile URL overflow**: Fixed long URLs breaking layout on narrow screens by adding proper width constraints and CSS fixes
17 - **GTK shortcut GLib-CRITICAL error**: Fixed NULL string assertion in shortcut callback system18 - **GTK shortcut GLib-CRITICAL error**: Fixed NULL string assertion in shortcut callback system
19+- **Focus management architecture**: Refactored workspace focus system from `active` field to `currentlyFocused` with dedicated FocusManager for improved reliability and consistency across pane operations
20+- **Stacked pane focus conflicts**: Fixed focus stealing issues in stacked panes by implementing focus-aware navigation that preserves active pane when spawning siblings
21+- **WebView title updates**: Enhanced webview title change handling to properly update stacked pane title bars in addition to database storage
22+- **GTK4 widget lifecycle**: Improved widget parenting/unparenting operations with proper GTK4 validation and automatic cleanup to prevent critical warnings
23+- **Stacked pane close behavior**: Fixed remaining pane becoming non-interactable (gray background) when closing stacked panes via Ctrl+W or Ctrl+P+X. Added proper widget visibility restoration and focus management during stack-to-regular conversion
24+- **Stacked pane title updates**: Fixed collapsed panes not showing current page titles when switching between stacked panes. Now follows Zellij-style layout where hidden panes display their up-to-date page titles in title bars with correct positioning above and below the active pane
18 25
19 ## [0.10.0] - 2025-09-2426 ## [0.10.0] - 2025-09-24
20 27
M README.md +6 -2
@@ -59,10 +59,12 @@ Dumber is a minimalist browser and launcher companion focused on speed and simpl
59 | **←** / **L** (in pane mode) | Split Left | Create new pane to the left |59 | **←** / **L** (in pane mode) | Split Left | Create new pane to the left |
60 | **↑** / **U** (in pane mode) | Split Up | Create new pane above |60 | **↑** / **U** (in pane mode) | Split Up | Create new pane above |
61 | **↓** / **D** (in pane mode) | Split Down | Create new pane below |61 | **↓** / **D** (in pane mode) | Split Down | Create new pane below |
62+| **S** (in pane mode) | Stack Pane | Create stacked pane (Zellij-style) |
62 | **X** (in pane mode) | Close Pane | Close current pane |63 | **X** (in pane mode) | Close Pane | Close current pane |
63 | **Enter** (in pane mode) | Confirm Action | Confirm pane operation |64 | **Enter** (in pane mode) | Confirm Action | Confirm pane operation |
64 | **Escape** (in pane mode) | Exit Pane Mode | Return to normal navigation |65 | **Escape** (in pane mode) | Exit Pane Mode | Return to normal navigation |
65 | **Alt+Arrow Keys** | Navigate Panes | Move focus between panes |66 | **Alt+Arrow Keys** | Navigate Panes | Move focus between panes |
67+| **Alt+Up/Down** | Navigate Stack | Navigate between stacked panes |
66 68
67 ### Mouse Controls69 ### Mouse Controls
68 | Action | Result | Notes |70 | Action | Result | Notes |
@@ -159,16 +161,18 @@ Dumber features a Zellij-inspired pane management system that allows you to spli
159 161
160 ### How it Works162 ### How it Works
161 1. **Enter Pane Mode**: Press `Ctrl+P` to enter pane mode (modal interface with timeout)163 1. **Enter Pane Mode**: Press `Ctrl+P` to enter pane mode (modal interface with timeout)
162-2. **Split Panes**: Use arrow keys or letter shortcuts to split:164+2. **Split or Stack Panes**: Use arrow keys or letter shortcuts:
163 - `→` or `R` - Split right165 - `→` or `R` - Split right
164 - `←` or `L` - Split left166 - `←` or `L` - Split left
165 - `↑` or `U` - Split up167 - `↑` or `U` - Split up
166 - `↓` or `D` - Split down168 - `↓` or `D` - Split down
167-3. **Navigate**: Use `Alt+Arrow Keys` to move focus between panes169+ - `S` - Stack panes (Zellij-style, shows title bars for collapsed panes)
170+3. **Navigate**: Use `Alt+Arrow Keys` to move focus between split panes, or `Alt+Up/Down` to navigate within stacked panes
168 4. **Close**: Press `X` in pane mode to close the current pane171 4. **Close**: Press `X` in pane mode to close the current pane
169 172
170 ### Features173 ### Features
171 - **Binary tree layout**: Panes are organized in a binary tree structure for optimal space usage174 - **Binary tree layout**: Panes are organized in a binary tree structure for optimal space usage
175+- **Stacked panes**: Zellij-style stacking with collapsed title bars showing "Page Title - domain" for inactive panes
172 - **Focus management**: Visual borders, hover-to-focus, and enhanced focus throttling (100ms) for intuitive navigation176 - **Focus management**: Visual borders, hover-to-focus, and enhanced focus throttling (100ms) for intuitive navigation
173 - **Independent sessions**: Each pane maintains its own browsing session, history, and zoom level177 - **Independent sessions**: Each pane maintains its own browsing session, history, and zoom level
174 - **Advanced popup handling**: Intelligent popup vs tab detection, OAuth auto-close, and popup deduplication with SHA256 fingerprinting178 - **Advanced popup handling**: Intelligent popup vs tab detection, OAuth auto-close, and popup deduplication with SHA256 fingerprinting
A dev/run-debug.fish +15 -0
@@ -0,0 +1,15 @@
1+#!/usr/bin/env fish
2+# dev/run-debug.fish - launch ./dist/dumber with verbose debug instrumentation
3+
4+# Enable core dumps so we get a crash backtrace if GLib aborts
5+ulimit -c unlimited
6+
7+# Go runtime crash dumps
8+set -gx GOTRACEBACK crash
9+
10+# Make GLib fatal on warnings/criticals and emit stack traces
11+set -gx G_DEBUG "fatal-criticals,fatal-warnings,stack-trace-on-fatal"
12+set -gx G_MESSAGES_DEBUG all
13+
14+# Execute the browser with any arguments forwarded
15+./dist/dumber $argv
D frontend/src/types/generated.ts +0 -83
@@ -1,83 +0,0 @@
1-// GENERATED FROM GO FILES: SEE THE GENERATOR SCRIPT
2-// Auto-generated TypeScript types from Go structs
3-// Generated on: 2025-09-22T12:41:27.395Z
4-// Generator script: scripts/generate-types.js
5-// Source files: internal/db/models.go, internal/parser/types.go
6-// Do not edit manually - run 'node scripts/generate-types.js' to regenerate
7-
8-export interface CertificateValidation {
9- id: number;
10- hostname: string;
11- certificate_hash: string;
12- user_decision: string;
13- created_at: string | null;
14- expires_at: string | null;
15-}
16-
17-export interface History {
18- id: number;
19- url: string;
20- title: string | null;
21- visit_count: number | null;
22- last_visited: string | null;
23- created_at: string | null;
24- favicon_url: string | null;
25-}
26-
27-export interface Shortcut {
28- id: number;
29- shortcut: string;
30- url_template: string;
31- description: string | null;
32- created_at: string | null;
33-}
34-
35-export interface ZoomLevel {
36- domain: string;
37- zoom_factor: number;
38- updated_at: string | null;
39-}
40-
41-export interface ParseResult {
42- type: InputType;
43- url: string;
44- query: string;
45- confidence: number;
46- fuzzy_matches?: FuzzyMatch[];
47- shortcut?: DetectedShortcut;
48- processing_time: number;
49-}
50-
51-export interface DetectedShortcut {
52- key: string;
53- query: string;
54- url: string;
55- description: string;
56-}
57-
58-export interface FuzzyMatch {
59- history_entry?: History;
60- score: number;
61- url_score: number;
62- title_score: number;
63- recency_score: number;
64- visit_score: number;
65- matched_field: string;
66-}
67-
68-export interface FuzzyConfig {
69- min_similarity_threshold: number;
70- max_results: number;
71- url_weight: number;
72- title_weight: number;
73- recency_weight: number;
74- visit_weight: number;
75- recency_decay_days: number;
76-}
77-
78-// Manual types and enums
79-
80-// Type aliases for frontend usage
81-export type HistoryEntry = History;
82-export type SearchShortcut = Shortcut;
83-export type ZoomEntry = ZoomSetting;
M gui/package-lock.json +0 -15 generated file: collapsed
M gui/src/injected/gui.ts +1 -4
@@ -13,7 +13,6 @@ import {
13 type WorkspaceConfigPayload,13 type WorkspaceConfigPayload,
14 type WorkspaceRuntime,14 type WorkspaceRuntime,
15 } from "./modules/workspace";15 } from "./modules/workspace";
16-import { initializeWindowOpenInterceptor } from "./modules/window-open";
17 import { keyboardService, type KeyboardService } from "$lib/keyboard";16 import { keyboardService, type KeyboardService } from "$lib/keyboard";
18 import type { Suggestion } from "../components/omnibox/types";17 import type { Suggestion } from "../components/omnibox/types";
19 // Note: color-scheme module is loaded separately at document-start by WebKit18 // Note: color-scheme module is loaded separately at document-start by WebKit
@@ -156,9 +155,7 @@ if (!window.__dumber_gui_ready) {
156 whenDOMReady(async () => {155 whenDOMReady(async () => {
157 try {156 try {
158 // Page-world bridge is injected by WebKit; avoid duplicate injection here157 // Page-world bridge is injected by WebKit; avoid duplicate injection here
159-158+ // window.open interceptor is now in main-world.ts (page-world context)
160- // Initialize window.open interceptor first (must be early)
161- initializeWindowOpenInterceptor();
162 159
163 // Initialize toast system first160 // Initialize toast system first
164 await initializeToast();161 await initializeToast();
D gui/src/injected/modules/window-open.ts +0 -29
@@ -1,29 +0,0 @@
1-/**
2- * Window Open Interceptor Module
3- *
4- * OBSOLETE: window.open() is now handled directly via page-world bridge bypass.
5- * This module is kept for compatibility but no longer performs active interception.
6- */
7-
8-let isInitialized = false;
9-
10-/**
11- * Initialize the window.open interceptor (no-op)
12- * Window.open is now handled directly via JavaScript bypass in page-world
13- */
14-export function initializeWindowOpenInterceptor(): void {
15- if (isInitialized) {
16- return;
17- }
18-
19- // No-op - window.open handled directly via page-world bridge
20- isInitialized = true;
21- console.log("[window-open] ✅ Interceptor initialized (bypass mode)");
22-}
23-
24-/**
25- * Get initialization status
26- */
27-export function isWindowOpenInterceptorInitialized(): boolean {
28- return isInitialized;
29-}
M gui/src/injected/modules/workspace.ts +10 -5
@@ -80,6 +80,7 @@ const DEFAULT_CONFIG: WorkspaceConfigNormalized = {
80 l: "split-left",80 l: "split-left",
81 u: "split-up",81 u: "split-up",
82 d: "split-down",82 d: "split-down",
83+ s: "stack-pane",
83 x: "close-pane",84 x: "close-pane",
84 enter: "confirm",85 enter: "confirm",
85 escape: "cancel",86 escape: "cancel",
@@ -254,7 +255,6 @@ class WorkspaceController implements WorkspaceRuntime {
254 clearTimeout(this.paneModeTimer);255 clearTimeout(this.paneModeTimer);
255 this.paneModeTimer = null;256 this.paneModeTimer = null;
256 }257 }
257- this.emitWorkspaceEvent("pane-mode-exited", { action: reason });
258 this.bridge({ event: "pane-mode-exited", reason });258 this.bridge({ event: "pane-mode-exited", reason });
259 }259 }
260 260
@@ -308,7 +308,7 @@ class WorkspaceController implements WorkspaceRuntime {
308 direction: "right",308 direction: "right",
309 config: this.config,309 config: this.config,
310 });310 });
311- this.bridge({ event: "pane-split", direction: "right" });311+ this.bridge({ event: "pane-split", direction: "right", action: "split-right" });
312 this.showToast("Split pane to the right");312 this.showToast("Split pane to the right");
313 this.exitPaneMode("split-right");313 this.exitPaneMode("split-right");
314 break;314 break;
@@ -317,7 +317,7 @@ class WorkspaceController implements WorkspaceRuntime {
317 direction: "left",317 direction: "left",
318 config: this.config,318 config: this.config,
319 });319 });
320- this.bridge({ event: "pane-split", direction: "left" });320+ this.bridge({ event: "pane-split", direction: "left", action: "split-left" });
321 this.showToast("Split pane to the left");321 this.showToast("Split pane to the left");
322 this.exitPaneMode("split-left");322 this.exitPaneMode("split-left");
323 break;323 break;
@@ -326,7 +326,7 @@ class WorkspaceController implements WorkspaceRuntime {
326 direction: "up",326 direction: "up",
327 config: this.config,327 config: this.config,
328 });328 });
329- this.bridge({ event: "pane-split", direction: "up" });329+ this.bridge({ event: "pane-split", direction: "up", action: "split-up" });
330 this.showToast("Split pane upwards");330 this.showToast("Split pane upwards");
331 this.exitPaneMode("split-up");331 this.exitPaneMode("split-up");
332 break;332 break;
@@ -335,10 +335,15 @@ class WorkspaceController implements WorkspaceRuntime {
335 direction: "down",335 direction: "down",
336 config: this.config,336 config: this.config,
337 });337 });
338- this.bridge({ event: "pane-split", direction: "down" });338+ this.bridge({ event: "pane-split", direction: "down", action: "split-down" });
339 this.showToast("Split pane downwards");339 this.showToast("Split pane downwards");
340 this.exitPaneMode("split-down");340 this.exitPaneMode("split-down");
341 break;341 break;
342+ case "stack-pane":
343+ this.bridge({ event: "pane-stack", action: "stack-pane" });
344+ this.showToast("Stacked pane");
345+ this.exitPaneMode("stack-pane");
346+ break;
342 case "close-pane":347 case "close-pane":
343 this.emitWorkspaceEvent("pane-closed", { key, config: this.config });348 this.emitWorkspaceEvent("pane-closed", { key, config: this.config });
344 this.bridge({ event: "pane-close", action });349 this.bridge({ event: "pane-close", action });
M gui/src/pages/Homepage.svelte +18 -37
@@ -16,7 +16,6 @@
16 interface Shortcut {16 interface Shortcut {
17 key: string;17 key: string;
18 description: string;18 description: string;
19- action: () => void;
20 }19 }
21 20
22 interface HistoryStats {21 interface HistoryStats {
@@ -95,95 +94,77 @@
95 // Browser shortcuts94 // Browser shortcuts
96 {95 {
97 key: 'Ctrl+L',96 key: 'Ctrl+L',
98- description: 'Focus address bar',97+ description: 'Focus address bar'
99- action: () => console.log('Ctrl+L triggered')
100 },98 },
101 {99 {
102 key: 'Ctrl+F',100 key: 'Ctrl+F',
103- description: 'Find in page',101+ description: 'Find in page'
104- action: () => console.log('Ctrl+F triggered')
105 },102 },
106 {103 {
107 key: 'Ctrl+Shift+C',104 key: 'Ctrl+Shift+C',
108- description: 'Copy URL',105+ description: 'Copy URL'
109- action: () => console.log('Ctrl+Shift+C triggered')
110 },106 },
111 {107 {
112 key: 'Ctrl+R / F5',108 key: 'Ctrl+R / F5',
113- description: 'Reload page',109+ description: 'Reload page'
114- action: () => window.location.reload()
115 },110 },
116 {111 {
117 key: 'Ctrl+Shift+R',112 key: 'Ctrl+Shift+R',
118- description: 'Hard reload',113+ description: 'Hard reload'
119- action: () => console.log('Ctrl+Shift+R triggered')
120 },114 },
121 {115 {
122 key: 'F12',116 key: 'F12',
123- description: 'DevTools',117+ description: 'DevTools'
124- action: () => console.log('F12 triggered')
125 },118 },
126 {119 {
127 key: 'Ctrl+← / Ctrl+→',120 key: 'Ctrl+← / Ctrl+→',
128- description: 'Navigate back/forward',121+ description: 'Navigate back/forward'
129- action: () => console.log('Ctrl+Arrow triggered')
130 },122 },
131 {123 {
132 key: 'Ctrl+0',124 key: 'Ctrl+0',
133- description: 'Reset zoom',125+ description: 'Reset zoom'
134- action: () => console.log('Ctrl+0 triggered')
135 },126 },
136 {127 {
137 key: 'Ctrl++ / Ctrl+-',128 key: 'Ctrl++ / Ctrl+-',
138- description: 'Zoom in/out',129+ description: 'Zoom in/out'
139- action: () => console.log('Ctrl+Zoom triggered')
140 },130 },
141 // Workspace pane mode131 // Workspace pane mode
142 {132 {
143 key: 'Ctrl+P',133 key: 'Ctrl+P',
144- description: 'Enter pane mode',134+ description: 'Enter pane mode'
145- action: () => console.log('Ctrl+P triggered')
146 },135 },
147 {136 {
148 key: '→ / R (pane mode)',137 key: '→ / R (pane mode)',
149- description: 'Split pane right',138+ description: 'Split pane right'
150- action: () => console.log('Split right triggered')
151 },139 },
152 {140 {
153 key: '← / L (pane mode)',141 key: '← / L (pane mode)',
154- description: 'Split pane left',142+ description: 'Split pane left'
155- action: () => console.log('Split left triggered')
156 },143 },
157 {144 {
158 key: '↑ / U (pane mode)',145 key: '↑ / U (pane mode)',
159- description: 'Split pane up',146+ description: 'Split pane up'
160- action: () => console.log('Split up triggered')
161 },147 },
162 {148 {
163 key: '↓ / D (pane mode)',149 key: '↓ / D (pane mode)',
164- description: 'Split pane down',150+ description: 'Split pane down'
165- action: () => console.log('Split down triggered')
166 },151 },
167 {152 {
168 key: 'X (pane mode)',153 key: 'X (pane mode)',
169- description: 'Close current pane',154+ description: 'Close current pane'
170- action: () => console.log('Close pane triggered')
171 },155 },
172 {156 {
173 key: 'Enter (pane mode)',157 key: 'Enter (pane mode)',
174- description: 'Confirm action',158+ description: 'Confirm action'
175- action: () => console.log('Confirm triggered')
176 },159 },
177 {160 {
178 key: 'Escape (pane mode)',161 key: 'Escape (pane mode)',
179- description: 'Exit pane mode',162+ description: 'Exit pane mode'
180- action: () => console.log('Exit pane mode triggered')
181 },163 },
182 // Workspace navigation164 // Workspace navigation
183 {165 {
184 key: 'Alt+Arrow Keys',166 key: 'Alt+Arrow Keys',
185- description: 'Navigate between panes',167+ description: 'Navigate between panes'
186- action: () => console.log('Navigate panes triggered')
187 }168 }
188 ];169 ];
189 shortcutsLoading = false;170 shortcutsLoading = false;
M internal/app/browser/browser.go +8 -0
@@ -110,6 +110,7 @@ func (app *BrowserApp) Initialize() error {
110 log.Printf("Warning: failed to initialize WebKit log capture: %v", err)110 log.Printf("Warning: failed to initialize WebKit log capture: %v", err)
111 } else {111 } else {
112 defer webkit.StopWebKitLogCapture()112 defer webkit.StopWebKitLogCapture()
113+ webkit.StartWebKitOutputCapture()
113 }114 }
114 }115 }
115 116
@@ -220,6 +221,13 @@ func (app *BrowserApp) cleanup() {
220 // setupOutputCapture initializes stdout/stderr capture if configured221 // setupOutputCapture initializes stdout/stderr capture if configured
221 func (app *BrowserApp) setupOutputCapture() error {222 func (app *BrowserApp) setupOutputCapture() error {
222 if app.config.Logging.CaptureStdout || app.config.Logging.CaptureStderr {223 if app.config.Logging.CaptureStdout || app.config.Logging.CaptureStderr {
224+ // Capturing stdout/stderr works for non-GTK builds, but in native GTK mode
225+ // it interferes with WebKit's own pipe management and crashes immediately.
226+ if webkit.IsNativeAvailable() {
227+ log.Printf("Warning: stdout/stderr capture is not supported in native GTK mode; skipping")
228+ return nil
229+ }
230+
223 log.Printf("Warning: stdout/stderr capture is experimental and may interfere with normal operations")231 log.Printf("Warning: stdout/stderr capture is experimental and may interfere with normal operations")
224 if logger := logging.GetLogger(); logger != nil {232 if logger := logging.GetLogger(); logger != nil {
225 outputCapture := logging.NewOutputCapture(logger)233 outputCapture := logging.NewOutputCapture(logger)
A internal/app/browser/focus_state_machine.go +1188 -0
@@ -0,0 +1,1188 @@
1+// focus_state_machine.go - Centralized focus state management with queue-based serialization
2+package browser
3+
4+import (
5+ "container/heap"
6+ "fmt"
7+ "log"
8+ "runtime/debug"
9+ "sync"
10+ "time"
11+
12+ "github.com/bnema/dumber/pkg/webkit"
13+)
14+
15+// FocusState represents the current state of the focus management system
16+type FocusState string
17+
18+const (
19+ StateInitializing FocusState = "initializing" // System startup, determining initial focus
20+ StateIdle FocusState = "idle" // No focus transition in progress, system stable
21+ StateTransitioning FocusState = "transitioning" // Processing a focus change request
22+ StateFocused FocusState = "focused" // Focus successfully applied, awaiting next request
23+ StateReconciling FocusState = "reconciling" // Fixing inconsistencies detected in CSS/focus state
24+)
25+
26+// FocusSource identifies where a focus request originated
27+type FocusSource string
28+
29+const (
30+ SourceKeyboard FocusSource = "keyboard" // User keyboard navigation
31+ SourceMouse FocusSource = "mouse" // Mouse hover/click
32+ SourceProgrammatic FocusSource = "programmatic" // API call
33+ SourceSystem FocusSource = "system" // System initialization
34+ SourceStackNav FocusSource = "stack-nav" // Stack navigation
35+ SourceSplit FocusSource = "split" // Pane split operation
36+ SourceClose FocusSource = "close" // Pane close operation
37+)
38+
39+// Priority levels for focus requests
40+const (
41+ PriorityLow = 10
42+ PriorityNormal = 50
43+ PriorityHigh = 90
44+ PriorityUrgent = 100
45+)
46+
47+// FocusRequest represents a request to change focus to a specific pane
48+type FocusRequest struct {
49+ ID string // Unique request identifier
50+ TargetNode *paneNode // Target pane to focus
51+ Source FocusSource // Where the request originated
52+ Priority int // Request priority (higher = more urgent)
53+ Timestamp time.Time // When the request was created
54+ Context map[string]interface{} // Additional metadata
55+}
56+
57+// FocusTransition records a completed focus change for debugging
58+type FocusTransition struct {
59+ From *paneNode // Previous focus target
60+ To *paneNode // New focus target
61+ State FocusState // State during transition
62+ Source FocusSource // Request source
63+ Timestamp time.Time // When transition occurred
64+ Success bool // Whether transition succeeded
65+ Error error // Error if transition failed
66+ Stack []byte // Stack trace for debugging
67+}
68+
69+// RingBuffer provides a thread-safe circular buffer for focus history
70+type RingBuffer[T any] struct {
71+ buffer []T
72+ head int
73+ tail int
74+ size int
75+ cap int
76+ mu sync.RWMutex
77+}
78+
79+// NewRingBuffer creates a new ring buffer with the specified capacity
80+func NewRingBuffer[T any](capacity int) *RingBuffer[T] {
81+ return &RingBuffer[T]{
82+ buffer: make([]T, capacity),
83+ cap: capacity,
84+ }
85+}
86+
87+// Add inserts an item into the ring buffer
88+func (rb *RingBuffer[T]) Add(item T) {
89+ rb.mu.Lock()
90+ defer rb.mu.Unlock()
91+
92+ rb.buffer[rb.head] = item
93+ rb.head = (rb.head + 1) % rb.cap
94+
95+ if rb.size < rb.cap {
96+ rb.size++
97+ } else {
98+ rb.tail = (rb.tail + 1) % rb.cap
99+ }
100+}
101+
102+// GetAll returns all items in the ring buffer in chronological order
103+func (rb *RingBuffer[T]) GetAll() []T {
104+ rb.mu.RLock()
105+ defer rb.mu.RUnlock()
106+
107+ if rb.size == 0 {
108+ return nil
109+ }
110+
111+ result := make([]T, rb.size)
112+ for i := 0; i < rb.size; i++ {
113+ idx := (rb.tail + i) % rb.cap
114+ result[i] = rb.buffer[idx]
115+ }
116+ return result
117+}
118+
119+// FocusPriorityQueue implements a priority queue for focus requests
120+type FocusPriorityQueue []*FocusRequest
121+
122+func (pq FocusPriorityQueue) Len() int { return len(pq) }
123+
124+func (pq FocusPriorityQueue) Less(i, j int) bool {
125+ // Higher priority value = higher priority (processed first)
126+ return pq[i].Priority > pq[j].Priority
127+}
128+
129+func (pq FocusPriorityQueue) Swap(i, j int) {
130+ pq[i], pq[j] = pq[j], pq[i]
131+}
132+
133+func (pq *FocusPriorityQueue) Push(x interface{}) {
134+ *pq = append(*pq, x.(*FocusRequest))
135+}
136+
137+func (pq *FocusPriorityQueue) Pop() interface{} {
138+ old := *pq
139+ n := len(old)
140+ item := old[n-1]
141+ *pq = old[0 : n-1]
142+ return item
143+}
144+
145+// RequestDeduplicator prevents duplicate requests from flooding the queue
146+type RequestDeduplicator struct {
147+ mu sync.RWMutex
148+ recentSigs map[string]time.Time
149+ ttl time.Duration
150+}
151+
152+// NewRequestDeduplicator creates a new request deduplicator
153+func NewRequestDeduplicator(ttl time.Duration) *RequestDeduplicator {
154+ return &RequestDeduplicator{
155+ recentSigs: make(map[string]time.Time),
156+ ttl: ttl,
157+ }
158+}
159+
160+// IsDuplicate checks if a request is a duplicate within the TTL window
161+func (rd *RequestDeduplicator) IsDuplicate(req FocusRequest) bool {
162+ rd.mu.Lock()
163+ defer rd.mu.Unlock()
164+
165+ // Clean up expired signatures
166+ now := time.Now()
167+ for sig, timestamp := range rd.recentSigs {
168+ if now.Sub(timestamp) > rd.ttl {
169+ delete(rd.recentSigs, sig)
170+ }
171+ }
172+
173+ // Generate signature for this request
174+ sig := fmt.Sprintf("%p:%s", req.TargetNode, req.Source)
175+
176+ // Check if this signature exists
177+ if _, exists := rd.recentSigs[sig]; exists {
178+ return true
179+ }
180+
181+ // Record this signature
182+ rd.recentSigs[sig] = now
183+ return false
184+}
185+
186+// FocusStateMachine manages all focus operations through a centralized state machine
187+type FocusStateMachine struct {
188+ mu sync.RWMutex
189+ wm *WorkspaceManager // Reference to workspace manager
190+ currentState FocusState // Current state of the focus system
191+ activePane *paneNode // Currently focused pane
192+ requestQueue chan FocusRequest // Queue for serializing focus requests
193+ priorityQueue *FocusPriorityQueue // Priority queue for request ordering
194+ pendingRequests chan struct{} // Signal channel for pending requests
195+ historyRing *RingBuffer[FocusTransition] // History of focus transitions
196+ validators []FocusValidator // Request validators
197+ settlingTimer *time.Timer // Timer for state settling
198+ queueProcessor *sync.WaitGroup // Wait group for queue processor
199+ shutdownChan chan struct{} // Shutdown signal
200+ requestIDCounter uint64 // Counter for generating request IDs
201+
202+ // Request coalescing for rapid focus changes
203+ lastRequestTime time.Time // Time of last request
204+ coalescingWindow time.Duration // Window for coalescing requests
205+ deduplicator *RequestDeduplicator // Request deduplication
206+
207+ // Performance metrics tracking
208+ metrics FocusMetrics // Performance metrics
209+ processTimes []time.Duration // Recent process times for averaging
210+ maxProcessTimes int // Max number of process times to keep
211+
212+ // Debug configuration
213+ debugEnabled bool // Enable focus debug logging
214+ metricsEnabled bool // Enable metrics tracking
215+ // Reconciliation loop prevention
216+ lastReconcileTime time.Time // Last reconciliation timestamp
217+ reconcileCount int // Recent reconciliation count
218+ maxReconcileAttempts int // Max reconcile attempts per second
219+}
220+
221+// FocusValidator validates focus requests before processing
222+type FocusValidator func(request FocusRequest) error
223+
224+// Predefined errors
225+var (
226+ ErrFocusQueueFull = fmt.Errorf("focus request queue is full")
227+ ErrInvalidTarget = fmt.Errorf("invalid focus target")
228+ ErrInvalidStackOperation = fmt.Errorf("invalid stack operation")
229+ ErrStateMachineShutdown = fmt.Errorf("focus state machine is shutting down")
230+)
231+
232+// NewFocusStateMachine creates a new focus state machine
233+func NewFocusStateMachine(wm *WorkspaceManager) *FocusStateMachine {
234+ pq := &FocusPriorityQueue{}
235+ heap.Init(pq)
236+
237+ fsm := &FocusStateMachine{
238+ wm: wm,
239+ currentState: StateInitializing,
240+ requestQueue: make(chan FocusRequest, 100), // Buffered channel with capacity 100
241+ priorityQueue: pq,
242+ pendingRequests: make(chan struct{}, 1), // Signal for priority queue processing
243+ historyRing: NewRingBuffer[FocusTransition](50), // Keep last 50 transitions
244+ validators: make([]FocusValidator, 0),
245+ queueProcessor: &sync.WaitGroup{},
246+ shutdownChan: make(chan struct{}),
247+ coalescingWindow: 50 * time.Millisecond, // 50ms coalescing window
248+ deduplicator: NewRequestDeduplicator(200 * time.Millisecond), // 200ms dedup TTL
249+ maxProcessTimes: 100, // Keep last 100 process times for averaging
250+ // Debug flags will be set via ConfigureDebug()
251+ debugEnabled: false,
252+ metricsEnabled: false,
253+ // Reconciliation loop prevention
254+ maxReconcileAttempts: 3, // Max 3 reconciliation attempts per second
255+ }
256+
257+ // Add default validators
258+ fsm.addDefaultValidators()
259+
260+ return fsm
261+}
262+
263+// ConfigureDebug configures debug settings for the focus state machine
264+func (fsm *FocusStateMachine) ConfigureDebug(focusDebug, _ bool, metricsEnabled bool) {
265+ fsm.mu.Lock()
266+ defer fsm.mu.Unlock()
267+
268+ fsm.debugEnabled = focusDebug
269+ fsm.metricsEnabled = metricsEnabled
270+
271+ if focusDebug {
272+ log.Printf("[FSM] Focus debug logging enabled")
273+ }
274+ if metricsEnabled {
275+ log.Printf("[FSM] Focus metrics tracking enabled")
276+ }
277+}
278+
279+// Initialize sets up the focus state machine and determines initial focus
280+func (fsm *FocusStateMachine) Initialize() error {
281+ fsm.mu.Lock()
282+ defer fsm.mu.Unlock()
283+
284+ log.Printf("[FSM] Initializing focus state machine")
285+ fsm.currentState = StateInitializing
286+
287+ // Determine initial focus
288+ leaves := fsm.wm.collectLeaves()
289+ if len(leaves) == 0 {
290+ log.Printf("[FSM] No panes found during initialization")
291+ fsm.currentState = StateIdle
292+ return nil
293+ }
294+
295+ initialPane := fsm.determineInitialFocus(leaves)
296+ if initialPane == nil {
297+ log.Printf("[FSM] Could not determine initial focus target")
298+ fsm.currentState = StateIdle
299+ return nil
300+ }
301+
302+ // Phase 3: Apply initial focus and CSS
303+ if err := fsm.applyInitialFocus(initialPane); err != nil {
304+ log.Printf("[FSM] Failed to apply initial focus: %v", err)
305+ fsm.currentState = StateIdle
306+ return err
307+ }
308+
309+ // Phase 4: Attach GTK focus controllers to all panes
310+ fsm.attachGTKControllersToAllPanes()
311+
312+ // Phase 5: Start queue processor
313+ fsm.queueProcessor.Add(1)
314+ go fsm.processQueue()
315+
316+ // Phase 6: Start CSS verification loop
317+ go fsm.startCSSVerificationLoop()
318+
319+ // Phase 7: Transition to idle state
320+ fsm.currentState = StateIdle
321+
322+ log.Printf("[FSM] Focus state machine initialized successfully with pane %p", initialPane)
323+ return nil
324+}
325+
326+// Shutdown gracefully shuts down the focus state machine
327+func (fsm *FocusStateMachine) Shutdown() {
328+ fsm.mu.Lock()
329+ defer fsm.mu.Unlock()
330+
331+ log.Printf("[FSM] Shutting down focus state machine")
332+
333+ // Signal shutdown
334+ close(fsm.shutdownChan)
335+
336+ // Wait for queue processor to finish
337+ fsm.queueProcessor.Wait()
338+
339+ // Clean up timers
340+ if fsm.settlingTimer != nil {
341+ fsm.settlingTimer.Stop()
342+ }
343+
344+ log.Printf("[FSM] Focus state machine shutdown complete")
345+}
346+
347+// RequestFocus queues a focus change request with priority and deduplication
348+func (fsm *FocusStateMachine) RequestFocus(node *paneNode, source FocusSource) error {
349+ if node == nil {
350+ return ErrInvalidTarget
351+ }
352+
353+ request := FocusRequest{
354+ ID: fsm.generateRequestID(),
355+ TargetNode: node,
356+ Source: source,
357+ Priority: fsm.calculatePriority(source),
358+ Timestamp: time.Now(),
359+ Context: make(map[string]interface{}),
360+ }
361+
362+ // Track total requests
363+ if fsm.metricsEnabled {
364+ fsm.mu.Lock()
365+ fsm.metrics.TotalRequests++
366+ fsm.mu.Unlock()
367+ }
368+
369+ // Check for duplicates
370+ if fsm.deduplicator.IsDuplicate(request) {
371+ if fsm.metricsEnabled {
372+ fsm.mu.Lock()
373+ fsm.metrics.DuplicateRequests++
374+ fsm.mu.Unlock()
375+ }
376+ if fsm.debugEnabled {
377+ log.Printf("[FSM] Duplicate request ignored: %s -> %p", request.Source, request.TargetNode)
378+ }
379+ return nil
380+ }
381+
382+ // Check for coalescing opportunity
383+ if fsm.shouldCoalesce(request) {
384+ if fsm.metricsEnabled {
385+ fsm.mu.Lock()
386+ fsm.metrics.CoalescedRequests++
387+ fsm.mu.Unlock()
388+ }
389+ if fsm.debugEnabled {
390+ log.Printf("[FSM] Request coalesced: %s -> %p", request.Source, request.TargetNode)
391+ }
392+ return nil
393+ }
394+
395+ // Add to priority queue instead of simple channel
396+ fsm.mu.Lock()
397+ heap.Push(fsm.priorityQueue, &request)
398+ fsm.lastRequestTime = request.Timestamp
399+
400+ // Track queue depth metrics
401+ currentDepth := fsm.priorityQueue.Len()
402+ if currentDepth > fsm.metrics.MaxQueueDepth {
403+ fsm.metrics.MaxQueueDepth = currentDepth
404+ }
405+ fsm.mu.Unlock()
406+
407+ // Signal that a request is pending
408+ select {
409+ case fsm.pendingRequests <- struct{}{}:
410+ default: // Channel is already signaled
411+ }
412+
413+ return nil
414+}
415+
416+// GetActivePane returns the currently active pane
417+func (fsm *FocusStateMachine) GetActivePane() *paneNode {
418+ fsm.mu.RLock()
419+ defer fsm.mu.RUnlock()
420+ return fsm.activePane
421+}
422+
423+// InvalidateActivePane clears the active pane if it matches the provided node.
424+// This is used when panes are destroyed so the focus state machine doesn't keep
425+// references to invalid widgets.
426+func (fsm *FocusStateMachine) InvalidateActivePane(node *paneNode) {
427+ if node == nil {
428+ return
429+ }
430+
431+ fsm.mu.Lock()
432+ defer fsm.mu.Unlock()
433+
434+ if fsm.activePane != node {
435+ return
436+ }
437+
438+ log.Printf("[FSM] Active pane invalidated: %p", node)
439+ fsm.activePane = nil
440+
441+ if fsm.wm != nil && fsm.wm.app != nil {
442+ fsm.wm.app.activePane = nil
443+ }
444+
445+ if fsm.currentState == StateFocused {
446+ fsm.currentState = StateIdle
447+ }
448+}
449+
450+// GetCurrentState returns the current state of the focus system
451+func (fsm *FocusStateMachine) GetCurrentState() FocusState {
452+ fsm.mu.RLock()
453+ defer fsm.mu.RUnlock()
454+ return fsm.currentState
455+}
456+
457+// AddValidator adds a focus request validator
458+func (fsm *FocusStateMachine) AddValidator(validator FocusValidator) {
459+ fsm.mu.Lock()
460+ defer fsm.mu.Unlock()
461+ fsm.validators = append(fsm.validators, validator)
462+}
463+
464+// Private methods
465+
466+// generateRequestID creates a unique request identifier
467+func (fsm *FocusStateMachine) generateRequestID() string {
468+ fsm.requestIDCounter++
469+ return fmt.Sprintf("req-%d-%d", time.Now().UnixNano(), fsm.requestIDCounter)
470+}
471+
472+// calculatePriority determines request priority based on source
473+func (fsm *FocusStateMachine) calculatePriority(source FocusSource) int {
474+ switch source {
475+ case SourceKeyboard:
476+ return PriorityHigh
477+ case SourceMouse:
478+ return PriorityNormal
479+ case SourceStackNav:
480+ return PriorityHigh
481+ case SourceSystem:
482+ return PriorityUrgent
483+ case SourceSplit, SourceClose:
484+ return PriorityHigh
485+ default:
486+ return PriorityLow
487+ }
488+}
489+
490+// addDefaultValidators adds standard focus request validators
491+func (fsm *FocusStateMachine) addDefaultValidators() {
492+ // Validate target node exists and is a leaf
493+ fsm.validators = append(fsm.validators, func(request FocusRequest) error {
494+ if request.TargetNode == nil {
495+ return ErrInvalidTarget
496+ }
497+ if !request.TargetNode.isLeaf {
498+ return fmt.Errorf("target node is not a leaf pane")
499+ }
500+ if request.TargetNode.pane == nil || request.TargetNode.pane.webView == nil {
501+ return fmt.Errorf("target node has no valid webview")
502+ }
503+ return nil
504+ })
505+
506+ // Validate target node still exists in workspace
507+ fsm.validators = append(fsm.validators, func(request FocusRequest) error {
508+ if fsm.wm.viewToNode[request.TargetNode.pane.webView] != request.TargetNode {
509+ return fmt.Errorf("target node no longer exists in workspace")
510+ }
511+ return nil
512+ })
513+}
514+
515+// validateRequest runs all validators on a focus request
516+func (fsm *FocusStateMachine) validateRequest(request FocusRequest) error {
517+ for _, validator := range fsm.validators {
518+ if err := validator(request); err != nil {
519+ return err
520+ }
521+ }
522+ return nil
523+}
524+
525+// determineInitialFocus selects the initial focus target based on priority rules
526+func (fsm *FocusStateMachine) determineInitialFocus(leaves []*paneNode) *paneNode {
527+ if len(leaves) == 0 {
528+ return nil
529+ }
530+
531+ // Priority 1: First pane with user content (not about:blank)
532+ for _, leaf := range leaves {
533+ if leaf.pane != nil && leaf.pane.webView != nil {
534+ url := leaf.pane.webView.GetCurrentURL()
535+ if url != "" && url != "about:blank" {
536+ log.Printf("[FSM] Selected pane with content as initial focus: %s", url)
537+ return leaf
538+ }
539+ }
540+ }
541+
542+ // Priority 2: Top-left pane (geometrically)
543+ if topLeft := fsm.findTopLeftPane(leaves); topLeft != nil {
544+ log.Printf("[FSM] Selected top-left pane as initial focus")
545+ return topLeft
546+ }
547+
548+ // Priority 3: First pane in tree order
549+ log.Printf("[FSM] Selected first pane as initial focus")
550+ return leaves[0]
551+}
552+
553+// findTopLeftPane finds the geometrically top-left pane
554+func (fsm *FocusStateMachine) findTopLeftPane(leaves []*paneNode) *paneNode {
555+ var bestPane *paneNode
556+ var bestScore float64 = 1e9
557+
558+ for _, leaf := range leaves {
559+ if leaf.container == 0 || !webkit.WidgetIsValid(leaf.container) {
560+ continue
561+ }
562+
563+ bounds, ok := webkit.WidgetGetBounds(leaf.container)
564+ if !ok {
565+ continue
566+ }
567+
568+ // Score based on distance from top-left corner (0,0)
569+ score := bounds.X + bounds.Y
570+ if score < bestScore {
571+ bestScore = score
572+ bestPane = leaf
573+ }
574+ }
575+
576+ return bestPane
577+}
578+
579+// applyInitialFocus applies focus to the initial target without going through the queue
580+func (fsm *FocusStateMachine) applyInitialFocus(node *paneNode) error {
581+ if node == nil {
582+ return ErrInvalidTarget
583+ }
584+
585+ log.Printf("[FSM] Applying initial focus to pane %p", node)
586+
587+ // Apply GTK focus
588+ if err := fsm.applyGTKFocus(node); err != nil {
589+ return fmt.Errorf("failed to apply GTK focus: %w", err)
590+ }
591+
592+ // Apply initial visual border
593+ if fsm.wm != nil {
594+ ctx := fsm.wm.determineBorderContext(node)
595+ fsm.wm.applyActivePaneBorder(ctx)
596+ }
597+
598+ // Update state
599+ fsm.activePane = node
600+
601+ // Record transition
602+ transition := FocusTransition{
603+ From: nil,
604+ To: node,
605+ State: StateInitializing,
606+ Source: SourceSystem,
607+ Timestamp: time.Now(),
608+ Success: true,
609+ Stack: debug.Stack(),
610+ }
611+ fsm.historyRing.Add(transition)
612+
613+ // Notify workspace manager
614+ if fsm.wm != nil && fsm.wm.app != nil && node.pane != nil {
615+ fsm.wm.app.activePane = node.pane
616+ }
617+
618+ return nil
619+}
620+
621+// applyGTKFocus applies GTK focus to the specified pane
622+func (fsm *FocusStateMachine) applyGTKFocus(node *paneNode) error {
623+ if node.pane == nil || node.pane.webView == nil {
624+ return fmt.Errorf("pane has no valid webview")
625+ }
626+
627+ viewWidget := node.pane.webView.Widget()
628+ if viewWidget == 0 {
629+ return fmt.Errorf("webview has no valid widget")
630+ }
631+
632+ webkit.WidgetGrabFocus(viewWidget)
633+ return nil
634+}
635+
636+// processQueue processes focus requests from the priority queue
637+func (fsm *FocusStateMachine) processQueue() {
638+ defer fsm.queueProcessor.Done()
639+
640+ log.Printf("[FSM] Focus request queue processor started")
641+
642+ for {
643+ select {
644+ case <-fsm.pendingRequests:
645+ // Process all pending requests from priority queue
646+ fsm.processPendingRequests()
647+ case request := <-fsm.requestQueue:
648+ // Legacy channel for backward compatibility
649+ fsm.handleFocusRequest(request)
650+ case <-fsm.shutdownChan:
651+ log.Printf("[FSM] Focus request queue processor shutting down")
652+ return
653+ }
654+ }
655+}
656+
657+// processPendingRequests processes all requests in the priority queue
658+func (fsm *FocusStateMachine) processPendingRequests() {
659+ for {
660+ fsm.mu.Lock()
661+ if fsm.priorityQueue.Len() == 0 {
662+ fsm.mu.Unlock()
663+ break
664+ }
665+
666+ // Get highest priority request
667+ request := heap.Pop(fsm.priorityQueue).(*FocusRequest)
668+ fsm.mu.Unlock()
669+
670+ // Check if a higher priority request has arrived while processing
671+ if fsm.hasHigherPriorityRequest(*request) {
672+ // Requeue current request and process higher priority one
673+ fsm.requeue(*request)
674+ continue
675+ }
676+
677+ fsm.handleFocusRequest(*request)
678+ }
679+}
680+
681+// handleFocusRequest processes a single focus request
682+func (fsm *FocusStateMachine) handleFocusRequest(request FocusRequest) {
683+ startTime := time.Now()
684+
685+ // Validate request
686+ if err := fsm.validateRequest(request); err != nil {
687+ fsm.mu.Lock()
688+ fsm.metrics.FailedRequests++
689+ fsm.mu.Unlock()
690+ log.Printf("[FSM] Invalid focus request %s: %v", request.ID, err)
691+ return
692+ }
693+
694+ // Check if we're already focused on the target
695+ fsm.mu.RLock()
696+ currentActive := fsm.activePane
697+ fsm.mu.RUnlock()
698+
699+ if currentActive == request.TargetNode {
700+ log.Printf("[FSM] Focus request %s: already focused on target", request.ID)
701+ fsm.recordProcessTime(time.Since(startTime))
702+ return
703+ }
704+
705+ log.Printf("[FSM] Processing focus request %s: %s -> %p",
706+ request.ID, request.Source, request.TargetNode)
707+
708+ // Execute focus change
709+ if err := fsm.executeFocusChange(request); err != nil {
710+ fsm.mu.Lock()
711+ fsm.metrics.FailedRequests++
712+ fsm.mu.Unlock()
713+ log.Printf("[FSM] Focus request %s failed: %v", request.ID, err)
714+ } else {
715+ fsm.mu.Lock()
716+ fsm.metrics.SuccessfulRequests++
717+ fsm.mu.Unlock()
718+ }
719+
720+ fsm.recordProcessTime(time.Since(startTime))
721+}
722+
723+// executeFocusChange executes a focus change with proper state management
724+func (fsm *FocusStateMachine) executeFocusChange(request FocusRequest) error {
725+ fsm.mu.Lock()
726+ defer fsm.mu.Unlock()
727+
728+ // Transition to processing state
729+ oldState := fsm.currentState
730+ fsm.currentState = StateTransitioning
731+
732+ oldPane := fsm.activePane
733+ newPane := request.TargetNode
734+
735+ // Record transition start
736+ transition := FocusTransition{
737+ From: oldPane,
738+ To: newPane,
739+ Source: request.Source,
740+ Timestamp: request.Timestamp,
741+ Stack: debug.Stack(),
742+ }
743+
744+ // Apply GTK focus
745+ if err := fsm.applyGTKFocus(newPane); err != nil {
746+ // Rollback state
747+ fsm.currentState = oldState
748+ transition.Success = false
749+ transition.Error = err
750+ fsm.historyRing.Add(transition)
751+ return fmt.Errorf("failed to apply GTK focus: %w", err)
752+ }
753+
754+ // Apply visual border changes
755+ if fsm.wm != nil {
756+ // Remove border from old pane
757+ if oldPane != nil {
758+ fsm.wm.removeActivePaneBorder(oldPane)
759+ }
760+ // Add border to new pane
761+ ctx := fsm.wm.determineBorderContext(newPane)
762+ fsm.wm.applyActivePaneBorder(ctx)
763+ }
764+
765+ // Update state
766+ fsm.activePane = newPane
767+ fsm.currentState = StateFocused
768+
769+ // Record successful transition
770+ transition.Success = true
771+ fsm.historyRing.Add(transition)
772+
773+ // Notify workspace manager
774+ if fsm.wm != nil && fsm.wm.app != nil && newPane.pane != nil {
775+ fsm.wm.app.activePane = newPane.pane
776+ }
777+
778+ // Start settling timer
779+ fsm.startSettlingTimer()
780+
781+ log.Printf("[FSM] Focus change completed: %p -> %p", oldPane, newPane)
782+ return nil
783+}
784+
785+// startSettlingTimer starts a timer to transition back to idle state
786+func (fsm *FocusStateMachine) startSettlingTimer() {
787+ if fsm.settlingTimer != nil {
788+ fsm.settlingTimer.Stop()
789+ }
790+
791+ fsm.settlingTimer = time.AfterFunc(50*time.Millisecond, func() {
792+ fsm.mu.Lock()
793+ if fsm.currentState == StateFocused {
794+ fsm.currentState = StateIdle
795+ }
796+ fsm.mu.Unlock()
797+ })
798+}
799+
800+// startCSSVerificationLoop starts the CSS verification background loop
801+func (fsm *FocusStateMachine) startCSSVerificationLoop() {
802+ ticker := time.NewTicker(100 * time.Millisecond)
803+
804+ go func() {
805+ defer ticker.Stop()
806+
807+ for {
808+ select {
809+ case <-ticker.C:
810+ fsm.verifyCSSState()
811+ case <-fsm.shutdownChan:
812+ return
813+ }
814+ }
815+ }()
816+}
817+
818+// verifyCSSState checks and corrects CSS class inconsistencies
819+func (fsm *FocusStateMachine) verifyCSSState() {
820+ fsm.mu.RLock()
821+ activePane := fsm.activePane
822+ fsm.mu.RUnlock()
823+
824+ if activePane == nil || fsm.wm == nil {
825+ return
826+ }
827+
828+ var issues []string
829+
830+ if len(issues) > 0 {
831+ // Prevent infinite reconciliation loops
832+ fsm.mu.Lock()
833+ now := time.Now()
834+ if now.Sub(fsm.lastReconcileTime) < time.Second {
835+ fsm.reconcileCount++
836+ } else {
837+ fsm.reconcileCount = 1
838+ fsm.lastReconcileTime = now
839+ }
840+
841+ if fsm.reconcileCount > fsm.maxReconcileAttempts {
842+ log.Printf("[FSM] WARNING: Reconciliation loop detected, skipping (attempt %d)", fsm.reconcileCount)
843+ fsm.mu.Unlock()
844+ return
845+ }
846+ fsm.mu.Unlock()
847+
848+ log.Printf("[FSM] CSS inconsistencies found: %v", issues)
849+ log.Printf("[FSM] Triggering CSS reconciliation (attempt %d)", fsm.reconcileCount)
850+
851+ // Track reconciliation in metrics
852+ fsm.mu.Lock()
853+ fsm.metrics.ReconciliationCount++
854+ fsm.mu.Unlock()
855+ }
856+}
857+
858+// shouldCoalesce checks if a request should be coalesced with recent requests
859+func (fsm *FocusStateMachine) shouldCoalesce(request FocusRequest) bool {
860+ fsm.mu.RLock()
861+ defer fsm.mu.RUnlock()
862+
863+ // Only coalesce if we have a recent request and it's to the same target
864+ if time.Since(fsm.lastRequestTime) > fsm.coalescingWindow {
865+ return false
866+ }
867+
868+ // Check if there's already a pending request to the same target
869+ for i := 0; i < fsm.priorityQueue.Len(); i++ {
870+ pending := (*fsm.priorityQueue)[i]
871+ if pending.TargetNode == request.TargetNode {
872+ log.Printf("[FSM] Coalescing request to same target: %p", request.TargetNode)
873+ return true
874+ }
875+ }
876+
877+ return false
878+}
879+
880+// hasHigherPriorityRequest checks if there's a higher priority request in the queue
881+func (fsm *FocusStateMachine) hasHigherPriorityRequest(current FocusRequest) bool {
882+ fsm.mu.RLock()
883+ defer fsm.mu.RUnlock()
884+
885+ if fsm.priorityQueue.Len() == 0 {
886+ return false
887+ }
888+
889+ // Check the highest priority request (at top of heap)
890+ highest := (*fsm.priorityQueue)[0]
891+ return highest.Priority > current.Priority
892+}
893+
894+// requeue adds a request back to the priority queue
895+func (fsm *FocusStateMachine) requeue(request FocusRequest) {
896+ fsm.mu.Lock()
897+ defer fsm.mu.Unlock()
898+
899+ heap.Push(fsm.priorityQueue, &request)
900+ log.Printf("[FSM] Requeued request due to higher priority: %s", request.ID)
901+
902+ // Signal that a request is pending
903+ select {
904+ case fsm.pendingRequests <- struct{}{}:
905+ default: // Channel is already signaled
906+ }
907+}
908+
909+// attachGTKController attaches a GTK focus controller to a pane node
910+func (fsm *FocusStateMachine) attachGTKController(node *paneNode) {
911+ if node == nil || node.pane == nil || node.pane.webView == nil {
912+ return
913+ }
914+
915+ if node.focusControllerToken != 0 {
916+ log.Printf("[FSM] Controller already attached to pane %p", node)
917+ return
918+ }
919+
920+ widget := node.pane.webView.Widget()
921+ if widget == 0 || !webkit.WidgetIsValid(widget) {
922+ return
923+ }
924+
925+ // Create focus enter/leave callbacks for this specific node
926+ onEnter := func() {
927+ log.Printf("[FSM] GTK focus enter: %p", node)
928+ // Don't automatically change focus on GTK enter - let user interactions drive this
929+ // This prevents infinite loops with our own focus changes
930+ }
931+
932+ onLeave := func() {
933+ log.Printf("[FSM] GTK focus leave: %p", node)
934+ // Similarly, don't react to GTK leave events automatically
935+ }
936+
937+ // Add the focus controller and store the token for cleanup
938+ token := webkit.WidgetAddFocusController(widget, onEnter, onLeave)
939+ if token != 0 {
940+ node.focusControllerToken = token
941+ log.Printf("[FSM] Attached GTK focus controller to pane %p with token %d", node, token)
942+ }
943+}
944+
945+// detachGTKController removes a GTK focus controller from a pane node
946+func (fsm *FocusStateMachine) detachGTKController(node *paneNode, token uintptr) {
947+ if node == nil || node.pane == nil || node.pane.webView == nil || token == 0 {
948+ return
949+ }
950+
951+ if node.focusControllerToken != token {
952+ log.Printf("[FSM] Token mismatch, skipping detach for pane %p", node)
953+ return
954+ }
955+
956+ node.focusControllerToken = 0
957+
958+ widget := node.pane.webView.Widget()
959+ if widget == 0 || !webkit.WidgetIsValid(widget) {
960+ return
961+ }
962+
963+ webkit.WidgetRemoveFocusController(widget, token)
964+ log.Printf("[FSM] Detached GTK focus controller from pane %p", node)
965+}
966+
967+// attachGTKControllersToAllPanes attaches focus controllers to all existing panes
968+func (fsm *FocusStateMachine) attachGTKControllersToAllPanes() {
969+ if fsm.wm == nil {
970+ return
971+ }
972+
973+ leaves := fsm.wm.collectLeaves()
974+ for _, leaf := range leaves {
975+ fsm.attachGTKController(leaf)
976+ }
977+
978+ log.Printf("[FSM] Attached GTK focus controllers to %d panes", len(leaves))
979+}
980+
981+// FocusDebugInfo contains comprehensive debug information about the focus system
982+type FocusDebugInfo struct {
983+ CurrentState FocusState `json:"current_state"`
984+ ActivePane *paneNode `json:"active_pane,omitempty"`
985+ QueueLength int `json:"queue_length"`
986+ PriorityQueueSize int `json:"priority_queue_size"`
987+ History []FocusTransition `json:"history"`
988+ CSSState map[string]interface{} `json:"css_state"`
989+ ValidationErrors []string `json:"validation_errors"`
990+ Metrics FocusMetrics `json:"metrics"`
991+ RecentRequests []string `json:"recent_requests"`
992+}
993+
994+// FocusMetrics tracks performance metrics for the focus system
995+type FocusMetrics struct {
996+ TotalRequests uint64 `json:"total_requests"`
997+ SuccessfulRequests uint64 `json:"successful_requests"`
998+ FailedRequests uint64 `json:"failed_requests"`
999+ CoalescedRequests uint64 `json:"coalesced_requests"`
1000+ DuplicateRequests uint64 `json:"duplicate_requests"`
1001+ AverageProcessTime time.Duration `json:"average_process_time"`
1002+ MaxQueueDepth int `json:"max_queue_depth"`
1003+ ReconciliationCount uint64 `json:"reconciliation_count"`
1004+}
1005+
1006+// DumpState returns comprehensive debug information about the focus system
1007+func (fsm *FocusStateMachine) DumpState() FocusDebugInfo {
1008+ fsm.mu.RLock()
1009+ defer fsm.mu.RUnlock()
1010+
1011+ info := FocusDebugInfo{
1012+ CurrentState: fsm.currentState,
1013+ ActivePane: fsm.activePane,
1014+ QueueLength: len(fsm.requestQueue),
1015+ PriorityQueueSize: fsm.priorityQueue.Len(),
1016+ History: fsm.historyRing.GetAll(),
1017+ ValidationErrors: fsm.getValidationErrors(),
1018+ RecentRequests: fsm.getRecentRequestSignatures(),
1019+ Metrics: fsm.GetMetrics(),
1020+ }
1021+
1022+ return info
1023+}
1024+
1025+// ForceValidation manually triggers CSS consistency check and validation
1026+func (fsm *FocusStateMachine) ForceValidation() []string {
1027+ log.Printf("[FSM] Force validation requested")
1028+
1029+ // Trigger CSS verification
1030+ fsm.verifyCSSState()
1031+
1032+ // Run all validators on current state
1033+ var issues []string
1034+
1035+ fsm.mu.RLock()
1036+ activePane := fsm.activePane
1037+ fsm.mu.RUnlock()
1038+
1039+ if activePane != nil {
1040+ dummyRequest := FocusRequest{
1041+ ID: "validation-check",
1042+ TargetNode: activePane,
1043+ Source: SourceSystem,
1044+ Priority: PriorityLow,
1045+ Timestamp: time.Now(),
1046+ }
1047+
1048+ if err := fsm.validateRequest(dummyRequest); err != nil {
1049+ issues = append(issues, fmt.Sprintf("Active pane validation failed: %v", err))
1050+ }
1051+ }
1052+
1053+ log.Printf("[FSM] Force validation completed: %d issues found", len(issues))
1054+ return issues
1055+}
1056+
1057+// GetQueueStatus returns detailed information about pending requests
1058+func (fsm *FocusStateMachine) GetQueueStatus() map[string]interface{} {
1059+ fsm.mu.RLock()
1060+ defer fsm.mu.RUnlock()
1061+
1062+ status := map[string]interface{}{
1063+ "channel_queue_length": len(fsm.requestQueue),
1064+ "priority_queue_length": fsm.priorityQueue.Len(),
1065+ "channel_queue_cap": cap(fsm.requestQueue),
1066+ "processing_state": fsm.currentState,
1067+ "last_request_time": fsm.lastRequestTime,
1068+ "coalescing_window": fsm.coalescingWindow,
1069+ }
1070+
1071+ // Add priority queue details
1072+ if fsm.priorityQueue.Len() > 0 {
1073+ priorities := make([]int, 0, fsm.priorityQueue.Len())
1074+ targets := make([]string, 0, fsm.priorityQueue.Len())
1075+
1076+ for i := 0; i < fsm.priorityQueue.Len(); i++ {
1077+ req := (*fsm.priorityQueue)[i]
1078+ priorities = append(priorities, req.Priority)
1079+ targets = append(targets, fmt.Sprintf("%p", req.TargetNode))
1080+ }
1081+
1082+ status["pending_priorities"] = priorities
1083+ status["pending_targets"] = targets
1084+ }
1085+
1086+ return status
1087+}
1088+
1089+// ClearHistory clears the focus transition history (useful for debugging sessions)
1090+func (fsm *FocusStateMachine) ClearHistory() {
1091+ fsm.mu.Lock()
1092+ defer fsm.mu.Unlock()
1093+
1094+ fsm.historyRing = NewRingBuffer[FocusTransition](50)
1095+ log.Printf("[FSM] Focus transition history cleared")
1096+}
1097+
1098+// GetMetrics returns performance metrics for the focus system
1099+func (fsm *FocusStateMachine) GetMetrics() FocusMetrics {
1100+ fsm.mu.RLock()
1101+ defer fsm.mu.RUnlock()
1102+
1103+ // Copy current metrics with calculated average
1104+ metrics := fsm.metrics
1105+ metrics.AverageProcessTime = fsm.calculateAverageProcessTime()
1106+
1107+ return metrics
1108+}
1109+
1110+// recordProcessTime adds a process time to the metrics and maintains the sliding window
1111+func (fsm *FocusStateMachine) recordProcessTime(duration time.Duration) {
1112+ fsm.mu.Lock()
1113+ defer fsm.mu.Unlock()
1114+
1115+ // Add to the list
1116+ fsm.processTimes = append(fsm.processTimes, duration)
1117+
1118+ // Maintain sliding window
1119+ if len(fsm.processTimes) > fsm.maxProcessTimes {
1120+ fsm.processTimes = fsm.processTimes[1:]
1121+ }
1122+}
1123+
1124+// calculateAverageProcessTime calculates the average of recent process times
1125+func (fsm *FocusStateMachine) calculateAverageProcessTime() time.Duration {
1126+ if len(fsm.processTimes) == 0 {
1127+ return 0
1128+ }
1129+
1130+ var total time.Duration
1131+ for _, t := range fsm.processTimes {
1132+ total += t
1133+ }
1134+
1135+ return total / time.Duration(len(fsm.processTimes))
1136+}
1137+
1138+// getValidationErrors checks for various validation issues
1139+func (fsm *FocusStateMachine) getValidationErrors() []string {
1140+ var errors []string
1141+
1142+ // Check if we have an active pane but it's not valid
1143+ if fsm.activePane != nil {
1144+ if !fsm.activePane.isLeaf {
1145+ errors = append(errors, "active pane is not a leaf node")
1146+ }
1147+ if fsm.activePane.pane == nil || fsm.activePane.pane.webView == nil {
1148+ errors = append(errors, "active pane has no valid webview")
1149+ }
1150+ if fsm.wm != nil && fsm.wm.viewToNode[fsm.activePane.pane.webView] != fsm.activePane {
1151+ errors = append(errors, "active pane not found in workspace view map")
1152+ }
1153+ }
1154+
1155+ // Check for orphaned requests in queue
1156+ if fsm.wm != nil {
1157+ validNodes := make(map[*paneNode]bool)
1158+ for _, node := range fsm.wm.viewToNode {
1159+ validNodes[node] = true
1160+ }
1161+
1162+ for i := 0; i < fsm.priorityQueue.Len(); i++ {
1163+ req := (*fsm.priorityQueue)[i]
1164+ if !validNodes[req.TargetNode] {
1165+ errors = append(errors, fmt.Sprintf("orphaned request for invalid node: %s", req.ID))
1166+ }
1167+ }
1168+ }
1169+
1170+ return errors
1171+}
1172+
1173+// getRecentRequestSignatures returns recent request signatures for debugging
1174+func (fsm *FocusStateMachine) getRecentRequestSignatures() []string {
1175+ if fsm.deduplicator == nil {
1176+ return nil
1177+ }
1178+
1179+ fsm.deduplicator.mu.RLock()
1180+ defer fsm.deduplicator.mu.RUnlock()
1181+
1182+ sigs := make([]string, 0, len(fsm.deduplicator.recentSigs))
1183+ for sig, timestamp := range fsm.deduplicator.recentSigs {
1184+ sigs = append(sigs, fmt.Sprintf("%s (age: %v)", sig, time.Since(timestamp)))
1185+ }
1186+
1187+ return sigs
1188+}
A internal/app/browser/focus_state_machine_test.go +415 -0
@@ -0,0 +1,415 @@
1+package browser
2+
3+import (
4+ "container/heap"
5+ "sync"
6+ "testing"
7+ "time"
8+)
9+
10+// TestRingBuffer_AddAndRetrieve tests basic add and retrieve operations
11+func TestRingBuffer_AddAndRetrieve(t *testing.T) {
12+ rb := NewRingBuffer[int](5)
13+
14+ // Add items
15+ rb.Add(1)
16+ rb.Add(2)
17+ rb.Add(3)
18+
19+ // Retrieve all
20+ items := rb.GetAll()
21+
22+ if len(items) != 3 {
23+ t.Errorf("expected 3 items, got %d", len(items))
24+ }
25+
26+ expected := []int{1, 2, 3}
27+ for i, item := range items {
28+ if item != expected[i] {
29+ t.Errorf("at index %d: expected %d, got %d", i, expected[i], item)
30+ }
31+ }
32+}
33+
34+// TestRingBuffer_CircularOverwrite tests circular buffer overflow behavior
35+func TestRingBuffer_CircularOverwrite(t *testing.T) {
36+ rb := NewRingBuffer[int](3)
37+
38+ // Fill buffer
39+ rb.Add(1)
40+ rb.Add(2)
41+ rb.Add(3)
42+
43+ // Overflow - should overwrite oldest
44+ rb.Add(4)
45+ rb.Add(5)
46+
47+ items := rb.GetAll()
48+
49+ if len(items) != 3 {
50+ t.Errorf("expected 3 items, got %d", len(items))
51+ }
52+
53+ // Should have 3, 4, 5 (oldest items 1, 2 overwritten)
54+ expected := []int{3, 4, 5}
55+ for i, item := range items {
56+ if item != expected[i] {
57+ t.Errorf("at index %d: expected %d, got %d", i, expected[i], item)
58+ }
59+ }
60+}
61+
62+// TestRingBuffer_EmptyBuffer tests empty buffer behavior
63+func TestRingBuffer_EmptyBuffer(t *testing.T) {
64+ rb := NewRingBuffer[string](5)
65+
66+ items := rb.GetAll()
67+
68+ if items != nil {
69+ t.Errorf("expected nil for empty buffer, got %v", items)
70+ }
71+}
72+
73+// TestRingBuffer_ConcurrentAccess tests thread-safety
74+func TestRingBuffer_ConcurrentAccess(t *testing.T) {
75+ rb := NewRingBuffer[int](100)
76+ var wg sync.WaitGroup
77+
78+ // Concurrent writes
79+ for i := 0; i < 10; i++ {
80+ wg.Add(1)
81+ go func(val int) {
82+ defer wg.Done()
83+ for j := 0; j < 10; j++ {
84+ rb.Add(val*10 + j)
85+ }
86+ }(i)
87+ }
88+
89+ // Concurrent reads
90+ for i := 0; i < 5; i++ {
91+ wg.Add(1)
92+ go func() {
93+ defer wg.Done()
94+ for j := 0; j < 20; j++ {
95+ _ = rb.GetAll()
96+ time.Sleep(time.Microsecond)
97+ }
98+ }()
99+ }
100+
101+ wg.Wait()
102+
103+ // Should have 100 items without panicking
104+ items := rb.GetAll()
105+ if len(items) != 100 {
106+ t.Errorf("expected 100 items, got %d", len(items))
107+ }
108+}
109+
110+// TestPriorityQueue_OrdersByPriority tests priority ordering
111+func TestPriorityQueue_OrdersByPriority(t *testing.T) {
112+ tests := []struct {
113+ name string
114+ requests []*FocusRequest
115+ expected []string // IDs in expected pop order
116+ }{
117+ {
118+ name: "urgent before normal",
119+ requests: []*FocusRequest{
120+ {ID: "normal", Priority: PriorityNormal, Timestamp: time.Now()},
121+ {ID: "urgent", Priority: PriorityUrgent, Timestamp: time.Now()},
122+ },
123+ expected: []string{"urgent", "normal"},
124+ },
125+ {
126+ name: "high before low",
127+ requests: []*FocusRequest{
128+ {ID: "low", Priority: PriorityLow, Timestamp: time.Now()},
129+ {ID: "high", Priority: PriorityHigh, Timestamp: time.Now()},
130+ {ID: "normal", Priority: PriorityNormal, Timestamp: time.Now()},
131+ },
132+ expected: []string{"high", "normal", "low"},
133+ },
134+ {
135+ name: "multiple same priority",
136+ requests: []*FocusRequest{
137+ {ID: "first", Priority: PriorityNormal, Timestamp: time.Now()},
138+ {ID: "second", Priority: PriorityNormal, Timestamp: time.Now()},
139+ {ID: "urgent", Priority: PriorityUrgent, Timestamp: time.Now()},
140+ },
141+ expected: []string{"urgent", "first", "second"},
142+ },
143+ }
144+
145+ for _, tt := range tests {
146+ t.Run(tt.name, func(t *testing.T) {
147+ pq := make(FocusPriorityQueue, 0)
148+ heap.Init(&pq)
149+
150+ // Push all requests
151+ for _, req := range tt.requests {
152+ heap.Push(&pq, req)
153+ }
154+
155+ // Pop and verify order
156+ for i, expectedID := range tt.expected {
157+ if pq.Len() == 0 {
158+ t.Fatalf("queue empty at position %d, expected %s", i, expectedID)
159+ }
160+ req := heap.Pop(&pq).(*FocusRequest)
161+ if req.ID != expectedID {
162+ t.Errorf("at position %d: expected ID %s, got %s", i, expectedID, req.ID)
163+ }
164+ }
165+
166+ if pq.Len() != 0 {
167+ t.Errorf("queue should be empty, has %d items", pq.Len())
168+ }
169+ })
170+ }
171+}
172+
173+// TestPriorityQueue_PushPop tests basic push/pop operations
174+func TestPriorityQueue_PushPop(t *testing.T) {
175+ pq := make(FocusPriorityQueue, 0)
176+ heap.Init(&pq)
177+
178+ // Push items
179+ heap.Push(&pq, &FocusRequest{ID: "1", Priority: 50})
180+ heap.Push(&pq, &FocusRequest{ID: "2", Priority: 90})
181+ heap.Push(&pq, &FocusRequest{ID: "3", Priority: 10})
182+
183+ if pq.Len() != 3 {
184+ t.Errorf("expected length 3, got %d", pq.Len())
185+ }
186+
187+ // Pop should return highest priority (90)
188+ first := heap.Pop(&pq).(*FocusRequest)
189+ if first.ID != "2" {
190+ t.Errorf("expected ID '2', got '%s'", first.ID)
191+ }
192+
193+ // Next should be 50
194+ second := heap.Pop(&pq).(*FocusRequest)
195+ if second.ID != "1" {
196+ t.Errorf("expected ID '1', got '%s'", second.ID)
197+ }
198+
199+ // Last should be 10
200+ third := heap.Pop(&pq).(*FocusRequest)
201+ if third.ID != "3" {
202+ t.Errorf("expected ID '3', got '%s'", third.ID)
203+ }
204+
205+ if pq.Len() != 0 {
206+ t.Errorf("queue should be empty")
207+ }
208+}
209+
210+// TestRequestDeduplicator_DetectsDuplicates tests duplicate detection
211+func TestRequestDeduplicator_DetectsDuplicates(t *testing.T) {
212+ dedup := NewRequestDeduplicator(100 * time.Millisecond)
213+
214+ // Create a dummy paneNode (nil is ok for signature generation)
215+ node := &paneNode{}
216+
217+ req1 := FocusRequest{
218+ TargetNode: node,
219+ Source: SourceKeyboard,
220+ Timestamp: time.Now(),
221+ }
222+
223+ // First request should not be duplicate
224+ if dedup.IsDuplicate(req1) {
225+ t.Error("first request should not be duplicate")
226+ }
227+
228+ // Immediate second request with same params should be duplicate
229+ req2 := FocusRequest{
230+ TargetNode: node,
231+ Source: SourceKeyboard,
232+ Timestamp: time.Now(),
233+ }
234+
235+ if !dedup.IsDuplicate(req2) {
236+ t.Error("second identical request should be duplicate")
237+ }
238+}
239+
240+// TestRequestDeduplicator_DifferentSourcesNotDuplicate tests different sources
241+func TestRequestDeduplicator_DifferentSourcesNotDuplicate(t *testing.T) {
242+ dedup := NewRequestDeduplicator(100 * time.Millisecond)
243+
244+ node := &paneNode{}
245+
246+ req1 := FocusRequest{
247+ TargetNode: node,
248+ Source: SourceKeyboard,
249+ }
250+
251+ req2 := FocusRequest{
252+ TargetNode: node,
253+ Source: SourceMouse,
254+ }
255+
256+ dedup.IsDuplicate(req1)
257+
258+ // Different source should not be duplicate
259+ if dedup.IsDuplicate(req2) {
260+ t.Error("requests with different sources should not be duplicates")
261+ }
262+}
263+
264+// TestRequestDeduplicator_ExpiresOldSignatures tests TTL expiration
265+func TestRequestDeduplicator_ExpiresOldSignatures(t *testing.T) {
266+ ttl := 50 * time.Millisecond
267+ dedup := NewRequestDeduplicator(ttl)
268+
269+ node := &paneNode{}
270+
271+ req := FocusRequest{
272+ TargetNode: node,
273+ Source: SourceKeyboard,
274+ }
275+
276+ // First request
277+ if dedup.IsDuplicate(req) {
278+ t.Error("first request should not be duplicate")
279+ }
280+
281+ // Immediate duplicate
282+ if !dedup.IsDuplicate(req) {
283+ t.Error("immediate duplicate should be detected")
284+ }
285+
286+ // Wait for TTL to expire
287+ time.Sleep(ttl + 10*time.Millisecond)
288+
289+ // Should not be duplicate after expiration
290+ if dedup.IsDuplicate(req) {
291+ t.Error("request after TTL should not be duplicate")
292+ }
293+}
294+
295+// TestRequestDeduplicator_ConcurrentAccess tests thread-safety
296+func TestRequestDeduplicator_ConcurrentAccess(t *testing.T) {
297+ dedup := NewRequestDeduplicator(100 * time.Millisecond)
298+ var wg sync.WaitGroup
299+
300+ nodes := make([]*paneNode, 10)
301+ for i := range nodes {
302+ nodes[i] = &paneNode{}
303+ }
304+
305+ // Concurrent duplicate checks
306+ for i := 0; i < 10; i++ {
307+ wg.Add(1)
308+ go func(idx int) {
309+ defer wg.Done()
310+ for j := 0; j < 100; j++ {
311+ req := FocusRequest{
312+ TargetNode: nodes[idx%len(nodes)],
313+ Source: SourceKeyboard,
314+ }
315+ _ = dedup.IsDuplicate(req)
316+ time.Sleep(time.Microsecond)
317+ }
318+ }(i)
319+ }
320+
321+ wg.Wait()
322+ // Should complete without race conditions or panics
323+}
324+
325+// TestFocusRequest_Validation tests request validation logic
326+func TestFocusRequest_Validation(t *testing.T) {
327+ tests := []struct {
328+ name string
329+ request *FocusRequest
330+ wantValid bool
331+ }{
332+ {
333+ name: "valid request",
334+ request: &FocusRequest{
335+ ID: "test-1",
336+ TargetNode: &paneNode{},
337+ Source: SourceKeyboard,
338+ Priority: PriorityNormal,
339+ Timestamp: time.Now(),
340+ },
341+ wantValid: true,
342+ },
343+ {
344+ name: "nil target node",
345+ request: &FocusRequest{
346+ ID: "test-2",
347+ Source: SourceKeyboard,
348+ Priority: PriorityNormal,
349+ Timestamp: time.Now(),
350+ },
351+ wantValid: false,
352+ },
353+ {
354+ name: "empty ID",
355+ request: &FocusRequest{
356+ ID: "",
357+ TargetNode: &paneNode{},
358+ Source: SourceKeyboard,
359+ Priority: PriorityNormal,
360+ Timestamp: time.Now(),
361+ },
362+ wantValid: false,
363+ },
364+ }
365+
366+ for _, tt := range tests {
367+ t.Run(tt.name, func(t *testing.T) {
368+ valid := tt.request != nil &&
369+ tt.request.TargetNode != nil &&
370+ tt.request.ID != ""
371+
372+ if valid != tt.wantValid {
373+ t.Errorf("expected valid=%v, got valid=%v", tt.wantValid, valid)
374+ }
375+ })
376+ }
377+}
378+
379+// TestPriorityCalculation_BySource tests priority assignment by source
380+func TestPriorityCalculation_BySource(t *testing.T) {
381+ tests := []struct {
382+ source FocusSource
383+ expected int
384+ }{
385+ {SourceSystem, PriorityUrgent},
386+ {SourceKeyboard, PriorityHigh},
387+ {SourceProgrammatic, PriorityNormal},
388+ {SourceMouse, PriorityNormal},
389+ {SourceStackNav, PriorityHigh},
390+ {SourceSplit, PriorityHigh},
391+ {SourceClose, PriorityUrgent},
392+ }
393+
394+ for _, tt := range tests {
395+ t.Run(string(tt.source), func(t *testing.T) {
396+ // This would be the actual priority calculation logic
397+ // For now, we just verify expected mappings
398+ var priority int
399+ switch tt.source {
400+ case SourceSystem, SourceClose:
401+ priority = PriorityUrgent
402+ case SourceKeyboard, SourceStackNav, SourceSplit:
403+ priority = PriorityHigh
404+ case SourceProgrammatic, SourceMouse:
405+ priority = PriorityNormal
406+ default:
407+ priority = PriorityLow
408+ }
409+
410+ if priority != tt.expected {
411+ t.Errorf("source %s: expected priority %d, got %d", tt.source, tt.expected, priority)
412+ }
413+ })
414+ }
415+}
M internal/app/browser/pane.go +18 -0
@@ -125,3 +125,21 @@ func (p *BrowserPane) Cleanup() {
125 p.guiComponents = nil125 p.guiComponents = nil
126 p.hasGUI = false126 p.hasGUI = false
127 }127 }
128+
129+// CleanupFromWorkspace removes this pane from workspace tracking maps and app.panes slice
130+// This should be called before Cleanup() to ensure proper workspace state management
131+func (p *BrowserPane) CleanupFromWorkspace(wm interface{}) {
132+ if p == nil || p.webView == nil {
133+ return
134+ }
135+
136+ // Type assert to avoid circular import - workspace manager will pass itself
137+ if workspaceManager, ok := wm.(interface {
138+ removeFromMaps(*webkit.WebView)
139+ removeFromAppPanes(*BrowserPane)
140+ }); ok {
141+ workspaceManager.removeFromMaps(p.webView)
142+ workspaceManager.removeFromAppPanes(p)
143+ log.Printf("[pane-%s] Cleaned up from workspace tracking", p.id)
144+ }
145+}
M internal/app/browser/shortcuts.go +2 -2
@@ -44,7 +44,7 @@ func (s *ShortcutHandler) isActivePane() bool {
44 44
45 // For related popups, they're active if parent chain contains the active node45 // For related popups, they're active if parent chain contains the active node
46 if node.isRelated && node.parentPane != nil {46 if node.isRelated && node.parentPane != nil {
47- activeNode := s.app.workspace.active47+ activeNode := s.app.workspace.GetActiveNode()
48 cur := node48 cur := node
49 for cur != nil {49 for cur != nil {
50 if cur == activeNode {50 if cur == activeNode {
@@ -56,7 +56,7 @@ func (s *ShortcutHandler) isActivePane() bool {
56 }56 }
57 57
58 // Independent panes: equal to active node58 // Independent panes: equal to active node
59- return node == s.app.workspace.active59+ return node == s.app.workspace.GetActiveNode()
60 }60 }
61 61
62 // RegisterShortcuts registers pane-specific keyboard shortcuts with focus guards62 // RegisterShortcuts registers pane-specific keyboard shortcuts with focus guards
A internal/app/browser/stacked_panes.go +854 -0
@@ -0,0 +1,854 @@
1+package browser
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "log"
7+ "time"
8+
9+ "github.com/bnema/dumber/pkg/webkit"
10+)
11+
12+// StackedPaneManager handles all stacked pane operations
13+type StackedPaneManager struct {
14+ wm *WorkspaceManager
15+}
16+
17+// NewStackedPaneManager creates a new stacked pane manager
18+func NewStackedPaneManager(wm *WorkspaceManager) *StackedPaneManager {
19+ return &StackedPaneManager{
20+ wm: wm,
21+ }
22+}
23+
24+// StackPane creates a new pane stacked on top of the target pane.
25+// This is the main entry point for creating stacked panes.
26+func (spm *StackedPaneManager) StackPane(target *paneNode) (*paneNode, error) {
27+ if target == nil || !target.isLeaf || target.pane == nil {
28+ return nil, errors.New("stack target must be a leaf pane")
29+ }
30+
31+ // Create the new pane first
32+ newLeaf, err := spm.prepareNewStackedPane()
33+ if err != nil {
34+ return nil, fmt.Errorf("failed to prepare new stacked pane: %w", err)
35+ }
36+
37+ var stackNode *paneNode
38+ var insertIndex int
39+
40+ // Check if target is already in a stack
41+ if target.parent != nil && target.parent.isStacked {
42+ // Target is already in a stack - add to existing stack
43+ stackNode, insertIndex, err = spm.addPaneToExistingStack(target, newLeaf)
44+ if err != nil {
45+ return nil, fmt.Errorf("failed to add pane to existing stack: %w", err)
46+ }
47+ } else {
48+ // Target is not stacked - create initial stack
49+ stackNode, insertIndex, err = spm.convertToStackedContainer(target, newLeaf)
50+ if err != nil {
51+ return nil, fmt.Errorf("failed to convert to stacked container: %w", err)
52+ }
53+ }
54+
55+ // Finalize the stack creation
56+ return spm.finalizeStackCreation(stackNode, newLeaf, insertIndex)
57+}
58+
59+// prepareNewStackedPane creates a new pane and its container
60+func (spm *StackedPaneManager) prepareNewStackedPane() (*paneNode, error) {
61+ newView, err := spm.wm.createWebView()
62+ if err != nil {
63+ return nil, err
64+ }
65+
66+ newPane, err := spm.wm.createPane(newView)
67+ if err != nil {
68+ return nil, err
69+ }
70+
71+ if handler := newPane.MessageHandler(); handler != nil {
72+ handler.SetWorkspaceObserver(spm.wm)
73+ }
74+
75+ newContainer := newPane.webView.RootWidget()
76+ if newContainer == 0 {
77+ return nil, errors.New("new pane missing container")
78+ }
79+ webkit.WidgetSetHExpand(newContainer, true)
80+ webkit.WidgetSetVExpand(newContainer, true)
81+ webkit.WidgetRealizeInContainer(newContainer)
82+
83+ // Create the new leaf node
84+ newLeaf := &paneNode{
85+ pane: newPane,
86+ isLeaf: true,
87+ }
88+ // Initialize widgets properly using workspace manager helper
89+ spm.wm.initializePaneWidgets(newLeaf, newContainer)
90+
91+ // Create title bar for the new pane
92+ newTitleBar := spm.createTitleBar(newLeaf)
93+ spm.wm.setTitleBar(newLeaf, newTitleBar)
94+
95+ // Keep new container hidden initially - will be shown after transition
96+ webkit.WidgetSetVisible(newContainer, false)
97+ webkit.WidgetSetVisible(newTitleBar, false)
98+
99+ return newLeaf, nil
100+}
101+
102+// addPaneToExistingStack adds a new pane to an existing stack
103+func (spm *StackedPaneManager) addPaneToExistingStack(target, newLeaf *paneNode) (*paneNode, int, error) {
104+ stackNode := target.parent
105+
106+ // Find the current position of the target pane in the stack
107+ currentIndex := -1
108+ for i, pane := range stackNode.stackedPanes {
109+ if pane == target {
110+ currentIndex = i
111+ break
112+ }
113+ }
114+
115+ if currentIndex == -1 {
116+ return nil, 0, errors.New("target pane not found in stack")
117+ }
118+
119+ // Insert the new pane right after the current pane
120+ insertIndex := currentIndex + 1
121+ log.Printf("[workspace] adding to existing stack: currentIndex=%d insertIndex=%d stackSize=%d",
122+ currentIndex, insertIndex, len(stackNode.stackedPanes))
123+
124+ // Set parent relationship
125+ newLeaf.parent = stackNode
126+
127+ // Insert the new pane at the correct position in the slice
128+ stackNode.stackedPanes = append(stackNode.stackedPanes, nil) // Expand slice
129+ copy(stackNode.stackedPanes[insertIndex+1:], stackNode.stackedPanes[insertIndex:]) // Shift elements
130+ stackNode.stackedPanes[insertIndex] = newLeaf // Insert new pane
131+
132+ // Unparent the new container before adding it to the stack (only if it has a parent)
133+ if newLeaf.container != 0 && webkit.WidgetIsValid(newLeaf.container) {
134+ if webkit.WidgetGetParent(newLeaf.container) != 0 {
135+ webkit.WidgetUnparent(newLeaf.container)
136+ }
137+ }
138+
139+ // Insert widgets at correct position for Zellij-style layout
140+ // Each pane has 2 widgets (titleBar, container), so position = insertIndex * 2
141+
142+ // Find the widget to insert after (based on insertIndex)
143+ var insertAfterWidget uintptr = 0
144+ if insertIndex > 0 {
145+ // Insert after the previous pane's container
146+ prevPane := stackNode.stackedPanes[insertIndex-1]
147+ if prevPane.container != 0 {
148+ insertAfterWidget = prevPane.container
149+ }
150+ }
151+
152+ if insertAfterWidget != 0 {
153+ // Insert titleBar after the previous pane's container
154+ if stackNode.stackWrapper != 0 && newLeaf.titleBar != 0 {
155+ webkit.BoxInsertChildAfter(stackNode.stackWrapper, newLeaf.titleBar, insertAfterWidget)
156+ }
157+ // Insert container after the newly inserted titleBar
158+ if stackNode.stackWrapper != 0 && newLeaf.container != 0 && newLeaf.titleBar != 0 {
159+ webkit.BoxInsertChildAfter(stackNode.stackWrapper, newLeaf.container, newLeaf.titleBar)
160+ }
161+ log.Printf("[workspace] inserted widgets at position %d (after widget %#x)", insertIndex, insertAfterWidget)
162+ } else {
163+ // Insert at the beginning (insertIndex = 0)
164+ if stackNode.stackWrapper != 0 && newLeaf.container != 0 {
165+ webkit.BoxPrepend(stackNode.stackWrapper, newLeaf.container)
166+ }
167+ if stackNode.stackWrapper != 0 && newLeaf.titleBar != 0 {
168+ webkit.BoxPrepend(stackNode.stackWrapper, newLeaf.titleBar)
169+ }
170+ log.Printf("[workspace] prepended widgets at position 0")
171+ }
172+
173+ return stackNode, insertIndex, nil
174+}
175+
176+// convertToStackedContainer converts a simple pane to a stacked container
177+func (spm *StackedPaneManager) convertToStackedContainer(target, newLeaf *paneNode) (*paneNode, int, error) {
178+ log.Printf("[workspace] converting pane to stacked: %p", target)
179+
180+ // Create the wrapper container - this is what will be used by splitNode
181+ stackWrapperContainer := webkit.NewBox(webkit.OrientationVertical, 0)
182+ if stackWrapperContainer == 0 {
183+ return nil, 0, errors.New("failed to create stack wrapper container")
184+ }
185+ webkit.WidgetSetHExpand(stackWrapperContainer, true)
186+ webkit.WidgetSetVExpand(stackWrapperContainer, true)
187+ webkit.WidgetRealizeInContainer(stackWrapperContainer) // Ensures size request is set like regular panes
188+
189+ // Add CSS class to the stack container for proper styling
190+ webkit.WidgetAddCSSClass(stackWrapperContainer, stackContainerClass)
191+
192+ // Create the internal box for the actual stacked widgets (titles + webviews)
193+ stackInternalBox := webkit.NewBox(webkit.OrientationVertical, 0)
194+ if stackInternalBox == 0 {
195+ return nil, 0, errors.New("failed to create stack internal box")
196+ }
197+ webkit.WidgetSetHExpand(stackInternalBox, true)
198+ webkit.WidgetSetVExpand(stackInternalBox, true)
199+
200+ // The internal box goes inside the wrapper
201+ webkit.BoxAppend(stackWrapperContainer, stackInternalBox)
202+
203+ // Get the existing container and parent info
204+ existingContainer := target.container
205+ parent := target.parent
206+
207+ // Create title bar for the existing pane
208+ titleBar := spm.createTitleBar(target)
209+
210+ // Keep existing container visible during transition to prevent rendering glitch
211+ webkit.WidgetSetVisible(titleBar, false)
212+
213+ // Detach existing container from its current parent first
214+ spm.detachFromParent(target, parent)
215+
216+ // Build the complete stack structure with hidden widgets
217+ webkit.BoxAppend(stackInternalBox, titleBar)
218+ if existingContainer != 0 {
219+ webkit.BoxAppend(stackInternalBox, existingContainer)
220+ }
221+
222+ // Immediately reattach the stack wrapper to minimize visibility gap
223+ spm.reattachToParent(stackWrapperContainer, target, parent)
224+
225+ // Keep target as a regular leaf node (it's just inside a stack now)
226+ // Individual panes within a stack should NOT be marked as isStacked
227+ // Only the stack container itself has isStacked=true
228+ target.isStacked = false
229+ target.isLeaf = true
230+ // container stays the same (existingContainer)
231+ spm.wm.setTitleBar(target, titleBar)
232+
233+ // Create the stack container node - container points to wrapper, stackWrapper points to internal box
234+ stackNode := &paneNode{
235+ isStacked: true,
236+ isLeaf: false,
237+ stackedPanes: []*paneNode{target},
238+ activeStackIndex: 0, // KEEP CURRENT PANE ACTIVE during transition (index 0)
239+ parent: parent,
240+ }
241+ spm.wm.setContainer(stackNode, stackWrapperContainer, "stack-wrapper") // Wrapper for GTK operations (splits, etc.)
242+ spm.wm.setStackWrapper(stackNode, stackInternalBox) // Internal box for stack operations
243+
244+ // Update target's parent to be the stack node
245+ target.parent = stackNode
246+
247+ // Update parent references (GTK operations already done above)
248+ if parent == nil {
249+ spm.wm.root = stackNode
250+ } else {
251+ if parent.left == target {
252+ parent.left = stackNode
253+ } else if parent.right == target {
254+ parent.right = stackNode
255+ }
256+ }
257+
258+ // Set up the new pane
259+ newLeaf.parent = stackNode
260+
261+ // Insert the new pane at index 1 (after the original pane)
262+ insertIndex := 1
263+ stackNode.stackedPanes = append(stackNode.stackedPanes, newLeaf)
264+
265+ // Unparent the new container before adding it to the stack
266+ // This is critical: even freshly created WebViews might have internal parent refs
267+ if newLeaf.container != 0 && webkit.WidgetIsValid(newLeaf.container) {
268+ parent := webkit.WidgetGetParent(newLeaf.container)
269+ if parent != 0 {
270+ log.Printf("[workspace] unparenting new pane container %#x from parent %#x before stack append", newLeaf.container, parent)
271+ webkit.WidgetUnparent(newLeaf.container)
272+ // Verify unparent succeeded
273+ if finalParent := webkit.WidgetGetParent(newLeaf.container); finalParent != 0 {
274+ log.Printf("[workspace] WARNING: container %#x still has parent %#x after unparent", newLeaf.container, finalParent)
275+ }
276+ }
277+ }
278+
279+ // Add the new widgets to the internal stack box
280+ if stackNode.stackWrapper != 0 && newLeaf.titleBar != 0 {
281+ webkit.BoxAppend(stackNode.stackWrapper, newLeaf.titleBar)
282+ }
283+ if stackNode.stackWrapper != 0 && newLeaf.container != 0 {
284+ webkit.BoxAppend(stackNode.stackWrapper, newLeaf.container)
285+ }
286+
287+ return stackNode, insertIndex, nil
288+}
289+
290+// detachFromParent removes a pane from its current parent
291+func (spm *StackedPaneManager) detachFromParent(target *paneNode, parent *paneNode) {
292+ if parent == nil {
293+ // Target is the root - remove it from window
294+ if spm.wm.window != nil {
295+ spm.wm.window.SetChild(0)
296+ }
297+ // Unparent if it has a GTK parent
298+ if target.container != 0 && webkit.WidgetIsValid(target.container) {
299+ if webkit.WidgetGetParent(target.container) != 0 {
300+ webkit.WidgetUnparent(target.container)
301+ }
302+ }
303+ } else if parent.container != 0 && webkit.WidgetIsValid(parent.container) {
304+ // Target has a parent paned - remove it (automatically unparents in GTK4)
305+ if parent.left == target {
306+ webkit.PanedSetStartChild(parent.container, 0)
307+ } else if parent.right == target {
308+ webkit.PanedSetEndChild(parent.container, 0)
309+ }
310+ }
311+}
312+
313+// reattachToParent attaches a container to the parent
314+func (spm *StackedPaneManager) reattachToParent(container uintptr, target *paneNode, parent *paneNode) {
315+ if parent == nil {
316+ if spm.wm.window != nil {
317+ spm.wm.window.SetChild(container)
318+ }
319+ } else if parent.container != 0 && webkit.WidgetIsValid(parent.container) {
320+ if parent.left == target {
321+ webkit.PanedSetStartChild(parent.container, container)
322+ } else if parent.right == target {
323+ webkit.PanedSetEndChild(parent.container, container)
324+ }
325+ }
326+}
327+
328+// finalizeStackCreation completes the stack creation process
329+func (spm *StackedPaneManager) finalizeStackCreation(stackNode, newLeaf *paneNode, insertIndex int) (*paneNode, error) {
330+ // Update workspace state first
331+ spm.wm.viewToNode[newLeaf.pane.webView] = newLeaf
332+ spm.wm.ensureHover(newLeaf)
333+ spm.wm.app.panes = append(spm.wm.app.panes, newLeaf.pane)
334+ if newLeaf.pane.zoomController != nil {
335+ newLeaf.pane.zoomController.ApplyInitialZoom()
336+ }
337+
338+ // Mark stack operation timestamp to prevent focus conflicts
339+ spm.wm.lastStackOperation = time.Now()
340+
341+ // This ensures Zellij-style layout where the previously active pane shows its current title
342+ currentActiveIndex := stackNode.activeStackIndex
343+ if currentActiveIndex >= 0 && currentActiveIndex < len(stackNode.stackedPanes) && currentActiveIndex != insertIndex {
344+ currentlyActivePane := stackNode.stackedPanes[currentActiveIndex]
345+ if currentlyActivePane.pane != nil && currentlyActivePane.pane.webView != nil && currentlyActivePane.titleBar != 0 {
346+ currentTitle := currentlyActivePane.pane.webView.GetTitle()
347+ if currentTitle != "" {
348+ spm.updateTitleBarLabel(currentlyActivePane, currentTitle)
349+ log.Printf("[workspace] updated title bar for pane becoming inactive during stack creation: %s", currentTitle)
350+ }
351+ }
352+ }
353+
354+ // Immediately switch to the new pane (don't use deprecated IdleAdd)
355+ stackNode.activeStackIndex = insertIndex
356+ spm.UpdateStackVisibility(stackNode)
357+
358+ // Set focus on the new pane synchronously
359+ spm.wm.SetActivePane(newLeaf, SourceSplit)
360+
361+ log.Printf("[workspace] stacked new pane: stackNode=%p newLeaf=%p stackSize=%d activeIndex=%d insertIndex=%d",
362+ stackNode, newLeaf, len(stackNode.stackedPanes), stackNode.activeStackIndex, insertIndex)
363+ return newLeaf, nil
364+}
365+
366+// UpdateStackVisibility updates the visibility of panes in a stack
367+func (spm *StackedPaneManager) UpdateStackVisibility(stackNode *paneNode) {
368+ log.Printf("[workspace] updateStackVisibility called: stackNode=%p", stackNode)
369+
370+ if stackNode == nil {
371+ log.Printf("[workspace] updateStackVisibility aborted: stackNode is nil")
372+ return
373+ }
374+
375+ if !stackNode.isStacked {
376+ log.Printf("[workspace] updateStackVisibility aborted: stackNode.isStacked=%v", stackNode.isStacked)
377+ return
378+ }
379+
380+ if len(stackNode.stackedPanes) == 0 {
381+ log.Printf("[workspace] updateStackVisibility aborted: stackedPanes empty, len=%d", len(stackNode.stackedPanes))
382+ return
383+ }
384+
385+ activeIndex := stackNode.activeStackIndex
386+ if activeIndex < 0 || activeIndex >= len(stackNode.stackedPanes) {
387+ log.Printf("[workspace] updateStackVisibility: correcting activeIndex from %d to 0", activeIndex)
388+ activeIndex = 0
389+ stackNode.activeStackIndex = activeIndex
390+ }
391+
392+ log.Printf("[workspace] updating stack visibility: activeIndex=%d stackSize=%d", activeIndex, len(stackNode.stackedPanes))
393+
394+ // CRITICAL: Process ALL panes in a single pass to prevent flickering
395+ for i, pane := range stackNode.stackedPanes {
396+ if i == activeIndex {
397+ // Active pane: show container, NEVER show title bar
398+ if pane.container != 0 {
399+ webkit.WidgetSetVisible(pane.container, true)
400+ }
401+ if pane.titleBar != 0 {
402+ webkit.WidgetSetVisible(pane.titleBar, false) // ABSOLUTE RULE: never visible for active pane
403+ }
404+ log.Printf("[workspace] active pane %d: container=visible, titleBar=HIDDEN", i)
405+ } else {
406+ // Inactive panes: hide container, show title bar
407+ if pane.container != 0 {
408+ webkit.WidgetSetVisible(pane.container, false)
409+ }
410+ if pane.titleBar != 0 {
411+ webkit.WidgetSetVisible(pane.titleBar, true)
412+ }
413+ }
414+ }
415+}
416+
417+// NavigateStack handles navigation within a stacked pane container
418+func (spm *StackedPaneManager) NavigateStack(direction string) bool {
419+ if spm.wm.GetActiveNode() == nil {
420+ return false
421+ }
422+
423+ // Find the stack container this pane belongs to
424+ var stackNode *paneNode
425+ current := spm.wm.GetActiveNode()
426+
427+ // Check if current pane is directly in a stack
428+ if current.parent != nil && current.parent.isStacked {
429+ stackNode = current.parent
430+ } else {
431+ // Current pane might be the stack container itself if it was the first pane converted to stack
432+ if current.isStacked {
433+ stackNode = current
434+ }
435+ }
436+
437+ if stackNode == nil || !stackNode.isStacked || len(stackNode.stackedPanes) <= 1 {
438+ return false // Not in a stack or stack has only one pane
439+ }
440+
441+ // Use the stackNode's activeStackIndex instead of searching for current pane
442+ // This is more reliable than trying to match panes
443+ currentIndex := stackNode.activeStackIndex
444+ if currentIndex < 0 || currentIndex >= len(stackNode.stackedPanes) {
445+ log.Printf("[workspace] navigateStack: invalid activeStackIndex=%d, resetting to 0", currentIndex)
446+ currentIndex = 0
447+ stackNode.activeStackIndex = 0
448+ }
449+
450+ // Calculate new index based on direction
451+ var newIndex int
452+ switch direction {
453+ case "up":
454+ newIndex = currentIndex - 1
455+ if newIndex < 0 {
456+ newIndex = len(stackNode.stackedPanes) - 1 // Wrap to last
457+ }
458+ case "down":
459+ newIndex = currentIndex + 1
460+ if newIndex >= len(stackNode.stackedPanes) {
461+ newIndex = 0 // Wrap to first
462+ }
463+ default:
464+ return false
465+ }
466+
467+ if newIndex == currentIndex {
468+ return false // No change
469+ }
470+
471+ // Update title bar for the pane transitioning from ACTIVE to INACTIVE
472+ // This ensures the collapsed pane shows its current page title (Zellij-style layout)
473+ currentActivePane := stackNode.stackedPanes[currentIndex]
474+ if currentActivePane.pane != nil && currentActivePane.pane.webView != nil && currentActivePane.titleBar != 0 {
475+ currentTitle := currentActivePane.pane.webView.GetTitle()
476+ if currentTitle != "" {
477+ spm.updateTitleBarLabel(currentActivePane, currentTitle)
478+ log.Printf("[workspace] updated title bar for pane transitioning to INACTIVE: %s", currentTitle)
479+ }
480+ }
481+
482+ // Update active stack index and visibility
483+ stackNode.activeStackIndex = newIndex
484+ spm.UpdateStackVisibility(stackNode)
485+
486+ // Focus the new active pane
487+ newActivePane := stackNode.stackedPanes[newIndex]
488+ spm.wm.SetActivePane(newActivePane, SourceStackNav)
489+
490+ log.Printf("[workspace] navigated stack: direction=%s from=%d to=%d stackSize=%d",
491+ direction, currentIndex, newIndex, len(stackNode.stackedPanes))
492+ return true
493+}
494+
495+// createTitleBar creates a title bar widget for a pane in a stack
496+func (spm *StackedPaneManager) createTitleBar(pane *paneNode) uintptr {
497+ titleBox := webkit.NewBox(webkit.OrientationHorizontal, 8)
498+ if titleBox == 0 {
499+ log.Printf("[workspace] failed to create title box")
500+ return 0
501+ }
502+
503+ webkit.WidgetAddCSSClass(titleBox, "stacked-pane-title")
504+ webkit.WidgetSetHExpand(titleBox, true)
505+ webkit.WidgetSetVExpand(titleBox, false)
506+
507+ // Get the actual title from the WebView instead of hardcoding "New Tab"
508+ var titleText string
509+ if pane.pane != nil && pane.pane.webView != nil {
510+ titleText = pane.pane.webView.GetTitle()
511+ if titleText == "" {
512+ titleText = "New Tab" // Fallback only when title is actually empty
513+ }
514+ } else {
515+ titleText = "New Tab"
516+ }
517+
518+ // Create title label with the actual title
519+ titleLabel := webkit.NewLabel(titleText)
520+ if titleLabel == 0 {
521+ log.Printf("[workspace] failed to create title label")
522+ return titleBox
523+ }
524+
525+ webkit.WidgetAddCSSClass(titleLabel, "stacked-pane-title-text")
526+ webkit.LabelSetMaxWidthChars(titleLabel, 50)
527+ webkit.LabelSetEllipsize(titleLabel, webkit.EllipsizeEnd)
528+
529+ webkit.BoxAppend(titleBox, titleLabel)
530+ webkit.WidgetShow(titleBox)
531+ webkit.WidgetShow(titleLabel)
532+
533+ return titleBox
534+}
535+
536+// UpdateTitleBar updates the title bar label for a WebView in stacked panes
537+func (spm *StackedPaneManager) UpdateTitleBar(webView *webkit.WebView, title string) {
538+ if spm.wm == nil || webView == nil || title == "" {
539+ return
540+ }
541+
542+ // Find the pane node for this WebView
543+ node, exists := spm.wm.viewToNode[webView]
544+ if !exists || node == nil || !node.isLeaf {
545+ return
546+ }
547+
548+ // Check if this pane is in a stack and has a title bar
549+ if node.parent != nil && node.parent.isStacked && node.titleBar != 0 {
550+ // CRITICAL: Only update title bar for INACTIVE panes
551+ // Active panes should never show their title bar
552+ stackNode := node.parent
553+ activeIndex := stackNode.activeStackIndex
554+
555+ // Find this pane's index in the stack
556+ paneIndex := -1
557+ for i, pane := range stackNode.stackedPanes {
558+ if pane == node {
559+ paneIndex = i
560+ break
561+ }
562+ }
563+
564+ if paneIndex != -1 && paneIndex != activeIndex {
565+ // This is an INACTIVE pane - safe to update title bar
566+ spm.updateTitleBarLabel(node, title)
567+ log.Printf("[workspace] updated title bar for INACTIVE WebView %s: %s", webView.ID(), title)
568+ } else {
569+ // This is the ACTIVE pane - title bar should remain hidden
570+ log.Printf("[workspace] skipped title bar update for ACTIVE WebView %s: %s", webView.ID(), title)
571+ }
572+ }
573+}
574+
575+// updateTitleBarLabel updates the label widget within a title bar by recreating it
576+func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string) {
577+ if node == nil || node.titleBar == 0 || node.parent == nil || !node.parent.isStacked {
578+ return
579+ }
580+
581+ // Format the title for display
582+ displayTitle := title
583+ if len(displayTitle) > 50 {
584+ displayTitle = displayTitle[:47] + "..."
585+ }
586+
587+ // Create new title bar with updated title
588+ newTitleBar := spm.createTitleBarWithTitle(displayTitle)
589+ if newTitleBar == 0 {
590+ log.Printf("[workspace] failed to create new title bar")
591+ return
592+ }
593+
594+ // Replace the old title bar in the stack
595+ if node.parent != nil && node.parent.isStacked && node.parent.stackWrapper != 0 {
596+ // Find the correct insertion position based on the pane's index in the stack
597+ paneIndex := -1
598+ for i, stackedPane := range node.parent.stackedPanes {
599+ if stackedPane == node {
600+ paneIndex = i
601+ break
602+ }
603+ }
604+
605+ // Remove old title bar
606+ if node.titleBar != 0 && webkit.WidgetIsValid(node.titleBar) {
607+ webkit.WidgetUnparent(node.titleBar)
608+ }
609+
610+ // Update the node's title bar reference
611+ spm.wm.setTitleBar(node, newTitleBar)
612+
613+ // Insert the new title bar at the correct position
614+ if paneIndex == 0 {
615+ // First pane - insert at the beginning
616+ if node.parent.stackWrapper != 0 {
617+ webkit.BoxPrepend(node.parent.stackWrapper, newTitleBar)
618+ }
619+ } else {
620+ // Insert after the previous pane's container
621+ prevPane := node.parent.stackedPanes[paneIndex-1]
622+ if node.parent.stackWrapper != 0 && prevPane.container != 0 {
623+ webkit.BoxInsertChildAfter(node.parent.stackWrapper, newTitleBar, prevPane.container)
624+ }
625+ }
626+
627+ log.Printf("[workspace] replaced title bar for pane %d: %p", paneIndex, node)
628+ }
629+}
630+
631+// createTitleBarWithTitle creates a title bar with a specific title
632+func (spm *StackedPaneManager) createTitleBarWithTitle(title string) uintptr {
633+ titleBox := webkit.NewBox(webkit.OrientationHorizontal, 8)
634+ if titleBox == 0 {
635+ return 0
636+ }
637+
638+ webkit.WidgetAddCSSClass(titleBox, "stacked-pane-title")
639+ webkit.WidgetSetHExpand(titleBox, true)
640+ webkit.WidgetSetVExpand(titleBox, false)
641+
642+ // Create title label with the specified title
643+ titleLabel := webkit.NewLabel(title)
644+ if titleLabel == 0 {
645+ return titleBox
646+ }
647+
648+ webkit.WidgetAddCSSClass(titleLabel, "stacked-pane-title-text")
649+ webkit.LabelSetMaxWidthChars(titleLabel, 50)
650+ webkit.LabelSetEllipsize(titleLabel, webkit.EllipsizeEnd)
651+
652+ webkit.BoxAppend(titleBox, titleLabel)
653+ webkit.WidgetShow(titleBox)
654+ webkit.WidgetShow(titleLabel)
655+
656+ return titleBox
657+}
658+
659+// CloseStackedPane handles closing a pane that is part of a stack.
660+// The logic mirrors the regular close path: we rebuild the tree, update focus,
661+// and let WorkspaceManager perform the actual cleanup/destroy work so that all
662+// bookkeeping stays consistent.
663+func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
664+ if node == nil || node.parent == nil || !node.parent.isStacked {
665+ return errors.New("node is not part of a stacked pane")
666+ }
667+
668+ stackNode := node.parent
669+
670+ // Locate the pane inside the stack slice.
671+ nodeIndex := -1
672+ for i, stackedPane := range stackNode.stackedPanes {
673+ if stackedPane == node {
674+ nodeIndex = i
675+ break
676+ }
677+ }
678+ if nodeIndex == -1 {
679+ return errors.New("node not found in stack")
680+ }
681+
682+ log.Printf("[workspace] closing stacked pane: index=%d stackSize=%d", nodeIndex, len(stackNode.stackedPanes))
683+
684+ // Detach hover/focus controllers before tearing down widgets to avoid GTK
685+ // callbacks referencing freed memory during destruction.
686+ if spm.wm != nil {
687+ spm.wm.detachHover(node)
688+ spm.wm.detachFocus(node)
689+ }
690+
691+ // Remove the pane widgets from the stack container before mutating the tree.
692+ stackBox := stackNode.stackWrapper
693+ if stackBox == 0 {
694+ stackBox = stackNode.container
695+ }
696+ if stackBox != 0 {
697+ if node.titleBar != 0 {
698+ webkit.BoxRemove(stackBox, node.titleBar)
699+ }
700+ if node.container != 0 {
701+ webkit.BoxRemove(stackBox, node.container)
702+ }
703+ }
704+
705+ // Remove the pane from the stack slice.
706+ stackNode.stackedPanes = append(stackNode.stackedPanes[:nodeIndex], stackNode.stackedPanes[nodeIndex+1:]...)
707+
708+ switch len(stackNode.stackedPanes) {
709+ case 0:
710+ // Stack container is now empty: remove it from the layout entirely.
711+ parent := stackNode.parent
712+ if parent == nil {
713+ // Stack was the root of the workspace — closing this pane exits the app.
714+ if stackNode.container != 0 && webkit.WidgetIsValid(stackNode.container) {
715+ if webkit.WidgetGetParent(stackNode.container) != 0 {
716+ webkit.WidgetUnparent(stackNode.container)
717+ }
718+ }
719+ if spm.wm.window != nil {
720+ spm.wm.window.SetChild(0)
721+ }
722+ _, err := spm.wm.cleanupAndExit(node)
723+ return err
724+ }
725+
726+ // Detach the empty stack container from its GtkPaned parent.
727+ if parent.container != 0 && webkit.WidgetIsValid(parent.container) {
728+ if parent.left == stackNode {
729+ webkit.PanedSetStartChild(parent.container, 0)
730+ } else if parent.right == stackNode {
731+ webkit.PanedSetEndChild(parent.container, 0)
732+ }
733+ webkit.WidgetQueueAllocate(parent.container)
734+ }
735+ if stackNode.container != 0 && webkit.WidgetIsValid(stackNode.container) {
736+ if webkit.WidgetGetParent(stackNode.container) != 0 {
737+ webkit.WidgetUnparent(stackNode.container)
738+ }
739+ }
740+
741+ sibling := spm.wm.getSibling(stackNode)
742+ grandparent := parent.parent
743+
744+ spm.wm.promoteSibling(grandparent, parent, sibling)
745+ spm.wm.swapContainers(grandparent, sibling)
746+
747+ generation := spm.wm.nextCleanupGeneration()
748+ spm.wm.cleanupPane(node, generation)
749+ spm.wm.decommissionParent(parent, generation)
750+
751+ if sibling != nil {
752+ spm.wm.setFocusToLeaf(sibling)
753+ } else {
754+ spm.wm.updateMainPane()
755+ }
756+
757+ stackNode.parent = nil
758+ stackNode.container = 0
759+ stackNode.stackWrapper = 0
760+ stackNode.stackedPanes = nil
761+
762+ return nil
763+
764+ case 1:
765+ // Promote the remaining pane back to a regular leaf node.
766+ lastPane := stackNode.stackedPanes[0]
767+ parent := stackNode.parent
768+ lastPaneContainer := lastPane.container
769+
770+ if lastPane.titleBar != 0 && stackBox != 0 {
771+ webkit.BoxRemove(stackBox, lastPane.titleBar)
772+ }
773+ if stackBox != 0 && lastPaneContainer != 0 {
774+ webkit.BoxRemove(stackBox, lastPaneContainer)
775+ }
776+ if stackNode.container != 0 && webkit.WidgetIsValid(stackNode.container) {
777+ if webkit.WidgetGetParent(stackNode.container) != 0 {
778+ webkit.WidgetUnparent(stackNode.container)
779+ }
780+ }
781+
782+ if parent == nil {
783+ spm.wm.root = lastPane
784+ lastPane.parent = nil
785+ if spm.wm.window != nil && lastPaneContainer != 0 {
786+ spm.wm.window.SetChild(lastPaneContainer)
787+ webkit.WidgetQueueAllocate(lastPaneContainer)
788+ webkit.WidgetShow(lastPaneContainer)
789+ }
790+ } else {
791+ if parent.left == stackNode {
792+ parent.left = lastPane
793+ } else if parent.right == stackNode {
794+ parent.right = lastPane
795+ }
796+ lastPane.parent = parent
797+ if parent.container != 0 && webkit.WidgetIsValid(parent.container) && lastPaneContainer != 0 {
798+ if parent.left == lastPane {
799+ webkit.PanedSetStartChild(parent.container, lastPaneContainer)
800+ } else {
801+ webkit.PanedSetEndChild(parent.container, lastPaneContainer)
802+ }
803+ webkit.WidgetQueueAllocate(parent.container)
804+ }
805+ if lastPaneContainer != 0 {
806+ webkit.WidgetSetVisible(lastPaneContainer, true)
807+ }
808+ }
809+
810+ lastPane.isStacked = false
811+ lastPane.stackedPanes = nil
812+ lastPane.titleBar = 0
813+ spm.wm.viewToNode[lastPane.pane.webView] = lastPane
814+
815+ generation := spm.wm.nextCleanupGeneration()
816+ spm.wm.cleanupPane(node, generation)
817+
818+ spm.wm.SetActivePane(lastPane, SourceClose)
819+ if lastPaneWidget := lastPane.pane.webView.Widget(); lastPaneWidget != 0 {
820+ webkit.WidgetShow(lastPaneWidget)
821+ webkit.WidgetGrabFocus(lastPaneWidget)
822+ }
823+
824+ stackNode.parent = nil
825+ stackNode.container = 0
826+ stackNode.stackWrapper = 0
827+ stackNode.stackedPanes = nil
828+
829+ log.Printf("[workspace] converted stack back to regular pane")
830+ return nil
831+
832+ default:
833+ // Still multiple panes in stack — adjust active index and visibility.
834+ if stackNode.activeStackIndex >= nodeIndex && stackNode.activeStackIndex > 0 {
835+ stackNode.activeStackIndex--
836+ }
837+ if stackNode.activeStackIndex >= len(stackNode.stackedPanes) {
838+ stackNode.activeStackIndex = len(stackNode.stackedPanes) - 1
839+ }
840+
841+ spm.UpdateStackVisibility(stackNode)
842+ if spm.wm.GetActiveNode() == node {
843+ newActivePane := stackNode.stackedPanes[stackNode.activeStackIndex]
844+ spm.wm.SetActivePane(newActivePane, SourceClose)
845+ }
846+
847+ generation := spm.wm.nextCleanupGeneration()
848+ spm.wm.cleanupPane(node, generation)
849+
850+ log.Printf("[workspace] closed pane from stack: remaining=%d activeIndex=%d",
851+ len(stackNode.stackedPanes), stackNode.activeStackIndex)
852+ return nil
853+ }
854+}
D internal/app/browser/test_helpers.go +0 -8
@@ -1,8 +0,0 @@
1-package browser
2-
3-// Mock WindowShortcutHandler for testing
4-type mockWindowShortcutHandler struct{}
5-
6-func (m *mockWindowShortcutHandler) Cleanup() {
7- // No-op for testing
8-}
M internal/app/browser/webview.go +7 -0
@@ -157,12 +157,19 @@ func (app *BrowserApp) attachPaneHandlers(pane *BrowserPane) {
157 if url == "" {157 if url == "" {
158 return158 return
159 }159 }
160+
161+ // Update database with new title
160 go func(url, title string) {162 go func(url, title string) {
161 ctx := context.Background()163 ctx := context.Background()
162 if err := app.browserService.UpdatePageTitle(ctx, url, title); err != nil {164 if err := app.browserService.UpdatePageTitle(ctx, url, title); err != nil {
163 log.Printf("Warning: failed to update page title: %v", err)165 log.Printf("Warning: failed to update page title: %v", err)
164 }166 }
165 }(url, title)167 }(url, title)
168+
169+ // Update stacked pane title bar if this pane is in a stack
170+ if app.workspace != nil {
171+ app.workspace.UpdateTitleBar(pane.webView, title)
172+ }
166 })173 })
167 174
168 pane.webView.RegisterScriptMessageHandler(func(payload string) {175 pane.webView.RegisterScriptMessageHandler(func(payload string) {
A internal/app/browser/workspace_css.go +178 -0
@@ -0,0 +1,178 @@
1+// workspace_css.go - CSS generation and styling for workspace panes
2+package browser
3+
4+import (
5+ "fmt"
6+ "strings"
7+
8+ "github.com/bnema/dumber/internal/config"
9+ "github.com/bnema/dumber/pkg/webkit"
10+)
11+
12+// Helper functions for stacked pane CSS colors
13+
14+// getStackTitleBg returns the background color for stack titles based on theme
15+func getStackTitleBg(isDark bool) string {
16+ if isDark {
17+ return "#404040"
18+ }
19+ return "#f0f0f0"
20+}
21+
22+// getStackTitleHoverBg returns the hover background color for stack titles based on theme
23+func getStackTitleHoverBg(isDark bool) string {
24+ if isDark {
25+ return "#505050"
26+ }
27+ return "#e0e0e0"
28+}
29+
30+// getStackTitleTextColor returns the text color for stack titles based on theme
31+func getStackTitleTextColor(isDark bool) string {
32+ if isDark {
33+ return "#ffffff"
34+ }
35+ return "#333333"
36+}
37+
38+// getActivePaneBorderColor returns the border color for active panes based on config and theme
39+func getActivePaneBorderColor(styling config.WorkspaceStylingConfig, isDark bool) string {
40+ // Use configured border color if set
41+ if styling.BorderColor != "" {
42+ // Check if it's a GTK theme variable (starts with @)
43+ if strings.HasPrefix(styling.BorderColor, "@") {
44+ return styling.BorderColor
45+ }
46+ // Return the configured color as-is (could be hex, rgb, etc.)
47+ return styling.BorderColor
48+ }
49+
50+ // Fallback to hardcoded colors based on theme
51+ if isDark {
52+ return "#4A90E2" // Deeper blue pastel for dark theme
53+ }
54+ return "#87CEEB" // Sky blue pastel for light theme
55+}
56+
57+// generateWorkspaceCSS generates the complete CSS for workspace panes and stacked panes
58+func (wm *WorkspaceManager) generateWorkspaceCSS() string {
59+ cfg := config.Get()
60+ styling := cfg.Workspace.Styling
61+
62+ // Get appropriate colors based on GTK theme preference
63+ var inactiveBorderColor, windowBackgroundColor string
64+ isDark := webkit.PrefersDarkTheme()
65+ if isDark {
66+ inactiveBorderColor = "#333333" // Dark border for dark theme
67+ windowBackgroundColor = "#2b2b2b" // Dark window background
68+ } else {
69+ inactiveBorderColor = "#dddddd" // Light border for light theme
70+ windowBackgroundColor = "#ffffff" // Light window background
71+ }
72+
73+ activeBorderColor := getActivePaneBorderColor(styling, isDark)
74+
75+ css := fmt.Sprintf(`window {
76+ background-color: %s;
77+ }
78+
79+ /* Base pane styling - transparent outline for smooth transitions */
80+ .workspace-pane, .stacked-pane-container {
81+ outline: 2px solid transparent;
82+ outline-offset: -2px;
83+ transition: outline-color %dms ease-in-out;
84+ }
85+
86+ /* Active pane border styling using outline (no layout impact) */
87+ .workspace-pane-active {
88+ outline-color: %s;
89+ }
90+
91+ /* Stacked panes styling */
92+ .stacked-pane-container {
93+ background-color: %s;
94+ border-radius: %dpx;
95+ }
96+
97+ /* Active stacked pane container gets the outline color */
98+ .stacked-pane-container.workspace-pane-active {
99+ outline-color: %s;
100+ }
101+
102+ .stacked-pane-title {
103+ background-color: %s;
104+ border-bottom: 1px solid %s;
105+ padding: 4px 8px;
106+ min-height: 24px;
107+ transition: background-color %dms ease-in-out;
108+ }
109+
110+ .stacked-pane-title:hover {
111+ background-color: %s;
112+ }
113+
114+ .stacked-pane-title-text {
115+ font-size: 12px;
116+ color: %s;
117+ font-weight: 500;
118+ }
119+
120+ .stacked-pane-active {
121+ /* Active pane is fully visible */
122+ }
123+
124+ .stacked-pane-collapsed {
125+ /* Collapsed panes are hidden - handled in code via widget visibility */
126+ }`,
127+ windowBackgroundColor, // window background
128+ styling.TransitionDuration, // base pane outline transition
129+ activeBorderColor, // workspace-pane-active outline-color
130+ windowBackgroundColor, // stacked-pane-container background
131+ styling.BorderRadius, // stacked-pane-container border-radius
132+ activeBorderColor, // stacked-pane-container.workspace-pane-active outline-color
133+ getStackTitleBg(isDark), // stacked-pane-title background
134+ inactiveBorderColor, // stacked-pane-title border-bottom
135+ styling.TransitionDuration, // stacked-pane-title transition
136+ getStackTitleHoverBg(isDark), // stacked-pane-title:hover background
137+ getStackTitleTextColor(isDark), // stacked-pane-title-text color
138+ )
139+
140+ return css
141+}
142+
143+// generateStackedPaneCSS generates the CSS for stacked pane title bars (deprecated - use generateWorkspaceCSS)
144+func (wm *WorkspaceManager) generateStackedPaneCSS() string {
145+ // Delegate to the new comprehensive CSS generator
146+ return wm.generateWorkspaceCSS()
147+}
148+
149+// Global CSS provider tracking to prevent duplication
150+var (
151+ globalCSSInitialized bool
152+ globalCSSContent string
153+)
154+
155+// ensureWorkspaceStyles ensures that CSS styles are applied for workspace panes
156+// Improved to prevent CSS provider duplication by using global CSS tracking
157+func (wm *WorkspaceManager) ensureWorkspaceStyles() {
158+ if wm == nil {
159+ return
160+ }
161+
162+ // Generate CSS content
163+ workspaceCSS := wm.generateWorkspaceCSS()
164+
165+ // Only apply CSS if it hasn't been applied globally or if content changed
166+ if !globalCSSInitialized || globalCSSContent != workspaceCSS {
167+ webkit.AddCSSProvider(workspaceCSS)
168+ globalCSSInitialized = true
169+ globalCSSContent = workspaceCSS
170+ }
171+
172+ wm.cssInitialized = true
173+}
174+
175+// ensureStackedPaneStyles ensures that CSS styles are applied for stacked panes (deprecated - use ensureWorkspaceStyles)
176+func (wm *WorkspaceManager) ensureStackedPaneStyles() {
177+ wm.ensureWorkspaceStyles()
178+}
A internal/app/browser/workspace_debug.go +36 -0
@@ -0,0 +1,36 @@
1+package browser
2+
3+import "log"
4+
5+func (wm *WorkspaceManager) paneCloseLogf(format string, args ...interface{}) {
6+ if wm == nil || !wm.debugPaneClose {
7+ return
8+ }
9+ log.Printf("[pane-close] "+format, args...)
10+}
11+
12+func (wm *WorkspaceManager) dumpTreeState(label string) {
13+ if wm == nil || wm.diagnostics == nil {
14+ return
15+ }
16+ wm.diagnostics.Capture(label, wm.root)
17+}
18+
19+func (wm *WorkspaceManager) DiagnosticsSnapshots() []TreeSnapshot {
20+ if wm == nil || wm.diagnostics == nil {
21+ return nil
22+ }
23+ return wm.diagnostics.Snapshots()
24+}
25+
26+func (wm *WorkspaceManager) SetPaneCloseDebug(enabled bool) {
27+ if wm == nil {
28+ return
29+ }
30+ wm.debugPaneClose = enabled
31+ if wm.diagnostics == nil {
32+ wm.diagnostics = NewWorkspaceDiagnostics(enabled)
33+ return
34+ }
35+ wm.diagnostics.SetEnabled(enabled)
36+}
A internal/app/browser/workspace_diagnostics.go +155 -0
@@ -0,0 +1,155 @@
1+package browser
2+
3+import (
4+ "fmt"
5+ "log"
6+ "strings"
7+ "sync"
8+)
9+
10+type TreeSnapshot struct {
11+ Label string
12+ Lines []string
13+}
14+
15+type WorkspaceDiagnostics struct {
16+ mu sync.Mutex
17+ enabled bool
18+ snapshots []TreeSnapshot
19+}
20+
21+func NewWorkspaceDiagnostics(enabled bool) *WorkspaceDiagnostics {
22+ return &WorkspaceDiagnostics{enabled: enabled}
23+}
24+
25+func (d *WorkspaceDiagnostics) Enabled() bool {
26+ if d == nil {
27+ return false
28+ }
29+ d.mu.Lock()
30+ defer d.mu.Unlock()
31+ return d.enabled
32+}
33+
34+func (d *WorkspaceDiagnostics) SetEnabled(enabled bool) {
35+ if d == nil {
36+ return
37+ }
38+ d.mu.Lock()
39+ d.enabled = enabled
40+ d.mu.Unlock()
41+}
42+
43+func (d *WorkspaceDiagnostics) Capture(label string, root *paneNode) TreeSnapshot {
44+ if d == nil {
45+ return TreeSnapshot{}
46+ }
47+
48+ d.mu.Lock()
49+ enabled := d.enabled
50+ d.mu.Unlock()
51+ if !enabled {
52+ return TreeSnapshot{}
53+ }
54+
55+ snapshot := TreeSnapshot{Label: label}
56+ visited := make(map[*paneNode]bool)
57+ buildTreeSnapshot(root, 0, "", visited, &snapshot.Lines)
58+ if len(snapshot.Lines) == 0 {
59+ snapshot.Lines = append(snapshot.Lines, "<empty workspace>")
60+ }
61+
62+ d.mu.Lock()
63+ d.snapshots = append(d.snapshots, snapshot)
64+ d.mu.Unlock()
65+
66+ log.Printf("[pane-close] snapshot %s\n%s", snapshot.Label, strings.Join(snapshot.Lines, "\n"))
67+ return snapshot
68+}
69+
70+func (d *WorkspaceDiagnostics) Snapshots() []TreeSnapshot {
71+ if d == nil {
72+ return nil
73+ }
74+ d.mu.Lock()
75+ defer d.mu.Unlock()
76+
77+ out := make([]TreeSnapshot, len(d.snapshots))
78+ copy(out, d.snapshots)
79+ return out
80+}
81+
82+func buildTreeSnapshot(node *paneNode, depth int, prefix string, visited map[*paneNode]bool, lines *[]string) {
83+ indent := strings.Repeat(" ", depth)
84+ if node == nil {
85+ *lines = append(*lines, fmt.Sprintf("%s%s<nil>", indent, prefix))
86+ return
87+ }
88+
89+ if depth > 64 {
90+ *lines = append(*lines, fmt.Sprintf("%s%s<max depth reached>", indent, prefix))
91+ return
92+ }
93+
94+ if visited[node] {
95+ *lines = append(*lines, fmt.Sprintf("%s%s<cycle node=%p>", indent, prefix, node))
96+ return
97+ }
98+
99+ visited[node] = true
100+ *lines = append(*lines, fmt.Sprintf("%s%s%s", indent, prefix, describePaneNode(node)))
101+
102+ if node.isStacked {
103+ for idx, child := range node.stackedPanes {
104+ childPrefix := fmt.Sprintf("[%d] ", idx)
105+ buildTreeSnapshot(child, depth+1, childPrefix, visited, lines)
106+ }
107+ return
108+ }
109+
110+ if node.left != nil || node.right != nil {
111+ buildTreeSnapshot(node.left, depth+1, "L: ", visited, lines)
112+ buildTreeSnapshot(node.right, depth+1, "R: ", visited, lines)
113+ }
114+}
115+
116+func describePaneNode(node *paneNode) string {
117+ if node == nil {
118+ return "<nil>"
119+ }
120+
121+ parts := []string{fmt.Sprintf("node=%p", node)}
122+
123+ if node.isLeaf {
124+ parts = append(parts, "leaf")
125+ if node.pane != nil && node.pane.ID() != "" {
126+ parts = append(parts, fmt.Sprintf("pane=%s", node.pane.ID()))
127+ }
128+ if node.isPopup {
129+ parts = append(parts, "popup")
130+ }
131+ } else if node.isStacked {
132+ parts = append(parts, fmt.Sprintf("stack size=%d active=%d", len(node.stackedPanes), node.activeStackIndex))
133+ } else {
134+ parts = append(parts, fmt.Sprintf("split orientation=%d", node.orientation))
135+ }
136+
137+ if node.container != 0 {
138+ parts = append(parts, fmt.Sprintf("widget=%#x", node.container))
139+ }
140+
141+ if node.parent != nil {
142+ parts = append(parts, fmt.Sprintf("parent=%p", node.parent))
143+ }
144+
145+ if !node.isLeaf && !node.isStacked {
146+ if node.left != nil {
147+ parts = append(parts, fmt.Sprintf("left=%p", node.left))
148+ }
149+ if node.right != nil {
150+ parts = append(parts, fmt.Sprintf("right=%p", node.right))
151+ }
152+ }
153+
154+ return strings.Join(parts, " ")
155+}
A internal/app/browser/workspace_geometry_validator.go +209 -0
@@ -0,0 +1,209 @@
1+// workspace_geometry_validator.go - Simplified geometry validation for split operations
2+package browser
3+
4+import (
5+ "fmt"
6+ "log"
7+
8+ "github.com/bnema/dumber/pkg/webkit"
9+)
10+
11+// GeometryValidator validates pane geometry constraints for splits and operations
12+type GeometryValidator struct {
13+ minPaneWidth int
14+ minPaneHeight int
15+ debugMode bool
16+}
17+
18+// PaneGeometry represents the geometry of a pane
19+type PaneGeometry struct {
20+ X int
21+ Y int
22+ Width int
23+ Height int
24+ IsValid bool
25+}
26+
27+// ValidationResult represents the result of a geometry validation
28+type ValidationResult struct {
29+ IsValid bool
30+ Reason string
31+ Geometry PaneGeometry
32+ RequiresRevalidation bool
33+}
34+
35+// NewGeometryValidator creates a new geometry validator
36+func NewGeometryValidator() *GeometryValidator {
37+ return &GeometryValidator{
38+ minPaneWidth: 300, // Minimum width for usable pane
39+ minPaneHeight: 200, // Minimum height for usable pane
40+ debugMode: false,
41+ }
42+}
43+
44+// SetDebugMode enables or disables debug logging
45+func (gv *GeometryValidator) SetDebugMode(debug bool) {
46+ gv.debugMode = debug
47+}
48+
49+// GetPaneGeometry extracts geometry information from a pane node
50+func (gv *GeometryValidator) GetPaneGeometry(node *paneNode) PaneGeometry {
51+ geometry := PaneGeometry{IsValid: false}
52+
53+ if node == nil || node.container == 0 {
54+ if gv.debugMode {
55+ log.Printf("[geometry] Invalid node or missing container")
56+ }
57+ return geometry
58+ }
59+
60+ // Get widget allocation directly - GTK operations are always on main thread
61+ allocation := webkit.WidgetGetAllocation(node.container)
62+
63+ geometry.X = allocation.X
64+ geometry.Y = allocation.Y
65+ geometry.Width = allocation.Width
66+ geometry.Height = allocation.Height
67+ geometry.IsValid = true
68+
69+ if gv.debugMode {
70+ log.Printf("[geometry] Pane geometry: %dx%d at (%d,%d)",
71+ geometry.Width, geometry.Height, geometry.X, geometry.Y)
72+ }
73+
74+ return geometry
75+}
76+
77+// prepareGeometryResult centralizes the common geometry validation logic used by
78+// split/stack checks. It returns the computed geometry, a populated
79+// ValidationResult (for early exits), and a boolean that indicates whether the
80+// caller should continue with additional constraints.
81+func (gv *GeometryValidator) prepareGeometryResult(node *paneNode) (PaneGeometry, ValidationResult, bool) {
82+ geometry := gv.GetPaneGeometry(node)
83+ result := ValidationResult{Geometry: geometry}
84+
85+ if !geometry.IsValid {
86+ if geometry.Width == 0 && geometry.Height == 0 {
87+ result.IsValid = true
88+ result.Reason = "geometry pending allocation"
89+ result.RequiresRevalidation = true
90+ } else {
91+ result.IsValid = false
92+ result.Reason = "could not determine pane geometry"
93+ }
94+ return geometry, result, false
95+ }
96+
97+ return geometry, result, true
98+}
99+
100+// ValidateHorizontalSplit validates if a pane can be split horizontally
101+func (gv *GeometryValidator) ValidateHorizontalSplit(node *paneNode) ValidationResult {
102+ geometry, result, ok := gv.prepareGeometryResult(node)
103+ if !ok {
104+ return result
105+ }
106+
107+ requiredWidth := gv.minPaneWidth * 2
108+ if geometry.Width < requiredWidth {
109+ result.IsValid = false
110+ result.Reason = fmt.Sprintf("pane width %d < required %d for horizontal split",
111+ geometry.Width, requiredWidth)
112+ return result
113+ }
114+
115+ if geometry.Height < gv.minPaneHeight {
116+ result.IsValid = false
117+ result.Reason = fmt.Sprintf("pane height %d < minimum %d",
118+ geometry.Height, gv.minPaneHeight)
119+ return result
120+ }
121+
122+ result.IsValid = true
123+ result.Reason = "horizontal split is valid"
124+ return result
125+}
126+
127+// ValidateVerticalSplit validates if a pane can be split vertically
128+func (gv *GeometryValidator) ValidateVerticalSplit(node *paneNode) ValidationResult {
129+ geometry, result, ok := gv.prepareGeometryResult(node)
130+ if !ok {
131+ return result
132+ }
133+
134+ requiredHeight := gv.minPaneHeight * 2
135+ if geometry.Height < requiredHeight {
136+ result.IsValid = false
137+ result.Reason = fmt.Sprintf("pane height %d < required %d for vertical split",
138+ geometry.Height, requiredHeight)
139+ return result
140+ }
141+
142+ if geometry.Width < gv.minPaneWidth {
143+ result.IsValid = false
144+ result.Reason = fmt.Sprintf("pane width %d < minimum %d",
145+ geometry.Width, gv.minPaneWidth)
146+ return result
147+ }
148+
149+ result.IsValid = true
150+ result.Reason = "vertical split is valid"
151+ return result
152+}
153+
154+// ValidateSplit validates a split operation based on direction
155+func (gv *GeometryValidator) ValidateSplit(node *paneNode, direction string) ValidationResult {
156+ if node == nil {
157+ return ValidationResult{IsValid: false, Reason: "node is nil"}
158+ }
159+
160+ if node.isStacked {
161+ return ValidationResult{IsValid: false, Reason: "cannot split stacked pane container"}
162+ }
163+
164+ if !node.isLeaf {
165+ return ValidationResult{IsValid: false, Reason: "can only split leaf panes"}
166+ }
167+
168+ switch direction {
169+ case "left", "right":
170+ return gv.ValidateHorizontalSplit(node)
171+ case "up", "down":
172+ return gv.ValidateVerticalSplit(node)
173+ default:
174+ return ValidationResult{IsValid: false, Reason: fmt.Sprintf("unknown split direction: %s", direction)}
175+ }
176+}
177+
178+// ValidateStackOperation validates if a pane can be converted to a stack
179+func (gv *GeometryValidator) ValidateStackOperation(node *paneNode) ValidationResult {
180+ geometry, result, ok := gv.prepareGeometryResult(node)
181+ if !ok {
182+ return result
183+ }
184+
185+ if node.isStacked {
186+ result.IsValid = false
187+ result.Reason = "pane is already stacked"
188+ return result
189+ }
190+
191+ minStackHeight := gv.minPaneHeight + 30 // Extra space for title bar
192+ if geometry.Height < minStackHeight {
193+ result.IsValid = false
194+ result.Reason = fmt.Sprintf("pane height %d < required %d for stacking",
195+ geometry.Height, minStackHeight)
196+ return result
197+ }
198+
199+ if geometry.Width < gv.minPaneWidth {
200+ result.IsValid = false
201+ result.Reason = fmt.Sprintf("pane width %d < minimum %d",
202+ geometry.Width, gv.minPaneWidth)
203+ return result
204+ }
205+
206+ result.IsValid = true
207+ result.Reason = "stack operation is valid"
208+ return result
209+}
M internal/app/browser/workspace_manager.go +255 -1963 large diff (2218 lines changed): collapsed
D internal/app/browser/workspace_manager_bench_test.go +0 -344
@@ -1,344 +0,0 @@
1-package browser
2-
3-import (
4- "testing"
5- "time"
6-
7- "github.com/bnema/dumber/pkg/webkit"
8-)
9-
10-func BenchmarkSplitNode(b *testing.B) {
11- wm := newTestWorkspaceManagerWithMocks(b)
12-
13- b.ResetTimer()
14- for i := 0; i < b.N; i++ {
15- b.StopTimer()
16- // Reset to single node for each iteration
17- wm = newTestWorkspaceManagerWithMocks(b)
18- b.StartTimer()
19-
20- _, err := wm.splitNode(wm.root, "right")
21- if err != nil {
22- b.Fatalf("Split failed: %v", err)
23- }
24- }
25-}
26-
27-func BenchmarkClosePane(b *testing.B) {
28- for i := 0; i < b.N; i++ {
29- b.StopTimer()
30- wm := newTestWorkspaceManagerWithMocks(b)
31- node, err := wm.splitNode(wm.root, "right")
32- if err != nil {
33- b.Fatalf("Setup split failed: %v", err)
34- }
35- b.StartTimer()
36-
37- err = wm.closePane(node)
38- if err != nil {
39- b.Fatalf("Close failed: %v", err)
40- }
41- }
42-}
43-
44-func BenchmarkTreeTraversal(b *testing.B) {
45- wm := newTestWorkspaceManagerWithMocks(b)
46-
47- // Build a complex tree with 15 leaves
48- leaves := []*paneNode{wm.root}
49- for i := 0; i < 14; i++ {
50- if len(leaves) > 0 {
51- // Split the first leaf
52- target := leaves[0]
53- leaves = leaves[1:] // Remove the target from leaves
54-
55- newNode, err := wm.splitNode(target, "right")
56- if err != nil {
57- b.Fatalf("Setup split %d failed: %v", i, err)
58- }
59-
60- // Add both resulting leaves back
61- leaves = append(leaves, target, newNode)
62- }
63- }
64-
65- b.ResetTimer()
66- for i := 0; i < b.N; i++ {
67- wm.collectLeaves()
68- }
69-}
70-
71-func BenchmarkCollectLeavesFrom(b *testing.B) {
72- wm := newTestWorkspaceManagerWithMocks(b)
73-
74- // Build a balanced binary tree
75- for i := 0; i < 10; i++ {
76- leaves := wm.collectLeaves()
77- if len(leaves) > 0 {
78- _, err := wm.splitNode(leaves[0], "right")
79- if err != nil {
80- b.Fatalf("Setup split %d failed: %v", i, err)
81- }
82- }
83- }
84-
85- b.ResetTimer()
86- for i := 0; i < b.N; i++ {
87- wm.collectLeavesFrom(wm.root)
88- }
89-}
90-
91-func BenchmarkLeftmostLeaf(b *testing.B) {
92- wm := newTestWorkspaceManagerWithMocks(b)
93-
94- // Create a deep left-leaning tree
95- current := wm.root
96- for i := 0; i < 20; i++ {
97- newNode, err := wm.splitNode(current, "left")
98- if err != nil {
99- b.Fatalf("Setup split %d failed: %v", i, err)
100- }
101- current = newNode
102- }
103-
104- b.ResetTimer()
105- for i := 0; i < b.N; i++ {
106- wm.leftmostLeaf(wm.root)
107- }
108-}
109-
110-func BenchmarkFindReplacementRoot(b *testing.B) {
111- wm := newTestWorkspaceManagerWithMocks(b)
112-
113- // Build a complex tree
114- leaves := wm.collectLeaves()
115- for i := 0; i < 15; i++ {
116- if len(leaves) > 0 {
117- target := leaves[i%len(leaves)]
118- newNode, err := wm.splitNode(target, "right")
119- if err != nil {
120- continue
121- }
122- leaves = append(leaves, newNode)
123- }
124- }
125-
126- excludeNode := wm.root
127-
128- b.ResetTimer()
129- for i := 0; i < b.N; i++ {
130- wm.findReplacementRoot(excludeNode)
131- }
132-}
133-
134-func BenchmarkFocusAdjacent(b *testing.B) {
135- wm := newTestWorkspaceManagerWithMocks(b)
136-
137- // Create a 2x2 grid of panes with known geometry
138- right, _ := wm.splitNode(wm.root, "right")
139- bottom, _ := wm.splitNode(wm.root, "down")
140- _, _ = wm.splitNode(right, "down")
141-
142- // Set up widget bounds for focus calculations
143- webkit.SetWidgetBoundsForTesting(wm.root.container, webkit.WidgetBounds{X: 0, Y: 0, Width: 100, Height: 100})
144- webkit.SetWidgetBoundsForTesting(right.container, webkit.WidgetBounds{X: 120, Y: 0, Width: 100, Height: 100})
145- webkit.SetWidgetBoundsForTesting(bottom.container, webkit.WidgetBounds{X: 0, Y: 120, Width: 100, Height: 100})
146-
147- wm.active = wm.root
148-
149- b.ResetTimer()
150- for i := 0; i < b.N; i++ {
151- wm.focusAdjacent("right")
152- wm.focusAdjacent("left")
153- }
154-}
155-
156-func BenchmarkStructuralNeighbor(b *testing.B) {
157- wm := newTestWorkspaceManagerWithMocks(b)
158-
159- // Create a complex nested structure
160- right, _ := wm.splitNode(wm.root, "right")
161- _, _ = wm.splitNode(right, "down")
162- _, _ = wm.splitNode(wm.root, "down")
163-
164- // Set up geometry
165- webkit.SetWidgetBoundsForTesting(wm.root.container, webkit.WidgetBounds{X: 0, Y: 0, Width: 100, Height: 100})
166-
167- b.ResetTimer()
168- for i := 0; i < b.N; i++ {
169- wm.structuralNeighbor(wm.root, "right")
170- wm.structuralNeighbor(wm.root, "down")
171- }
172-}
173-
174-func BenchmarkClosestLeafFromSubtree(b *testing.B) {
175- wm := newTestWorkspaceManagerWithMocks(b)
176-
177- // Build a subtree with multiple leaves
178- right, _ := wm.splitNode(wm.root, "right")
179- _, _ = wm.splitNode(right, "down")
180- _, _ = wm.splitNode(right, "up")
181-
182- // Set up geometry for all nodes
183- leaves := wm.collectLeavesFrom(right)
184- for i, leaf := range leaves {
185- webkit.SetWidgetBoundsForTesting(leaf.container, webkit.WidgetBounds{
186- X: float64(i * 50), Y: 0, Width: 45, Height: 100,
187- })
188- }
189-
190- b.ResetTimer()
191- for i := 0; i < b.N; i++ {
192- wm.closestLeafFromSubtree(right, 25, 50, "right")
193- }
194-}
195-
196-func BenchmarkComplexTreeOperations(b *testing.B) {
197- b.Run("SplitAndClose", func(b *testing.B) {
198- for i := 0; i < b.N; i++ {
199- b.StopTimer()
200- wm := newTestWorkspaceManagerWithMocks(b)
201- b.StartTimer()
202-
203- // Split multiple times then close in reverse order
204- nodes := []*paneNode{wm.root}
205- for j := 0; j < 5; j++ {
206- newNode, err := wm.splitNode(nodes[len(nodes)-1], "right")
207- if err != nil {
208- b.Fatalf("Split failed: %v", err)
209- }
210- nodes = append(nodes, newNode)
211- }
212-
213- // Close all but root
214- for j := len(nodes) - 1; j > 0; j-- {
215- err := wm.closePane(nodes[j])
216- if err != nil {
217- b.Fatalf("Close failed: %v", err)
218- }
219- }
220- }
221- })
222-
223- b.Run("BuildBalancedTree", func(b *testing.B) {
224- for i := 0; i < b.N; i++ {
225- b.StopTimer()
226- wm := newTestWorkspaceManagerWithMocks(b)
227- b.StartTimer()
228-
229- // Build a balanced tree by alternating split directions
230- leaves := []*paneNode{wm.root}
231- directions := []string{"right", "down", "left", "up"}
232-
233- for j := 0; j < 15 && len(leaves) > 0; j++ {
234- target := leaves[j%len(leaves)]
235- direction := directions[j%len(directions)]
236-
237- newNode, err := wm.splitNode(target, direction)
238- if err != nil {
239- continue
240- }
241- leaves = append(leaves, newNode)
242- }
243- }
244- })
245-}
246-
247-func BenchmarkMemoryOperations(b *testing.B) {
248- b.Run("ViewToNodeMapping", func(b *testing.B) {
249- wm := newTestWorkspaceManagerWithMocks(b)
250-
251- // Create many nodes to stress the map
252- for i := 0; i < 100; i++ {
253- leaves := wm.collectLeaves()
254- if len(leaves) > 0 {
255- _, _ = wm.splitNode(leaves[0], "right")
256- }
257- }
258-
259- b.ResetTimer()
260- for i := 0; i < b.N; i++ {
261- // Access all mappings
262- for view, node := range wm.viewToNode {
263- _ = view
264- _ = node
265- }
266- }
267- })
268-
269- b.Run("TreeWalkMemory", func(b *testing.B) {
270- wm := newTestWorkspaceManagerWithMocks(b)
271-
272- // Build deep tree
273- current := wm.root
274- for i := 0; i < 50; i++ {
275- newNode, err := wm.splitNode(current, "right")
276- if err != nil {
277- break
278- }
279- current = newNode
280- }
281-
282- b.ResetTimer()
283- for i := 0; i < b.N; i++ {
284- // Multiple tree operations that allocate
285- leaves := wm.collectLeaves()
286- _ = leaves
287- countNodes(wm.root)
288- wm.leftmostLeaf(wm.root)
289- }
290- })
291-}
292-
293-// Helper function for benchmarks
294-func newTestWorkspaceManagerWithMocks(tb testing.TB) *WorkspaceManager {
295- tb.Helper()
296- webkit.ResetWidgetStubsForTesting()
297-
298- rootView, err := webkit.NewWebView(&webkit.Config{})
299- if err != nil {
300- tb.Fatalf("failed to create root webview: %v", err)
301- }
302-
303- rootPane := &BrowserPane{
304- webView: rootView,
305- id: "root",
306- }
307-
308- app := &BrowserApp{
309- panes: []*BrowserPane{rootPane},
310- activePane: rootPane,
311- webView: rootView,
312- }
313-
314- wm := &WorkspaceManager{
315- app: app,
316- window: rootView.Window(),
317- viewToNode: make(map[*webkit.WebView]*paneNode),
318- lastSplitMsg: make(map[*webkit.WebView]time.Time),
319- lastExitMsg: make(map[*webkit.WebView]time.Time),
320- }
321-
322- wm.createWebViewFn = func() (*webkit.WebView, error) {
323- return webkit.NewWebView(&webkit.Config{})
324- }
325-
326- wm.createPaneFn = func(view *webkit.WebView) (*BrowserPane, error) {
327- return &BrowserPane{
328- webView: view,
329- id: "bench-pane",
330- }, nil
331- }
332-
333- root := &paneNode{
334- pane: rootPane,
335- container: rootView.RootWidget(),
336- isLeaf: true,
337- }
338- wm.root = root
339- wm.active = root
340- wm.mainPane = root
341- wm.viewToNode[rootView] = root
342-
343- return wm
344-}
D internal/app/browser/workspace_manager_recursion_test.go +0 -172
@@ -1,172 +0,0 @@
1-package browser
2-
3-import (
4- "testing"
5- "time"
6-
7- "github.com/bnema/dumber/pkg/webkit"
8-)
9-
10-func TestInfiniteRecursionProtection(t *testing.T) {
11- t.Run("BoundaryFallback with deep tree", func(t *testing.T) {
12- wm := newTestWorkspaceManagerWithMocksForTree(t)
13-
14- // Create a pathologically deep tree
15- current := wm.root
16- for i := 0; i < 20; i++ {
17- newNode, err := wm.splitNode(current, "right")
18- if err != nil {
19- t.Fatalf("Split %d failed: %v", i, err)
20- }
21- current = newNode
22- }
23-
24- // Test that boundaryFallback doesn't hang even with deep trees
25- done := make(chan bool, 1)
26- go func() {
27- // This should complete quickly without infinite recursion
28- result := wm.boundaryFallback(wm.root, "right")
29- _ = result // We just care that it returns
30- done <- true
31- }()
32-
33- select {
34- case <-done:
35- // Good - function completed
36- case <-time.After(1 * time.Second):
37- t.Fatal("boundaryFallback took too long - likely infinite recursion")
38- }
39- })
40-
41- t.Run("CollectLeaves with complex tree", func(t *testing.T) {
42- wm := newTestWorkspaceManagerWithMocksForTree(t)
43-
44- // Build a complex tree that could potentially cause issues
45- leaves := []*paneNode{wm.root}
46- for i := 0; i < 15; i++ {
47- if len(leaves) > 0 {
48- target := leaves[i%len(leaves)]
49- newNode, err := wm.splitNode(target, "right")
50- if err != nil {
51- continue
52- }
53- leaves = append(leaves, newNode)
54- }
55- }
56-
57- // Test that collectLeaves doesn't hang
58- done := make(chan []*paneNode, 1)
59- go func() {
60- result := wm.collectLeaves()
61- done <- result
62- }()
63-
64- select {
65- case result := <-done:
66- // Verify we got a reasonable result
67- if len(result) == 0 {
68- t.Error("Expected some leaves to be collected")
69- }
70- t.Logf("Collected %d leaves from complex tree", len(result))
71- case <-time.After(1 * time.Second):
72- t.Fatal("collectLeaves took too long - likely infinite recursion")
73- }
74- })
75-
76- t.Run("Focus calculation performance", func(t *testing.T) {
77- wm := newTestWorkspaceManagerWithMocksForTree(t)
78-
79- // Create a tree with geometry
80- right, err := wm.splitNode(wm.root, "right")
81- if err != nil {
82- t.Fatalf("Failed to split right: %v", err)
83- }
84- bottom, err := wm.splitNode(right, "down")
85- if err != nil {
86- t.Fatalf("Failed to split down: %v", err)
87- }
88-
89- // Set widget bounds
90- webkit.SetWidgetBoundsForTesting(wm.root.container, webkit.WidgetBounds{X: 0, Y: 0, Width: 100, Height: 100})
91- if right != nil {
92- webkit.SetWidgetBoundsForTesting(right.container, webkit.WidgetBounds{X: 120, Y: 0, Width: 100, Height: 100})
93- }
94- if bottom != nil {
95- webkit.SetWidgetBoundsForTesting(bottom.container, webkit.WidgetBounds{X: 0, Y: 120, Width: 100, Height: 100})
96- }
97-
98- // Test that focus neighbor calculations complete quickly
99- start := time.Now()
100-
101- // Perform multiple focus operations
102- for i := 0; i < 10; i++ {
103- wm.FocusNeighbor("right")
104- wm.FocusNeighbor("left")
105- wm.FocusNeighbor("down")
106- wm.FocusNeighbor("up")
107- }
108-
109- elapsed := time.Since(start)
110- if elapsed > 100*time.Millisecond {
111- t.Errorf("Focus operations took too long: %v", elapsed)
112- }
113- t.Logf("40 focus operations completed in %v", elapsed)
114- })
115-
116- t.Run("StructuralNeighbor depth protection", func(t *testing.T) {
117- wm := newTestWorkspaceManagerWithMocksForTree(t)
118-
119- // Create nested structure
120- node1, _ := wm.splitNode(wm.root, "right")
121- node2, _ := wm.splitNode(node1, "down")
122- node3, _ := wm.splitNode(node2, "right")
123-
124- // Set some bounds for the calculation
125- webkit.SetWidgetBoundsForTesting(node3.container, webkit.WidgetBounds{X: 0, Y: 0, Width: 100, Height: 100})
126-
127- // Test structural neighbor calculation doesn't hang
128- done := make(chan *paneNode, 1)
129- go func() {
130- result := wm.structuralNeighbor(node3, "left")
131- done <- result
132- }()
133-
134- select {
135- case result := <-done:
136- t.Logf("Structural neighbor calculation completed, result: %v", result != nil)
137- case <-time.After(500 * time.Millisecond):
138- t.Fatal("structuralNeighbor took too long")
139- }
140- })
141-}
142-
143-func TestRecursionLimits(t *testing.T) {
144- t.Run("MaxDepth protection", func(t *testing.T) {
145- wm := newTestWorkspaceManagerWithMocksForTree(t)
146-
147- // Create an artificially corrupted node for testing
148- // (This would never happen in normal operation)
149- corruptedNode := &paneNode{
150- isLeaf: false,
151- left: nil, // This creates a problematic state
152- right: nil, // No children but not a leaf
153- }
154-
155- // Test that boundaryFallback handles this gracefully
156- result := wm.boundaryFallback(corruptedNode, "right")
157- if result != nil {
158- t.Error("Expected nil result for corrupted node")
159- }
160-
161- // The function should return quickly, not hang
162- start := time.Now()
163- for i := 0; i < 10; i++ {
164- wm.boundaryFallback(corruptedNode, "left")
165- }
166- elapsed := time.Since(start)
167-
168- if elapsed > 10*time.Millisecond {
169- t.Errorf("Corrupted node handling took too long: %v", elapsed)
170- }
171- })
172-}
D internal/app/browser/workspace_manager_test.go +0 -271
@@ -1,271 +0,0 @@
1-package browser
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/dumber/pkg/webkit"
7-)
8-
9-func newTestWorkspaceManager(t *testing.T) *WorkspaceManager {
10- t.Helper()
11- webkit.ResetWidgetStubsForTesting()
12-
13- rootView, err := webkit.NewWebView(&webkit.Config{})
14- if err != nil {
15- t.Fatalf("failed to create root webview: %v", err)
16- }
17-
18- rootPane := &BrowserPane{webView: rootView}
19-
20- // Create mock window shortcut handler
21- mockWindowShortcutHandler := &mockWindowShortcutHandler{}
22-
23- app := &BrowserApp{}
24- app.panes = []*BrowserPane{rootPane}
25- app.activePane = rootPane
26- app.webView = rootView
27- app.windowShortcutHandler = mockWindowShortcutHandler
28-
29- wm := NewWorkspaceManager(app, rootPane)
30- wm.createWebViewFn = func() (*webkit.WebView, error) {
31- return webkit.NewWebView(&webkit.Config{})
32- }
33- wm.createPaneFn = func(view *webkit.WebView) (*BrowserPane, error) {
34- return &BrowserPane{webView: view}, nil
35- }
36-
37- return wm
38-}
39-
40-func TestSplitNodeCreatesExpectedTree(t *testing.T) {
41- cases := []struct {
42- name string
43- direction string
44- expectOrientation webkit.Orientation
45- existingIsStart bool
46- }{
47- {name: "Right", direction: "right", expectOrientation: webkit.OrientationHorizontal, existingIsStart: true},
48- {name: "Left", direction: "left", expectOrientation: webkit.OrientationHorizontal, existingIsStart: false},
49- {name: "Up", direction: "up", expectOrientation: webkit.OrientationVertical, existingIsStart: false},
50- {name: "Down", direction: "down", expectOrientation: webkit.OrientationVertical, existingIsStart: true},
51- }
52-
53- for _, tc := range cases {
54- t.Run(tc.name, func(t *testing.T) {
55- wm := newTestWorkspaceManager(t)
56- original := wm.active
57-
58- newLeaf, err := wm.splitNode(original, tc.direction)
59- if err != nil {
60- t.Fatalf("splitNode(%s) returned error: %v", tc.direction, err)
61- }
62-
63- parent := newLeaf.parent
64- if parent == nil {
65- t.Fatalf("expected new leaf to have parent")
66- }
67-
68- if parent.orientation != tc.expectOrientation {
69- t.Fatalf("expected orientation %v got %v", tc.expectOrientation, parent.orientation)
70- }
71-
72- if tc.existingIsStart {
73- if parent.left != original {
74- t.Fatalf("expected original pane to remain as start child")
75- }
76- if parent.right != newLeaf {
77- t.Fatalf("expected new pane to be end child")
78- }
79- } else {
80- if parent.left != newLeaf {
81- t.Fatalf("expected new pane to be start child")
82- }
83- if parent.right != original {
84- t.Fatalf("expected original pane to be end child")
85- }
86- }
87-
88- if wm.root != parent {
89- t.Fatalf("expected parent to become new root")
90- }
91-
92- if len(wm.app.panes) != 2 {
93- t.Fatalf("expected 2 panes registered, got %d", len(wm.app.panes))
94- }
95-
96- mapped := wm.viewToNode[newLeaf.pane.webView]
97- if mapped != newLeaf {
98- t.Fatalf("expected new webview to map to new leaf")
99- }
100- })
101- }
102-}
103-
104-func TestClosePanePromotesSibling(t *testing.T) {
105- wm := newTestWorkspaceManager(t)
106- original := wm.active
107-
108- newLeaf, err := wm.splitNode(original, "right")
109- if err != nil {
110- t.Fatalf("split failed: %v", err)
111- }
112-
113- if err := wm.closePane(newLeaf); err != nil {
114- t.Fatalf("closePane failed: %v", err)
115- }
116-
117- if wm.root != original {
118- t.Fatalf("expected original leaf to become root after closing sibling")
119- }
120-
121- if original.parent != nil {
122- t.Fatalf("expected original leaf to have no parent after promotion")
123- }
124-
125- if len(wm.app.panes) != 1 {
126- t.Fatalf("expected single pane remaining, got %d", len(wm.app.panes))
127- }
128-
129- if wm.app.panes[0] != original.pane {
130- t.Fatalf("expected remaining pane to match original")
131- }
132-
133- if wm.active != original {
134- t.Fatalf("expected focus to move to promoted pane")
135- }
136-}
137-
138-func TestFocusNeighborWithBounds(t *testing.T) {
139- wm := newTestWorkspaceManager(t)
140- left := wm.active
141-
142- right, err := wm.splitNode(left, "right")
143- if err != nil {
144- t.Fatalf("split failed: %v", err)
145- }
146-
147- if right == nil {
148- t.Fatalf("expected new right leaf")
149- }
150-
151- // Provide geometry hints for focus calculations.
152- webkit.SetWidgetBoundsForTesting(left.container, webkit.WidgetBounds{X: 0, Y: 0, Width: 100, Height: 100})
153- webkit.SetWidgetBoundsForTesting(right.container, webkit.WidgetBounds{X: 120, Y: 0, Width: 100, Height: 100})
154-
155- if !wm.FocusNeighbor("left") {
156- t.Fatalf("expected focus neighbor left to succeed")
157- }
158-
159- if wm.active != left {
160- t.Fatalf("expected focus to move to left pane")
161- }
162-
163- // Vertical split from the left pane to test up/down logic.
164- bottom, err := wm.splitNode(left, "down")
165- if err != nil {
166- t.Fatalf("vertical split failed: %v", err)
167- }
168-
169- top := bottom.parent.left
170-
171- webkit.SetWidgetBoundsForTesting(top.container, webkit.WidgetBounds{X: 0, Y: 0, Width: 100, Height: 90})
172- webkit.SetWidgetBoundsForTesting(bottom.container, webkit.WidgetBounds{X: 0, Y: 120, Width: 100, Height: 90})
173-
174- // Active pane should be bottom after split. Move focus up.
175- if wm.active != bottom {
176- t.Fatalf("expected bottom pane to be active after split")
177- }
178-
179- if !wm.FocusNeighbor("up") {
180- t.Fatalf("expected focus neighbor up to succeed")
181- }
182-
183- if wm.active != top {
184- t.Fatalf("expected focus to move to top pane")
185- }
186-}
187-
188-func TestFocusNeighborWithoutPeer(t *testing.T) {
189- wm := newTestWorkspaceManager(t)
190- if wm.FocusNeighbor("left") {
191- t.Fatalf("expected focus move to fail when only one pane exists")
192- }
193-}
194-
195-// TestWorkspaceNavigationAfterFocusChanges tests that workspace navigation
196-// works correctly after focus changes between panes. This validates that
197-// the FocusNeighbor functionality works regardless of which pane previously
198-// had focus (since Alt+Arrow shortcuts are now handled globally).
199-func TestWorkspaceNavigationAfterFocusChanges(t *testing.T) {
200- wm := newTestWorkspaceManager(t)
201-
202- // Split to create two panes
203- right, err := wm.splitNode(wm.root, "right")
204- if err != nil {
205- t.Fatalf("split failed: %v", err)
206- }
207-
208- left := right.parent.left
209-
210- // Set up geometry for navigation calculations
211- webkit.SetWidgetBoundsForTesting(left.container, webkit.WidgetBounds{X: 0, Y: 0, Width: 100, Height: 100})
212- webkit.SetWidgetBoundsForTesting(right.container, webkit.WidgetBounds{X: 120, Y: 0, Width: 100, Height: 100})
213-
214- // Initially, the active pane should be the right one (newly created)
215- if wm.GetActiveNode() != right {
216- t.Fatalf("expected right pane to be active initially")
217- }
218-
219- // Test that navigation works from right to left
220- if !wm.FocusNeighbor("left") {
221- t.Fatalf("expected focus neighbor left to succeed from right pane")
222- }
223-
224- if wm.GetActiveNode() != left {
225- t.Fatalf("expected focus to move to left pane")
226- }
227-
228- // Test that navigation works from left to right
229- if !wm.FocusNeighbor("right") {
230- t.Fatalf("expected focus neighbor right to succeed from left pane")
231- }
232-
233- if wm.GetActiveNode() != right {
234- t.Fatalf("expected focus to move back to right pane")
235- }
236-
237- // Simulate multiple focus changes to verify that workspace navigation
238- // works consistently (the old bug was per-webview shortcut registration)
239- for i := 0; i < 3; i++ {
240- // Move focus left
241- wm.focusNode(left)
242- if wm.GetActiveNode() != left {
243- t.Fatalf("expected left pane to be active after focusNode call %d", i)
244- }
245-
246- // Test navigation still works
247- if !wm.FocusNeighbor("right") {
248- t.Fatalf("expected navigation right to work after focus change %d", i)
249- }
250-
251- // Move focus right
252- wm.focusNode(right)
253- if wm.GetActiveNode() != right {
254- t.Fatalf("expected right pane to be active after focusNode call %d", i)
255- }
256-
257- // Test navigation still works
258- if !wm.FocusNeighbor("left") {
259- t.Fatalf("expected navigation left to work after focus change %d", i)
260- }
261- }
262-
263- // Final verification that we can navigate in both directions
264- if !wm.FocusNeighbor("right") {
265- t.Fatalf("expected final navigation right to work")
266- }
267-
268- if wm.GetActiveNode() != right {
269- t.Fatalf("expected final active pane to be right")
270- }
271-}
D internal/app/browser/workspace_manager_tree_test.go +0 -933
@@ -1,933 +0,0 @@
1-package browser
2-
3-import (
4- "fmt"
5- "testing"
6- "time"
7-
8- "github.com/bnema/dumber/pkg/webkit"
9-)
10-
11-// Test helper to create mock pane node
12-func newMockPaneNode(id string) *paneNode {
13- container := uintptr(100 + len(id))
14-
15- return &paneNode{
16- pane: &BrowserPane{id: id},
17- container: container,
18- isLeaf: true,
19- }
20-}
21-
22-// Test tree validation helper
23-func validateTreeStructure(t *testing.T, node *paneNode) {
24- t.Helper()
25-
26- if node == nil {
27- return
28- }
29-
30- if node.isLeaf {
31- // Leaf nodes should not have children
32- if node.left != nil || node.right != nil {
33- t.Errorf("Leaf node has children: left=%v, right=%v", node.left, node.right)
34- }
35- // Leaf nodes must have a pane
36- if node.pane == nil {
37- t.Error("Leaf node missing pane")
38- }
39- } else {
40- // Branch nodes must have both children
41- if node.left == nil || node.right == nil {
42- t.Errorf("Branch node missing children: left=%v, right=%v", node.left, node.right)
43- }
44- // Branch nodes should not have a pane
45- if node.pane != nil {
46- t.Error("Branch node should not have pane")
47- }
48- // Validate children's parent pointers
49- if node.left != nil && node.left.parent != node {
50- t.Error("Left child's parent pointer incorrect")
51- }
52- if node.right != nil && node.right.parent != node {
53- t.Error("Right child's parent pointer incorrect")
54- }
55- // Recursively validate subtrees
56- validateTreeStructure(t, node.left)
57- validateTreeStructure(t, node.right)
58- }
59-}
60-
61-// Count nodes in tree
62-func countNodes(node *paneNode) (leaves, branches int) {
63- if node == nil {
64- return 0, 0
65- }
66- if node.isLeaf {
67- return 1, 0
68- }
69- leftLeaves, leftBranches := countNodes(node.left)
70- rightLeaves, rightBranches := countNodes(node.right)
71- return leftLeaves + rightLeaves, leftBranches + rightBranches + 1
72-}
73-
74-func TestPaneNodeTreeInvariants(t *testing.T) {
75- t.Run("Initial root is leaf", func(t *testing.T) {
76- wm := newTestWorkspaceManagerWithMocksForTree(t)
77-
78- if !wm.root.isLeaf {
79- t.Error("Initial root should be a leaf")
80- }
81- if wm.root.parent != nil {
82- t.Error("Root should have no parent")
83- }
84- validateTreeStructure(t, wm.root)
85- })
86-
87- t.Run("Tree structure after single split", func(t *testing.T) {
88- wm := newTestWorkspaceManagerWithMocksForTree(t)
89- original := wm.root
90-
91- // Split right
92- newNode, err := wm.splitNode(original, "right")
93- if err != nil {
94- t.Fatalf("Split failed: %v", err)
95- }
96-
97- // Validate new tree structure
98- validateTreeStructure(t, wm.root)
99-
100- // Root should now be a branch
101- if wm.root.isLeaf {
102- t.Error("Root should be branch after split")
103- }
104-
105- // Original and new nodes should be siblings
106- if wm.root.left != original {
107- t.Error("Original node should be left child")
108- }
109- if wm.root.right != newNode {
110- t.Error("New node should be right child")
111- }
112-
113- // Count nodes
114- leaves, branches := countNodes(wm.root)
115- if leaves != 2 {
116- t.Errorf("Expected 2 leaves, got %d", leaves)
117- }
118- if branches != 1 {
119- t.Errorf("Expected 1 branch, got %d", branches)
120- }
121- })
122-}
123-
124-func TestPaneSplitDirections(t *testing.T) {
125- testCases := []struct {
126- name string
127- direction string
128- expectedOrientation webkit.Orientation
129- expectedOriginalPosition string // "left" or "right" child
130- }{
131- {"SplitRight", "right", webkit.OrientationHorizontal, "left"},
132- {"SplitLeft", "left", webkit.OrientationHorizontal, "right"},
133- {"SplitDown", "down", webkit.OrientationVertical, "left"},
134- {"SplitUp", "up", webkit.OrientationVertical, "right"},
135- }
136-
137- for _, tc := range testCases {
138- t.Run(tc.name, func(t *testing.T) {
139- wm := newTestWorkspaceManagerWithMocksForTree(t)
140- original := wm.root
141-
142- newNode, err := wm.splitNode(original, tc.direction)
143- if err != nil {
144- t.Fatalf("Split failed: %v", err)
145- }
146-
147- parent := original.parent
148- if parent.orientation != tc.expectedOrientation {
149- t.Errorf("Expected orientation %v, got %v",
150- tc.expectedOrientation, parent.orientation)
151- }
152-
153- if tc.expectedOriginalPosition == "left" {
154- if parent.left != original {
155- t.Error("Original should be left child")
156- }
157- if parent.right != newNode {
158- t.Error("New node should be right child")
159- }
160- } else {
161- if parent.right != original {
162- t.Error("Original should be right child")
163- }
164- if parent.left != newNode {
165- t.Error("New node should be left child")
166- }
167- }
168-
169- validateTreeStructure(t, wm.root)
170- })
171- }
172-}
173-
174-func TestComplexTreeOperations(t *testing.T) {
175- t.Run("Multiple nested splits", func(t *testing.T) {
176- wm := newTestWorkspaceManagerWithMocksForTree(t)
177-
178- // Create complex tree: split right, then split the right pane down
179- node1, _ := wm.splitNode(wm.root, "right")
180- node2, _ := wm.splitNode(node1, "down")
181-
182- validateTreeStructure(t, wm.root)
183-
184- // Count nodes
185- leaves, branches := countNodes(wm.root)
186- if leaves != 3 {
187- t.Errorf("Expected 3 leaves, got %d", leaves)
188- }
189- if branches != 2 {
190- t.Errorf("Expected 2 branches, got %d", branches)
191- }
192-
193- // Verify node2 is correctly positioned
194- if node2 == nil {
195- t.Error("node2 should not be nil")
196- }
197- })
198-
199- t.Run("Deep nesting", func(t *testing.T) {
200- wm := newTestWorkspaceManagerWithMocksForTree(t)
201-
202- // Create a chain of splits
203- current := wm.root
204- for i := 0; i < 5; i++ {
205- newNode, err := wm.splitNode(current, "right")
206- if err != nil {
207- t.Fatalf("Split %d failed: %v", i, err)
208- }
209- current = newNode
210- }
211-
212- validateTreeStructure(t, wm.root)
213-
214- leaves, branches := countNodes(wm.root)
215- if leaves != 6 {
216- t.Errorf("Expected 6 leaves, got %d", leaves)
217- }
218- if branches != 5 {
219- t.Errorf("Expected 5 branches, got %d", branches)
220- }
221- })
222-}
223-
224-func TestPaneDeletion(t *testing.T) {
225- t.Run("Close leaf promotes sibling", func(t *testing.T) {
226- wm := newTestWorkspaceManagerWithMocksForTree(t)
227- original := wm.root
228-
229- // Split and then close the new pane
230- newNode, _ := wm.splitNode(original, "right")
231-
232- err := wm.closePane(newNode)
233- if err != nil {
234- t.Fatalf("Close failed: %v", err)
235- }
236-
237- // Original should be root again
238- if wm.root != original {
239- t.Error("Original should be promoted to root")
240- }
241- if original.parent != nil {
242- t.Error("Promoted node should have no parent")
243- }
244-
245- validateTreeStructure(t, wm.root)
246- })
247-
248- t.Run("Close in complex tree", func(t *testing.T) {
249- wm := newTestWorkspaceManagerWithMocksForTree(t)
250-
251- // Build tree: root splits right to A and B, then B splits down to C and D
252- A := wm.root
253- B, _ := wm.splitNode(A, "right")
254- C, _ := wm.splitNode(B, "up")
255- // D is the node that B became after split (positioned down)
256- D := B.parent.right
257-
258- // Close C - D should be promoted to take the place of C's parent
259- err := wm.closePane(C)
260- if err != nil {
261- t.Fatalf("Close failed: %v", err)
262- }
263-
264- validateTreeStructure(t, wm.root)
265-
266- // Tree should now have A and D as siblings
267- if wm.root.left != A && wm.root.right != A {
268- t.Error("A should still be child of root")
269- }
270- if wm.root.left != D && wm.root.right != D {
271- t.Error("D should be promoted as child of root")
272- }
273-
274- leaves, branches := countNodes(wm.root)
275- if leaves != 2 {
276- t.Errorf("Expected 2 leaves after deletion, got %d", leaves)
277- }
278- if branches != 1 {
279- t.Errorf("Expected 1 branch after deletion, got %d", branches)
280- }
281- })
282-}
283-
284-func TestRootDelegation(t *testing.T) {
285- t.Run("Closing root with siblings delegates correctly", func(t *testing.T) {
286- wm := newTestWorkspaceManagerWithMocksForTree(t)
287-
288- // Create structure where root will need delegation
289- originalRoot := wm.root
290- rightNode, _ := wm.splitNode(originalRoot, "right")
291-
292- // Now originalRoot and rightNode are siblings under new root
293- // Try to close the left child (originalRoot)
294- err := wm.closePane(originalRoot)
295- if err != nil {
296- t.Fatalf("Close failed: %v", err)
297- }
298-
299- // rightNode should now be the root
300- if wm.root != rightNode {
301- t.Error("Right sibling should be promoted to root")
302- }
303- if rightNode.parent != nil {
304- t.Error("New root should have no parent")
305- }
306-
307- validateTreeStructure(t, wm.root)
308- })
309-}
310-
311-func TestFindReplacementRoot(t *testing.T) {
312- t.Run("Find replacement in complex tree", func(t *testing.T) {
313- wm := newTestWorkspaceManagerWithMocksForTree(t)
314-
315- // Build a complex tree
316- A := wm.root
317- B, _ := wm.splitNode(A, "right")
318- _, _ = wm.splitNode(B, "down")
319-
320- // Find replacement excluding A
321- replacement := wm.findReplacementRoot(A)
322-
323- if replacement == nil {
324- t.Error("Should find replacement root")
325- }
326- if replacement == A {
327- t.Error("Replacement should not be excluded node")
328- }
329- })
330-
331- t.Run("No replacement when only one pane", func(t *testing.T) {
332- wm := newTestWorkspaceManagerWithMocksForTree(t)
333-
334- replacement := wm.findReplacementRoot(wm.root)
335- if replacement != nil {
336- t.Error("Should not find replacement when only one pane exists")
337- }
338- })
339-}
340-
341-func TestTreeTraversal(t *testing.T) {
342- t.Run("CollectLeaves gathers all leaf nodes", func(t *testing.T) {
343- wm := newTestWorkspaceManagerWithMocksForTree(t)
344-
345- // Create tree with 4 leaves
346- // Keep track of the original root which will become a leaf after split
347- original := wm.root
348- node1, _ := wm.splitNode(original, "right")
349- _, _ = wm.splitNode(node1, "down")
350- _, _ = wm.splitNode(original, "down") // Split the original leaf, not the new root branch
351-
352- leaves := wm.collectLeaves()
353-
354- if len(leaves) != 4 {
355- t.Errorf("Expected 4 leaves, got %d", len(leaves))
356- }
357-
358- // All collected nodes should be leaves
359- for _, leaf := range leaves {
360- if !leaf.isLeaf {
361- t.Error("Non-leaf node in collectLeaves result")
362- }
363- }
364- })
365-
366- t.Run("LeftmostLeaf finds correct node", func(t *testing.T) {
367- wm := newTestWorkspaceManagerWithMocksForTree(t)
368-
369- original := wm.root
370- right, _ := wm.splitNode(original, "right")
371- _, _ = wm.splitNode(right, "right")
372-
373- leftmost := wm.leftmostLeaf(wm.root)
374- if leftmost != original {
375- t.Error("Leftmost should be original node")
376- }
377- })
378-
379- t.Run("LeftmostLeaf handles nil", func(t *testing.T) {
380- wm := newTestWorkspaceManagerWithMocksForTree(t)
381-
382- leftmost := wm.leftmostLeaf(nil)
383- if leftmost != nil {
384- t.Error("LeftmostLeaf should return nil for nil input")
385- }
386- })
387-}
388-
389-func TestErrorHandling(t *testing.T) {
390- t.Run("Cannot split non-leaf", func(t *testing.T) {
391- wm := newTestWorkspaceManagerWithMocksForTree(t)
392-
393- // Create a branch node
394- _, err := wm.splitNode(wm.root, "right")
395- if err != nil {
396- t.Fatalf("Failed to create branch node: %v", err)
397- }
398-
399- // Try to split the branch (root is now a branch)
400- _, err = wm.splitNode(wm.root, "down")
401-
402- if err == nil {
403- t.Error("Should not allow splitting branch nodes")
404- }
405- })
406-
407- t.Run("Cannot close non-leaf", func(t *testing.T) {
408- wm := newTestWorkspaceManagerWithMocksForTree(t)
409-
410- _, err := wm.splitNode(wm.root, "right")
411- if err != nil {
412- t.Fatalf("Failed to create branch node: %v", err)
413- }
414-
415- // Try to close the branch node
416- err = wm.closePane(wm.root)
417-
418- if err == nil {
419- t.Error("Should not allow closing branch nodes")
420- }
421- })
422-
423- t.Run("Cannot split nil node", func(t *testing.T) {
424- wm := newTestWorkspaceManagerWithMocksForTree(t)
425-
426- _, err := wm.splitNode(nil, "right")
427-
428- if err == nil {
429- t.Error("Should not allow splitting nil nodes")
430- }
431- })
432-
433- t.Run("Cannot close nil node", func(t *testing.T) {
434- wm := newTestWorkspaceManagerWithMocksForTree(t)
435-
436- err := wm.closePane(nil)
437-
438- if err == nil {
439- t.Error("Should not allow closing nil nodes")
440- }
441- })
442-}
443-
444-func TestFocusManagement(t *testing.T) {
445- t.Run("Focus transfers after closing active pane", func(t *testing.T) {
446- wm := newTestWorkspaceManagerWithMocksForTree(t)
447-
448- original := wm.root
449- newNode, _ := wm.splitNode(original, "right")
450-
451- // Set new node as active
452- wm.active = newNode
453-
454- // Close the active node
455- err := wm.closePane(newNode)
456- if err != nil {
457- t.Fatalf("Close failed: %v", err)
458- }
459-
460- // Focus should transfer to original
461- if wm.active != original {
462- t.Error("Focus should transfer to remaining pane")
463- }
464- })
465-
466- t.Run("Focus preserved when closing non-active pane", func(t *testing.T) {
467- wm := newTestWorkspaceManagerWithMocksForTree(t)
468-
469- original := wm.root
470- newNode, _ := wm.splitNode(original, "right")
471-
472- // Keep original as active
473- wm.active = original
474-
475- // Close the non-active node
476- err := wm.closePane(newNode)
477- if err != nil {
478- t.Fatalf("Close failed: %v", err)
479- }
480-
481- // Focus should remain on original
482- if wm.active != original {
483- t.Error("Focus should remain on original pane")
484- }
485- })
486-}
487-
488-// TestBugClosePaneWithBranchSibling reproduces the bug where closing a pane
489-// whose sibling is a branch (not a leaf) causes "unexpected tree state" error
490-func TestBugClosePaneWithBranchSibling(t *testing.T) {
491- t.Run("Closing pane when sibling is branch with children", func(t *testing.T) {
492- wm := newTestWorkspaceManagerWithMocksForTree(t)
493-
494- // Create the exact scenario from the bug:
495- // 1. Start with root pane A
496- A := wm.root
497-
498- // 2. Split down to create B (A is now top, B is bottom)
499- B, err := wm.splitNode(A, "down")
500- if err != nil {
501- t.Fatalf("Split down failed: %v", err)
502- }
503-
504- // After this split:
505- // Root (vertical paned) has A (top) and B (bottom)
506-
507- // 3. Split B down again to create C
508- C, err := wm.splitNode(B, "down")
509- if err != nil {
510- t.Fatalf("Split B down failed: %v", err)
511- }
512-
513- // After this split, tree structure is:
514- // Root (vertical) -> A (top), SubPaned (vertical, bottom)
515- // SubPaned -> B (top), C (bottom)
516- // Total: 3 leaf panes (A, B, C), 2 branch nodes
517-
518- // Update app.panes to reflect actual structure
519- wm.app.panes = []*BrowserPane{A.pane, B.pane, C.pane}
520-
521- // Verify initial structure
522- validateTreeStructure(t, wm.root)
523- leaves, branches := countNodes(wm.root)
524- if leaves != 3 {
525- t.Errorf("Expected 3 leaves initially, got %d", leaves)
526- }
527- if branches != 2 {
528- t.Errorf("Expected 2 branches initially, got %d", branches)
529- }
530-
531- // Now close pane A - this should promote the SubPaned (containing B and C) to root
532- // This is where the bug occurs: parent is root, but 2 panes remain (B and C)
533- // The current code incorrectly thinks this is an error
534- err = wm.closePane(A)
535-
536- // THIS IS THE BUG: The current implementation returns an error here
537- // but it shouldn't - it's a valid operation
538- if err != nil {
539- // For now, we expect this to fail with the bug
540- if err.Error() == "unexpected tree state during pane closure" {
541- t.Logf("Bug reproduced: %v", err)
542- t.Logf("This error should not occur - closing pane with branch sibling is valid")
543- // Comment out the return to see what the final state should be
544- // return
545- } else {
546- t.Fatalf("Unexpected error: %v", err)
547- }
548- }
549-
550- // What SHOULD happen after successful close:
551- // The SubPaned (containing B and C) should become the new root
552- validateTreeStructure(t, wm.root)
553-
554- // Verify final state (what we expect after fix)
555- finalLeaves, finalBranches := countNodes(wm.root)
556- if finalLeaves != 2 {
557- t.Errorf("Expected 2 leaves after close, got %d", finalLeaves)
558- }
559- if finalBranches != 1 {
560- t.Errorf("Expected 1 branch after close, got %d", finalBranches)
561- }
562-
563- // The new root should be a branch (the old SubPaned)
564- if wm.root.isLeaf {
565- t.Error("Root should be a branch node after closing A")
566- }
567-
568- // B and C should still be in the tree
569- foundB := false
570- foundC := false
571- for _, leaf := range wm.collectLeaves() {
572- if leaf == B {
573- foundB = true
574- }
575- if leaf == C {
576- foundC = true
577- }
578- }
579- if !foundB {
580- t.Error("Pane B should still be in tree")
581- }
582- if !foundC {
583- t.Error("Pane C should still be in tree")
584- }
585- })
586-
587- t.Run("Alternative scenario - closing left child when right has grandchildren", func(t *testing.T) {
588- wm := newTestWorkspaceManagerWithMocksForTree(t)
589-
590- // Create structure: A | (B / C)
591- // where | is horizontal split and / is vertical split
592- A := wm.root
593- B, err := wm.splitNode(A, "right")
594- if err != nil {
595- t.Fatalf("Split right failed: %v", err)
596- }
597-
598- C, err := wm.splitNode(B, "down")
599- if err != nil {
600- t.Fatalf("Split down failed: %v", err)
601- }
602-
603- // Update app.panes
604- wm.app.panes = []*BrowserPane{A.pane, B.pane, C.pane}
605-
606- // Tree is: Root(h) -> A, Paned(v) -> B, C
607- validateTreeStructure(t, wm.root)
608-
609- // Close A - the Paned(v) containing B and C should become root
610- err = wm.closePane(A)
611-
612- // Bug should occur here too
613- if err != nil {
614- if err.Error() == "unexpected tree state during pane closure" {
615- t.Logf("Bug reproduced in alternative scenario: %v", err)
616- }
617- }
618-
619- // After fix, the vertical paned with B and C should be root
620- validateTreeStructure(t, wm.root)
621- })
622-}
623-
624-func TestEdgeCases(t *testing.T) {
625- t.Run("Closing last pane", func(t *testing.T) {
626- wm := newTestWorkspaceManagerWithMocksForTree(t)
627-
628- // Mock the app.panes slice to have only one pane
629- wm.app.panes = []*BrowserPane{wm.root.pane}
630-
631- // Closing the last pane should handle gracefully
632- err := wm.closePane(wm.root)
633- if err != nil {
634- t.Logf("Expected behavior: closing last pane returned error: %v", err)
635- }
636- })
637-
638- t.Run("Split with invalid direction", func(t *testing.T) {
639- wm := newTestWorkspaceManagerWithMocksForTree(t)
640-
641- // Test default behavior with invalid direction
642- newNode, err := wm.splitNode(wm.root, "invalid")
643- if err != nil {
644- t.Fatalf("Split with invalid direction failed: %v", err)
645- }
646-
647- // Should default to horizontal orientation
648- if newNode.parent.orientation != webkit.OrientationHorizontal {
649- t.Error("Invalid direction should default to horizontal")
650- }
651- })
652-}
653-
654-func TestInfiniteSiblingsClosingIssue(t *testing.T) {
655- t.Run("Infinite siblings issue reproduction", func(t *testing.T) {
656- wm := newTestWorkspaceManagerWithMocksForTree(t)
657-
658- // Reproduce the scenario: Create multiple siblings by repeatedly splitting right
659- original := wm.root
660- rightPane1, err := wm.splitNode(original, "right")
661- if err != nil {
662- t.Fatalf("First split failed: %v", err)
663- }
664-
665- // Split the right pane down to create a nested structure
666- downPane, err := wm.splitNode(rightPane1, "down")
667- if err != nil {
668- t.Fatalf("Down split failed: %v", err)
669- }
670-
671- // Verify tree structure is correct
672- validateTreeStructure(t, wm.root)
673-
674- // Count initial panes
675- initialLeaves, initialBranches := countNodes(wm.root)
676- if initialLeaves != 3 {
677- t.Errorf("Expected 3 leaves after splits, got %d", initialLeaves)
678- }
679- if initialBranches != 2 {
680- t.Errorf("Expected 2 branches after splits, got %d", initialBranches)
681- }
682-
683- // Update app.panes to reflect the actual structure
684- wm.app.panes = []*BrowserPane{original.pane, rightPane1.pane, downPane.pane}
685-
686- // First close the downPane (the non-direct sibling)
687- err = wm.closePane(downPane)
688- if err != nil {
689- t.Fatalf("Close downPane failed: %v", err)
690- }
691-
692- validateTreeStructure(t, wm.root)
693-
694- // Now try to close rightPane1 (which should leave only original)
695- // This should cause the promotion of original to root
696- err = wm.closePane(rightPane1)
697- if err != nil {
698- t.Fatalf("Close rightPane1 failed: %v", err)
699- }
700-
701- validateTreeStructure(t, wm.root)
702-
703- // Final tree should have only original as root
704- if wm.root != original {
705- t.Error("Original should be promoted to root after closing siblings")
706- }
707- if !wm.root.isLeaf {
708- t.Error("Root should be a leaf after closing all siblings")
709- }
710-
711- finalLeaves, finalBranches := countNodes(wm.root)
712- if finalLeaves != 1 {
713- t.Errorf("Expected 1 leaf after closing siblings, got %d", finalLeaves)
714- }
715- if finalBranches != 0 {
716- t.Errorf("Expected 0 branches after closing siblings, got %d", finalBranches)
717- }
718- })
719-
720- t.Run("Closing direct sibling of root reproduces GTK error", func(t *testing.T) {
721- wm := newTestWorkspaceManagerWithMocksForTree(t)
722-
723- // Create a simple two-pane layout (root + sibling)
724- original := wm.root
725- sibling, err := wm.splitNode(original, "right")
726- if err != nil {
727- t.Fatalf("Split failed: %v", err)
728- }
729-
730- // Update app.panes
731- wm.app.panes = []*BrowserPane{original.pane, sibling.pane}
732-
733- validateTreeStructure(t, wm.root)
734-
735- // Now close the sibling - this should promote original back to root
736- // and should NOT cause GTK-CRITICAL errors
737- err = wm.closePane(sibling)
738- if err != nil {
739- t.Fatalf("Close sibling failed: %v", err)
740- }
741-
742- validateTreeStructure(t, wm.root)
743-
744- // Verify the tree is properly cleaned up
745- if wm.root != original {
746- t.Error("Original should be promoted to root")
747- }
748- if !wm.root.isLeaf {
749- t.Error("Root should be a leaf after closing sibling")
750- }
751- if wm.root.parent != nil {
752- t.Error("Root should have no parent")
753- }
754- })
755-
756- t.Run("Complex closing sequence without direct root sibling", func(t *testing.T) {
757- wm := newTestWorkspaceManagerWithMocksForTree(t)
758-
759- // Create a more complex tree that can test the issue where
760- // closing requires closing until only the direct sibling of root remains
761-
762- // Start with root (A)
763- A := wm.root
764-
765- // Split right to create B
766- B, err := wm.splitNode(A, "right")
767- if err != nil {
768- t.Fatalf("Split A->B failed: %v", err)
769- }
770-
771- // Split B down to create C (B becomes internal node, C is new leaf)
772- C, err := wm.splitNode(B, "down")
773- if err != nil {
774- t.Fatalf("Split B->C failed: %v", err)
775- }
776- // After this split, the tree structure is:
777- // Root(horizontal) -> A (left), Parent(vertical) (right)
778- // -> B (top), C (bottom)
779-
780- // Split C right to create D
781- D, err := wm.splitNode(C, "right")
782- if err != nil {
783- t.Fatalf("Split C->D failed: %v", err)
784- }
785-
786- // Update app.panes to reflect actual panes
787- wm.app.panes = []*BrowserPane{A.pane, B.pane, C.pane, D.pane}
788-
789- validateTreeStructure(t, wm.root)
790-
791- // Count nodes to verify structure
792- leaves, branches := countNodes(wm.root)
793- if leaves != 4 {
794- t.Errorf("Expected 4 leaves, got %d", leaves)
795- }
796- if branches != 3 {
797- t.Errorf("Expected 3 branches, got %d", branches)
798- }
799-
800- // Now start closing panes in a sequence that would eventually
801- // require promoting siblings and potentially trigger the GTK error
802-
803- // Close D first
804- err = wm.closePane(D)
805- if err != nil {
806- t.Fatalf("Close D failed: %v", err)
807- }
808- validateTreeStructure(t, wm.root)
809-
810- // Close C next
811- err = wm.closePane(C)
812- if err != nil {
813- t.Fatalf("Close C failed: %v", err)
814- }
815- validateTreeStructure(t, wm.root)
816-
817- // Close B next - this should leave only A and trigger the root sibling issue
818- err = wm.closePane(B)
819- if err != nil {
820- t.Fatalf("Close B failed: %v", err)
821- }
822- validateTreeStructure(t, wm.root)
823-
824- // Verify final state
825- if wm.root != A {
826- t.Error("A should be the final root")
827- }
828- if !wm.root.isLeaf {
829- t.Error("Final root should be a leaf")
830- }
831-
832- finalLeaves, finalBranches := countNodes(wm.root)
833- if finalLeaves != 1 {
834- t.Errorf("Expected 1 final leaf, got %d", finalLeaves)
835- }
836- if finalBranches != 0 {
837- t.Errorf("Expected 0 final branches, got %d", finalBranches)
838- }
839- })
840-}
841-
842-func TestViewToNodeMapping(t *testing.T) {
843- t.Run("ViewToNode map updates correctly", func(t *testing.T) {
844- wm := newTestWorkspaceManagerWithMocksForTree(t)
845-
846- originalNode := wm.root
847- originalView := wm.root.pane.webView
848-
849- // Split should add new mapping
850- newNode, _ := wm.splitNode(wm.root, "right")
851- newView := newNode.pane.webView
852-
853- // Check mapping exists for new view
854- if mapped, ok := wm.viewToNode[newView]; !ok || mapped != newNode {
855- t.Error("New webview should be mapped to new node")
856- }
857-
858- // Original mapping should still exist - note that wm.root may have changed after split
859- if mapped, ok := wm.viewToNode[originalView]; !ok || mapped != originalNode {
860- t.Error("Original webview mapping should be preserved")
861- }
862-
863- // Close should remove mapping
864- err := wm.closePane(newNode)
865- if err != nil {
866- t.Fatalf("Failed to close pane: %v", err)
867- }
868-
869- if _, ok := wm.viewToNode[newView]; ok {
870- t.Error("Closed pane's webview should be removed from mapping")
871- }
872- })
873-}
874-
875-// Helper to create WorkspaceManager with minimal mocked dependencies
876-func newTestWorkspaceManagerWithMocksForTree(t *testing.T) *WorkspaceManager {
877- t.Helper()
878- webkit.ResetWidgetStubsForTesting()
879-
880- // Create a real WebView for testing (stub implementation)
881- rootView, err := webkit.NewWebView(&webkit.Config{})
882- if err != nil {
883- t.Fatalf("failed to create root webview: %v", err)
884- }
885-
886- rootPane := &BrowserPane{
887- webView: rootView,
888- id: "root",
889- }
890-
891- // Create mock window shortcut handler
892- mockWindowShortcutHandler := &mockWindowShortcutHandler{}
893-
894- app := &BrowserApp{
895- panes: []*BrowserPane{rootPane},
896- activePane: rootPane,
897- webView: rootView,
898- windowShortcutHandler: mockWindowShortcutHandler,
899- }
900-
901- wm := &WorkspaceManager{
902- app: app,
903- window: rootView.Window(),
904- viewToNode: make(map[*webkit.WebView]*paneNode),
905- lastSplitMsg: make(map[*webkit.WebView]time.Time),
906- lastExitMsg: make(map[*webkit.WebView]time.Time),
907- }
908-
909- // Set up mock factories
910- wm.createWebViewFn = func() (*webkit.WebView, error) {
911- return webkit.NewWebView(&webkit.Config{})
912- }
913-
914- wm.createPaneFn = func(view *webkit.WebView) (*BrowserPane, error) {
915- return &BrowserPane{
916- webView: view,
917- id: fmt.Sprintf("pane-%p", view),
918- }, nil
919- }
920-
921- // Initialize root
922- root := &paneNode{
923- pane: rootPane,
924- container: rootView.RootWidget(),
925- isLeaf: true,
926- }
927- wm.root = root
928- wm.active = root
929- wm.mainPane = root
930- wm.viewToNode[rootView] = root
931-
932- return wm
933-}
A internal/app/browser/workspace_navigation.go +470 -0
@@ -0,0 +1,470 @@
1+// workspace_navigation.go - Navigation and focus management for workspace panes
2+package browser
3+
4+import (
5+ "fmt"
6+ "log"
7+ "math"
8+ "strings"
9+ "time"
10+
11+ "github.com/bnema/dumber/pkg/webkit"
12+)
13+
14+const (
15+ // focusThrottleInterval prevents rapid focus changes that could cause infinite loops
16+ focusThrottleInterval = 100 * time.Millisecond
17+)
18+
19+// DispatchPaneFocusEvent sends a workspace focus event to a pane's webview
20+func (wm *WorkspaceManager) DispatchPaneFocusEvent(node *paneNode, active bool) {
21+ if node == nil || node.pane == nil || node.pane.webView == nil {
22+ return
23+ }
24+
25+ detail := map[string]any{
26+ "active": active,
27+ "webview": fmt.Sprintf("%p", node.pane.webView),
28+ "webviewId": node.pane.webView.ID(),
29+ "hasGUI": node.pane.HasGUI(),
30+ "timestamp": time.Now().UnixMilli(),
31+ }
32+
33+ if err := node.pane.webView.DispatchCustomEvent("dumber:workspace-focus", detail); err != nil {
34+ log.Printf("[workspace] failed to dispatch focus event: %v", err)
35+ } else if wm.app.config != nil && wm.app.config.Debug.EnableWorkspaceDebug {
36+ log.Printf("[workspace] dispatched focus event for webview %s (active=%v)", node.pane.webView.ID(), active)
37+ }
38+}
39+
40+// focusAfterPaneMode restores focus when a pane mode operation completes without
41+// stealing focus from the active pane inside a stack that just spawned a sibling.
42+func (wm *WorkspaceManager) focusAfterPaneMode(node *paneNode) {
43+ wm.focusRespectingStack(node, "focus-after-pane-mode")
44+}
45+
46+// focusRespectingStack handles focus while respecting stacked pane hierarchies
47+func (wm *WorkspaceManager) focusRespectingStack(node *paneNode, reason string) {
48+ if node == nil {
49+ return
50+ }
51+
52+ if stack := node.parent; stack != nil && stack.isStacked {
53+ activeIndex := stack.activeStackIndex
54+ if activeIndex >= 0 && activeIndex < len(stack.stackedPanes) {
55+ activePane := stack.stackedPanes[activeIndex]
56+ if activePane != nil && activePane != node {
57+ if reason != "" {
58+ log.Printf("[workspace] %s: preserving active stacked pane index=%d", reason, activeIndex)
59+ }
60+ wm.SetActivePane(activePane, SourceKeyboard)
61+ return
62+ }
63+ }
64+ }
65+
66+ wm.SetActivePane(node, SourceKeyboard)
67+}
68+
69+// focusByView changes focus to the pane containing the specified WebView
70+func (wm *WorkspaceManager) focusByView(view *webkit.WebView) {
71+ if wm == nil || view == nil {
72+ return
73+ }
74+
75+ // Throttle focus changes to prevent infinite loops
76+ wm.focusThrottleMutex.Lock()
77+ if time.Since(wm.lastFocusChange) < focusThrottleInterval {
78+ wm.focusThrottleMutex.Unlock()
79+ return
80+ }
81+ wm.lastFocusChange = time.Now()
82+ wm.focusThrottleMutex.Unlock()
83+
84+ if node, ok := wm.viewToNode[view]; ok {
85+ if wm.GetActiveNode() != node {
86+ wm.focusRespectingStack(node, "focus-by-view")
87+ }
88+ }
89+}
90+
91+// ensureHover sets up hover handlers for a pane node
92+func (wm *WorkspaceManager) ensureHover(node *paneNode) {
93+ if wm == nil || node == nil || !node.isLeaf {
94+ return
95+ }
96+ if node.container == 0 || node.hoverToken != 0 {
97+ return
98+ }
99+
100+ token := webkit.WidgetAddHoverHandler(node.container, func() {
101+ if wm == nil {
102+ return
103+ }
104+ wm.SetActivePane(node, SourceMouse)
105+ })
106+ node.hoverToken = token
107+ if token == 0 {
108+ log.Printf("[workspace] failed to attach hover handler")
109+ }
110+}
111+
112+// detachHover removes hover handlers from a pane node
113+func (wm *WorkspaceManager) detachHover(node *paneNode) {
114+ if wm == nil || node == nil || node.hoverToken == 0 {
115+ return
116+ }
117+
118+ if node.container == 0 || !webkit.WidgetIsValid(node.container) {
119+ node.hoverToken = 0
120+ return
121+ }
122+
123+ token := node.hoverToken
124+ node.hoverToken = 0
125+
126+ if webkit.WidgetIsValid(node.container) {
127+ webkit.WidgetRemoveHoverHandler(node.container, token)
128+ }
129+}
130+
131+// detachFocus removes the GTK focus controller attached to a pane, if any.
132+func (wm *WorkspaceManager) detachFocus(node *paneNode) {
133+ if wm == nil || node == nil || node.focusControllerToken == 0 {
134+ return
135+ }
136+
137+ if wm.focusStateMachine != nil {
138+ wm.focusStateMachine.detachGTKController(node, node.focusControllerToken)
139+ }
140+
141+ node.focusControllerToken = 0
142+}
143+
144+// FocusNeighbor moves focus to the nearest pane in the requested direction using the
145+// actual widget geometry to determine adjacency. For stacked panes, "up" and "down"
146+// navigate within the stack.
147+func (wm *WorkspaceManager) FocusNeighbor(direction string) bool {
148+ if wm == nil {
149+ return false
150+ }
151+ switch strings.ToLower(direction) {
152+ case "up", "down":
153+ // Check if current pane is part of a stack and handle stack navigation
154+ if wm.stackedPaneManager.NavigateStack(strings.ToLower(direction)) {
155+ return true
156+ }
157+ // Fall back to regular adjacency navigation
158+ return wm.focusAdjacent(strings.ToLower(direction))
159+ case "left", "right":
160+ return wm.focusAdjacent(strings.ToLower(direction))
161+ default:
162+ log.Printf("[workspace] invalid focus direction: %s (expected: up, down, left, right)", direction)
163+ return false
164+ }
165+}
166+
167+// navigateStack handles navigation within a stacked pane container.
168+func (wm *WorkspaceManager) navigateStack(direction string) bool {
169+ currentFocused := wm.GetActiveNode()
170+ if currentFocused == nil {
171+ return false
172+ }
173+
174+ // Find the stack container this pane belongs to
175+ var stackNode *paneNode
176+ current := currentFocused
177+
178+ // Check if current pane is directly in a stack
179+ if current.parent != nil && current.parent.isStacked {
180+ stackNode = current.parent
181+ } else {
182+ // Current pane might be the stack container itself if it was the first pane converted to stack
183+ if current.isStacked {
184+ stackNode = current
185+ }
186+ }
187+
188+ if stackNode == nil || !stackNode.isStacked || len(stackNode.stackedPanes) <= 1 {
189+ return false // Not in a stack or stack has only one pane
190+ }
191+
192+ // Find current pane's index in the stack
193+ currentIndex := -1
194+ for i, pane := range stackNode.stackedPanes {
195+ if pane == current || (pane.pane != nil && current.pane != nil && pane.pane.webView == current.pane.webView) {
196+ currentIndex = i
197+ break
198+ }
199+ }
200+
201+ if currentIndex == -1 {
202+ log.Printf("[workspace] navigateStack: current pane not found in stack")
203+ return false
204+ }
205+
206+ // Calculate new index based on direction
207+ var newIndex int
208+ switch direction {
209+ case "up":
210+ newIndex = currentIndex - 1
211+ if newIndex < 0 {
212+ newIndex = len(stackNode.stackedPanes) - 1 // Wrap to last
213+ }
214+ case "down":
215+ newIndex = currentIndex + 1
216+ if newIndex >= len(stackNode.stackedPanes) {
217+ newIndex = 0 // Wrap to first
218+ }
219+ default:
220+ return false
221+ }
222+
223+ if newIndex == currentIndex {
224+ return false // No change
225+ }
226+
227+ // Update active stack index and visibility
228+ stackNode.activeStackIndex = newIndex
229+ wm.stackedPaneManager.UpdateStackVisibility(stackNode)
230+
231+ // Focus the new active pane
232+ newActivePane := stackNode.stackedPanes[newIndex]
233+ wm.SetActivePane(newActivePane, SourceStackNav)
234+
235+ log.Printf("[workspace] navigated stack: direction=%s from=%d to=%d stackSize=%d",
236+ direction, currentIndex, newIndex, len(stackNode.stackedPanes))
237+ return true
238+}
239+
240+// focusAdjacent uses geometry-based calculations to find and focus adjacent panes
241+func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
242+ currentFocused := wm.GetActiveNode()
243+ if currentFocused == nil || !currentFocused.isLeaf || currentFocused.container == 0 {
244+ return false
245+ }
246+
247+ if neighbor := wm.structuralNeighbor(currentFocused, direction); neighbor != nil {
248+ wm.SetActivePane(neighbor, SourceKeyboard)
249+ return true
250+ }
251+
252+ currentBounds, ok := webkit.WidgetGetBounds(currentFocused.container)
253+ if !ok {
254+ log.Printf("[workspace] unable to compute bounds for active pane")
255+ return false
256+ }
257+
258+ cx := currentBounds.X + currentBounds.Width/2.0
259+ cy := currentBounds.Y + currentBounds.Height/2.0
260+
261+ leaves := wm.collectLeaves()
262+ bestScore := math.MaxFloat64
263+ var best *paneNode
264+ var debugCandidates []string
265+
266+ for _, candidate := range leaves {
267+ if candidate == nil || candidate == currentFocused || candidate.container == 0 {
268+ continue
269+ }
270+
271+ bounds, ok := webkit.WidgetGetBounds(candidate.container)
272+ if !ok {
273+ continue
274+ }
275+ tx := bounds.X + bounds.Width/2.0
276+ ty := bounds.Y + bounds.Height/2.0
277+
278+ dx := tx - cx
279+ dy := ty - cy
280+
281+ var score float64
282+ switch direction {
283+ case "left":
284+ if dx >= -focusEpsilon {
285+ continue
286+ }
287+ score = math.Abs(dx)*1000 + math.Abs(dy)
288+ case "right":
289+ if dx <= focusEpsilon {
290+ continue
291+ }
292+ score = math.Abs(dx)*1000 + math.Abs(dy)
293+ case "up":
294+ if dy >= -focusEpsilon {
295+ continue
296+ }
297+ score = math.Abs(dy)*1000 + math.Abs(dx)
298+ case "down":
299+ if dy <= focusEpsilon {
300+ continue
301+ }
302+ score = math.Abs(dy)*1000 + math.Abs(dx)
303+ }
304+
305+ if direction == "up" || direction == "down" {
306+ debugCandidates = append(debugCandidates, fmt.Sprintf("cand=%#x dx=%.2f dy=%.2f score=%.2f", candidate.container, dx, dy, score))
307+ }
308+
309+ if score < bestScore {
310+ bestScore = score
311+ best = candidate
312+ }
313+ }
314+
315+ if best != nil {
316+ wm.SetActivePane(best, SourceKeyboard)
317+ return true
318+ }
319+
320+ if len(debugCandidates) > 0 {
321+ log.Printf("[workspace] focusAdjacent no candidate direction=%s current=%#x candidates=%s", direction, currentFocused.container, strings.Join(debugCandidates, "; "))
322+ }
323+ return false
324+}
325+
326+// structuralNeighbor finds neighbors based on the tree structure rather than geometry
327+func (wm *WorkspaceManager) structuralNeighbor(node *paneNode, direction string) *paneNode {
328+ if node == nil || node.container == 0 {
329+ return nil
330+ }
331+
332+ refBounds, ok := webkit.WidgetGetBounds(node.container)
333+ if !ok {
334+ return nil
335+ }
336+ cx := refBounds.X + refBounds.Width/2.0
337+ cy := refBounds.Y + refBounds.Height/2.0
338+ axisVertical := direction == "up" || direction == "down"
339+
340+ for parent := node.parent; parent != nil; parent = parent.parent {
341+ switch direction {
342+ case "up":
343+ if axisVertical && parent.orientation == webkit.OrientationVertical && parent.right == node {
344+ if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil {
345+ return leaf
346+ }
347+ }
348+ case "down":
349+ if axisVertical && parent.orientation == webkit.OrientationVertical && parent.left == node {
350+ if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil {
351+ return leaf
352+ }
353+ }
354+ case "left":
355+ if !axisVertical && parent.orientation == webkit.OrientationHorizontal && parent.right == node {
356+ if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil {
357+ return leaf
358+ }
359+ }
360+ case "right":
361+ if !axisVertical && parent.orientation == webkit.OrientationHorizontal && parent.left == node {
362+ if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil {
363+ return leaf
364+ }
365+ }
366+ }
367+ node = parent
368+ }
369+ return nil
370+}
371+
372+// closestLeafFromSubtree finds the closest leaf node in a subtree based on direction
373+func (wm *WorkspaceManager) closestLeafFromSubtree(node *paneNode, cx, cy float64, direction string) *paneNode {
374+ leaves := wm.collectLeavesFrom(node)
375+ bestScore := math.MaxFloat64
376+ var best *paneNode
377+ for _, leaf := range leaves {
378+ if leaf == nil || leaf.container == 0 {
379+ continue
380+ }
381+
382+ bounds, ok := webkit.WidgetGetBounds(leaf.container)
383+ if !ok {
384+ continue
385+ }
386+ tx := bounds.X + bounds.Width/2.0
387+ ty := bounds.Y + bounds.Height/2.0
388+ dx := tx - cx
389+ dy := ty - cy
390+ var score float64
391+ switch direction {
392+ case "left":
393+ if dx >= -focusEpsilon {
394+ continue
395+ }
396+ score = math.Abs(dx)*1000 + math.Abs(dy)
397+ case "right":
398+ if dx <= focusEpsilon {
399+ continue
400+ }
401+ score = math.Abs(dx)*1000 + math.Abs(dy)
402+ case "up":
403+ if dy >= -focusEpsilon {
404+ continue
405+ }
406+ score = math.Abs(dy)*1000 + math.Abs(dx)
407+ case "down":
408+ if dy <= focusEpsilon {
409+ continue
410+ }
411+ score = math.Abs(dy)*1000 + math.Abs(dx)
412+ default:
413+ continue
414+ }
415+ if score < bestScore {
416+ bestScore = score
417+ best = leaf
418+ }
419+ }
420+ if best == nil {
421+ return wm.boundaryFallback(node, direction)
422+ }
423+ return best
424+}
425+
426+// boundaryFallback provides fallback navigation when geometric search fails
427+func (wm *WorkspaceManager) boundaryFallback(node *paneNode, direction string) *paneNode {
428+ return wm.boundaryFallbackWithDepth(node, direction, 0)
429+}
430+
431+// boundaryFallbackWithDepth provides recursive fallback with depth protection
432+func (wm *WorkspaceManager) boundaryFallbackWithDepth(node *paneNode, direction string, depth int) *paneNode {
433+ // Prevent infinite recursion - max tree depth should be reasonable
434+ const maxDepth = 50
435+ if depth > maxDepth {
436+ log.Printf("[workspace] boundaryFallback: max depth exceeded, possible tree corruption")
437+ return nil
438+ }
439+
440+ if node == nil {
441+ return nil
442+ }
443+ if node.isLeaf {
444+ return node
445+ }
446+ switch direction {
447+ case "up":
448+ if leaf := wm.boundaryFallbackWithDepth(node.right, direction, depth+1); leaf != nil {
449+ return leaf
450+ }
451+ return wm.boundaryFallbackWithDepth(node.left, direction, depth+1)
452+ case "down":
453+ if leaf := wm.boundaryFallbackWithDepth(node.left, direction, depth+1); leaf != nil {
454+ return leaf
455+ }
456+ return wm.boundaryFallbackWithDepth(node.right, direction, depth+1)
457+ case "left":
458+ if leaf := wm.boundaryFallbackWithDepth(node.right, direction, depth+1); leaf != nil {
459+ return leaf
460+ }
461+ return wm.boundaryFallbackWithDepth(node.left, direction, depth+1)
462+ case "right":
463+ if leaf := wm.boundaryFallbackWithDepth(node.left, direction, depth+1); leaf != nil {
464+ return leaf
465+ }
466+ return wm.boundaryFallbackWithDepth(node.right, direction, depth+1)
467+ default:
468+ return nil
469+ }
470+}
A internal/app/browser/workspace_operations.go +354 -0
@@ -0,0 +1,354 @@
1+// workspace_operations.go - Workspace operation methods with validation and safety
2+package browser
3+
4+import (
5+ "fmt"
6+ "log"
7+
8+ "github.com/bnema/dumber/pkg/webkit"
9+)
10+
11+// SplitPane performs a split operation with validation and safety checks
12+func (wm *WorkspaceManager) SplitPane(target *paneNode, direction string) (*paneNode, error) {
13+ if wm == nil {
14+ return nil, fmt.Errorf("workspace manager is nil")
15+ }
16+
17+ // Validate direction parameter
18+ validDirections := map[string]bool{
19+ "left": true,
20+ "right": true,
21+ "up": true,
22+ "down": true,
23+ }
24+ if !validDirections[direction] {
25+ return nil, fmt.Errorf("invalid split direction '%s', expected one of: left, right, up, down", direction)
26+ }
27+
28+ log.Printf("[workspace] Starting split operation: target=%p direction=%s", target, direction)
29+
30+ // Step 1: Validate geometry constraints (only in DebugBasic+)
31+ if wm.debugLevel >= DebugBasic {
32+ validation := wm.geometryValidator.ValidateSplit(target, direction)
33+ if !validation.IsValid {
34+ if wm.debugLevel == DebugFull {
35+ return nil, fmt.Errorf("split validation failed: %s", validation.Reason)
36+ }
37+ log.Printf("[workspace] WARNING: Split validation failed but allowing operation: %s", validation.Reason)
38+ }
39+
40+ // Log if re-validation will be needed due to pending widget allocation
41+ if validation.RequiresRevalidation {
42+ log.Printf("[workspace] Split validation passed with pending allocation - operation will proceed")
43+ }
44+
45+ // Step 3: Validate tree invariants before operation
46+ if err := wm.treeValidator.ValidateTree(wm.root, "before_split"); err != nil {
47+ if wm.debugLevel == DebugFull {
48+ return nil, fmt.Errorf("tree validation failed before split: %w", err)
49+ }
50+ log.Printf("[workspace] WARNING: Tree validation failed before split but allowing operation: %v", err)
51+ }
52+ }
53+
54+ // Step 2: Execute directly if we're already on the GTK main thread
55+ if webkit.IsMainThread() {
56+ log.Printf("[workspace] Already on main thread, executing split directly")
57+ newNode, err := wm.splitNode(target, direction)
58+ if err != nil {
59+ return nil, err
60+ }
61+
62+ if wm.debugLevel >= DebugBasic {
63+ if err := wm.treeValidator.ValidateTree(wm.root, "after_split"); err != nil {
64+ log.Printf("[workspace] Tree validation failed after split: %v", err)
65+ }
66+ }
67+
68+ // Tree rebalancing is only needed after close promotions. Splits have correct allocation from GTK.
69+
70+ log.Printf("[workspace] Split operation completed successfully (direct execution): newNode=%p", newNode)
71+ return newNode, nil
72+ }
73+
74+ // Step 3: Not on main thread, marshal via IdleAdd
75+ log.Printf("[workspace] Not on main thread, marshalling split via IdleAdd")
76+ var newNode *paneNode
77+ var splitErr error
78+ done := make(chan struct{})
79+
80+ _ = webkit.IdleAdd(func() bool {
81+ newNode, splitErr = wm.splitNode(target, direction)
82+ if splitErr == nil {
83+ if wm.debugLevel >= DebugBasic {
84+ if verr := wm.treeValidator.ValidateTree(wm.root, "after_split"); verr != nil {
85+ log.Printf("[workspace] Tree validation failed after split: %v", verr)
86+ }
87+ }
88+ }
89+ close(done)
90+ return false
91+ })
92+
93+ <-done
94+
95+ if splitErr != nil {
96+ return nil, splitErr
97+ }
98+
99+ log.Printf("[workspace] Split operation completed successfully: newNode=%p", newNode)
100+ return newNode, nil
101+}
102+
103+// ClosePane performs a close operation with validation and safety checks
104+func (wm *WorkspaceManager) ClosePane(node *paneNode) error {
105+ if wm == nil {
106+ return fmt.Errorf("workspace manager is nil")
107+ }
108+
109+ log.Printf("[workspace] Starting close operation: node=%p", node)
110+ wm.paneCloseLogf("start bulletproof close node=%p", node)
111+ wm.dumpTreeState("before_close")
112+
113+ // Step 1: Validate tree invariants before operation (only in DebugBasic+)
114+ if wm.debugLevel >= DebugBasic {
115+ if err := wm.treeValidator.ValidateTree(wm.root, "before_close"); err != nil {
116+ if wm.debugLevel == DebugFull {
117+ return fmt.Errorf("tree validation failed before close: %w", err)
118+ }
119+ log.Printf("[workspace] WARNING: Tree validation failed before close but allowing operation: %v", err)
120+ }
121+ }
122+
123+ // Step 3: Check if this is a stacked pane and use enhanced lifecycle management
124+ if node.parent != nil && node.parent.isStacked {
125+ log.Printf("[workspace] Using enhanced stack lifecycle management for close")
126+ return wm.stackLifecycleManager.CloseStackedPaneWithLifecycle(node)
127+ }
128+
129+ // Step 4: Execute directly when already on the GTK main thread
130+ if webkit.IsMainThread() {
131+ log.Printf("[workspace] Already on main thread, executing close directly")
132+
133+ wm.paneCloseLogf("invoking closePane node=%p", node)
134+ promoted, err := wm.closePane(node)
135+ if err != nil {
136+ wm.paneCloseLogf("closePane failed node=%p err=%v", node, err)
137+ wm.dumpTreeState("after_close_error")
138+ return err
139+ }
140+
141+ if wm.debugLevel >= DebugBasic {
142+ if err := wm.treeValidator.ValidateTree(wm.root, "after_close"); err != nil {
143+ log.Printf("[workspace] Tree validation failed after close: %v", err)
144+ }
145+ }
146+ wm.paneCloseLogf("closePane succeeded node=%p promoted=%p root=%p", node, promoted, wm.root)
147+ wm.dumpTreeState("after_close_success")
148+
149+ if wm.treeRebalancer != nil {
150+ if err := wm.treeRebalancer.RebalanceAfterClose(node, promoted); err != nil {
151+ log.Printf("[workspace] Tree rebalancing failed after close: %v", err)
152+ }
153+ }
154+
155+ log.Printf("[workspace] Close operation completed successfully (direct execution)")
156+ return nil
157+ }
158+
159+ // Step 5: Not on main thread, marshal via IdleAdd
160+ log.Printf("[workspace] Not on main thread, marshalling close via IdleAdd")
161+ var promoted *paneNode
162+ var closeErr error
163+ done := make(chan struct{})
164+
165+ _ = webkit.IdleAdd(func() bool {
166+ wm.paneCloseLogf("invoking closePane node=%p", node)
167+ promoted, closeErr = wm.closePane(node)
168+ if closeErr != nil {
169+ wm.paneCloseLogf("closePane failed node=%p err=%v", node, closeErr)
170+ wm.dumpTreeState("after_close_error")
171+ } else {
172+ if wm.debugLevel >= DebugBasic {
173+ if verr := wm.treeValidator.ValidateTree(wm.root, "after_close"); verr != nil {
174+ log.Printf("[workspace] Tree validation failed after close: %v", verr)
175+ }
176+ }
177+ wm.paneCloseLogf("closePane succeeded node=%p promoted=%p root=%p", node, promoted, wm.root)
178+ wm.dumpTreeState("after_close_success")
179+
180+ if wm.treeRebalancer != nil {
181+ if rerr := wm.treeRebalancer.RebalanceAfterClose(node, promoted); rerr != nil {
182+ log.Printf("[workspace] Tree rebalancing failed after close: %v", rerr)
183+ }
184+ }
185+ }
186+ close(done)
187+ return false
188+ })
189+
190+ <-done
191+
192+ if closeErr != nil {
193+ return closeErr
194+ }
195+
196+ log.Printf("[workspace] Close operation completed successfully")
197+ return nil
198+}
199+
200+// StackPane performs a stack operation with validation and safety checks
201+func (wm *WorkspaceManager) StackPane(target *paneNode) (*paneNode, error) {
202+ if wm == nil {
203+ return nil, fmt.Errorf("workspace manager is nil")
204+ }
205+
206+ log.Printf("[workspace] Starting stack operation: target=%p", target)
207+
208+ // Step 1: Validate stack operation constraints (only in DebugBasic+)
209+ if wm.debugLevel >= DebugBasic {
210+ validation := wm.geometryValidator.ValidateStackOperation(target)
211+ if !validation.IsValid {
212+ if wm.debugLevel == DebugFull {
213+ return nil, fmt.Errorf("stack validation failed: %s", validation.Reason)
214+ }
215+ log.Printf("[workspace] WARNING: Stack validation failed but allowing operation: %s", validation.Reason)
216+ }
217+
218+ // Log if re-validation will be needed due to pending widget allocation
219+ if validation.RequiresRevalidation {
220+ log.Printf("[workspace] Stack validation passed with pending allocation - operation will proceed")
221+ }
222+ }
223+
224+ // Step 2: Validate tree invariants before operation (only in DebugBasic+)
225+ if wm.debugLevel >= DebugBasic {
226+ if err := wm.treeValidator.ValidateTree(wm.root, "before_stack"); err != nil {
227+ if wm.debugLevel == DebugFull {
228+ return nil, fmt.Errorf("tree validation failed before stack: %w", err)
229+ }
230+ log.Printf("[workspace] WARNING: Tree validation failed before stack but allowing operation: %v", err)
231+ }
232+ }
233+
234+ // Step 3: Execute directly if already on the GTK main thread
235+ if webkit.IsMainThread() {
236+ log.Printf("[workspace] Already on main thread, executing stack directly")
237+ newNode, err := wm.stackedPaneManager.StackPane(target)
238+ if err != nil {
239+ return nil, err
240+ }
241+
242+ if wm.debugLevel >= DebugBasic {
243+ if err := wm.treeValidator.ValidateTree(wm.root, "after_stack"); err != nil {
244+ log.Printf("[workspace] Tree validation failed after stack: %v", err)
245+ }
246+ }
247+
248+ log.Printf("[workspace] Stack operation completed successfully (direct execution): newNode=%p", newNode)
249+ return newNode, nil
250+ }
251+
252+ // Step 4: Not on main thread, marshal via IdleAdd
253+ log.Printf("[workspace] Not on main thread, marshalling stack via IdleAdd")
254+ var newNode *paneNode
255+ var stackErr error
256+ done := make(chan struct{})
257+
258+ _ = webkit.IdleAdd(func() bool {
259+ newNode, stackErr = wm.stackedPaneManager.StackPane(target)
260+ if stackErr == nil {
261+ if wm.debugLevel >= DebugBasic {
262+ if verr := wm.treeValidator.ValidateTree(wm.root, "after_stack"); verr != nil {
263+ log.Printf("[workspace] Tree validation failed after stack: %v", verr)
264+ }
265+ }
266+ }
267+ close(done)
268+ return false
269+ })
270+
271+ <-done
272+
273+ if stackErr != nil {
274+ return nil, stackErr
275+ }
276+
277+ log.Printf("[workspace] Stack operation completed successfully: newNode=%p", newNode)
278+ return newNode, nil
279+}
280+
281+// EnableEnhancedMode enables all enhanced validation features
282+func (wm *WorkspaceManager) EnableEnhancedMode() {
283+ if wm.treeValidator != nil {
284+ wm.treeValidator.Enable()
285+ }
286+ if wm.treeRebalancer != nil {
287+ wm.treeRebalancer.Enable()
288+ }
289+ log.Printf("[workspace] Enhanced validation mode enabled")
290+}
291+
292+// DisableEnhancedMode disables enhanced features for performance
293+func (wm *WorkspaceManager) DisableEnhancedMode() {
294+ if wm.treeValidator != nil {
295+ wm.treeValidator.Disable()
296+ }
297+ if wm.treeRebalancer != nil {
298+ wm.treeRebalancer.Disable()
299+ }
300+ log.Printf("[workspace] Enhanced validation mode disabled")
301+}
302+
303+// SetEnhancedDebugMode enables/disables debug mode for all enhanced validation components
304+func (wm *WorkspaceManager) SetEnhancedDebugMode(debug bool) {
305+ if wm.treeValidator != nil {
306+ wm.treeValidator.SetDebugMode(debug)
307+ }
308+ if wm.geometryValidator != nil {
309+ wm.geometryValidator.SetDebugMode(debug)
310+ }
311+ log.Printf("[workspace] Enhanced debug mode set to: %v", debug)
312+}
313+
314+// GetEnhancedStats returns comprehensive statistics about all enhanced validation components
315+func (wm *WorkspaceManager) GetEnhancedStats() map[string]interface{} {
316+ stats := make(map[string]interface{})
317+
318+ if wm.treeValidator != nil {
319+ stats["tree_validation"] = wm.treeValidator.GetValidationStats()
320+ }
321+
322+ if wm.treeRebalancer != nil {
323+ stats["tree_rebalancing"] = wm.treeRebalancer.GetRebalancingStats()
324+ }
325+
326+ // Geometry validator no longer tracks stats - simplified
327+
328+ return stats
329+}
330+
331+// ValidateWorkspaceIntegrity performs a comprehensive validation of the workspace
332+func (wm *WorkspaceManager) ValidateWorkspaceIntegrity() error {
333+ log.Printf("[workspace] Performing comprehensive workspace integrity check")
334+
335+ // Tree structure validation
336+ if wm.treeValidator != nil {
337+ if err := wm.treeValidator.ValidateTree(wm.root, "integrity_check"); err != nil {
338+ return fmt.Errorf("tree validation failed: %w", err)
339+ }
340+ }
341+
342+ // Geometry validation simplified - ValidateWorkspaceLayout removed
343+
344+ log.Printf("[workspace] Workspace integrity check completed successfully")
345+ return nil
346+}
347+
348+// ShutdownEnhancedComponents gracefully shuts down all enhanced validation components
349+func (wm *WorkspaceManager) ShutdownEnhancedComponents() {
350+ log.Printf("[workspace] Shutting down enhanced validation components")
351+
352+ // Other components don't require explicit shutdown currently
353+ log.Printf("[workspace] Enhanced validation components shutdown complete")
354+}
A internal/app/browser/workspace_pane_ops.go +1365 -0
@@ -0,0 +1,1365 @@
1+// workspace_pane_ops.go - Pane creation, splitting, closing and tree operations
2+package browser
3+
4+import (
5+ "errors"
6+ "log"
7+
8+ "github.com/bnema/dumber/pkg/webkit"
9+)
10+
11+// closeContext manages the context for a pane close operation
12+type closeContext struct {
13+ wm *WorkspaceManager
14+ target *paneNode
15+ remaining int
16+ err error
17+ generation uint
18+}
19+
20+// beginClose initializes a close context with validation
21+func (wm *WorkspaceManager) beginClose(node *paneNode) closeContext {
22+ ctx := closeContext{wm: wm, target: node, generation: wm.nextCleanupGeneration()}
23+ switch {
24+ case wm == nil:
25+ ctx.err = errors.New("workspace manager nil")
26+ case node == nil || !node.isLeaf:
27+ ctx.err = errors.New("invalid close target")
28+ case node.pane == nil || node.pane.webView == nil:
29+ ctx.err = errors.New("close target missing webview")
30+ default:
31+ ctx.remaining = len(wm.app.panes)
32+ }
33+ return ctx
34+}
35+
36+// Generation returns the cleanup generation for this context
37+func (ctx closeContext) Generation() uint {
38+ return ctx.generation
39+}
40+
41+// finish handles cleanup operations after close completion
42+func (ctx closeContext) finish() {
43+ if ctx.err == nil {
44+ ctx.wm.updateMainPane()
45+ }
46+}
47+
48+// collectLeaves returns all leaf nodes in the workspace tree
49+func (wm *WorkspaceManager) collectLeaves() []*paneNode {
50+ return wm.collectLeavesFrom(wm.root)
51+}
52+
53+// collectLeavesFrom collects all leaf nodes from a given subtree
54+func (wm *WorkspaceManager) collectLeavesFrom(node *paneNode) []*paneNode {
55+ var leaves []*paneNode
56+ visited := make(map[*paneNode]bool)
57+
58+ var walk func(*paneNode, int)
59+ walk = func(n *paneNode, depth int) {
60+ // Prevent infinite recursion and cycles
61+ const maxDepth = 50
62+ if n == nil || depth > maxDepth {
63+ return
64+ }
65+ if visited[n] {
66+ log.Printf("[workspace] collectLeavesFrom: cycle detected in tree")
67+ return
68+ }
69+ visited[n] = true
70+
71+ if n.isLeaf {
72+ leaves = append(leaves, n)
73+ return
74+ }
75+
76+ // Handle stacked panes - only include the currently ACTIVE pane as a leaf
77+ // This prevents multiple CSS classes and ensures correct focus management
78+ if n.isStacked && len(n.stackedPanes) > 0 {
79+ activeIndex := n.activeStackIndex
80+ if activeIndex >= 0 && activeIndex < len(n.stackedPanes) {
81+ // Only the active pane in the stack counts as a leaf for focus/navigation
82+ activePaneInStack := n.stackedPanes[activeIndex]
83+ walk(activePaneInStack, depth+1)
84+ }
85+ return
86+ }
87+
88+ // Handle regular split nodes
89+ walk(n.left, depth+1)
90+ walk(n.right, depth+1)
91+ }
92+ walk(node, 0)
93+ return leaves
94+}
95+
96+// createWebView creates a new WebView with the workspace's configuration
97+func (wm *WorkspaceManager) createWebView() (*webkit.WebView, error) {
98+ if wm == nil || wm.createWebViewFn == nil {
99+ return nil, errors.New("workspace manager missing webview factory")
100+ }
101+ return wm.createWebViewFn()
102+}
103+
104+// createPane creates a new BrowserPane for the given WebView
105+func (wm *WorkspaceManager) createPane(view *webkit.WebView) (*BrowserPane, error) {
106+ if wm == nil || wm.createPaneFn == nil {
107+ return nil, errors.New("workspace manager missing pane factory")
108+ }
109+ return wm.createPaneFn(view)
110+}
111+
112+// clonePaneState sets up a new pane with cloned state from another pane
113+func (wm *WorkspaceManager) clonePaneState(_ *paneNode, target *paneNode) {
114+ if wm == nil || target == nil {
115+ return
116+ }
117+ if target.pane == nil || target.pane.webView == nil {
118+ return
119+ }
120+
121+ const blankURL = "about:blank"
122+
123+ if err := target.pane.webView.LoadURL(blankURL); err != nil {
124+ log.Printf("[workspace] failed to prepare blank pane: %v", err)
125+ }
126+
127+ // Wait for about:blank to load before opening omnibox
128+ target.pane.webView.RegisterURIChangedHandler(func(uri string) {
129+ if uri == blankURL {
130+ // Defer omnibox opening to allow page to fully initialize
131+ wm.scheduleIdleGuarded(func() bool {
132+ if target == nil || target.pane == nil || target.pane.webView == nil {
133+ return false
134+ }
135+ if injErr := target.pane.webView.InjectScript("window.__dumber_omnibox?.open('omnibox');"); injErr != nil {
136+ log.Printf("[workspace] failed to open omnibox: %v", injErr)
137+ }
138+ return false // Remove idle callback
139+ }, target)
140+ }
141+ })
142+}
143+
144+// safelyDetachControllersBeforeReparent removes GTK controllers that GTK will auto-clean up during reparenting.
145+// It marks nodes for reattachment once the widget hierarchy settles after the split operation.
146+func (wm *WorkspaceManager) safelyDetachControllersBeforeReparent(node *paneNode) {
147+ if wm == nil || node == nil {
148+ return
149+ }
150+
151+ markForDetachment := func(target *paneNode) {
152+ if target == nil {
153+ return
154+ }
155+
156+ if target.hoverToken != 0 {
157+ target.pendingHoverReattach = true
158+ wm.detachHover(target)
159+ }
160+
161+ if wm.focusStateMachine != nil && target.focusControllerToken != 0 {
162+ token := target.focusControllerToken
163+ target.pendingFocusReattach = true
164+ wm.focusStateMachine.detachGTKController(target, token)
165+ }
166+ }
167+
168+ if node.isStacked {
169+ for _, child := range node.stackedPanes {
170+ markForDetachment(child)
171+ }
172+ return
173+ }
174+
175+ if node.isLeaf {
176+ markForDetachment(node)
177+ }
178+}
179+
180+// closeCurrentPane closes the currently focused pane
181+func (wm *WorkspaceManager) closeCurrentPane() {
182+ if wm == nil || wm.GetActiveNode() == nil {
183+ return
184+ }
185+ if err := wm.ClosePane(wm.GetActiveNode()); err != nil {
186+ log.Printf("[workspace] close current pane failed: %v", err)
187+ }
188+}
189+
190+// leftmostLeaf finds the leftmost leaf node in a subtree
191+func (wm *WorkspaceManager) leftmostLeaf(node *paneNode) *paneNode {
192+ for node != nil && !node.isLeaf {
193+ if node.isStacked {
194+ if node.activeStackIndex >= 0 && node.activeStackIndex < len(node.stackedPanes) {
195+ candidate := node.stackedPanes[node.activeStackIndex]
196+ if candidate != nil {
197+ return candidate
198+ }
199+ }
200+ if len(node.stackedPanes) > 0 {
201+ return node.stackedPanes[0]
202+ }
203+ return nil
204+ }
205+ if node.left != nil {
206+ node = node.left
207+ continue
208+ }
209+ node = node.right
210+ }
211+ return node
212+}
213+
214+// findReplacementRoot finds a suitable replacement when closing the current root pane
215+func (wm *WorkspaceManager) findReplacementRoot(excludeNode *paneNode) *paneNode {
216+ if wm == nil || wm.root == nil {
217+ return nil
218+ }
219+
220+ // If root is being closed and there are other panes, find a replacement
221+ leaves := wm.collectLeaves()
222+ for _, leaf := range leaves {
223+ if leaf != excludeNode && leaf != nil && leaf.isLeaf {
224+ // Find the topmost ancestor that's not the current root
225+ current := leaf
226+ for current.parent != nil && current.parent != wm.root {
227+ current = current.parent
228+ }
229+
230+ // If this leaf is a direct child of root, or root only has one subtree,
231+ // we can promote this subtree to be the new root
232+ if current.parent == wm.root {
233+ // If the sibling is being excluded, promote this subtree
234+ var sibling *paneNode
235+ if wm.root.left == current {
236+ sibling = wm.root.right
237+ } else {
238+ sibling = wm.root.left
239+ }
240+
241+ if sibling == excludeNode {
242+ // The sibling is being closed, so promote this subtree
243+ return current
244+ }
245+ }
246+
247+ // Otherwise, return the first suitable leaf
248+ return leaf
249+ }
250+ }
251+
252+ return nil
253+}
254+
255+// updateMainPane updates the main pane reference based on current state
256+func (wm *WorkspaceManager) updateMainPane() {
257+ if len(wm.app.panes) == 1 {
258+ if leaf := wm.viewToNode[wm.app.panes[0].webView]; leaf != nil {
259+ wm.mainPane = leaf
260+ }
261+ return
262+ }
263+
264+ if wm.mainPane == nil || !wm.mainPane.isLeaf {
265+ if wm.GetActiveNode() != nil && wm.GetActiveNode().isLeaf {
266+ wm.mainPane = wm.GetActiveNode()
267+ }
268+ }
269+}
270+
271+// Helper methods to support clean pane removal from workspace tracking
272+// These methods implement the interface expected by BrowserPane.CleanupFromWorkspace()
273+
274+// removeFromMaps removes a WebView from all workspace tracking maps
275+func (wm *WorkspaceManager) removeFromMaps(webView *webkit.WebView) {
276+ if wm == nil || webView == nil {
277+ return
278+ }
279+
280+ delete(wm.viewToNode, webView)
281+ delete(wm.lastSplitMsg, webView)
282+ delete(wm.lastExitMsg, webView)
283+ log.Printf("[workspace] removed webview %p from tracking maps", webView)
284+}
285+
286+// removeFromAppPanes removes a BrowserPane from the app.panes slice
287+func (wm *WorkspaceManager) removeFromAppPanes(pane *BrowserPane) {
288+ if wm == nil || wm.app == nil || pane == nil {
289+ return
290+ }
291+
292+ for i, p := range wm.app.panes {
293+ if p == pane {
294+ wm.app.panes = append(wm.app.panes[:i], wm.app.panes[i+1:]...)
295+ log.Printf("[workspace] removed pane from app.panes slice, remaining: %d", len(wm.app.panes))
296+ return
297+ }
298+ }
299+}
300+
301+// insertPopupPane inserts a pre-created popup pane into the workspace
302+func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPane, direction string) error {
303+ if target == nil {
304+ return errors.New("insert target cannot be nil")
305+ }
306+
307+ // Accept both leaf panes and stacked panes as valid targets
308+ if !target.isLeaf && !target.isStacked {
309+ return errors.New("insert target must be a leaf pane or stacked pane")
310+ }
311+
312+ // For leaf panes, require a pane
313+ if target.isLeaf && target.pane == nil {
314+ return errors.New("leaf target missing pane")
315+ }
316+
317+ // For stacked panes, we trust the caller - production stacks have varied structures
318+ // Do not validate internal stack structure here
319+
320+ if newPane == nil || newPane.webView == nil {
321+ return errors.New("new pane missing webview")
322+ }
323+
324+ if handler := newPane.MessageHandler(); handler != nil {
325+ handler.SetWorkspaceObserver(wm)
326+ }
327+
328+ newContainer := newPane.webView.RootWidget()
329+ if newContainer == 0 {
330+ return errors.New("new pane missing container")
331+ }
332+
333+ webkit.WidgetSetHExpand(newContainer, true)
334+ webkit.WidgetSetVExpand(newContainer, true)
335+ webkit.WidgetRealizeInContainer(newContainer)
336+
337+ // Also realize the WebView widget itself for proper popup rendering
338+ webViewWidget := newPane.webView.Widget()
339+ if webViewWidget != 0 {
340+ webkit.WidgetRealizeInContainer(webViewWidget)
341+ }
342+
343+ if target.container == 0 {
344+ return errors.New("existing pane missing container")
345+ }
346+
347+ orientation, existingFirst := mapDirection(direction)
348+ log.Printf("[workspace] inserting popup direction=%s orientation=%v existingFirst=%v target.parent=%p", direction, orientation, existingFirst, target.parent)
349+
350+ paned := webkit.NewPaned(orientation)
351+ if paned == 0 {
352+ return errors.New("failed to create GtkPaned")
353+ }
354+ webkit.WidgetSetHExpand(paned, true)
355+ webkit.WidgetSetVExpand(paned, true)
356+ // For popup panes: preserve existing pane size, let popup take remaining space
357+ webkit.PanedSetResizeStart(paned, false) // existing pane keeps its size
358+ webkit.PanedSetResizeEnd(paned, true) // popup can resize
359+ log.Printf("[workspace] configured paned for popup: start=fixed, end=flexible")
360+
361+ newLeaf := &paneNode{
362+ pane: newPane,
363+ isLeaf: true,
364+ }
365+ wm.initializePaneWidgets(newLeaf, newContainer)
366+
367+ split := &paneNode{
368+ parent: target.parent,
369+ orientation: orientation,
370+ isLeaf: false,
371+ }
372+ wm.initializePaneWidgets(split, paned)
373+
374+ parent := split.parent
375+
376+ // CRITICAL: Capture parent paned's divider position BEFORE reparenting to preserve layout
377+ var parentDividerPos int
378+ if parent != nil && parent.container != 0 && webkit.WidgetIsValid(parent.container) {
379+ parentDividerPos = webkit.PanedGetPosition(parent.container)
380+ log.Printf("[workspace] captured parent divider position: %d", parentDividerPos)
381+ }
382+
383+ // Clear CSS classes before reparenting to avoid GTK bloom filter corruption
384+ existingContainer := target.container
385+ if existingContainer != 0 && webkit.WidgetIsValid(existingContainer) {
386+ // Remove active border class if present (prevents GTK bloom filter corruption during unparent)
387+ if webkit.WidgetHasCSSClass(existingContainer, activePaneClass) {
388+ webkit.WidgetRemoveCSSClass(existingContainer, activePaneClass)
389+ }
390+
391+ // CRITICAL: Hide widget before reparenting to disconnect WebKitGTK rendering pipeline
392+ // This forces WebKit to detach its compositor, preventing rendering corruption
393+ webkit.WidgetHide(existingContainer)
394+ log.Printf("[workspace] Hidden existing container before reparenting: %#x", existingContainer)
395+ }
396+
397+ // Prevent GTK from auto-removing controllers while the widget is temporarily unparented.
398+ wm.safelyDetachControllersBeforeReparent(target)
399+
400+ // Detach existing container from its current GTK parent before inserting into new paned.
401+ if parent == nil {
402+ // Target is the root - remove it from the window
403+ log.Printf("[workspace] removing existing container=%#x from window", existingContainer)
404+ if wm.window != nil {
405+ wm.window.SetChild(0)
406+ }
407+ // GTK4 will auto-unparent when we add to new paned - no manual unparent needed
408+ } else if parent.container != 0 {
409+ // Target has a parent paned - unparent it from there
410+ log.Printf("[workspace] unparenting existing container from parent paned")
411+ if webkit.WidgetIsValid(parent.container) {
412+ log.Printf("[workspace] executing unparent: parentPtr=%#x", parent.container)
413+ if parent.left == target {
414+ webkit.PanedSetStartChild(parent.container, 0)
415+ } else if parent.right == target {
416+ webkit.PanedSetEndChild(parent.container, 0)
417+ }
418+ webkit.WidgetQueueAllocate(parent.container)
419+ }
420+ }
421+
422+ // Add both containers to the new paned
423+ if existingFirst {
424+ split.left = target
425+ split.right = newLeaf
426+ webkit.PanedSetStartChild(paned, existingContainer)
427+ webkit.PanedSetEndChild(paned, newContainer)
428+ log.Printf("[workspace] added existing=%#x as start child, new=%#x as end child", existingContainer, newContainer)
429+ } else {
430+ split.left = newLeaf
431+ split.right = target
432+ webkit.PanedSetStartChild(paned, newContainer)
433+ webkit.PanedSetEndChild(paned, existingContainer)
434+ log.Printf("[workspace] added new=%#x as start child, existing=%#x as end child", newContainer, existingContainer)
435+ }
436+
437+ target.parent = split
438+ newLeaf.parent = split
439+
440+ if parent == nil {
441+ wm.root = split
442+ if wm.window != nil {
443+ wm.window.SetChild(paned)
444+ webkit.WidgetQueueAllocate(paned)
445+ log.Printf("[workspace] paned set as window child: paned=%#x", paned)
446+ }
447+ } else {
448+ if webkit.WidgetIsValid(parent.container) {
449+ log.Printf("[workspace] executing parent reparent: parentPtr=%#x, paned=%#x", parent.container, paned)
450+ if parent.left == target {
451+ parent.left = split
452+ webkit.PanedSetStartChild(parent.container, paned)
453+ } else if parent.right == target {
454+ parent.right = split
455+ webkit.PanedSetEndChild(parent.container, paned)
456+ }
457+ webkit.WidgetQueueAllocate(parent.container)
458+ webkit.WidgetQueueAllocate(paned)
459+ log.Printf("[workspace] paned inserted into parent successfully")
460+
461+ // CRITICAL: Restore parent paned's divider position to preserve existing layout
462+ if parentDividerPos > 0 {
463+ webkit.PanedSetPosition(parent.container, parentDividerPos)
464+ log.Printf("[workspace] restored parent divider position: %d", parentDividerPos)
465+ }
466+ }
467+ }
468+
469+ webkit.WidgetShow(paned)
470+
471+ // Force GTK to re-layout the new GtkPaned container once both children are attached.
472+ wm.scheduleIdleGuarded(func() bool {
473+ if split == nil || !split.widgetValid {
474+ return false
475+ }
476+ if split.container != 0 && webkit.WidgetIsValid(split.container) {
477+ webkit.WidgetQueueResize(split.container)
478+ webkit.WidgetQueueDraw(split.container)
479+ }
480+ return false
481+ }, split)
482+
483+ // CRITICAL: Set initial 50/50 split position after showing paned
484+ // GTK needs the widget to be realized before we can set position based on allocation
485+ // Schedule this to run after GTK has allocated space to the paned
486+ wm.scheduleIdleGuarded(func() bool {
487+ if !webkit.WidgetIsValid(paned) {
488+ return false
489+ }
490+ // Get paned allocation to calculate 50% position
491+ alloc := webkit.WidgetGetAllocation(paned)
492+ var splitPos int
493+ if orientation == webkit.OrientationHorizontal {
494+ splitPos = alloc.Width / 2
495+ } else {
496+ splitPos = alloc.Height / 2
497+ }
498+ if splitPos > 0 {
499+ webkit.PanedSetPosition(paned, splitPos)
500+ log.Printf("[workspace] Set initial paned position to 50%%: %d (orientation=%d, size=%dx%d)", splitPos, orientation, alloc.Width, alloc.Height)
501+ }
502+ return false
503+ }, split)
504+
505+ // CRITICAL: Show the existing container and force GTK to recreate rendering surface after reparenting
506+ // This reconnects WebKitGTK's rendering pipeline and fixes compositor sync issues
507+ if target != nil && target.container != 0 {
508+ wm.scheduleIdleGuarded(func() bool {
509+ if target == nil || !target.widgetValid || target.container == 0 {
510+ return false
511+ }
512+ // Show widget to reconnect WebKit rendering pipeline
513+ webkit.WidgetShow(target.container)
514+ // Force GTK to recalculate size and recreate rendering surface
515+ webkit.WidgetQueueResize(target.container)
516+ webkit.WidgetQueueDraw(target.container)
517+ log.Printf("[workspace] Shown and queued resize+draw for target container after reparenting")
518+ return false
519+ }, target)
520+ }
521+
522+ wm.viewToNode[newPane.webView] = newLeaf
523+ wm.ensureHover(newLeaf)
524+ wm.ensureHover(target)
525+ wm.app.panes = append(wm.app.panes, newPane)
526+ if newPane.zoomController != nil {
527+ newPane.zoomController.ApplyInitialZoom()
528+ }
529+
530+ // Update CSS classes for all panes now that we have multiple panes
531+
532+ reattachTargets := []*paneNode{target}
533+ if target != nil && target.isStacked {
534+ reattachTargets = append(reattachTargets, target.stackedPanes...)
535+ }
536+ guardNodes := append([]*paneNode{newLeaf}, reattachTargets...)
537+ wm.scheduleIdleGuarded(func() bool {
538+ if newLeaf == nil || !newLeaf.widgetValid {
539+ return false
540+ }
541+ if newContainer != 0 && webkit.WidgetIsValid(newContainer) {
542+ webkit.WidgetShow(newContainer)
543+ }
544+ // Attach GTK focus controller to new pane
545+ if wm.focusStateMachine != nil {
546+ wm.focusStateMachine.attachGTKController(newLeaf)
547+ }
548+
549+ for _, candidate := range reattachTargets {
550+ if candidate == nil || !candidate.widgetValid {
551+ continue
552+ }
553+ if candidate.pendingHoverReattach {
554+ wm.ensureHover(candidate)
555+ if candidate.hoverToken != 0 {
556+ candidate.pendingHoverReattach = false
557+ }
558+ }
559+ if candidate.pendingFocusReattach && wm.focusStateMachine != nil {
560+ wm.focusStateMachine.attachGTKController(candidate)
561+ if candidate.focusControllerToken != 0 {
562+ candidate.pendingFocusReattach = false
563+ }
564+ }
565+ }
566+
567+ wm.SetActivePane(newLeaf, SourceSplit)
568+ return false
569+ }, guardNodes...)
570+
571+ return nil
572+}
573+
574+// splitNode splits a target pane into two panes in the specified direction
575+func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*paneNode, error) {
576+ if target == nil || !target.isLeaf || target.pane == nil {
577+ return nil, errors.New("split target must be a leaf pane")
578+ }
579+
580+ // IMPORTANT: If the target is inside a stacked pane, we need to split AROUND the stack,
581+ // not the individual pane or the stack itself. We create a new split at the stack's parent level.
582+ originalTarget := target
583+ var stackContainer *paneNode
584+ if target.parent != nil && target.parent.isStacked {
585+ // Target is in a stack - we'll create the split around the entire stack
586+ stackContainer = target.parent
587+ log.Printf("[workspace] target is in stack, will split around the stack: originalTarget=%p stackContainer=%p", originalTarget, stackContainer)
588+ }
589+
590+ // Determine what we're actually splitting
591+ var splitTarget *paneNode
592+ var splitTargetContainer uintptr
593+
594+ if stackContainer != nil {
595+ // Case: splitting from inside a stack - we split around the entire stack
596+ splitTarget = stackContainer
597+ splitTargetContainer = stackContainer.container
598+ log.Printf("[workspace] splitting around stack: stackContainer=%p", splitTarget)
599+ } else {
600+ // Case: normal split from a simple pane
601+ splitTarget = target
602+ splitTargetContainer = target.container
603+ log.Printf("[workspace] normal split from pane: target=%p", splitTarget)
604+ }
605+
606+ newView, err := wm.createWebView()
607+ if err != nil {
608+ return nil, err
609+ }
610+
611+ newPane, err := wm.createPane(newView)
612+ if err != nil {
613+ return nil, err
614+ }
615+
616+ if handler := newPane.MessageHandler(); handler != nil {
617+ handler.SetWorkspaceObserver(wm)
618+ }
619+
620+ newContainer := newPane.webView.RootWidget()
621+ if newContainer == 0 {
622+ return nil, errors.New("new pane missing container")
623+ }
624+ webkit.WidgetSetHExpand(newContainer, true)
625+ webkit.WidgetSetVExpand(newContainer, true)
626+ webkit.WidgetRealizeInContainer(newContainer)
627+
628+ // Use the determined split target container
629+ if splitTargetContainer == 0 {
630+ return nil, errors.New("split target missing container")
631+ }
632+
633+ // Only apply GTK operations if this is a simple widget (leaf pane)
634+ // For stacked containers, we need to be more careful
635+ if splitTarget.isLeaf {
636+ if webkit.WidgetIsValid(splitTargetContainer) {
637+ webkit.WidgetSetHExpand(splitTargetContainer, true)
638+ webkit.WidgetSetVExpand(splitTargetContainer, true)
639+ webkit.WidgetRealizeInContainer(splitTargetContainer)
640+ }
641+ } else if splitTarget.isStacked {
642+ // Stack containers need the same setup as regular panes for proper splitting
643+ if webkit.WidgetIsValid(splitTargetContainer) {
644+ webkit.WidgetSetHExpand(splitTargetContainer, true)
645+ webkit.WidgetSetVExpand(splitTargetContainer, true)
646+ webkit.WidgetRealizeInContainer(splitTargetContainer)
647+ log.Printf("[workspace] configured stack wrapper for split: %#x", splitTargetContainer)
648+ }
649+ }
650+
651+ orientation, existingFirst := mapDirection(direction)
652+ log.Printf("[workspace] splitting direction=%s orientation=%v existingFirst=%v splitTarget.parent=%p splitTarget.isStacked=%v",
653+ direction, orientation, existingFirst, splitTarget.parent, splitTarget.isStacked)
654+
655+ paned := webkit.NewPaned(orientation)
656+ if paned == 0 {
657+ return nil, errors.New("failed to create GtkPaned")
658+ }
659+ webkit.WidgetSetHExpand(paned, true)
660+ webkit.WidgetSetVExpand(paned, true)
661+ webkit.PanedSetResizeStart(paned, true)
662+ webkit.PanedSetResizeEnd(paned, true)
663+
664+ newLeaf := &paneNode{
665+ pane: newPane,
666+ isLeaf: true,
667+ }
668+ wm.initializePaneWidgets(newLeaf, newContainer)
669+
670+ split := &paneNode{
671+ parent: splitTarget.parent,
672+ orientation: orientation,
673+ isLeaf: false,
674+ }
675+ wm.initializePaneWidgets(split, paned)
676+
677+ parent := split.parent
678+
679+ // Clear CSS classes before reparenting to avoid GTK bloom filter corruption
680+ if splitTargetContainer != 0 && webkit.WidgetIsValid(splitTargetContainer) {
681+ // Remove active border class if present (prevents GTK bloom filter corruption during unparent)
682+ if webkit.WidgetHasCSSClass(splitTargetContainer, activePaneClass) {
683+ webkit.WidgetRemoveCSSClass(splitTargetContainer, activePaneClass)
684+ }
685+ }
686+
687+ // Prevent GTK from auto-dropping controllers while the widget is temporarily unparented.
688+ wm.safelyDetachControllersBeforeReparent(splitTarget)
689+
690+ // Detach split target container from its current GTK parent before inserting into new paned.
691+ if parent == nil {
692+ // Split target is the root - remove it from the window
693+ log.Printf("[workspace] removing split target container=%#x from window", splitTargetContainer)
694+ if wm.window != nil {
695+ wm.window.SetChild(0)
696+ }
697+ // GTK4 will auto-unparent when we add to new paned - no manual unparent needed
698+ } else if parent.container != 0 {
699+ // Split target has a parent paned - remove it (automatically unparents in GTK4)
700+ log.Printf("[workspace] unparenting split target container=%#x from parent paned=%#x", splitTargetContainer, parent.container)
701+ if webkit.WidgetIsValid(parent.container) {
702+ if parent.left == splitTarget {
703+ webkit.PanedSetStartChild(parent.container, 0)
704+ } else if parent.right == splitTarget {
705+ webkit.PanedSetEndChild(parent.container, 0)
706+ }
707+ // GTK4 automatically unparents when we set paned child to 0 - no manual unparent needed
708+ webkit.WidgetQueueAllocate(parent.container)
709+ }
710+ }
711+
712+ // Set up the tree structure first
713+ if existingFirst {
714+ split.left = splitTarget
715+ split.right = newLeaf
716+ } else {
717+ split.left = newLeaf
718+ split.right = splitTarget
719+ }
720+
721+ splitTarget.parent = split
722+ newLeaf.parent = split
723+
724+ // Update tree root/parent references
725+ if parent == nil {
726+ wm.root = split
727+ } else {
728+ if parent.left == splitTarget {
729+ parent.left = split
730+ } else if parent.right == splitTarget {
731+ parent.right = split
732+ }
733+ }
734+
735+ // GTK4 handles widget operations automatically - no need to force redraw
736+
737+ // Add both containers to the new paned
738+ if existingFirst {
739+ if webkit.WidgetIsValid(splitTargetContainer) {
740+ webkit.PanedSetStartChild(paned, splitTargetContainer)
741+ webkit.PanedSetEndChild(paned, newContainer)
742+ log.Printf("[workspace] added splitTarget=%#x as start child, new=%#x as end child", splitTargetContainer, newContainer)
743+ }
744+ } else {
745+ if webkit.WidgetIsValid(splitTargetContainer) {
746+ webkit.PanedSetStartChild(paned, newContainer)
747+ webkit.PanedSetEndChild(paned, splitTargetContainer)
748+ log.Printf("[workspace] added new=%#x as start child, splitTarget=%#x as end child", newContainer, splitTargetContainer)
749+ }
750+ }
751+
752+ // Attach the new paned to its parent
753+ if parent == nil {
754+ if wm.window != nil {
755+ wm.window.SetChild(paned)
756+ webkit.WidgetQueueAllocate(paned)
757+ log.Printf("[workspace] paned set as window child: paned=%#x", paned)
758+ }
759+ } else {
760+ if webkit.WidgetIsValid(parent.container) {
761+ if parent.left == split {
762+ webkit.PanedSetStartChild(parent.container, paned)
763+ } else if parent.right == split {
764+ webkit.PanedSetEndChild(parent.container, paned)
765+ }
766+ webkit.WidgetQueueAllocate(parent.container)
767+ webkit.WidgetQueueAllocate(paned)
768+ log.Printf("[workspace] paned inserted into parent=%#x", parent.container)
769+ }
770+ }
771+
772+ webkit.WidgetShow(paned)
773+
774+ // CRITICAL: Set initial 50/50 split position after showing paned
775+ // GTK needs the widget to be realized before we can set position based on allocation
776+ // Schedule this to run after GTK has allocated space to the paned
777+ wm.scheduleIdleGuarded(func() bool {
778+ if !webkit.WidgetIsValid(paned) {
779+ return false
780+ }
781+ // Get paned allocation to calculate 50% position
782+ alloc := webkit.WidgetGetAllocation(paned)
783+ var splitPos int
784+ if orientation == webkit.OrientationHorizontal {
785+ splitPos = alloc.Width / 2
786+ } else {
787+ splitPos = alloc.Height / 2
788+ }
789+ if splitPos > 0 {
790+ webkit.PanedSetPosition(paned, splitPos)
791+ log.Printf("[workspace] Set initial paned position to 50%%: %d (orientation=%d, size=%dx%d)", splitPos, orientation, alloc.Width, alloc.Height)
792+ }
793+ return false
794+ }, split)
795+
796+ // CRITICAL: Show the existing container and force GTK to recreate rendering surface after reparenting
797+ // This reconnects WebKitGTK's rendering pipeline and fixes compositor sync issues
798+ if target != nil && target.container != 0 {
799+ wm.scheduleIdleGuarded(func() bool {
800+ if target == nil || !target.widgetValid || target.container == 0 {
801+ return false
802+ }
803+ // Show widget to reconnect WebKit rendering pipeline
804+ webkit.WidgetShow(target.container)
805+ // Force GTK to recalculate size and recreate rendering surface
806+ webkit.WidgetQueueResize(target.container)
807+ webkit.WidgetQueueDraw(target.container)
808+ log.Printf("[workspace] Shown and queued resize+draw for target container after reparenting")
809+ return false
810+ }, target)
811+ }
812+
813+ wm.viewToNode[newPane.webView] = newLeaf
814+ wm.ensureHover(newLeaf)
815+
816+ // Don't ensure hover on originalTarget when splitting from stack - it competes with new pane focus
817+ // The originalTarget is still in the stack and should not interfere with the new split pane
818+ if originalTarget == target {
819+ // Only ensure hover on target if it's a regular (non-stack) split
820+ if !target.pendingHoverReattach {
821+ wm.ensureHover(target)
822+ }
823+ }
824+
825+ wm.app.panes = append(wm.app.panes, newPane)
826+ if newPane.zoomController != nil {
827+ newPane.zoomController.ApplyInitialZoom()
828+ }
829+
830+ // Update CSS classes for all panes now that we have multiple panes
831+
832+ reattachTargets := []*paneNode{splitTarget}
833+ if splitTarget != nil && splitTarget.isStacked {
834+ reattachTargets = append(reattachTargets, splitTarget.stackedPanes...)
835+ }
836+ guardNodes := append([]*paneNode{newLeaf}, reattachTargets...)
837+ wm.scheduleIdleGuarded(func() bool {
838+ if newLeaf == nil || !newLeaf.widgetValid {
839+ return false
840+ }
841+ if newContainer != 0 && webkit.WidgetIsValid(newContainer) {
842+ webkit.WidgetShow(newContainer)
843+ }
844+ // Attach GTK focus controller to new pane
845+ if wm.focusStateMachine != nil {
846+ wm.focusStateMachine.attachGTKController(newLeaf)
847+ }
848+
849+ for _, candidate := range reattachTargets {
850+ if candidate == nil || !candidate.widgetValid {
851+ continue
852+ }
853+ if candidate.pendingHoverReattach {
854+ wm.ensureHover(candidate)
855+ if candidate.hoverToken != 0 {
856+ candidate.pendingHoverReattach = false
857+ }
858+ }
859+ if candidate.pendingFocusReattach && wm.focusStateMachine != nil {
860+ wm.focusStateMachine.attachGTKController(candidate)
861+ if candidate.focusControllerToken != 0 {
862+ candidate.pendingFocusReattach = false
863+ }
864+ }
865+ }
866+ wm.SetActivePane(newLeaf, SourceSplit)
867+ return false
868+ }, guardNodes...)
869+
870+ return newLeaf, nil
871+}
872+
873+// Helper functions for the simplified closePane implementation
874+
875+// getSibling returns the sibling node for a given node
876+func (wm *WorkspaceManager) getSibling(node *paneNode) *paneNode {
877+ if node.parent == nil {
878+ return nil
879+ }
880+ if node.parent.left == node {
881+ return node.parent.right
882+ }
883+ return node.parent.left
884+}
885+
886+// nextCleanupGeneration returns the next cleanup generation counter
887+func (wm *WorkspaceManager) nextCleanupGeneration() uint {
888+ wm.cleanupCounter++
889+ return wm.cleanupCounter
890+}
891+
892+// promoteSibling promotes a sibling node to replace its parent in the tree
893+func (wm *WorkspaceManager) promoteSibling(grand *paneNode, parent *paneNode, sibling *paneNode) {
894+ if grand == nil {
895+ wm.root = sibling
896+ sibling.parent = nil
897+ return
898+ }
899+ sibling.parent = grand
900+ if grand.left == parent {
901+ grand.left = sibling
902+ } else {
903+ grand.right = sibling
904+ }
905+}
906+
907+// swapContainers updates GTK widget hierarchy for promoted siblings
908+func (wm *WorkspaceManager) swapContainers(grand *paneNode, sibling *paneNode) {
909+ if grand == nil {
910+ // When TreeRebalancer is enabled, skip GTK attachment here as it will be
911+ // handled by the rebalancer's promotion transaction to avoid double-attachment
912+ if wm.treeRebalancer == nil || !wm.treeRebalancer.enabled {
913+ wm.attachRoot(sibling)
914+ }
915+ return
916+ }
917+ // Clear CSS classes before reparenting to avoid GTK bloom filter corruption
918+ if sibling.container != 0 && webkit.WidgetIsValid(sibling.container) {
919+ if webkit.WidgetHasCSSClass(sibling.container, activePaneClass) {
920+ webkit.WidgetRemoveCSSClass(sibling.container, activePaneClass)
921+ }
922+ }
923+
924+ if webkit.WidgetIsValid(grand.container) && webkit.WidgetIsValid(sibling.container) {
925+ // GTK4 PanedSetStartChild/EndChild auto-unparent from current parent
926+ // Focus was already cleared before unparenting closed pane
927+ if parent := webkit.WidgetGetParent(sibling.container); parent != 0 && parent != grand.container {
928+ log.Printf("[workspace] widget %#x reparenting from %#x to %#x (GTK4 will auto-unparent)", sibling.container, parent, grand.container)
929+ }
930+
931+ if grand.left == sibling {
932+ webkit.PanedSetStartChild(grand.container, sibling.container)
933+ log.Printf("[workspace] swapContainers: set widget %#x as start child of paned %#x", sibling.container, grand.container)
934+ } else {
935+ webkit.PanedSetEndChild(grand.container, sibling.container)
936+ log.Printf("[workspace] swapContainers: set widget %#x as end child of paned %#x", sibling.container, grand.container)
937+ }
938+ webkit.WidgetQueueAllocate(grand.container)
939+ }
940+}
941+
942+// cascadePromotion handles the case where a paned node has only one child
943+// This happens when closing a pane leaves the grandparent with only one child
944+func (wm *WorkspaceManager) cascadePromotion(singleChildPaned *paneNode) {
945+ if singleChildPaned == nil {
946+ return
947+ }
948+
949+ // Find the single child
950+ var onlyChild *paneNode
951+ if singleChildPaned.left != nil && singleChildPaned.right == nil {
952+ onlyChild = singleChildPaned.left
953+ } else if singleChildPaned.right != nil && singleChildPaned.left == nil {
954+ onlyChild = singleChildPaned.right
955+ } else {
956+ // Either no children or two children - nothing to cascade
957+ return
958+ }
959+
960+ log.Printf("[workspace] Cascading promotion: replacing paned %p with its only child %p", singleChildPaned, onlyChild)
961+
962+ greatGrandparent := singleChildPaned.parent
963+
964+ // Update tree structure
965+ onlyChild.parent = greatGrandparent
966+ if greatGrandparent == nil {
967+ // Single child becomes new root
968+ wm.root = onlyChild
969+ log.Printf("[workspace] Only child %p promoted to root", onlyChild)
970+
971+ // Attach to window
972+ if onlyChild.container != 0 && webkit.WidgetIsValid(onlyChild.container) {
973+ wm.attachRoot(onlyChild)
974+ }
975+
976+ // Cleanup the now-orphaned paned
977+ wm.decommissionParent(singleChildPaned, wm.cleanupCounter)
978+ } else {
979+ // Replace paned in great-grandparent
980+ if greatGrandparent.left == singleChildPaned {
981+ greatGrandparent.left = onlyChild
982+ } else {
983+ greatGrandparent.right = onlyChild
984+ }
985+
986+ log.Printf("[workspace] Only child %p attached to great-grandparent %p", onlyChild, greatGrandparent)
987+
988+ // Reparent widget in GTK
989+ if onlyChild.container != 0 && webkit.WidgetIsValid(onlyChild.container) && greatGrandparent.container != 0 && webkit.WidgetIsValid(greatGrandparent.container) {
990+ // GTK4 PanedSet*Child automatically handles unparenting
991+ if greatGrandparent.left == onlyChild {
992+ webkit.PanedSetStartChild(greatGrandparent.container, onlyChild.container)
993+ } else {
994+ webkit.PanedSetEndChild(greatGrandparent.container, onlyChild.container)
995+ }
996+
997+ webkit.WidgetSetHExpand(onlyChild.container, true)
998+ webkit.WidgetSetVExpand(onlyChild.container, true)
999+ webkit.WidgetQueueAllocate(onlyChild.container)
1000+ webkit.WidgetQueueAllocate(greatGrandparent.container)
1001+ wm.scheduleIdleGuarded(func() bool {
1002+ if onlyChild == nil || !onlyChild.widgetValid {
1003+ return false
1004+ }
1005+ if onlyChild.container != 0 && webkit.WidgetIsValid(onlyChild.container) {
1006+ webkit.WidgetShow(onlyChild.container)
1007+ webkit.WidgetQueueResize(onlyChild.container)
1008+ webkit.WidgetQueueDraw(onlyChild.container)
1009+ }
1010+ return false
1011+ }, onlyChild)
1012+ }
1013+
1014+ // Cleanup the now-orphaned paned
1015+ wm.decommissionParent(singleChildPaned, wm.cleanupCounter)
1016+
1017+ // Check if great-grandparent now also has only one child (recursive case)
1018+ if greatGrandparent != nil && ((greatGrandparent.left == nil) != (greatGrandparent.right == nil)) {
1019+ log.Printf("[workspace] Great-grandparent %p also has only one child, continuing cascade", greatGrandparent)
1020+ wm.cascadePromotion(greatGrandparent)
1021+ }
1022+ }
1023+}
1024+
1025+// attachRoot attaches a node as the new window root
1026+func (wm *WorkspaceManager) attachRoot(root *paneNode) {
1027+ if root == nil || root.container == 0 || wm.window == nil {
1028+ return
1029+ }
1030+ if !webkit.WidgetIsValid(root.container) {
1031+ return
1032+ }
1033+
1034+ // Hold a transient reference so GTK does not destroy the widget while detached
1035+ releaseRef := webkit.WidgetRef(root.container)
1036+ if !releaseRef {
1037+ log.Printf("[workspace] WARNING: failed to acquire temporary ref for widget %#x; aborting attachRoot", root.container)
1038+ return
1039+ }
1040+ defer func() {
1041+ if webkit.WidgetIsValid(root.container) {
1042+ webkit.WidgetUnref(root.container)
1043+ }
1044+ }()
1045+
1046+ // Detach from previous container (paned, stack, etc.) before replacing the window child.
1047+ if parent := webkit.WidgetGetParent(root.container); parent != 0 {
1048+ log.Printf("[workspace] unparenting widget %#x from parent %#x before window attach", root.container, parent)
1049+ webkit.WidgetUnparent(root.container)
1050+ if !webkit.WidgetIsValid(root.container) {
1051+ log.Printf("[workspace] WARNING: widget %#x became invalid during unparent; aborting attachRoot", root.container)
1052+ return
1053+ }
1054+ }
1055+
1056+ // Clear any existing window child to avoid GTK warnings when swapping roots.
1057+ wm.window.SetChild(0)
1058+
1059+ if webkit.WidgetIsValid(root.container) {
1060+ webkit.WidgetSetHExpand(root.container, true)
1061+ webkit.WidgetSetVExpand(root.container, true)
1062+ }
1063+
1064+ if !webkit.WidgetIsValid(root.container) {
1065+ log.Printf("[workspace] WARNING: widget %#x invalid prior to window attach", root.container)
1066+ return
1067+ }
1068+
1069+ wm.window.SetChild(root.container)
1070+ if webkit.WidgetIsValid(root.container) {
1071+ webkit.WidgetQueueAllocate(root.container)
1072+ webkit.WidgetShow(root.container)
1073+ webkit.WidgetQueueResize(root.container)
1074+ webkit.WidgetQueueDraw(root.container)
1075+ }
1076+
1077+ wm.scheduleIdleGuarded(func() bool {
1078+ if root == nil || !root.widgetValid {
1079+ return false
1080+ }
1081+ if root.container != 0 && webkit.WidgetIsValid(root.container) {
1082+ webkit.WidgetQueueResize(root.container)
1083+ webkit.WidgetQueueDraw(root.container)
1084+ }
1085+ return false
1086+ }, root)
1087+
1088+ // Verify attachment succeeded
1089+ if finalParent := webkit.WidgetGetParent(root.container); finalParent == 0 {
1090+ log.Printf("[workspace] WARNING: widget %#x has no parent after SetChild", root.container)
1091+ } else {
1092+ log.Printf("[workspace] attachRoot successful: widget %#x now child of %#x", root.container, finalParent)
1093+ }
1094+}
1095+
1096+// cleanupPane safely cleans up a pane node with generation tracking
1097+func (wm *WorkspaceManager) cleanupPane(node *paneNode, generation uint) {
1098+ if node == nil {
1099+ return
1100+ }
1101+ if !node.widgetValid {
1102+ return
1103+ }
1104+
1105+ if wm.focusStateMachine != nil {
1106+ wm.focusStateMachine.InvalidateActivePane(node)
1107+ }
1108+
1109+ wm.cancelIdleHandles(node)
1110+
1111+ node.widgetValid = false
1112+ node.cleanupGeneration = generation
1113+
1114+ if node.pane != nil {
1115+ pane := node.pane
1116+ pane.Cleanup()
1117+ pane.CleanupFromWorkspace(wm)
1118+ if pane.webView != nil {
1119+ log.Printf("[workspace] releasing WebView reference: %p", pane.webView)
1120+ pane.webView = nil
1121+ }
1122+ node.pane = nil
1123+ }
1124+
1125+ if node.container != 0 {
1126+ // Container widget will be destroyed by GTK when unparented
1127+ node.container = 0
1128+ }
1129+
1130+ node.pendingIdleHandles = nil
1131+ node.parent = nil
1132+ node.left = nil
1133+ node.right = nil
1134+}
1135+
1136+// decommissionParent cleans up a parent node after promotion
1137+func (wm *WorkspaceManager) decommissionParent(parent *paneNode, generation uint) {
1138+ if parent == nil {
1139+ return
1140+ }
1141+
1142+ // For branch nodes (paneds), destroy the widget
1143+ // Children have already been unparented during promotion/close, no need to clear them
1144+ if !parent.isLeaf && parent.container != 0 && webkit.WidgetIsValid(parent.container) {
1145+ webkit.WidgetHookDestroy(parent.container)
1146+ log.Printf("[workspace] destroyed paned widget: %#x", parent.container)
1147+ }
1148+
1149+ wm.cleanupPane(parent, generation)
1150+}
1151+
1152+// promoteNewRoot handles root replacement when closing the root pane
1153+func (wm *WorkspaceManager) promoteNewRoot(ctx closeContext, oldRoot *paneNode) (*paneNode, error) {
1154+ candidate := wm.findReplacementRoot(oldRoot)
1155+ if candidate == nil {
1156+ return wm.cleanupAndExit(oldRoot)
1157+ }
1158+
1159+ sibling := wm.getSibling(candidate)
1160+ if sibling != nil {
1161+ wm.promoteSibling(candidate.parent.parent, candidate.parent, sibling)
1162+ }
1163+
1164+ candidate.parent = nil
1165+ wm.root = candidate
1166+
1167+ wm.attachRoot(candidate)
1168+
1169+ wm.cleanupPane(oldRoot, ctx.Generation())
1170+ return candidate, nil
1171+}
1172+
1173+// cleanupAndExit handles the final pane cleanup and application exit
1174+func (wm *WorkspaceManager) cleanupAndExit(node *paneNode) (*paneNode, error) {
1175+ log.Printf("[workspace] closing final pane; exiting browser")
1176+ wm.cleanupPane(node, wm.nextCleanupGeneration())
1177+ wm.detachHover(node)
1178+ webkit.QuitMainLoop()
1179+ return nil, nil
1180+}
1181+
1182+// setFocusToLeaf sets focus to the leftmost leaf of a node
1183+func (wm *WorkspaceManager) setFocusToLeaf(node *paneNode) {
1184+ focusTarget := wm.leftmostLeaf(node)
1185+ if focusTarget != nil {
1186+ wm.SetActivePane(focusTarget, SourceClose)
1187+ }
1188+}
1189+
1190+// closePane closes a specific pane and handles tree restructuring (simplified implementation)
1191+func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) {
1192+ ctx := wm.beginClose(node)
1193+ defer ctx.finish()
1194+
1195+ // STEP 1: Basic validation (quick fail)
1196+ if ctx.err != nil {
1197+ return nil, ctx.err
1198+ }
1199+
1200+ // STEP 2: Handle stacked panes via compatibility shim
1201+ if node.parent != nil && node.parent.isStacked {
1202+ return wm.closeStackedPaneCompat(node)
1203+ }
1204+
1205+ // Ensure we detach hover/focus controllers before the widget hierarchy changes.
1206+ wm.detachHover(node)
1207+ wm.detachFocus(node)
1208+
1209+ // STEP 3: Handle trivial exit cases
1210+ if ctx.remaining == 1 {
1211+ return wm.cleanupAndExit(node)
1212+ }
1213+ if node == wm.root {
1214+ return wm.promoteNewRoot(ctx, node)
1215+ }
1216+
1217+ // STEP 4: Unparent closed pane FIRST to avoid leaving paned with single child
1218+ parent := node.parent
1219+ sibling := wm.getSibling(node)
1220+ grandparent := parent.parent
1221+
1222+ // Clear grandparent's focus child BEFORE unparenting closed pane
1223+ // GTK propagates focus events upward, so we must clear grandparent focus first
1224+ if grandparent != nil && grandparent.container != 0 && webkit.WidgetIsValid(grandparent.container) {
1225+ webkit.WidgetSetFocusChild(grandparent.container, 0)
1226+ }
1227+
1228+ // Unparent the closed pane's container from the paned BEFORE promoting sibling
1229+ if node.container != 0 && webkit.WidgetIsValid(node.container) {
1230+ if containerParent := webkit.WidgetGetParent(node.container); containerParent == parent.container {
1231+ if parent.left == node {
1232+ webkit.PanedSetStartChild(parent.container, 0)
1233+ } else if parent.right == node {
1234+ webkit.PanedSetEndChild(parent.container, 0)
1235+ }
1236+ log.Printf("[workspace] unparented closed pane container: %#x", node.container)
1237+ }
1238+ }
1239+
1240+ // STEP 5: Promote sibling in-place (tree structure)
1241+ wm.promoteSibling(grandparent, parent, sibling)
1242+
1243+ // STEP 6: GTK handles all reparenting automatically
1244+ // PanedSetStartChild/EndChild and window.SetChild auto-unparent widgets
1245+ wm.swapContainers(grandparent, sibling)
1246+
1247+ // Reconnect the promoted sibling's rendering pipeline after GTK reparenting.
1248+ if sibling != nil && sibling.container != 0 {
1249+ wm.scheduleIdleGuarded(func() bool {
1250+ if sibling == nil || !sibling.widgetValid {
1251+ return false
1252+ }
1253+ if sibling.container != 0 && webkit.WidgetIsValid(sibling.container) {
1254+ webkit.WidgetShow(sibling.container)
1255+ webkit.WidgetQueueResize(sibling.container)
1256+ webkit.WidgetQueueDraw(sibling.container)
1257+ }
1258+ return false
1259+ }, sibling)
1260+ }
1261+
1262+ // STEP 7: Check if grandparent now has only one child (cascade promotion needed)
1263+ if grandparent != nil && ((grandparent.left == nil) != (grandparent.right == nil)) {
1264+ // Grandparent has exactly one child - it should be promoted too
1265+ log.Printf("[workspace] Grandparent %p now has only one child, cascading promotion", grandparent)
1266+ wm.cascadePromotion(grandparent)
1267+ }
1268+
1269+ // STEP 8: Cleanup & focus
1270+ wm.cleanupPane(node, ctx.Generation())
1271+ wm.decommissionParent(parent, ctx.Generation())
1272+ if sibling != nil {
1273+ wm.scheduleIdleGuarded(func() bool {
1274+ if sibling == nil || !sibling.widgetValid {
1275+ return false
1276+ }
1277+ wm.setFocusToLeaf(sibling)
1278+ return false
1279+ }, sibling)
1280+ }
1281+
1282+ return sibling, nil
1283+}
1284+
1285+// closeStackedPaneCompat provides compatibility layer for closing stacked panes
1286+func (wm *WorkspaceManager) closeStackedPaneCompat(node *paneNode) (*paneNode, error) {
1287+ stack := node.parent
1288+
1289+ // Find index
1290+ index := -1
1291+ for i, pane := range stack.stackedPanes {
1292+ if pane == node {
1293+ index = i
1294+ break
1295+ }
1296+ }
1297+
1298+ if index == -1 {
1299+ return nil, errors.New("pane not in stack")
1300+ }
1301+
1302+ // Remove from array
1303+ stack.stackedPanes = append(
1304+ stack.stackedPanes[:index],
1305+ stack.stackedPanes[index+1:]...,
1306+ )
1307+
1308+ // If only one pane left, unstack it
1309+ if len(stack.stackedPanes) == 1 {
1310+ remaining := stack.stackedPanes[0]
1311+
1312+ // Replace stack with remaining pane in tree
1313+ remaining.parent = stack.parent
1314+ if stack.parent != nil {
1315+ if stack.parent.left == stack {
1316+ stack.parent.left = remaining
1317+ } else {
1318+ stack.parent.right = remaining
1319+ }
1320+
1321+ // Update GTK widgets
1322+ if webkit.WidgetIsValid(stack.parent.container) && webkit.WidgetIsValid(remaining.container) {
1323+ if stack.parent.left == remaining {
1324+ webkit.PanedSetStartChild(stack.parent.container, remaining.container)
1325+ } else {
1326+ webkit.PanedSetEndChild(stack.parent.container, remaining.container)
1327+ }
1328+ }
1329+ } else {
1330+ // Stack was root
1331+ wm.root = remaining
1332+ if webkit.WidgetIsValid(remaining.container) {
1333+ wm.window.SetChild(remaining.container)
1334+ }
1335+ }
1336+
1337+ // Cleanup stack container
1338+ wm.cleanupPane(stack, wm.nextCleanupGeneration())
1339+
1340+ return remaining, nil
1341+ }
1342+
1343+ // Update active index if needed
1344+ if stack.activeStackIndex >= len(stack.stackedPanes) {
1345+ stack.activeStackIndex = len(stack.stackedPanes) - 1
1346+ }
1347+
1348+ // Remove widget from GTK box
1349+ if webkit.WidgetIsValid(stack.container) && webkit.WidgetIsValid(node.container) {
1350+ webkit.BoxRemove(stack.container, node.container)
1351+ }
1352+
1353+ // Show new active pane
1354+ if stack.activeStackIndex >= 0 {
1355+ active := stack.stackedPanes[stack.activeStackIndex]
1356+ if webkit.WidgetIsValid(active.container) {
1357+ webkit.WidgetShow(active.container)
1358+ }
1359+ }
1360+
1361+ // Cleanup closed pane
1362+ wm.cleanupPane(node, wm.nextCleanupGeneration())
1363+
1364+ return stack, nil
1365+}
A internal/app/browser/workspace_popup.go +821 -0
@@ -0,0 +1,821 @@
1+// workspace_popup.go - Popup window handling and related pane management
2+package browser
3+
4+import (
5+ "fmt"
6+ "log"
7+ "strings"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/app/messaging"
11+ "github.com/bnema/dumber/pkg/webkit"
12+)
13+
14+const (
15+ // Default popup window dimensions - sized for OAuth flows
16+ defaultPopupMinWidth = 500 // Minimum width for OAuth popups
17+ defaultPopupMinHeight = 600 // Minimum height for OAuth popups
18+)
19+
20+// HandlePopup handles popup window creation requests from WebViews
21+func (wm *WorkspaceManager) HandlePopup(source *webkit.WebView, url string) *webkit.WebView {
22+ log.Printf("[workspace] HandlePopup called for URL: %s", url)
23+
24+ // Check for frame type markers added by WebKit layer
25+ isBlankTarget := strings.HasSuffix(url, "#__dumber_frame_blank")
26+ isPopupTarget := strings.HasSuffix(url, "#__dumber_frame_popup")
27+
28+ // Clean the URL by removing our markers
29+ if isBlankTarget || isPopupTarget {
30+ if isBlankTarget {
31+ url = strings.TrimSuffix(url, "#__dumber_frame_blank")
32+ log.Printf("[workspace] Detected _blank target - will create regular pane for: %s", url)
33+ } else {
34+ url = strings.TrimSuffix(url, "#__dumber_frame_popup")
35+ log.Printf("[workspace] Detected popup target - will create popup pane for: %s", url)
36+ }
37+ }
38+
39+ if wm == nil || source == nil {
40+ log.Printf("[workspace] HandlePopup: nil workspace manager or source - allowing native popup")
41+ return nil
42+ }
43+
44+ node := wm.viewToNode[source]
45+ if node == nil {
46+ log.Printf("[workspace] popup from unknown webview - allowing native popup")
47+ return nil
48+ }
49+
50+ // Note: HandlePopup is now obsolete - window.open is handled directly via JavaScript bypass
51+
52+ cfg := wm.app.config
53+ if cfg == nil {
54+ log.Printf("[workspace] HandlePopup: nil config - allowing native popup")
55+ return nil
56+ }
57+
58+ popCfg := cfg.Workspace.Popups
59+ log.Printf("[workspace] Popup config - OpenInNewPane: %v, Placement: %s", popCfg.OpenInNewPane, popCfg.Placement)
60+
61+ if !popCfg.OpenInNewPane {
62+ log.Printf("[workspace] Popup creation disabled in config - allowing native popup")
63+ return nil
64+ }
65+
66+ // Smart detection path: create temporary view and decide placement once type is known
67+ if popCfg.EnableSmartDetection {
68+ webkitCfg, err := wm.app.buildWebkitConfig()
69+ if err != nil {
70+ log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
71+ return nil
72+ }
73+ webkitCfg.CreateWindow = false
74+ // Create as related to avoid WindowFeatures crash; we'll decide final placement later
75+ newView, err := webkit.NewWebViewWithRelated(webkitCfg, source)
76+ if err != nil {
77+ log.Printf("[workspace] failed to create temp WebView: %v - allowing native popup", err)
78+ return nil
79+ }
80+
81+ // Register detection callback
82+ newView.OnWindowTypeDetected(func(t webkit.WindowType, feat *webkit.WindowFeatures) {
83+ wm.RunOnUI(func() {
84+ wm.handleDetectedWindowType(node, newView, url, t, feat)
85+ })
86+ })
87+
88+ // Fallback: if detection never fires, treat as popup as before
89+ go func() {
90+ time.Sleep(1500 * time.Millisecond)
91+ if newView != nil {
92+ wm.RunOnUI(func() {
93+ if wm.viewToNode[newView] == nil { // not yet placed
94+ wm.handleDetectedWindowType(node, newView, url, webkit.WindowTypePopup, nil)
95+ }
96+ })
97+ }
98+ }()
99+
100+ return newView
101+ }
102+
103+ // Legacy path preserved
104+ webkitCfg, err := wm.app.buildWebkitConfig()
105+ if err != nil {
106+ log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
107+ return nil
108+ }
109+ webkitCfg.CreateWindow = false
110+ newView, err := webkit.NewWebViewWithRelated(webkitCfg, source)
111+ if err != nil {
112+ log.Printf("[workspace] failed to create placeholder WebView: %v - allowing native popup", err)
113+ return nil
114+ }
115+
116+ // Workspace navigation shortcuts are now handled globally by WindowShortcutHandler
117+
118+ // Create a pane for the new WebView
119+ newPane, err := wm.createPane(newView)
120+ if err != nil {
121+ log.Printf("[workspace] failed to create popup pane: %v - allowing native popup", err)
122+ return nil
123+ }
124+
125+ // Determine placement direction
126+ direction := strings.ToLower(popCfg.Placement)
127+ if direction == "" {
128+ direction = "right"
129+ }
130+
131+ // Determine target node for splitting
132+ target := node
133+ if !popCfg.FollowPaneContext && wm.GetActiveNode() != nil {
134+ target = wm.GetActiveNode()
135+ }
136+
137+ // Add the popup pane to the workspace using manual pane insertion
138+ if err := wm.insertPopupPane(target, newPane, direction); err != nil {
139+ log.Printf("[workspace] popup pane insertion failed: %v - allowing native popup", err)
140+ return nil
141+ }
142+
143+ // Get the new node and popup WebView ID for tracking
144+ newNode := wm.viewToNode[newView]
145+ popupWebViewID := newView.ID()
146+
147+ // Apply different behavior based on target type
148+ if isBlankTarget {
149+ log.Printf("[workspace] Treating _blank target as regular pane - no auto-close behavior")
150+ // For _blank targets, set up parent relationship but no auto-close
151+ if newNode != nil {
152+ newNode.parentPane = node
153+ }
154+
155+ // Register close handler to clean up activePopupChildren tracking
156+ newView.RegisterCloseHandler(func() {
157+ log.Printf("[workspace] _blank popup requesting close via window.close()")
158+ // Remove this popup from parent's activePopupChildren
159+ if node != nil && popupWebViewID != "" {
160+ for i, childID := range node.activePopupChildren {
161+ if childID == popupWebViewID {
162+ node.activePopupChildren = append(node.activePopupChildren[:i], node.activePopupChildren[i+1:]...)
163+ log.Printf("[workspace] Removed _blank popup %s from parent's activePopupChildren (remaining: %d)", popupWebViewID, len(node.activePopupChildren))
164+ break
165+ }
166+ }
167+ }
168+ })
169+ } else {
170+ log.Printf("[workspace] Treating as popup pane - applying popup-specific behavior")
171+ // Mark as popup for auto-close handling (OAuth flows, etc.)
172+ if newNode != nil {
173+ newNode.isPopup = true
174+ newNode.parentPane = node
175+ log.Printf("[workspace] Marked pane as popup for auto-close handling")
176+
177+ // Register close handler for popup auto-close on window.close()
178+ newView.RegisterCloseHandler(func() {
179+ log.Printf("[workspace] Popup requesting close via window.close()")
180+
181+ // Remove this popup from parent's activePopupChildren
182+ if node != nil && popupWebViewID != "" {
183+ for i, childID := range node.activePopupChildren {
184+ if childID == popupWebViewID {
185+ node.activePopupChildren = append(node.activePopupChildren[:i], node.activePopupChildren[i+1:]...)
186+ log.Printf("[workspace] Removed popup %s from parent's activePopupChildren (remaining: %d)", popupWebViewID, len(node.activePopupChildren))
187+ break
188+ }
189+ }
190+ }
191+
192+ // Look up the node at close time
193+ if node := wm.viewToNode[newView]; node != nil && node.isPopup {
194+ log.Printf("[workspace] Closing popup pane")
195+ // Brief delay to allow any final redirects to complete
196+ time.AfterFunc(200*time.Millisecond, func() {
197+ wm.scheduleIdleGuarded(func() bool {
198+ if node == nil || !node.widgetValid {
199+ return false
200+ }
201+ if err := wm.ClosePane(node); err != nil {
202+ log.Printf("[workspace] Failed to close popup pane: %v", err)
203+ }
204+ return false
205+ }, node)
206+ })
207+ } else {
208+ log.Printf("[workspace] Could not find popup node for close handler")
209+ }
210+ })
211+ } else {
212+ log.Printf("[workspace] Warning: could not find node for popup WebView in viewToNode map")
213+ }
214+ }
215+
216+ // Track this popup in the parent's activePopupChildren (for both _blank and popup types)
217+ // This is critical to prevent OAuth flows from hijacking the parent pane via window.opener
218+ if node != nil && popupWebViewID != "" {
219+ if node.activePopupChildren == nil {
220+ node.activePopupChildren = make([]string, 0)
221+ }
222+ node.activePopupChildren = append(node.activePopupChildren, popupWebViewID)
223+ log.Printf("[workspace] Added popup %s to parent's activePopupChildren (count: %d)", popupWebViewID, len(node.activePopupChildren))
224+
225+ // Register navigation policy handler for parent to block script-initiated navigations
226+ // when it has active popup children (prevents OAuth from hijacking parent pane)
227+ if node.pane != nil && node.pane.WebView() != nil {
228+ node.pane.WebView().RegisterNavigationPolicyHandler(func(url string, isUserGesture bool) bool {
229+ // Always allow user-initiated navigations
230+ if isUserGesture {
231+ return true
232+ }
233+
234+ // Block script-initiated navigations if this pane has active popup children
235+ if len(node.activePopupChildren) > 0 {
236+ log.Printf("[workspace] BLOCKED script-initiated navigation in parent pane (has %d active popups): %s", len(node.activePopupChildren), url)
237+ return false
238+ }
239+
240+ // Allow if no active popups
241+ return true
242+ })
243+ log.Printf("[workspace] Registered navigation policy handler for parent pane")
244+ }
245+ }
246+
247+ // Ensure GUI components are available in the new pane
248+ wm.ensureGUIInPane(newPane)
249+
250+ // Inject GUI components into the popup pane
251+ wm.ensureGUIInPane(newPane)
252+
253+ // Load the URL if provided
254+ if url != "" {
255+ paneType := "popup"
256+ if isBlankTarget {
257+ paneType = "_blank target"
258+ }
259+ log.Printf("[webkit] LoadURL (%s): %s", paneType, url)
260+ if err := newView.LoadURL(url); err != nil {
261+ log.Printf("[workspace] failed to load %s URL: %v", paneType, err)
262+ }
263+ // Ensure the WebView is visible after loading URL
264+ if err := newView.Show(); err != nil {
265+ log.Printf("[workspace] failed to show popup WebView: %v", err)
266+ }
267+ }
268+
269+ if isBlankTarget {
270+ log.Printf("[workspace] Created regular pane for _blank target URL: %s", url)
271+ } else {
272+ log.Printf("[workspace] Created popup pane for URL: %s", url)
273+ }
274+ return newView
275+}
276+
277+// registerOAuthAutoClose sets up OAuth auto-close functionality for popups
278+// Note: OAuth detection is now handled by the main-world.js injection script
279+func (wm *WorkspaceManager) registerOAuthAutoClose(view *webkit.WebView, url string) {
280+ log.Printf("[workspace] OAuth auto-close enabled for popup with URL: %s", url)
281+ log.Printf("[workspace] OAuth detection will be handled by main-world.js injection script")
282+}
283+
284+// applyWindowFeatures applies window features to a WebView based on intent
285+func (wm *WorkspaceManager) applyWindowFeatures(view *webkit.WebView, intent *messaging.WindowIntent, isPopup bool) {
286+ if intent == nil {
287+ return
288+ }
289+
290+ features := &webkit.WindowFeatures{}
291+
292+ // Apply dimensions if specified
293+ if intent.Width != nil {
294+ features.Width = *intent.Width
295+ }
296+ if intent.Height != nil {
297+ features.Height = *intent.Height
298+ }
299+
300+ // Apply visibility features based on window type
301+ defaultToolbar := !isPopup
302+ defaultLocation := !isPopup
303+ defaultMenubar := !isPopup
304+
305+ if intent.Toolbar != nil {
306+ features.ToolbarVisible = *intent.Toolbar
307+ } else {
308+ features.ToolbarVisible = defaultToolbar
309+ }
310+
311+ if intent.Location != nil {
312+ features.LocationbarVisible = *intent.Location
313+ } else {
314+ features.LocationbarVisible = defaultLocation
315+ }
316+
317+ if intent.Menubar != nil {
318+ features.MenubarVisible = *intent.Menubar
319+ } else {
320+ features.MenubarVisible = defaultMenubar
321+ }
322+
323+ if intent.Resizable != nil {
324+ features.Resizable = *intent.Resizable
325+ } else {
326+ features.Resizable = true // Usually resizable unless explicitly disabled
327+ }
328+
329+ view.SetWindowFeatures(features)
330+ windowTypeStr := "tab"
331+ if isPopup {
332+ windowTypeStr = "popup"
333+ }
334+ log.Printf("[workspace] Applied %s window features from intent: size=%dx%d, toolbar=%t, location=%t, menubar=%t, resizable=%t",
335+ windowTypeStr, features.Width, features.Height, features.ToolbarVisible, features.LocationbarVisible, features.MenubarVisible, features.Resizable)
336+}
337+
338+// handleIntentAsTab creates a tab pane based on window.open intent
339+func (wm *WorkspaceManager) handleIntentAsTab(sourceNode *paneNode, url string, intent *messaging.WindowIntent) *webkit.WebView {
340+ log.Printf("[workspace] Handling intent as tab: %s", url)
341+
342+ webkitCfg, err := wm.app.buildWebkitConfig()
343+ if err != nil {
344+ log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
345+ return nil
346+ }
347+ webkitCfg.CreateWindow = false
348+
349+ newView, err := webkit.NewWebView(webkitCfg)
350+ if err != nil {
351+ log.Printf("[workspace] failed to create tab WebView: %v - allowing native popup", err)
352+ return nil
353+ }
354+
355+ newPane, err := wm.createPane(newView)
356+ if err != nil {
357+ log.Printf("[workspace] failed to create tab pane: %v - allowing native popup", err)
358+ return nil
359+ }
360+
361+ direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
362+ if direction == "" {
363+ direction = "right"
364+ }
365+
366+ if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil {
367+ log.Printf("[workspace] tab pane insertion failed: %v - allowing native popup", err)
368+ return nil
369+ }
370+
371+ node := wm.viewToNode[newView]
372+ if node != nil {
373+ node.windowType = webkit.WindowTypeTab
374+ node.isRelated = false
375+
376+ // Apply window features from JavaScript intent
377+ wm.applyWindowFeatures(newView, intent, false)
378+ }
379+
380+ wm.ensureGUIInPane(newPane)
381+
382+ if url != "" {
383+ if err := newView.LoadURL(url); err != nil {
384+ log.Printf("[workspace] failed to load tab URL: %v", err)
385+ }
386+ if err := newView.Show(); err != nil {
387+ log.Printf("[workspace] failed to show tab WebView: %v", err)
388+ }
389+ }
390+
391+ log.Printf("[workspace] Created tab pane for URL: %s", url)
392+ return newView
393+}
394+
395+// handleIntentAsPopup creates a related popup pane based on window.open intent
396+func (wm *WorkspaceManager) handleIntentAsPopup(sourceNode *paneNode, url string, intent *messaging.WindowIntent) *webkit.WebView {
397+ log.Printf("[workspace] Handling intent as popup: %s", url)
398+
399+ webkitCfg, err := wm.app.buildWebkitConfig()
400+ if err != nil {
401+ log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
402+ return nil
403+ }
404+ webkitCfg.CreateWindow = false
405+
406+ newView, err := webkit.NewWebViewWithRelated(webkitCfg, sourceNode.pane.webView)
407+ if err != nil {
408+ log.Printf("[workspace] failed to create popup WebView: %v - allowing native popup", err)
409+ return nil
410+ }
411+
412+ // Log the parent-popup WebView ID relationship for OAuth auto-close
413+ parentWebViewID := sourceNode.pane.webView.ID()
414+ popupWebViewID := newView.ID()
415+ log.Printf("[workspace] Created popup WebView: parentID=%s popupID=%s url=%s", parentWebViewID, popupWebViewID, url)
416+
417+ // Store popup WebView ID in parent's localStorage for OAuth callback lookup
418+ storeScript := fmt.Sprintf(`
419+ try {
420+ const parentWebViewId = '%s';
421+ const popupWebViewId = '%s';
422+ const popupMapping = {
423+ parentId: parentWebViewId,
424+ popupId: popupWebViewId,
425+ timestamp: Date.now(),
426+ url: '%s'
427+ };
428+ localStorage.setItem('popup_mapping_' + parentWebViewId, JSON.stringify(popupMapping));
429+ console.log('[workspace] Stored popup mapping:', popupMapping);
430+ } catch(e) {
431+ console.warn('[workspace] Failed to store popup mapping:', e);
432+ }
433+ `, parentWebViewID, popupWebViewID, url)
434+
435+ // Inject into parent WebView so it can find its popup later
436+ if err := sourceNode.pane.webView.InjectScript(storeScript); err != nil {
437+ log.Printf("[workspace] Failed to inject popup mapping script into parent: %v", err)
438+ }
439+
440+ newPane, err := wm.createPane(newView)
441+ if err != nil {
442+ log.Printf("[workspace] failed to create popup pane: %v - allowing native popup", err)
443+ return nil
444+ }
445+
446+ direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
447+ if direction == "" {
448+ direction = "right"
449+ }
450+
451+ if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil {
452+ log.Printf("[workspace] popup pane insertion failed: %v - allowing native popup", err)
453+ return nil
454+ }
455+
456+ node := wm.viewToNode[newView]
457+ var requestID string
458+ if node != nil {
459+ node.windowType = webkit.WindowTypePopup
460+ node.isRelated = true
461+ node.parentPane = sourceNode
462+ node.isPopup = true
463+ node.autoClose = wm.shouldAutoClose(url)
464+
465+ // Track this popup in the parent's activePopupChildren
466+ if sourceNode != nil {
467+ if sourceNode.activePopupChildren == nil {
468+ sourceNode.activePopupChildren = make([]string, 0)
469+ }
470+ sourceNode.activePopupChildren = append(sourceNode.activePopupChildren, popupWebViewID)
471+ log.Printf("[workspace] Added popup %s to parent's activePopupChildren (count: %d)", popupWebViewID, len(sourceNode.activePopupChildren))
472+
473+ // Register navigation policy handler for parent to block script-initiated navigations
474+ // when it has active popup children (prevents OAuth from hijacking parent pane)
475+ if sourceNode.pane != nil && sourceNode.pane.WebView() != nil {
476+ sourceNode.pane.WebView().RegisterNavigationPolicyHandler(func(url string, isUserGesture bool) bool {
477+ // Always allow user-initiated navigations
478+ if isUserGesture {
479+ return true
480+ }
481+
482+ // Block script-initiated navigations if this pane has active popup children
483+ if len(sourceNode.activePopupChildren) > 0 {
484+ log.Printf("[workspace] BLOCKED script-initiated navigation in parent pane (has %d active popups): %s", len(sourceNode.activePopupChildren), url)
485+ return false
486+ }
487+
488+ // Allow if no active popups
489+ return true
490+ })
491+ log.Printf("[workspace] Registered navigation policy handler for parent pane")
492+ }
493+ }
494+
495+ // Store requestID for deduplication cleanup
496+ if intent != nil {
497+ requestID = intent.RequestID
498+ node.requestID = requestID
499+ }
500+
501+ // Apply window features from JavaScript intent
502+ wm.applyWindowFeatures(newView, intent, true)
503+
504+ // Apply minimum size constraints to prevent compression
505+ var width, height int
506+ if intent != nil {
507+ if intent.Width != nil {
508+ width = *intent.Width
509+ }
510+ if intent.Height != nil {
511+ height = *intent.Height
512+ }
513+ }
514+ wm.applyPopupSizeConstraints(newView, width, height)
515+ }
516+
517+ // Register close handler for popup auto-close
518+ newView.RegisterCloseHandler(func() {
519+ log.Printf("[workspace] Popup requesting close via window.close()")
520+
521+ // Clear the RequestID from deduplicator to allow new popups with same ID
522+ if requestID != "" && wm.paneDeduplicator != nil {
523+ wm.paneDeduplicator.ClearRequestID(requestID)
524+ }
525+
526+ // Remove this popup from parent's activePopupChildren
527+ if sourceNode != nil && popupWebViewID != "" {
528+ for i, childID := range sourceNode.activePopupChildren {
529+ if childID == popupWebViewID {
530+ sourceNode.activePopupChildren = append(sourceNode.activePopupChildren[:i], sourceNode.activePopupChildren[i+1:]...)
531+ log.Printf("[workspace] Removed popup %s from parent's activePopupChildren (remaining: %d)", popupWebViewID, len(sourceNode.activePopupChildren))
532+ break
533+ }
534+ }
535+ }
536+
537+ if n := wm.viewToNode[newView]; n != nil && n.isPopup {
538+ time.AfterFunc(200*time.Millisecond, func() {
539+ wm.scheduleIdleGuarded(func() bool {
540+ if n == nil || !n.widgetValid {
541+ return false
542+ }
543+ if err := wm.ClosePane(n); err != nil {
544+ log.Printf("[workspace] Failed to close popup pane: %v", err)
545+ }
546+ return false
547+ }, n)
548+ })
549+ }
550+ })
551+
552+ // URL-based auto-close for OAuth popups
553+ if node != nil && node.isPopup && node.autoClose {
554+ wm.registerOAuthAutoClose(newView, url)
555+ }
556+
557+ wm.ensureGUIInPane(newPane)
558+
559+ if url != "" {
560+ if err := newView.LoadURL(url); err != nil {
561+ log.Printf("[workspace] failed to load popup URL: %v", err)
562+ }
563+ if err := newView.Show(); err != nil {
564+ log.Printf("[workspace] failed to show popup WebView: %v", err)
565+ }
566+ }
567+
568+ log.Printf("[workspace] Created popup pane for URL: %s", url)
569+ return newView
570+}
571+
572+// insertIndependentPane inserts a new independent pane next to the source
573+func (wm *WorkspaceManager) insertIndependentPane(sourceNode *paneNode, webView *webkit.WebView, url string) error {
574+ newPane, err := wm.createPane(webView)
575+ if err != nil {
576+ return err
577+ }
578+ direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
579+ if direction == "" {
580+ direction = "right"
581+ }
582+ if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil { // reuse insertion primitive
583+ return err
584+ }
585+ node := wm.viewToNode[webView]
586+ if node != nil {
587+ node.windowType = webkit.WindowTypeTab
588+ node.isRelated = false
589+ }
590+ if url != "" {
591+ _ = webView.LoadURL(url)
592+ }
593+ return nil
594+}
595+
596+// configureRelatedPopup creates a related view and inserts it
597+func (wm *WorkspaceManager) configureRelatedPopup(sourceNode *paneNode, webView *webkit.WebView, url string, feat *webkit.WindowFeatures) {
598+ // Use the WebView that was already created and returned to WebKit
599+ related := webView
600+ newPane, err := wm.createPane(related)
601+ if err != nil {
602+ log.Printf("[workspace] failed to create related popup pane: %v", err)
603+ return
604+ }
605+ direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
606+ if direction == "" {
607+ direction = "right"
608+ }
609+ if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil {
610+ log.Printf("[workspace] failed to insert related popup pane: %v", err)
611+ return
612+ }
613+ node := wm.viewToNode[related]
614+ popupWebViewID := related.ID()
615+
616+ if node != nil {
617+ node.windowType = webkit.WindowTypePopup
618+ node.windowFeatures = feat
619+ node.isRelated = true
620+ node.parentPane = sourceNode
621+ node.isPopup = true
622+ // Heuristic + config for auto-close intent
623+ node.autoClose = wm.shouldAutoClose(url)
624+
625+ // Apply minimum size constraints to prevent compression
626+ var width, height int
627+ if feat != nil {
628+ width = feat.Width
629+ height = feat.Height
630+ }
631+ wm.applyPopupSizeConstraints(related, width, height)
632+ }
633+
634+ // Track this popup in the parent's activePopupChildren
635+ // This is critical to prevent OAuth flows from hijacking the parent pane via window.opener
636+ if sourceNode != nil && popupWebViewID != "" {
637+ if sourceNode.activePopupChildren == nil {
638+ sourceNode.activePopupChildren = make([]string, 0)
639+ }
640+ sourceNode.activePopupChildren = append(sourceNode.activePopupChildren, popupWebViewID)
641+ log.Printf("[workspace] Added popup %s to parent's activePopupChildren (count: %d)", popupWebViewID, len(sourceNode.activePopupChildren))
642+
643+ // Register navigation policy handler for parent to block script-initiated navigations
644+ // when it has active popup children (prevents OAuth from hijacking parent pane)
645+ if sourceNode.pane != nil && sourceNode.pane.WebView() != nil {
646+ sourceNode.pane.WebView().RegisterNavigationPolicyHandler(func(url string, isUserGesture bool) bool {
647+ // Always allow user-initiated navigations
648+ if isUserGesture {
649+ return true
650+ }
651+
652+ // Block script-initiated navigations if this pane has active popup children
653+ if len(sourceNode.activePopupChildren) > 0 {
654+ log.Printf("[workspace] BLOCKED script-initiated navigation in parent pane (has %d active popups): %s", len(sourceNode.activePopupChildren), url)
655+ return false
656+ }
657+
658+ // Allow if no active popups
659+ return true
660+ })
661+ log.Printf("[workspace] Registered navigation policy handler for parent pane")
662+ }
663+ }
664+
665+ // Pipe into existing auto-close flow only for popups (confirmed by detection)
666+ related.RegisterCloseHandler(func() {
667+ log.Printf("[workspace] Popup requesting close via window.close()")
668+
669+ // Remove this popup from parent's activePopupChildren
670+ if sourceNode != nil && popupWebViewID != "" {
671+ for i, childID := range sourceNode.activePopupChildren {
672+ if childID == popupWebViewID {
673+ sourceNode.activePopupChildren = append(sourceNode.activePopupChildren[:i], sourceNode.activePopupChildren[i+1:]...)
674+ log.Printf("[workspace] Removed popup %s from parent's activePopupChildren (remaining: %d)", popupWebViewID, len(sourceNode.activePopupChildren))
675+ break
676+ }
677+ }
678+ }
679+
680+ if n := wm.viewToNode[related]; n != nil && n.isPopup {
681+ time.AfterFunc(200*time.Millisecond, func() {
682+ wm.scheduleIdleGuarded(func() bool {
683+ if n == nil || !n.widgetValid {
684+ return false
685+ }
686+ if err := wm.ClosePane(n); err != nil {
687+ log.Printf("[workspace] Failed to close popup pane: %v", err)
688+ }
689+ return false
690+ }, n)
691+ })
692+ }
693+ })
694+
695+ // URL-based fallback: if providers don't call window.close(), auto-close on OAuth callback URLs
696+ if node != nil && node.isPopup && node.autoClose {
697+ wm.registerOAuthAutoClose(related, url)
698+ }
699+ if url != "" {
700+ _ = related.LoadURL(url)
701+ }
702+}
703+
704+// applyPopupSizeConstraints applies minimum size constraints to prevent OAuth popup compression
705+func (wm *WorkspaceManager) applyPopupSizeConstraints(view *webkit.WebView, width, height int) {
706+ minWidth := defaultPopupMinWidth
707+ minHeight := defaultPopupMinHeight
708+
709+ // Use provided dimensions if valid
710+ if width > 0 {
711+ minWidth = width
712+ }
713+ if height > 0 {
714+ minHeight = height
715+ }
716+
717+ // Apply to container
718+ if container := view.RootWidget(); container != 0 && webkit.WidgetIsValid(container) {
719+ webkit.WidgetSetSizeRequest(container, minWidth, minHeight)
720+ webkit.WidgetQueueResize(container)
721+ log.Printf("[workspace] Applied minimum size %dx%d to popup container=%#x", minWidth, minHeight, container)
722+ }
723+
724+ // Apply to WebView widget
725+ if webViewWidget := view.Widget(); webViewWidget != 0 && webkit.WidgetIsValid(webViewWidget) {
726+ webkit.WidgetSetSizeRequest(webViewWidget, minWidth, minHeight)
727+ webkit.WidgetQueueResize(webViewWidget)
728+ log.Printf("[workspace] Applied minimum size %dx%d to popup webview=%#x", minWidth, minHeight, webViewWidget)
729+ }
730+}
731+
732+// shouldAutoClose checks simple OAuth-like URL patterns and config flag
733+func (wm *WorkspaceManager) shouldAutoClose(url string) bool {
734+ log.Printf("[workspace] shouldAutoClose called for URL: %s", url)
735+
736+ if wm == nil || wm.app == nil || wm.app.config == nil {
737+ log.Printf("[workspace] shouldAutoClose: missing config, returning true")
738+ return true
739+ }
740+ if !wm.app.config.Workspace.Popups.OAuthAutoClose {
741+ log.Printf("[workspace] shouldAutoClose: OAuthAutoClose disabled in config, returning false")
742+ return false
743+ }
744+
745+ u := strings.ToLower(url)
746+ log.Printf("[workspace] shouldAutoClose: checking lowercase URL: %s", u)
747+
748+ // RFC 6749 compliant OAuth 2.0 URL patterns
749+ oauthPatterns := []string{
750+ // Standard OAuth endpoints
751+ "oauth", "authorize", "authorization",
752+ // Standard callback/redirect patterns
753+ "callback", "redirect", "auth/callback",
754+ // OpenID Connect patterns
755+ "oidc", "openid",
756+ // Common OAuth parameter indicators
757+ "response_type=", "client_id=", "redirect_uri=", "scope=", "state=",
758+ // Standard OAuth response parameters
759+ "code=", "access_token=", "id_token=", "token_type=",
760+ // Error response parameters
761+ "error=", "error_description=",
762+ }
763+
764+ // Check for OAuth patterns in URL
765+ for _, pattern := range oauthPatterns {
766+ if strings.Contains(u, pattern) {
767+ log.Printf("[workspace] shouldAutoClose: MATCHED pattern '%s' in URL, returning true", pattern)
768+ return true
769+ }
770+ }
771+
772+ log.Printf("[workspace] shouldAutoClose: no OAuth patterns matched, returning false")
773+ return false
774+}
775+
776+// RunOnUI schedules a function; here simply executes inline as GTK main loop is single-threaded
777+func (wm *WorkspaceManager) RunOnUI(fn func()) {
778+ if fn != nil {
779+ fn()
780+ }
781+}
782+
783+// handleDetectedWindowType handles window type detection from smart detection path
784+func (wm *WorkspaceManager) handleDetectedWindowType(sourceNode *paneNode, webView *webkit.WebView, url string, windowType webkit.WindowType, features *webkit.WindowFeatures) {
785+ if wm.viewToNode[webView] != nil {
786+ return // Already placed
787+ }
788+
789+ log.Printf("[workspace] Smart detection result: type=%d url=%s", windowType, url)
790+
791+ switch windowType {
792+ case webkit.WindowTypeTab:
793+ // For tabs, create a NEW independent WebView (can't use the related one)
794+ webkitCfg, err := wm.app.buildWebkitConfig()
795+ if err != nil {
796+ log.Printf("[workspace] failed to build webkit config for tab: %v", err)
797+ return
798+ }
799+ webkitCfg.CreateWindow = false
800+
801+ // Create independent WebView like handleIntentAsTab does
802+ independentView, err := webkit.NewWebView(webkitCfg)
803+ if err != nil {
804+ log.Printf("[workspace] failed to create independent tab WebView: %v", err)
805+ return
806+ }
807+
808+ // The related webView was just for detection - we don't use it for tabs
809+ // Insert the new independent view as a tab
810+ if err := wm.insertIndependentPane(sourceNode, independentView, url); err != nil {
811+ log.Printf("[workspace] Failed to insert independent pane: %v", err)
812+ }
813+
814+ case webkit.WindowTypePopup:
815+ // For popups, use the related WebView we already created
816+ wm.configureRelatedPopup(sourceNode, webView, url, features)
817+ default:
818+ // Fallback to popup behavior
819+ wm.configureRelatedPopup(sourceNode, webView, url, features)
820+ }
821+}
A internal/app/browser/workspace_stack_lifecycle.go +92 -0
@@ -0,0 +1,92 @@
1+// workspace_stack_lifecycle.go - Stack pane lifecycle coordination without SafeWidget wrappers
2+package browser
3+
4+import (
5+ "errors"
6+ "fmt"
7+ "log"
8+)
9+
10+// StackLifecycleManager manages the lifecycle hooks around stacked pane operations.
11+type StackLifecycleManager struct {
12+ wm *WorkspaceManager
13+ treeValidator *TreeValidator
14+}
15+
16+// NewStackLifecycleManager creates a new stack lifecycle manager.
17+func NewStackLifecycleManager(wm *WorkspaceManager, treeValidator *TreeValidator) *StackLifecycleManager {
18+ return &StackLifecycleManager{
19+ wm: wm,
20+ treeValidator: treeValidator,
21+ }
22+}
23+
24+// CloseStackedPaneWithLifecycle validates the stack state and delegates the actual
25+// close operation to the stacked pane manager. This keeps widget handling simple
26+// (raw GTK pointers) while still giving us a single place for additional safety checks.
27+func (slm *StackLifecycleManager) CloseStackedPaneWithLifecycle(node *paneNode) error {
28+ if node == nil {
29+ return errors.New("node is nil")
30+ }
31+
32+ stackNode := node.parent
33+ if stackNode == nil || !stackNode.isStacked {
34+ return errors.New("node is not part of a stacked pane")
35+ }
36+
37+ log.Printf("[stack-lifecycle] Closing stacked pane: node=%p stack=%p stackSize=%d",
38+ node, stackNode, len(stackNode.stackedPanes))
39+
40+ if err := slm.validateStackState(stackNode, "before close"); err != nil {
41+ return fmt.Errorf("invalid stack state before close: %w", err)
42+ }
43+
44+ if slm.wm == nil || slm.wm.stackedPaneManager == nil {
45+ return errors.New("stacked pane manager not available")
46+ }
47+
48+ if err := slm.wm.stackedPaneManager.CloseStackedPane(node); err != nil {
49+ return err
50+ }
51+
52+ if slm.treeValidator != nil {
53+ if err := slm.treeValidator.ValidateTree(slm.wm.root, "after stack close"); err != nil {
54+ log.Printf("[stack-lifecycle] Tree validation failed after stack close: %v", err)
55+ }
56+ }
57+
58+ return nil
59+}
60+
61+// validateStackState validates the current state of a stack before we perform operations.
62+func (slm *StackLifecycleManager) validateStackState(stackNode *paneNode, operation string) error {
63+ if stackNode == nil {
64+ return errors.New("stack node is nil")
65+ }
66+
67+ if !stackNode.isStacked {
68+ return errors.New("node is not marked as stacked")
69+ }
70+
71+ if len(stackNode.stackedPanes) == 0 {
72+ return errors.New("stack has no panes")
73+ }
74+
75+ if stackNode.activeStackIndex < 0 || stackNode.activeStackIndex >= len(stackNode.stackedPanes) {
76+ return fmt.Errorf("invalid active stack index: %d (size: %d)",
77+ stackNode.activeStackIndex, len(stackNode.stackedPanes))
78+ }
79+
80+ for i, pane := range stackNode.stackedPanes {
81+ if pane == nil {
82+ return fmt.Errorf("stacked pane at index %d is nil", i)
83+ }
84+ if pane.parent != stackNode {
85+ return fmt.Errorf("stacked pane at index %d has wrong parent", i)
86+ }
87+ }
88+
89+ log.Printf("[stack-lifecycle] Stack state valid for operation '%s': size=%d activeIndex=%d",
90+ operation, len(stackNode.stackedPanes), stackNode.activeStackIndex)
91+ return nil
92+}
A internal/app/browser/workspace_tree_rebalancer.go +65 -0
@@ -0,0 +1,65 @@
1+// workspace_tree_rebalancer.go - Simplified no-op tree rebalancer placeholder.
2+//
3+// The original TreeRebalancer relied on SafeWidget abstractions. Until a
4+// pointer-based implementation is restored, we keep a lightweight stub so the
5+// rest of the workspace code can continue to depend on the API without
6+// carrying dead logic.
7+package browser
8+
9+// TreeRebalancer is currently a no-op shim kept for API compatibility.
10+type TreeRebalancer struct {
11+ enabled bool
12+}
13+
14+// RebalanceOperation describes an attempted rebalance step (unused in shim).
15+type RebalanceOperation struct {
16+ Type RebalanceType
17+ Node *paneNode
18+ Description string
19+}
20+
21+// RebalanceType enumerates rebalancer operations (kept for completeness).
22+type RebalanceType int
23+
24+const (
25+ RebalanceRotateLeft RebalanceType = iota
26+ RebalanceRotateRight
27+ RebalancePromote
28+ RebalanceRestructure
29+)
30+
31+// TreeMetrics represents the structural metrics of the workspace tree.
32+type TreeMetrics struct{}
33+
34+// NewTreeRebalancer returns a disabled stub implementation.
35+// NOTE: Tree rebalancing logic has been temporarily disabled in favor of manual geometry management.
36+// Future work may restore automatic rebalancing or replace it with an alternative approach.
37+func NewTreeRebalancer(_ *WorkspaceManager, _ *TreeValidator) *TreeRebalancer {
38+ return &TreeRebalancer{}
39+}
40+
41+// SetMaxImbalance is preserved for API compatibility.
42+func (tr *TreeRebalancer) SetMaxImbalance(_ int) {}
43+
44+// Enable toggles the stub on (no functional impact).
45+func (tr *TreeRebalancer) Enable() { tr.enabled = true }
46+
47+// Disable toggles the stub off.
48+func (tr *TreeRebalancer) Disable() { tr.enabled = false }
49+
50+// RebalanceAfterClose is a no-op in the stub implementation.
51+func (tr *TreeRebalancer) RebalanceAfterClose(_ *paneNode, _ *paneNode) error {
52+ return nil
53+}
54+
55+// CalculateTreeMetrics returns empty metrics in the stub.
56+func (tr *TreeRebalancer) CalculateTreeMetrics(_ *paneNode) TreeMetrics {
57+ return TreeMetrics{}
58+}
59+
60+// GetRebalancingStats exposes minimal instrumentation for diagnostics.
61+func (tr *TreeRebalancer) GetRebalancingStats() map[string]interface{} {
62+ return map[string]interface{}{
63+ "enabled": tr != nil && tr.enabled,
64+ }
65+}
A internal/app/browser/workspace_tree_validator.go +53 -0
@@ -0,0 +1,53 @@
1+// workspace_tree_validator.go - Simplified validator stub to keep API surface.
2+package browser
3+
4+import "fmt"
5+
6+// TreeValidator previously performed extensive tree checks. The SafeWidget
7+// removal rendered the implementation obsolete, so we keep a lightweight shim
8+// until a new validator is written.
9+type TreeValidator struct {
10+ enabled bool
11+}
12+
13+// NewTreeValidator returns a stub validator instance.
14+func NewTreeValidator(enabled bool, _ bool) *TreeValidator {
15+ return &TreeValidator{enabled: enabled}
16+}
17+
18+// ValidateTree is a no-op that reports success unless validation is explicitly enabled.
19+func (tv *TreeValidator) ValidateTree(_ *paneNode, _ string) error {
20+ if tv == nil || !tv.enabled {
21+ return nil
22+ }
23+ return fmt.Errorf("tree validation not implemented")
24+}
25+
26+// SetDebugMode preserved for compatibility.
27+func (tv *TreeValidator) SetDebugMode(_ bool) {}
28+
29+// Enable turns validation on (no functional impact in the stub).
30+func (tv *TreeValidator) Enable() {
31+ if tv != nil {
32+ tv.enabled = true
33+ }
34+}
35+
36+// Disable turns validation off.
37+func (tv *TreeValidator) Disable() {
38+ if tv != nil {
39+ tv.enabled = false
40+ }
41+}
42+
43+// GetValidationStats mimics the old diagnostic output.
44+func (tv *TreeValidator) GetValidationStats() map[string]interface{} {
45+ return map[string]interface{}{
46+ "enabled": tv != nil && tv.enabled,
47+ }
48+}
49+
50+// Enabled reports current state (used by tests/instrumentation).
51+func (tv *TreeValidator) Enabled() bool {
52+ return tv != nil && tv.enabled
53+}
A internal/app/browser/workspace_types.go +94 -0
@@ -0,0 +1,94 @@
1+// workspace_types.go - Type definitions and constants for workspace management
2+package browser
3+
4+import (
5+ "os"
6+
7+ "github.com/bnema/dumber/pkg/webkit"
8+)
9+
10+// DebugLevel controls validation and safety checks
11+type DebugLevel int
12+
13+const (
14+ // DebugOff disables all validation (production mode)
15+ DebugOff DebugLevel = iota
16+ // DebugBasic enables basic validation only (development default)
17+ DebugBasic
18+ // DebugFull enables all validation and detailed logging (testing)
19+ DebugFull
20+)
21+
22+// getDebugLevel reads debug level from environment variable
23+func getDebugLevel() DebugLevel {
24+ switch os.Getenv("DUMBER_DEBUG_WORKSPACE") {
25+ case "off", "0":
26+ return DebugOff
27+ case "basic", "1":
28+ return DebugBasic
29+ case "full", "2":
30+ return DebugFull
31+ default:
32+ // Default to basic for development
33+ return DebugBasic
34+ }
35+}
36+
37+// paneNode represents a node in the workspace pane tree structure.
38+// It can be either a leaf node (containing a browser pane) or a branch node
39+// (containing child nodes for split panes or stacked panes).
40+//
41+// Node Types:
42+// 1. Regular Leaf (isLeaf=true, windowType=Tab): Normal browsing pane
43+// 2. Popup Leaf (isLeaf=true, windowType=Popup): OAuth/feature-restricted popup
44+// 3. Stacked Container (isStacked=true, no left/right): Terminal branch with stackedPanes[]
45+// 4. Split Branch (isLeaf=false, has left+right): Pure layout node with GtkPaned
46+type paneNode struct {
47+ pane *BrowserPane
48+ parent *paneNode
49+ left *paneNode
50+ right *paneNode
51+
52+ // Widget management - direct GTK pointers (all ops on main thread)
53+ container uintptr // Main container: GtkPaned (branch), GtkBox (stack), or WebView container (leaf)
54+ orientation webkit.Orientation
55+ isLeaf bool
56+ isPopup bool // Deprecated: use windowType instead
57+
58+ // Window type tracking
59+ windowType webkit.WindowType // Tab or Popup
60+ windowFeatures *webkit.WindowFeatures // Features if popup
61+ isRelated bool // Shares context
62+ parentPane *paneNode // Parent for related views
63+ activePopupChildren []string // WebView IDs of active popup children (for tracking related popups)
64+ autoClose bool // Auto-close on OAuth success
65+ requestID string // Request ID for popup (used for localStorage cleanup)
66+ hoverToken uintptr
67+ focusControllerToken uintptr
68+ pendingHoverReattach bool
69+ pendingFocusReattach bool
70+
71+ // Stacked panes support - terminal branch nodes
72+ isStacked bool // Whether this node contains stacked panes
73+ stackedPanes []*paneNode // List of stacked panes (if isStacked)
74+ activeStackIndex int // Index of currently visible pane in stack
75+ titleBar uintptr // GtkBox for title bar (when collapsed)
76+ stackWrapper uintptr // Internal GtkBox containing the actual stacked widgets (titles + webviews)
77+
78+ // Cleanup tracking
79+ widgetValid bool // Guard flagged before GTK destruction
80+ cleanupGeneration uint // Helps assert that asynchronous callbacks skip stale nodes
81+ pendingIdleHandles map[uintptr]struct{} // Idle callbacks touching this node
82+}
83+
84+// Workspace CSS class constants
85+const (
86+ basePaneClass = "workspace-pane"
87+ multiPaneClass = "workspace-multi-pane"
88+ activePaneClass = "workspace-pane-active"
89+ outlinePaneClass = "workspace-pane-active-outline"
90+ stackContainerClass = "stacked-pane-container"
91+)
92+
93+// Focus calculation epsilon for geometric comparisons
94+const focusEpsilon = 1e-3
A internal/app/browser/workspace_utils.go +335 -0
@@ -0,0 +1,335 @@
1+// workspace_utils.go - Utility functions and helpers for workspace management
2+package browser
3+
4+import (
5+ "fmt"
6+ "log"
7+
8+ "github.com/bnema/dumber/pkg/webkit"
9+)
10+
11+// hasMultiplePanes returns true if there are multiple panes in the workspace
12+func (wm *WorkspaceManager) hasMultiplePanes() bool {
13+ return wm != nil && wm.app != nil && len(wm.app.panes) > 1
14+}
15+
16+// UpdateTitleBar updates the title bar label for a WebView in stacked panes
17+func (wm *WorkspaceManager) UpdateTitleBar(webView *webkit.WebView, title string) {
18+ // Delegate to StackedPaneManager which has the correct logic
19+ if wm.stackedPaneManager != nil {
20+ wm.stackedPaneManager.UpdateTitleBar(webView, title)
21+ }
22+}
23+
24+// ensureGUIInPane lazily loads GUI components into a pane when it gains focus
25+func (wm *WorkspaceManager) ensureGUIInPane(pane *BrowserPane) {
26+ if pane == nil {
27+ return
28+ }
29+
30+ // GUI is already injected globally via WebKit's enableUserContentManager
31+ if !pane.HasGUI() {
32+ pane.SetHasGUI(true)
33+ pane.SetGUIComponent("manager", true)
34+ pane.SetGUIComponent("omnibox", true)
35+ }
36+}
37+
38+// mapDirection maps a direction string to GTK orientation and positioning
39+func mapDirection(direction string) (webkit.Orientation, bool) {
40+ switch direction {
41+ case "left":
42+ return webkit.OrientationHorizontal, false
43+ case "up":
44+ return webkit.OrientationVertical, false
45+ case "down":
46+ return webkit.OrientationVertical, true
47+ default:
48+ return webkit.OrientationHorizontal, true
49+ }
50+}
51+
52+// Widget management utilities
53+
54+// initializePaneWidgets sets up widgets for a paneNode
55+func (wm *WorkspaceManager) initializePaneWidgets(node *paneNode, containerPtr uintptr) {
56+ node.container = containerPtr
57+
58+ // Add base CSS class to the container
59+ if node.container != 0 && webkit.WidgetIsValid(node.container) {
60+ webkit.WidgetAddCSSClass(node.container, basePaneClass)
61+ }
62+
63+ // Initialize other widget fields as zero (will be set when needed)
64+ node.titleBar = 0
65+ node.stackWrapper = 0
66+
67+ // Initialize cleanup tracking fields
68+ node.widgetValid = true
69+ node.cleanupGeneration = 0
70+}
71+
72+// setContainer sets the container widget pointer
73+func (wm *WorkspaceManager) setContainer(node *paneNode, ptr uintptr, typeInfo string) {
74+ _ = typeInfo // Type info kept for API compatibility but not used
75+ node.container = ptr
76+}
77+
78+// setTitleBar sets the titleBar widget pointer
79+func (wm *WorkspaceManager) setTitleBar(node *paneNode, ptr uintptr) {
80+ node.titleBar = ptr
81+}
82+
83+// setStackWrapper sets the stackWrapper widget pointer
84+func (wm *WorkspaceManager) setStackWrapper(node *paneNode, ptr uintptr) {
85+ node.stackWrapper = ptr
86+}
87+
88+// Centralized Active Pane Border Management System
89+// Handles all pane types: regular, popup, stacked, split panes
90+
91+// PaneBorderContext holds the context for applying borders to any pane type
92+type PaneBorderContext struct {
93+ webViewWidget uintptr // The WebView's native widget (for margin)
94+ borderContainer uintptr // The container that gets background color
95+ cssClasses []string // Additional CSS classes to apply
96+ paneType string // Description for debugging
97+}
98+
99+// determineBorderContext analyzes a pane node and determines the correct border context
100+func (wm *WorkspaceManager) determineBorderContext(node *paneNode) *PaneBorderContext {
101+ if node == nil {
102+ return nil
103+ }
104+
105+ ctx := &PaneBorderContext{}
106+
107+ // Step 1: Get WebView widget for margin (same for all pane types)
108+ if node.pane != nil && node.pane.webView != nil {
109+ ctx.webViewWidget = node.pane.webView.RootWidget()
110+ }
111+
112+ // Step 2: Determine the border container based on pane type
113+ switch {
114+ case node.windowType == webkit.WindowTypePopup:
115+ // Popup panes: Border on popup container
116+ ctx.borderContainer = node.container
117+ ctx.cssClasses = []string{activePaneClass}
118+ ctx.paneType = "popup"
119+
120+ case node.parent != nil && node.parent.isStacked:
121+ // Pane in stack: Border on stack wrapper container
122+ ctx.borderContainer = node.parent.stackWrapper
123+ ctx.cssClasses = []string{activePaneClass}
124+ ctx.paneType = "stacked"
125+
126+ default:
127+ // Regular pane: Border on pane container
128+ ctx.borderContainer = node.container
129+ ctx.cssClasses = []string{activePaneClass}
130+ ctx.paneType = "regular"
131+ }
132+
133+ return ctx
134+}
135+
136+// applyActivePaneBorder applies the active pane border using the border context
137+func (wm *WorkspaceManager) applyActivePaneBorder(ctx *PaneBorderContext) {
138+ if ctx == nil {
139+ log.Printf("[workspace] applyActivePaneBorder: nil context")
140+ return
141+ }
142+
143+ // Apply margin to WebView widget (creates space for border)
144+ if ctx.webViewWidget != 0 && webkit.WidgetIsValid(ctx.webViewWidget) {
145+ webkit.WidgetSetMargin(ctx.webViewWidget, 2)
146+ }
147+
148+ // Apply CSS classes to border container
149+ if ctx.borderContainer != 0 && webkit.WidgetIsValid(ctx.borderContainer) {
150+ for _, class := range ctx.cssClasses {
151+ webkit.WidgetAddCSSClass(ctx.borderContainer, class)
152+ }
153+ }
154+
155+ log.Printf("[workspace] Applied active border: type=%s webView=%#x container=%#x",
156+ ctx.paneType, ctx.webViewWidget, ctx.borderContainer)
157+}
158+
159+// removeActivePaneBorder removes the active pane border from a node
160+func (wm *WorkspaceManager) removeActivePaneBorder(node *paneNode) {
161+ if node == nil {
162+ return
163+ }
164+
165+ ctx := wm.determineBorderContext(node)
166+ if ctx == nil {
167+ return
168+ }
169+
170+ // Remove margin from WebView widget
171+ if ctx.webViewWidget != 0 && webkit.WidgetIsValid(ctx.webViewWidget) {
172+ webkit.WidgetSetMargin(ctx.webViewWidget, 0)
173+ }
174+
175+ // Remove CSS classes from border container
176+ if ctx.borderContainer != 0 && webkit.WidgetIsValid(ctx.borderContainer) {
177+ for _, class := range ctx.cssClasses {
178+ webkit.WidgetRemoveCSSClass(ctx.borderContainer, class)
179+ }
180+ }
181+
182+ log.Printf("[workspace] Removed active border: type=%s webView=%#x container=%#x",
183+ ctx.paneType, ctx.webViewWidget, ctx.borderContainer)
184+}
185+
186+// Widget cleanup utilities
187+
188+// cleanupWidget safely invalidates and cleans up a widget
189+func (wm *WorkspaceManager) cleanupWidget(ptr uintptr) {
190+ if ptr == 0 {
191+ return
192+ }
193+
194+ // Widget will be destroyed by GTK when unparented/removed
195+ // No manual cleanup needed - GTK4 handles reference counting
196+}
197+
198+// scheduleIdleGuarded queues a callback to run on the GTK main loop and tracks it against
199+// the provided pane nodes so it can be cancelled safely if the nodes are destroyed.
200+func (wm *WorkspaceManager) scheduleIdleGuarded(fn func() bool, nodes ...*paneNode) uintptr {
201+ if wm == nil || fn == nil {
202+ return 0
203+ }
204+ guards := uniquePaneNodes(nodes...)
205+ var handle uintptr
206+ handle = webkit.IdleAdd(func() bool {
207+ defer wm.releaseIdleHandle(handle)
208+ for _, node := range guards {
209+ if node == nil {
210+ continue
211+ }
212+ if !node.widgetValid {
213+ return false
214+ }
215+ }
216+ return fn()
217+ })
218+ if handle != 0 {
219+ wm.registerIdleHandle(handle, guards...)
220+ }
221+ return handle
222+}
223+
224+func uniquePaneNodes(nodes ...*paneNode) []*paneNode {
225+ if len(nodes) == 0 {
226+ return nil
227+ }
228+ seen := make(map[*paneNode]struct{}, len(nodes))
229+ unique := make([]*paneNode, 0, len(nodes))
230+ for _, node := range nodes {
231+ if node == nil {
232+ continue
233+ }
234+ if _, ok := seen[node]; ok {
235+ continue
236+ }
237+ seen[node] = struct{}{}
238+ unique = append(unique, node)
239+ }
240+ return unique
241+}
242+
243+func (wm *WorkspaceManager) registerIdleHandle(handle uintptr, nodes ...*paneNode) {
244+ if wm == nil || handle == 0 {
245+ return
246+ }
247+ unique := uniquePaneNodes(nodes...)
248+ if len(unique) == 0 {
249+ return
250+ }
251+ if wm.pendingIdle == nil {
252+ wm.pendingIdle = make(map[uintptr][]*paneNode)
253+ }
254+ wm.pendingIdle[handle] = unique
255+ for _, node := range unique {
256+ if node.pendingIdleHandles == nil {
257+ node.pendingIdleHandles = make(map[uintptr]struct{})
258+ }
259+ node.pendingIdleHandles[handle] = struct{}{}
260+ }
261+}
262+
263+func (wm *WorkspaceManager) releaseIdleHandle(handle uintptr) {
264+ if wm == nil || handle == 0 {
265+ return
266+ }
267+ if wm.pendingIdle == nil {
268+ return
269+ }
270+ nodes, ok := wm.pendingIdle[handle]
271+ if !ok {
272+ return
273+ }
274+ delete(wm.pendingIdle, handle)
275+ for _, node := range nodes {
276+ if node == nil || node.pendingIdleHandles == nil {
277+ continue
278+ }
279+ delete(node.pendingIdleHandles, handle)
280+ if len(node.pendingIdleHandles) == 0 {
281+ node.pendingIdleHandles = nil
282+ }
283+ }
284+}
285+
286+func (wm *WorkspaceManager) cancelIdleHandle(handle uintptr) {
287+ if handle == 0 {
288+ return
289+ }
290+ wm.releaseIdleHandle(handle)
291+ webkit.IdleRemove(handle)
292+}
293+
294+func (wm *WorkspaceManager) cancelIdleHandles(node *paneNode) {
295+ if wm == nil || node == nil || len(node.pendingIdleHandles) == 0 {
296+ return
297+ }
298+ handles := make([]uintptr, 0, len(node.pendingIdleHandles))
299+ for handle := range node.pendingIdleHandles {
300+ handles = append(handles, handle)
301+ }
302+ for _, handle := range handles {
303+ wm.cancelIdleHandle(handle)
304+ }
305+}
306+
307+// Note: nextCleanupGeneration, updateMainPane, dumpTreeState, paneCloseLogf
308+// are defined in workspace_pane_ops.go and workspace_debug.go
309+
310+// formatPaneInfo returns a debug string for a pane node
311+func formatPaneInfo(node *paneNode) string {
312+ if node == nil {
313+ return "nil"
314+ }
315+
316+ var info string
317+ if node.isLeaf {
318+ info = fmt.Sprintf("leaf(container=%#x", node.container)
319+ if node.pane != nil && node.pane.webView != nil {
320+ info += fmt.Sprintf(" webview=%s", node.pane.webView.ID())
321+ }
322+ if node.windowType == webkit.WindowTypePopup {
323+ info += " popup"
324+ }
325+ info += ")"
326+ } else if node.isStacked {
327+ info = fmt.Sprintf("stack(panes=%d active=%d container=%#x)",
328+ len(node.stackedPanes), node.activeStackIndex, node.container)
329+ } else {
330+ info = fmt.Sprintf("branch(container=%#x orientation=%v)",
331+ node.container, node.orientation)
332+ }
333+
334+ return info
335+}
M internal/app/messaging/handler.go +1 -1
@@ -355,7 +355,7 @@ func (h *Handler) handleQuery(msg Message) {
355 } else {355 } else {
356 // Prefer unified page-world API; fallback to legacy global function356 // Prefer unified page-world API; fallback to legacy global function
357 script := "(window.__dumber?.omnibox?.suggestions ? window.__dumber.omnibox.suggestions(" + string(b) + ") : (window.__dumber_omnibox_suggestions && window.__dumber_omnibox_suggestions(" + string(b) + ")))"357 script := "(window.__dumber?.omnibox?.suggestions ? window.__dumber.omnibox.suggestions(" + string(b) + ") : (window.__dumber_omnibox_suggestions && window.__dumber_omnibox_suggestions(" + string(b) + ")))"
358- log.Printf("[DEBUG] Injecting omnibox suggestions: %s", script)358+ log.Print("[DEBUG] Injecting omnibox suggestions")
359 if injErr := h.webView.InjectScript(script); injErr != nil {359 if injErr := h.webView.InjectScript(script); injErr != nil {
360 log.Printf("[ERROR] Failed to inject suggestions script: %v", injErr)360 log.Printf("[ERROR] Failed to inject suggestions script: %v", injErr)
361 } else {361 } else {
M internal/config/config.go +26 -0
@@ -215,6 +215,18 @@ type DebugConfig struct {
215 215
216 // Enable workspace navigation and focus debug logs216 // Enable workspace navigation and focus debug logs
217 EnableWorkspaceDebug bool `mapstructure:"enable_workspace_debug" yaml:"enable_workspace_debug"`217 EnableWorkspaceDebug bool `mapstructure:"enable_workspace_debug" yaml:"enable_workspace_debug"`
218+
219+ // Enable focus state machine debug logs
220+ EnableFocusDebug bool `mapstructure:"enable_focus_debug" yaml:"enable_focus_debug"`
221+
222+ // Enable CSS reconciler debug logs
223+ EnableCSSDebug bool `mapstructure:"enable_css_debug" yaml:"enable_css_debug"`
224+
225+ // Enable focus metrics tracking
226+ EnableFocusMetrics bool `mapstructure:"enable_focus_metrics" yaml:"enable_focus_metrics"`
227+
228+ // Enable detailed pane close instrumentation and tree snapshots
229+ EnablePaneCloseDebug bool `mapstructure:"enable_pane_close_debug" yaml:"enable_pane_close_debug"`
218 }230 }
219 231
220 // APISecurityConfig holds optional API key protection for dumb://api endpoints232 // APISecurityConfig holds optional API key protection for dumb://api endpoints
@@ -634,6 +646,20 @@ func (m *Manager) setDefaults() {
634 m.viper.SetDefault("logging.debug_file", defaults.Logging.DebugFile)646 m.viper.SetDefault("logging.debug_file", defaults.Logging.DebugFile)
635 m.viper.SetDefault("logging.verbose_webkit", defaults.Logging.VerboseWebKit)647 m.viper.SetDefault("logging.verbose_webkit", defaults.Logging.VerboseWebKit)
636 648
649+ // Debug defaults
650+ m.viper.SetDefault("debug.enable_webkit_debug", defaults.Debug.EnableWebKitDebug)
651+ m.viper.SetDefault("debug.webkit_debug_categories", defaults.Debug.WebKitDebugCategories)
652+ m.viper.SetDefault("debug.enable_filtering_debug", defaults.Debug.EnableFilteringDebug)
653+ m.viper.SetDefault("debug.enable_webview_debug", defaults.Debug.EnableWebViewDebug)
654+ m.viper.SetDefault("debug.log_webkit_crashes", defaults.Debug.LogWebKitCrashes)
655+ m.viper.SetDefault("debug.enable_script_debug", defaults.Debug.EnableScriptDebug)
656+ m.viper.SetDefault("debug.enable_general_debug", defaults.Debug.EnableGeneralDebug)
657+ m.viper.SetDefault("debug.enable_workspace_debug", defaults.Debug.EnableWorkspaceDebug)
658+ m.viper.SetDefault("debug.enable_focus_debug", defaults.Debug.EnableFocusDebug)
659+ m.viper.SetDefault("debug.enable_css_debug", defaults.Debug.EnableCSSDebug)
660+ m.viper.SetDefault("debug.enable_focus_metrics", defaults.Debug.EnableFocusMetrics)
661+ m.viper.SetDefault("debug.enable_pane_close_debug", defaults.Debug.EnablePaneCloseDebug)
662+
637 // Appearance defaults663 // Appearance defaults
638 m.viper.SetDefault("appearance.sans_font", defaults.Appearance.SansFont)664 m.viper.SetDefault("appearance.sans_font", defaults.Appearance.SansFont)
639 m.viper.SetDefault("appearance.serif_font", defaults.Appearance.SerifFont)665 m.viper.SetDefault("appearance.serif_font", defaults.Appearance.SerifFont)
M internal/config/defaults.go +5 -0
@@ -151,6 +151,11 @@ func DefaultConfig() *Config {
151 LogWebKitCrashes: true, // Always log crashes151 LogWebKitCrashes: true, // Always log crashes
152 EnableScriptDebug: false,152 EnableScriptDebug: false,
153 EnableGeneralDebug: false,153 EnableGeneralDebug: false,
154+ EnableWorkspaceDebug: false,
155+ EnableFocusDebug: false,
156+ EnableCSSDebug: false,
157+ EnableFocusMetrics: false,
158+ EnablePaneCloseDebug: false,
154 },159 },
155 APISecurity: APISecurityConfig{160 APISecurity: APISecurityConfig{
156 Token: "",161 Token: "",
M internal/services/browser.go +0 -3
@@ -589,7 +589,6 @@ func zoomKeyFromURL(raw string) string {
589 // ZoomKeyForLog exposes the derived zoom key (host or raw URL) for logging from other packages.589 // ZoomKeyForLog exposes the derived zoom key (host or raw URL) for logging from other packages.
590 func ZoomKeyForLog(raw string) string { return zoomKeyFromURL(raw) }590 func ZoomKeyForLog(raw string) string { return zoomKeyFromURL(raw) }
591 591
592-
593 // handleFaviconChanged processes favicon data received from WebKit (legacy binary data method)592 // handleFaviconChanged processes favicon data received from WebKit (legacy binary data method)
594 func (s *BrowserService) handleFaviconChanged(pageURL string, pngData []byte) {593 func (s *BrowserService) handleFaviconChanged(pageURL string, pngData []byte) {
595 log.Printf("[favicon] Processing favicon for %s, data size: %d bytes", pageURL, len(pngData))594 log.Printf("[favicon] Processing favicon for %s, data size: %d bytes", pageURL, len(pngData))
@@ -663,7 +662,5 @@ func (s *BrowserService) handleFaviconURIChanged(pageURL string, faviconURI stri
663 faviconNullString := sql.NullString{String: faviconURI, Valid: true}662 faviconNullString := sql.NullString{String: faviconURI, Valid: true}
664 if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {663 if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {
665 log.Printf("[browser] Failed to update favicon URI in database for %s: %v", pageURL, err)664 log.Printf("[browser] Failed to update favicon URI in database for %s: %v", pageURL, err)
666- } else {
667- log.Printf("[browser] Successfully stored favicon URI for %s: %s", pageURL, faviconURI)
668 }665 }
669 }666 }
M pkg/webkit/build_flags_stub.go +15 -0
@@ -5,3 +5,18 @@ package webkit
5 // IsNativeAvailable reports whether the native WebKit2GTK backend is compiled in.5 // IsNativeAvailable reports whether the native WebKit2GTK backend is compiled in.
6 // In non-CGO builds, this returns false and WebView methods are logical no-ops.6 // In non-CGO builds, this returns false and WebView methods are logical no-ops.
7 func IsNativeAvailable() bool { return false }7 func IsNativeAvailable() bool { return false }
8+
9+// InitMainThread is a no-op in non-CGO builds.
10+func InitMainThread() {}
11+
12+// IsMainThread always returns true in non-CGO builds since there's no GTK main thread.
13+func IsMainThread() bool { return true }
14+
15+// IterateMainLoop is a no-op in non-CGO builds and always returns false.
16+func IterateMainLoop() bool { return false }
17+
18+// PanedGetStartChild is a no-op in non-CGO builds and returns 0.
19+func PanedGetStartChild(paned uintptr) uintptr { return 0 }
20+
21+// PanedGetEndChild is a no-op in non-CGO builds and returns 0.
22+func PanedGetEndChild(paned uintptr) uintptr { return 0 }
M pkg/webkit/memory.go +13 -2
@@ -27,6 +27,7 @@ type WebViewMemoryManager struct {
27 recycleThreshold int27 recycleThreshold int
28 monitoringInterval time.Duration28 monitoringInterval time.Duration
29 stopMonitoring chan struct{}29 stopMonitoring chan struct{}
30+ lastLoggedMemory int64
30 }31 }
31 32
32 // NewWebViewMemoryManager creates a new memory manager33 // NewWebViewMemoryManager creates a new memory manager
@@ -105,9 +106,19 @@ func (mgr *WebViewMemoryManager) checkMemoryUsage() {
105 totalMemory += proc.VmRSS106 totalMemory += proc.VmRSS
106 }107 }
107 108
109+ // Only log if memory usage changed by more than 10MB (10240 KB)
110+ const memoryChangeThreshold = 10240 // 10MB in KB
108 if mgr.enableMonitoring {111 if mgr.enableMonitoring {
109- log.Printf("[webkit] Memory usage: %d processes, total %.1f MB RSS",112+ memoryDiff := totalMemory - mgr.lastLoggedMemory
110- len(processes), float64(totalMemory)/1024.0)113+ if memoryDiff < 0 {
114+ memoryDiff = -memoryDiff
115+ }
116+
117+ if mgr.lastLoggedMemory == 0 || memoryDiff >= memoryChangeThreshold {
118+ log.Printf("[webkit] Memory usage: %d processes, total %.1f MB RSS",
119+ len(processes), float64(totalMemory)/1024.0)
120+ mgr.lastLoggedMemory = totalMemory
121+ }
111 }122 }
112 123
113 // Check for recycling needs124 // Check for recycling needs
A pkg/webkit/thread_helpers.c +20 -0
@@ -0,0 +1,20 @@
1+//go:build webkit_cgo
2+// +build webkit_cgo
3+
4+#include <glib.h>
5+#include <pthread.h>
6+
7+static pthread_t main_thread_id;
8+
9+void store_main_thread_id() {
10+ main_thread_id = pthread_self();
11+}
12+
13+int is_main_thread() {
14+ return pthread_equal(pthread_self(), main_thread_id);
15+}
16+
17+int iterate_main_loop() {
18+ GMainContext *ctx = g_main_context_default();
19+ return g_main_context_iteration(ctx, FALSE);
20+}
M pkg/webkit/webview.go +15 -7
@@ -32,13 +32,14 @@ type WebView struct {
32 uriHandler func(uri string)32 uriHandler func(uri string)
33 faviconHandler func(data []byte)33 faviconHandler func(data []byte)
34 faviconURIHandler func(pageURI, faviconURI string)34 faviconURIHandler func(pageURI, faviconURI string)
35- zoomHandler func(level float64)35+ zoomHandler func(level float64)
36- popupHandler func(string) *WebView36+ popupHandler func(string) *WebView
37- closeHandler func()37+ closeHandler func()
38- useDomZoom bool38+ navigationPolicyHandler func(url string, isUserGesture bool) bool
39- domZoomSeed float6439+ useDomZoom bool
40- container uintptr40+ domZoomSeed float64
41- id uintptr // WebView unique identifier41+ container uintptr
42+ id uintptr // WebView unique identifier
42 43
43 // Window type fields (no-op in stub)44 // Window type fields (no-op in stub)
44 windowType WindowType45 windowType WindowType
@@ -125,6 +126,9 @@ func (w *WebView) Window() *Window { return w.window }
125 // GetCurrentURL returns the last requested URL (non-CGO build approximation).126 // GetCurrentURL returns the last requested URL (non-CGO build approximation).
126 func (w *WebView) GetCurrentURL() string { return w.url }127 func (w *WebView) GetCurrentURL() string { return w.url }
127 128
129+// GetTitle returns a stub page title.
130+func (w *WebView) GetTitle() string { return "New Tab" }
131+
128 // GoBack is not supported in the non-CGO stub.132 // GoBack is not supported in the non-CGO stub.
129 func (w *WebView) GoBack() error { return ErrNotImplemented }133 func (w *WebView) GoBack() error { return ErrNotImplemented }
130 134
@@ -153,6 +157,10 @@ func (w *WebView) RegisterPopupHandler(cb func(string) *WebView) { w.popupHandle
153 157
154 func (w *WebView) RegisterCloseHandler(cb func()) { w.closeHandler = cb }158 func (w *WebView) RegisterCloseHandler(cb func()) { w.closeHandler = cb }
155 159
160+func (w *WebView) RegisterNavigationPolicyHandler(cb func(url string, isUserGesture bool) bool) {
161+ w.navigationPolicyHandler = cb
162+}
163+
156 func (w *WebView) dispatchScriptMessage(payload string) { //nolint:unused // Called from CGO WebKit callbacks164 func (w *WebView) dispatchScriptMessage(payload string) { //nolint:unused // Called from CGO WebKit callbacks
157 if w != nil && w.msgHandler != nil {165 if w != nil && w.msgHandler != nil {
158 w.msgHandler(payload)166 w.msgHandler(payload)
M pkg/webkit/webview_cgo.go +102 -11
@@ -80,7 +80,10 @@ static void on_webview_close(WebKitWebView* web_view, gpointer user_data) {
80 }80 }
81 81
82 82
83-// Handle decide-policy signal for new window requests - more robust than create signal83+// Forward declare navigation policy handler
84+extern gboolean goHandleNavigationPolicy(unsigned long view_id, char* uri, gboolean is_user_gesture);
85+
86+// Handle decide-policy signal for new window requests and navigations
84 static gboolean on_decide_policy(WebKitWebView *web_view,87 static gboolean on_decide_policy(WebKitWebView *web_view,
85 WebKitPolicyDecision *decision,88 WebKitPolicyDecision *decision,
86 WebKitPolicyDecisionType type,89 WebKitPolicyDecisionType type,
@@ -90,6 +93,32 @@ static gboolean on_decide_policy(WebKitWebView *web_view,
90 const char* type_name = (type >= 0 && type < 3) ? type_names[type] : "UNKNOWN";93 const char* type_name = (type >= 0 && type < 3) ? type_names[type] : "UNKNOWN";
91 printf("[webkit-policy] Policy decision: type=%s (%d)\n", type_name, type);94 printf("[webkit-policy] Policy decision: type=%s (%d)\n", type_name, type);
92 95
96+ unsigned long view_id = (unsigned long)user_data;
97+
98+ if (type == WEBKIT_POLICY_DECISION_TYPE_NAVIGATION_ACTION) {
99+ WebKitNavigationPolicyDecision *nav_decision =
100+ WEBKIT_NAVIGATION_POLICY_DECISION(decision);
101+ WebKitNavigationAction *action =
102+ webkit_navigation_policy_decision_get_navigation_action(nav_decision);
103+ WebKitURIRequest *request =
104+ webkit_navigation_action_get_request(action);
105+ const char* uri = webkit_uri_request_get_uri(request);
106+ gboolean is_user_gesture = webkit_navigation_action_is_user_gesture(action);
107+
108+ // Ask Go if this navigation should be allowed
109+ gboolean allowed = goHandleNavigationPolicy(view_id, (char*)uri, is_user_gesture);
110+
111+ if (!allowed) {
112+ printf("[webkit-policy] Navigation BLOCKED by Go handler: uri=%s is_user_gesture=%d\n", uri, is_user_gesture);
113+ webkit_policy_decision_ignore(decision);
114+ return TRUE;
115+ }
116+
117+ // Allow navigation
118+ webkit_policy_decision_use(decision);
119+ return TRUE;
120+ }
121+
93 if (type == WEBKIT_POLICY_DECISION_TYPE_NEW_WINDOW_ACTION) {122 if (type == WEBKIT_POLICY_DECISION_TYPE_NEW_WINDOW_ACTION) {
94 WebKitNavigationPolicyDecision *nav_decision =123 WebKitNavigationPolicyDecision *nav_decision =
95 WEBKIT_NAVIGATION_POLICY_DECISION(decision);124 WEBKIT_NAVIGATION_POLICY_DECISION(decision);
@@ -111,7 +140,6 @@ static gboolean on_decide_policy(WebKitWebView *web_view,
111 guint mouse_button = webkit_navigation_action_get_mouse_button(action);140 guint mouse_button = webkit_navigation_action_get_mouse_button(action);
112 141
113 // Call Go handler for popup creation142 // Call Go handler for popup creation
114- unsigned long view_id = (unsigned long)user_data;
115 gboolean handled = goHandleNewWindowPolicy(view_id, (char*)uri, nav_type, (char*)frame_name,143 gboolean handled = goHandleNewWindowPolicy(view_id, (char*)uri, nav_type, (char*)frame_name,
116 is_user_gesture, modifiers, mouse_button);144 is_user_gesture, modifiers, mouse_button);
117 145
@@ -248,9 +276,9 @@ static GtkWidget* new_webview_related(WebKitWebView* related_view, WebKitUserCon
248 276
249 static void connect_policy_handler(WebKitWebView* web_view, unsigned long id) {277 static void connect_policy_handler(WebKitWebView* web_view, unsigned long id) {
250 if (!web_view) return;278 if (!web_view) return;
251- // Create signal is now handled in window_handler_cgo.go279+ // Connect decide-policy signal for navigation control
252- // Use ConnectCreateSignal from Go to connect the signal280+ g_signal_connect_data(G_OBJECT(web_view), "decide-policy", G_CALLBACK(on_decide_policy), (gpointer)id, NULL, 0);
253- printf("[webkit-debug] Policy handler connected (create signal handled separately)\n");281+ printf("[webkit-debug] Policy handler connected for WebView id: %lu\n", id);
254 }282 }
255 283
256 // Connect close signal to a WebView for popup auto-close functionality284 // Connect close signal to a WebView for popup auto-close functionality
@@ -987,12 +1015,13 @@ type WebView struct {
987 wasReparented bool // Track if this WebView has been reparented1015 wasReparented bool // Track if this WebView has been reparented
988 1016
989 // Window type detection and relationships1017 // Window type detection and relationships
990- windowType WindowType1018+ windowType WindowType
991- windowFeatures *WindowFeatures1019+ windowFeatures *WindowFeatures
992- windowTypeCallback func(WindowType, *WindowFeatures)1020+ windowTypeCallback func(WindowType, *WindowFeatures)
993- parentWebView *WebView1021+ parentWebView *WebView
994- isRelated bool1022+ isRelated bool
995- isActive bool // Track if this WebView is currently active/focused1023+ isActive bool // Track if this WebView is currently active/focused
1024+ navigationPolicyHandler func(url string, isUserGesture bool) bool // Handler to decide if navigation should be allowed
996 }1025 }
997 1026
998 // NewWebView constructs a new WebView instance with native WebKit2GTK widgets.1027 // NewWebView constructs a new WebView instance with native WebKit2GTK widgets.
@@ -1002,6 +1031,8 @@ func NewWebView(cfg *Config) (*WebView, error) {
1002 return nil, errors.New("failed to initialize GTK")1031 return nil, errors.New("failed to initialize GTK")
1003 }1032 }
1004 1033
1034+ InitMainThread()
1035+
1005 if cfg == nil {1036 if cfg == nil {
1006 cfg = &Config{}1037 cfg = &Config{}
1007 }1038 }
@@ -1458,6 +1489,8 @@ func NewWebViewWithRelated(cfg *Config, relatedView *WebView) (*WebView, error)
1458 return nil, errors.New("failed to initialize GTK")1489 return nil, errors.New("failed to initialize GTK")
1459 }1490 }
1460 1491
1492+ InitMainThread()
1493+
1461 if cfg == nil {1494 if cfg == nil {
1462 cfg = &Config{}1495 cfg = &Config{}
1463 }1496 }
@@ -1626,21 +1659,26 @@ func (w *WebView) Destroy() error {
1626 return ErrNotImplemented1659 return ErrNotImplemented
1627 }1660 }
1628 1661
1662+ log.Printf("[webkit-destroy] begin destroy: id=%d container=%p win=%p view=%p", w.id, w.native.container, w.native.win, w.native.view)
1629 // Stop periodic GC if running1663 // Stop periodic GC if running
1630 w.stopPeriodicGC()1664 w.stopPeriodicGC()
1665+ log.Printf("[webkit-destroy] periodic GC stopped: id=%d", w.id)
1631 1666
1632 // Clean up memory pressure settings1667 // Clean up memory pressure settings
1633 if w.memStats != nil && w.memStats.memoryPressureSettings != nil {1668 if w.memStats != nil && w.memStats.memoryPressureSettings != nil {
1634 // WebKit will clean up the memory pressure settings when context is destroyed1669 // WebKit will clean up the memory pressure settings when context is destroyed
1635 w.memStats.memoryPressureSettings = nil1670 w.memStats.memoryPressureSettings = nil
1671+ log.Printf("[webkit-destroy] cleared memory pressure settings: id=%d", w.id)
1636 }1672 }
1637 1673
1638 // Unregister from memory manager1674 // Unregister from memory manager
1639 if globalMemoryManager != nil {1675 if globalMemoryManager != nil {
1640 globalMemoryManager.UnregisterWebView(w.id)1676 globalMemoryManager.UnregisterWebView(w.id)
1677+ log.Printf("[webkit-destroy] unregistered from memory manager: id=%d", w.id)
1641 }1678 }
1642 1679
1643 if w.native != nil && w.native.ucm != nil && w.domBridgeScript != nil {1680 if w.native != nil && w.native.ucm != nil && w.domBridgeScript != nil {
1681+ log.Printf("[webkit-destroy] removing DOM bridge script: id=%d", w.id)
1644 C.webkit_user_content_manager_remove_script(w.native.ucm, w.domBridgeScript)1682 C.webkit_user_content_manager_remove_script(w.native.ucm, w.domBridgeScript)
1645 C.webkit_user_script_unref(w.domBridgeScript)1683 C.webkit_user_script_unref(w.domBridgeScript)
1646 w.domBridgeScript = nil1684 w.domBridgeScript = nil
@@ -1648,6 +1686,7 @@ func (w *WebView) Destroy() error {
1648 1686
1649 // GTK4: destroy windows via gtk_window_destroy if we created one1687 // GTK4: destroy windows via gtk_window_destroy if we created one
1650 if w.native.win != nil && w.config != nil && w.config.CreateWindow {1688 if w.native.win != nil && w.config != nil && w.config.CreateWindow {
1689+ log.Printf("[webkit-destroy] destroying GTK window: id=%d win=%p", w.id, w.native.win)
1651 C.gtk_window_destroy((*C.GtkWindow)(unsafe.Pointer(w.native.win)))1690 C.gtk_window_destroy((*C.GtkWindow)(unsafe.Pointer(w.native.win)))
1652 w.native.win = nil1691 w.native.win = nil
1653 if w.window != nil {1692 if w.window != nil {
@@ -1656,13 +1695,17 @@ func (w *WebView) Destroy() error {
1656 log.Printf("[webkit] Destroy window")1695 log.Printf("[webkit] Destroy window")
1657 }1696 }
1658 1697
1698+ log.Printf("[webkit-destroy] releasing native widgets: id=%d", w.id)
1659 w.releaseNativeWidgets()1699 w.releaseNativeWidgets()
1700+ log.Printf("[webkit-destroy] native widgets released: id=%d", w.id)
1660 w.destroyed = true1701 w.destroyed = true
1661 1702
1662 // Unregister from dynamic protection updates1703 // Unregister from dynamic protection updates
1663 UnregisterActiveWebView(w)1704 UnregisterActiveWebView(w)
1705+ log.Printf("[webkit-destroy] unregistered active view: id=%d", w.id)
1664 1706
1665 unregisterView(w.id)1707 unregisterView(w.id)
1708+ log.Printf("[webkit-destroy] destroy complete: id=%d", w.id)
1666 return nil1709 return nil
1667 }1710 }
1668 1711
@@ -1696,6 +1739,18 @@ func (w *WebView) GetCurrentURL() string {
1696 return C.GoString((*C.char)(unsafe.Pointer(uri)))1739 return C.GoString((*C.char)(unsafe.Pointer(uri)))
1697 }1740 }
1698 1741
1742+// GetTitle returns the current page title from WebKit.
1743+func (w *WebView) GetTitle() string {
1744+ if w == nil || w.native == nil || w.native.wv == nil {
1745+ return ""
1746+ }
1747+ title := C.webkit_web_view_get_title(w.native.wv)
1748+ if title == nil {
1749+ return ""
1750+ }
1751+ return C.GoString((*C.char)(unsafe.Pointer(title)))
1752+}
1753+
1699 func (w *WebView) GoBack() error {1754 func (w *WebView) GoBack() error {
1700 if w == nil || w.native == nil || w.native.wv == nil {1755 if w == nil || w.native == nil || w.native.wv == nil {
1701 return ErrNotImplemented1756 return ErrNotImplemented
@@ -1743,6 +1798,11 @@ func (w *WebView) RegisterCloseHandler(cb func()) {
1743 }1798 }
1744 }1799 }
1745 1800
1801+// RegisterNavigationPolicyHandler sets a handler to control navigation decisions
1802+func (w *WebView) RegisterNavigationPolicyHandler(cb func(url string, isUserGesture bool) bool) {
1803+ w.navigationPolicyHandler = cb
1804+}
1805+
1746 // EnablePrintInterception connects the print signal to intercept Ctrl+P1806 // EnablePrintInterception connects the print signal to intercept Ctrl+P
1747 func (w *WebView) EnablePrintInterception() {1807 func (w *WebView) EnablePrintInterception() {
1748 if w.native != nil && w.native.wv != nil {1808 if w.native != nil && w.native.wv != nil {
@@ -2344,6 +2404,37 @@ func goHandleConsoleMessage(message *C.char) {
2344 logging.CaptureWebKitLog(messageText)2404 logging.CaptureWebKitLog(messageText)
2345 }2405 }
2346 2406
2407+//export goHandleNavigationPolicy
2408+func goHandleNavigationPolicy(viewID C.ulong, uri *C.char, isUserGesture C.gboolean) C.gboolean {
2409+ id := uintptr(viewID)
2410+ urlStr := C.GoString(uri)
2411+ userGesture := isUserGesture != 0
2412+
2413+ log.Printf("[navigation-policy] WebView %d navigating to: %s (user_gesture=%t)", id, urlStr, userGesture)
2414+
2415+ // Find the WebView
2416+ regMu.RLock()
2417+ view, exists := viewByID[id]
2418+ regMu.RUnlock()
2419+
2420+ if !exists || view == nil {
2421+ log.Printf("[navigation-policy] WebView %d not found, allowing navigation", id)
2422+ return C.TRUE // Allow by default if view not found
2423+ }
2424+
2425+ // Check if this webview has a navigation policy handler
2426+ if view.navigationPolicyHandler != nil {
2427+ allowed := view.navigationPolicyHandler(urlStr, userGesture)
2428+ if !allowed {
2429+ log.Printf("[navigation-policy] WebView %d: Navigation BLOCKED by handler", id)
2430+ return C.FALSE
2431+ }
2432+ }
2433+
2434+ // Allow navigation
2435+ return C.TRUE
2436+}
2437+
2347 // showTLSWarningDialog displays a warning dialog for TLS certificate errors with storage capability2438 // showTLSWarningDialog displays a warning dialog for TLS certificate errors with storage capability
2348 func (w *WebView) showTLSWarningDialog(hostname, uri string, errorFlags int, certificateInfo string) bool {2439 func (w *WebView) showTLSWarningDialog(hostname, uri string, errorFlags int, certificateInfo string) bool {
2349 if w == nil || w.native == nil || w.native.win == nil {2440 if w == nil || w.native == nil || w.native.win == nil {
M pkg/webkit/window.go +35 -1
@@ -2,10 +2,14 @@
2 2
3 package webkit3 package webkit
4 4
5+import "sync"
6+
5 // Window represents a top-level application window hosting a WebView.7 // Window represents a top-level application window hosting a WebView.
6 // Stub for future GTK application window management.8 // Stub for future GTK application window management.
7 type Window struct {9 type Window struct {
8 Title string10 Title string
11+ child uintptr
12+ mu sync.Mutex
9 }13 }
10 14
11 // NewWindow constructs a Window. In real implementation, this would create a GTK window.15 // NewWindow constructs a Window. In real implementation, this would create a GTK window.
@@ -20,6 +24,36 @@ func (w *Window) SetTitle(title string) {
20 }24 }
21 25
22 // SetChild is a stubbed window child setter for non-CGO builds.26 // SetChild is a stubbed window child setter for non-CGO builds.
27+// Simulates GTK4 behavior where setting child to 0 unparents the current child.
23 func (w *Window) SetChild(child uintptr) {28 func (w *Window) SetChild(child uintptr) {
24- _ = child29+ if w == nil {
30+ return
31+ }
32+
33+ w.mu.Lock()
34+ defer w.mu.Unlock()
35+
36+ // GTK4 behavior: Setting child to 0 removes and potentially invalidates the current child
37+ if child == 0 && w.child != 0 {
38+ // GTK4 automatically unparents the current child but keeps the widget alive
39+ widgetMu.Lock()
40+ if stub, ok := widgetState[w.child]; ok {
41+ stub.parent = 0
42+ }
43+ widgetMu.Unlock()
44+ }
45+
46+ // GTK4 validation: if child is not 0, it must be a valid widget
47+ if child != 0 {
48+ widgetMu.Lock()
49+ childStub, childExists := widgetState[child]
50+ if !childExists || childStub.isDestroyed {
51+ widgetMu.Unlock()
52+ panic("GTK-CRITICAL simulation: gtk_window_set_child: assertion 'GTK_IS_WIDGET (widget)' failed - child is invalid")
53+ }
54+ childStub.parent = 1 // Simplified window handle
55+ widgetMu.Unlock()
56+ }
57+
58+ w.child = child
25 }59 }
M pkg/webkit/window_cgo.go +2 -0
@@ -27,6 +27,8 @@ func NewWindow(title string) (*Window, error) {
27 if C.gtk_init_check() == 0 {27 if C.gtk_init_check() == 0 {
28 return nil, ErrNotImplemented28 return nil, ErrNotImplemented
29 }29 }
30+
31+ InitMainThread()
30 // GTK4: gtk_window_new() returns GtkWindow*, no toplevel enum32 // GTK4: gtk_window_new() returns GtkWindow*, no toplevel enum
31 w := (*C.GtkWidget)(unsafe.Pointer(C.gtk_window_new()))33 w := (*C.GtkWidget)(unsafe.Pointer(C.gtk_window_new()))
32 if w == nil {34 if w == nil {
M pkg/webkit/workspace_widgets_cgo.go +707 -14
@@ -5,26 +5,50 @@ package webkit
5 /*5 /*
6 #cgo pkg-config: gtk46 #cgo pkg-config: gtk4
7 #include <gtk/gtk.h>7 #include <gtk/gtk.h>
8+#include <gtk/gtktestutils.h>
9+#include <gtk/gtkbox.h>
10+#include <gtk/gtkpaned.h>
8 #include <gdk/gdk.h>11 #include <gdk/gdk.h>
9 #include <glib.h>12 #include <glib.h>
10 #include <glib-object.h>13 #include <glib-object.h>
11 #include <graphene.h>14 #include <graphene.h>
15+#include <pthread.h>
12 #include <stdint.h>16 #include <stdint.h>
13 #include <stdlib.h>17 #include <stdlib.h>
14 18
15 // Logging helper for diagnostics19 // Logging helper for diagnostics
16 #define WORKSPACE_LOG(fmt, ...) g_printerr("[workspace-cgo] " fmt "\n", ##__VA_ARGS__)20 #define WORKSPACE_LOG(fmt, ...) g_printerr("[workspace-cgo] " fmt "\n", ##__VA_ARGS__)
17 21
22+// Helper functions for widget type checking
23+static gboolean is_paned_widget(GtkWidget *widget) {
24+ return GTK_IS_PANED(widget);
25+}
26+
27+static gboolean is_box_widget(GtkWidget *widget) {
28+ return GTK_IS_BOX(widget);
29+}
30+
18 // Callback function for idle handling following the existing pattern31 // Callback function for idle handling following the existing pattern
19 extern gboolean goIdleCallback(uintptr_t handle);32 extern gboolean goIdleCallback(uintptr_t handle);
20 extern void goHoverCallback(uintptr_t handle);33 extern void goHoverCallback(uintptr_t handle);
34+extern void goFocusEnterCallback(uintptr_t handle);
35+extern void goFocusLeaveCallback(uintptr_t handle);
36+
37+// Main thread helpers implemented in thread_helpers.c
38+void store_main_thread_id();
39+int is_main_thread();
40+int iterate_main_loop();
21 41
22 static gboolean idle_callback_wrapper(gpointer data) {42 static gboolean idle_callback_wrapper(gpointer data) {
23 return goIdleCallback((uintptr_t)data);43 return goIdleCallback((uintptr_t)data);
24 }44 }
25 45
26-static void add_idle_callback(uintptr_t handle) {46+static guint add_idle_callback(uintptr_t handle) {
27- g_idle_add_full(G_PRIORITY_DEFAULT_IDLE, idle_callback_wrapper, (gpointer)handle, NULL);47+ return g_idle_add_full(G_PRIORITY_DEFAULT_IDLE, idle_callback_wrapper, (gpointer)handle, NULL);
48+}
49+
50+static gboolean remove_idle_callback(uintptr_t handle) {
51+ return g_idle_remove_by_data((gpointer)handle);
28 }52 }
29 53
30 static void motion_enter_cb(GtkEventControllerMotion* controller, gdouble x, gdouble y, gpointer user_data) {54 static void motion_enter_cb(GtkEventControllerMotion* controller, gdouble x, gdouble y, gpointer user_data) {
@@ -78,6 +102,93 @@ static gboolean is_valid_widget(gpointer widget) {
78 return widget != NULL && GTK_IS_WIDGET(widget);102 return widget != NULL && GTK_IS_WIDGET(widget);
79 }103 }
80 104
105+// Forward declarations for focus controller callbacks defined later in this file
106+static void focus_enter_cb(GtkEventControllerFocus* controller, gpointer user_data);
107+static void focus_leave_cb(GtkEventControllerFocus* controller, gpointer user_data);
108+
109+typedef struct {
110+ GtkEventController* controller;
111+ GtkWidget* widget;
112+ gboolean is_attached;
113+} ControllerTracker;
114+
115+static GHashTable* controller_registry = NULL;
116+
117+static void init_controller_registry(void) {
118+ if (controller_registry == NULL) {
119+ controller_registry = g_hash_table_new_full(g_direct_hash, g_direct_equal, NULL, g_free);
120+ }
121+}
122+
123+static void controller_on_widget_destroy(GtkWidget* widget, gpointer user_data) {
124+ (void)widget;
125+ ControllerTracker* tracker = (ControllerTracker*)user_data;
126+ if (!tracker) {
127+ return;
128+ }
129+ tracker->is_attached = FALSE;
130+}
131+
132+static GtkEventController* widget_add_focus_controller(GtkWidget* widget, uintptr_t nodePtr) {
133+ if (!widget) {
134+ return NULL;
135+ }
136+
137+ init_controller_registry();
138+
139+ GtkEventController* focus = gtk_event_controller_focus_new();
140+ if (!focus) {
141+ return NULL;
142+ }
143+
144+ gtk_event_controller_set_propagation_phase(focus, GTK_PHASE_CAPTURE);
145+ g_signal_connect(focus, "enter", G_CALLBACK(focus_enter_cb), (gpointer)nodePtr);
146+ g_signal_connect(focus, "leave", G_CALLBACK(focus_leave_cb), (gpointer)nodePtr);
147+
148+ ControllerTracker* tracker = g_new0(ControllerTracker, 1);
149+ tracker->controller = focus;
150+ tracker->widget = widget;
151+ tracker->is_attached = TRUE;
152+
153+ g_hash_table_insert(controller_registry, focus, tracker);
154+
155+ g_signal_connect(widget, "destroy", G_CALLBACK(controller_on_widget_destroy), tracker);
156+
157+ gtk_widget_add_controller(widget, focus);
158+ return focus;
159+}
160+
161+static void widget_remove_focus_controller(GtkWidget* widget, GtkEventController* controller) {
162+ if (!controller_registry || !controller) {
163+ return;
164+ }
165+
166+ ControllerTracker* tracker = g_hash_table_lookup(controller_registry, controller);
167+ if (!tracker) {
168+ return;
169+ }
170+
171+ if (!tracker->is_attached) {
172+ return;
173+ }
174+
175+ if (!GTK_IS_WIDGET(widget)) {
176+ tracker->is_attached = FALSE;
177+ return;
178+ }
179+
180+ GtkWidget* owner = gtk_event_controller_get_widget(controller);
181+ if (GTK_IS_WIDGET(owner) && owner == widget) {
182+ gtk_widget_remove_controller(widget, controller);
183+ }
184+
185+ tracker->is_attached = FALSE;
186+
187+ // Remove from registry and unref controller to avoid leaks
188+ g_hash_table_remove(controller_registry, controller);
189+ g_object_unref(controller);
190+}
191+
81 static GtkWidget* paned_get_start_child(GtkWidget* paned) {192 static GtkWidget* paned_get_start_child(GtkWidget* paned) {
82 if (!paned) return NULL;193 if (!paned) return NULL;
83 return gtk_paned_get_start_child(GTK_PANED(paned));194 return gtk_paned_get_start_child(GTK_PANED(paned));
@@ -147,6 +258,116 @@ static void widget_remove_css_class(GtkWidget* widget, const char* name) {
147 if (!widget || !name) return;258 if (!widget || !name) return;
148 gtk_widget_remove_css_class(widget, name);259 gtk_widget_remove_css_class(widget, name);
149 }260 }
261+
262+// GtkBox helpers for stacked panes
263+static GtkWidget* box_new(GtkOrientation orientation, int spacing) {
264+ return gtk_box_new(orientation, spacing);
265+}
266+
267+static void box_append(GtkWidget* box, GtkWidget* child) {
268+ if (!box || !child) return;
269+ gtk_box_append(GTK_BOX(box), child);
270+}
271+
272+static void box_prepend(GtkWidget* box, GtkWidget* child) {
273+ if (!box || !child) return;
274+ gtk_box_prepend(GTK_BOX(box), child);
275+}
276+
277+static void box_remove(GtkWidget* box, GtkWidget* child) {
278+ if (!box || !child) return;
279+ gtk_box_remove(GTK_BOX(box), child);
280+}
281+
282+static void box_insert_child_after(GtkWidget* box, GtkWidget* child, GtkWidget* sibling) {
283+ if (!box || !child) return;
284+ gtk_box_insert_child_after(GTK_BOX(box), child, sibling);
285+}
286+
287+// Widget visibility helpers for stacked panes
288+static void widget_set_visible(GtkWidget* widget, gboolean visible) {
289+ if (!widget) return;
290+ gtk_widget_set_visible(widget, visible);
291+}
292+
293+
294+static gboolean widget_get_visible(GtkWidget* widget) {
295+ if (!widget) return FALSE;
296+ return gtk_widget_get_visible(widget);
297+}
298+
299+static void widget_hide(GtkWidget* widget) {
300+ if (!widget) return;
301+ gtk_widget_set_visible(widget, FALSE);
302+}
303+
304+static void widget_show(GtkWidget* widget) {
305+ if (!widget) return;
306+ gtk_widget_set_visible(widget, TRUE);
307+}
308+
309+// workspace_gtk_test_init removed - moved to test files only
310+
311+// Label helpers for title bars
312+static GtkWidget* label_new(const char* text) {
313+ return gtk_label_new(text);
314+}
315+
316+static void label_set_text(GtkWidget* label, const char* text) {
317+ if (!label || !text) return;
318+ gtk_label_set_text(GTK_LABEL(label), text);
319+}
320+
321+static const char* label_get_text(GtkWidget* label) {
322+ if (!label) return NULL;
323+ return gtk_label_get_text(GTK_LABEL(label));
324+}
325+
326+static void label_set_ellipsize(GtkWidget* label, int mode) {
327+ if (!label) return;
328+ gtk_label_set_ellipsize(GTK_LABEL(label), (PangoEllipsizeMode)mode);
329+}
330+
331+static void label_set_max_width_chars(GtkWidget* label, int n_chars) {
332+ if (!label) return;
333+ gtk_label_set_max_width_chars(GTK_LABEL(label), n_chars);
334+}
335+
336+// GTK4 Focus Controller helpers for focus state machine
337+static void focus_enter_cb(GtkEventControllerFocus* controller, gpointer user_data) {
338+ (void)controller;
339+ uintptr_t nodePtr = (uintptr_t)user_data;
340+ goFocusEnterCallback(nodePtr);
341+}
342+
343+static void focus_leave_cb(GtkEventControllerFocus* controller, gpointer user_data) {
344+ (void)controller;
345+ uintptr_t nodePtr = (uintptr_t)user_data;
346+ goFocusLeaveCallback(nodePtr);
347+}
348+
349+// Widget allocation helper using modern GTK4 API
350+static void widget_get_allocation_modern(GtkWidget* widget, int* x, int* y, int* width, int* height) {
351+ if (!widget || !x || !y || !width || !height) {
352+ *x = *y = *width = *height = 0;
353+ return;
354+ }
355+
356+ graphene_rect_t bounds;
357+ // Compute bounds relative to the widget itself (pass widget as target)
358+ if (gtk_widget_compute_bounds(widget, widget, &bounds)) {
359+ // Extract integer coordinates from the graphene rect
360+ *x = (int)bounds.origin.x;
361+ *y = (int)bounds.origin.y;
362+ *width = (int)bounds.size.width;
363+ *height = (int)bounds.size.height;
364+ } else {
365+ // Fallback if compute_bounds fails
366+ *x = *y = 0;
367+ *width = gtk_widget_get_width(widget);
368+ *height = gtk_widget_get_height(widget);
369+ }
370+}
150 */371 */
151 import "C"372 import "C"
152 373
@@ -164,13 +385,52 @@ type WidgetBounds struct {
164 Height float64385 Height float64
165 }386 }
166 387
388+// FocusCallbacks holds the enter and leave callbacks for focus events
389+type FocusCallbacks struct {
390+ OnEnter func()
391+ OnLeave func()
392+}
393+
394+// WidgetAllocation represents a widget's allocation (position and size)
395+type WidgetAllocation struct {
396+ X int
397+ Y int
398+ Width int
399+ Height int
400+}
401+
167 var (402 var (
168 hoverMu sync.Mutex403 hoverMu sync.Mutex
169 hoverCallbacks = make(map[uintptr]func())404 hoverCallbacks = make(map[uintptr]func())
170 hoverControllers = make(map[uintptr]uintptr)405 hoverControllers = make(map[uintptr]uintptr)
171 nextHoverID uintptr = 1406 nextHoverID uintptr = 1
407+ // gtkTestInitOnce removed - no longer needed
408+
409+ // Focus controller management
410+ focusMu sync.Mutex
411+ focusCallbacks = make(map[uintptr]FocusCallbacks)
412+ focusControllers = make(map[uintptr]uintptr)
413+ nextFocusID uintptr = 1
414+
415+ mainThreadInitialized bool
172 )416 )
173 417
418+// InitMainThread should be called once during gtk_init to store the main thread ID.
419+func InitMainThread() {
420+ if !mainThreadInitialized {
421+ C.store_main_thread_id()
422+ mainThreadInitialized = true
423+ }
424+}
425+
426+// IsMainThread returns true if the current goroutine is running on the GTK main thread.
427+func IsMainThread() bool {
428+ if !mainThreadInitialized {
429+ return false
430+ }
431+ return C.is_main_thread() == 1
432+}
433+
174 func widgetIsValid(widget uintptr) bool {434 func widgetIsValid(widget uintptr) bool {
175 if widget == 0 {435 if widget == 0 {
176 return false436 return false
@@ -190,6 +450,22 @@ func WidgetIsValid(widget uintptr) bool {
190 return widgetIsValid(widget)450 return widgetIsValid(widget)
191 }451 }
192 452
453+// IsPaned checks if a widget is a GtkPaned container.
454+func IsPaned(widget uintptr) bool {
455+ if widget == 0 {
456+ return false
457+ }
458+ return C.is_paned_widget((*C.GtkWidget)(unsafe.Pointer(widget))) != 0
459+}
460+
461+// IsBox checks if a widget is a GtkBox container.
462+func IsBox(widget uintptr) bool {
463+ if widget == 0 {
464+ return false
465+ }
466+ return C.is_box_widget((*C.GtkWidget)(unsafe.Pointer(widget))) != 0
467+}
468+
193 // WidgetRefCount exposes the widget reference count for debugging.469 // WidgetRefCount exposes the widget reference count for debugging.
194 func WidgetRefCount(widget uintptr) uint {470 func WidgetRefCount(widget uintptr) uint {
195 return widgetRefCount(widget)471 return widgetRefCount(widget)
@@ -314,24 +590,65 @@ func PanedSetPosition(paned uintptr, pos int) {
314 C.gtk_paned_set_position((*C.GtkPaned)(unsafe.Pointer(paned)), C.int(pos))590 C.gtk_paned_set_position((*C.GtkPaned)(unsafe.Pointer(paned)), C.int(pos))
315 }591 }
316 592
593+// PanedGetPosition gets the current position of the divider in a GtkPaned
594+func PanedGetPosition(paned uintptr) int {
595+ if paned == 0 {
596+ return 0
597+ }
598+ if !widgetIsValid(paned) {
599+ return 0
600+ }
601+ return int(C.gtk_paned_get_position((*C.GtkPaned)(unsafe.Pointer(paned))))
602+}
603+
317 // WidgetUnparent detaches a widget from its current parent.604 // WidgetUnparent detaches a widget from its current parent.
318 func WidgetUnparent(widget uintptr) {605 func WidgetUnparent(widget uintptr) {
606+ if ok := WidgetUnparentChecked(widget); !ok {
607+ log.Printf("[workspace] WidgetUnparent skipped for widget=%#x", widget)
608+ }
609+}
610+
611+// WidgetUnparentChecked safely detaches a widget from its parent if still valid.
612+//
613+// Return semantics:
614+// - Returns false if the widget pointer is zero or not a valid GTK widget (invalid input).
615+// - Returns true if the widget has no parent (already detached).
616+// - Returns true if the widget was successfully unparented (parent changed or is now nil).
617+// - Returns false if the widget is still valid and the parent did not change (unparent failed).
618+//
619+// Callers can use the return value to distinguish between invalid input, already-detached widgets, and successful unparenting.
620+func WidgetUnparentChecked(widget uintptr) bool {
319 if widget == 0 {621 if widget == 0 {
320- return622+ log.Printf("[workspace] WidgetUnparentChecked: widget=0")
623+ return false
321 }624 }
625+
322 if !widgetIsValid(widget) {626 if !widgetIsValid(widget) {
323- log.Printf("[workspace] WidgetUnparent skipped invalid widget=%#x", widget)627+ log.Printf("[workspace] WidgetUnparentChecked: widget=%#x invalid before unparent", widget)
324- return628+ return false
325 }629 }
326- before := widgetRefCount(widget)630+
327- log.Printf("[workspace] WidgetUnparent widget=%#x ref_before=%d", widget, before)631+ parentBefore := WidgetGetParent(widget)
632+ if parentBefore == 0 {
633+ log.Printf("[workspace] WidgetUnparentChecked: widget=%#x has no parent", widget)
634+ return true
635+ }
636+
637+ refBefore := widgetRefCount(widget)
638+ log.Printf("[workspace] WidgetUnparentChecked: widget=%#x parent=%#x ref_before=%d", widget, parentBefore, refBefore)
639+
328 C.gtk_widget_unparent((*C.GtkWidget)(unsafe.Pointer(widget)))640 C.gtk_widget_unparent((*C.GtkWidget)(unsafe.Pointer(widget)))
329- if widgetIsValid(widget) {641+
330- after := widgetRefCount(widget)642+ if !widgetIsValid(widget) {
331- log.Printf("[workspace] WidgetUnparent widget=%#x ref_after=%d", widget, after)643+ log.Printf("[workspace] WidgetUnparentChecked: widget=%#x invalid after unparent", widget)
332- } else {644+ return true
333- log.Printf("[workspace] WidgetUnparent widget=%#x now invalid", widget)
334 }645 }
646+
647+ parentAfter := WidgetGetParent(widget)
648+ refAfter := widgetRefCount(widget)
649+ log.Printf("[workspace] WidgetUnparentChecked: widget=%#x parent_after=%#x ref_after=%d", widget, parentAfter, refAfter)
650+
651+ return parentAfter == 0 || parentAfter != parentBefore
335 }652 }
336 653
337 // WidgetSetHExpand configures horizontal expand for a widget.654 // WidgetSetHExpand configures horizontal expand for a widget.
@@ -366,6 +683,24 @@ func WidgetSetVExpand(widget uintptr, expand bool) {
366 C.gtk_widget_set_vexpand((*C.GtkWidget)(unsafe.Pointer(widget)), goBool(expand))683 C.gtk_widget_set_vexpand((*C.GtkWidget)(unsafe.Pointer(widget)), goBool(expand))
367 }684 }
368 685
686+// WidgetSetSizeRequest sets the minimum size request for a widget.
687+// Use -1 for width or height to unset that dimension.
688+func WidgetSetSizeRequest(widget uintptr, width, height int) {
689+ if widget == 0 || !widgetIsValid(widget) {
690+ return
691+ }
692+ log.Printf("[workspace] WidgetSetSizeRequest widget=%#x width=%d height=%d", widget, width, height)
693+ C.gtk_widget_set_size_request((*C.GtkWidget)(unsafe.Pointer(widget)), C.int(width), C.int(height))
694+}
695+
696+// WidgetResetSizeRequest clears explicit size constraints so GTK can recalculate allocation.
697+func WidgetResetSizeRequest(widget uintptr) {
698+ if widget == 0 || !widgetIsValid(widget) {
699+ return
700+ }
701+ C.gtk_widget_set_size_request((*C.GtkWidget)(unsafe.Pointer(widget)), C.int(-1), C.int(-1))
702+}
703+
369 // WidgetShow makes the widget visible.704 // WidgetShow makes the widget visible.
370 func WidgetShow(widget uintptr) {705 func WidgetShow(widget uintptr) {
371 if widget == 0 {706 if widget == 0 {
@@ -388,6 +723,28 @@ func WidgetGrabFocus(widget uintptr) {
388 C.gtk_widget_grab_focus((*C.GtkWidget)(unsafe.Pointer(widget)))723 C.gtk_widget_grab_focus((*C.GtkWidget)(unsafe.Pointer(widget)))
389 }724 }
390 725
726+// WidgetSetFocusChild sets or clears the focus child of a widget.
727+// Pass 0 as child to clear the focus child (prevents GTK focus chain warnings during reparenting).
728+func WidgetSetFocusChild(widget uintptr, child uintptr) {
729+ if widget == 0 {
730+ return
731+ }
732+ if !widgetIsValid(widget) {
733+ log.Printf("[workspace] WidgetSetFocusChild skipped invalid widget=%#x", widget)
734+ return
735+ }
736+ var childPtr *C.GtkWidget
737+ if child != 0 {
738+ if !widgetIsValid(child) {
739+ log.Printf("[workspace] WidgetSetFocusChild skipped invalid child=%#x", child)
740+ return
741+ }
742+ childPtr = (*C.GtkWidget)(unsafe.Pointer(child))
743+ }
744+ C.gtk_widget_set_focus_child((*C.GtkWidget)(unsafe.Pointer(widget)), childPtr)
745+ log.Printf("[workspace] WidgetSetFocusChild widget=%#x child=%#x", widget, child)
746+}
747+
391 // WidgetRef increments the reference count to keep the widget alive during reparenting.748 // WidgetRef increments the reference count to keep the widget alive during reparenting.
392 func WidgetRef(widget uintptr) bool {749 func WidgetRef(widget uintptr) bool {
393 if widget == 0 {750 if widget == 0 {
@@ -434,6 +791,20 @@ func WidgetQueueAllocate(widget uintptr) {
434 C.gtk_widget_queue_allocate((*C.GtkWidget)(unsafe.Pointer(widget)))791 C.gtk_widget_queue_allocate((*C.GtkWidget)(unsafe.Pointer(widget)))
435 }792 }
436 793
794+// WidgetQueueResize forces GTK to recalculate the widget's layout and size allocation.
795+// This is useful when a widget's size requirements have changed (e.g., after adding children).
796+func WidgetQueueResize(widget uintptr) {
797+ if widget == 0 {
798+ return
799+ }
800+ if !widgetIsValid(widget) {
801+ log.Printf("[workspace] WidgetQueueResize skipped invalid widget=%#x", widget)
802+ return
803+ }
804+ log.Printf("[workspace] WidgetQueueResize widget=%#x", widget)
805+ C.gtk_widget_queue_resize((*C.GtkWidget)(unsafe.Pointer(widget)))
806+}
807+
437 // WidgetRealizeInContainer ensures a widget is properly realized within a container808 // WidgetRealizeInContainer ensures a widget is properly realized within a container
438 func WidgetRealizeInContainer(widget uintptr) {809 func WidgetRealizeInContainer(widget uintptr) {
439 if widget == 0 {810 if widget == 0 {
@@ -518,11 +889,32 @@ var (
518 )889 )
519 890
520 // IdleAdd schedules a function to be called when the main loop is idle.891 // IdleAdd schedules a function to be called when the main loop is idle.
521-func IdleAdd(fn func() bool) {892+func IdleAdd(fn func() bool) uintptr {
893+ if fn == nil {
894+ return 0
895+ }
522 id := nextIdleID896 id := nextIdleID
523 nextIdleID++897 nextIdleID++
524 idleCallbacks[id] = fn898 idleCallbacks[id] = fn
525- C.add_idle_callback(C.uintptr_t(id))899+ if C.add_idle_callback(C.uintptr_t(id)) == 0 {
900+ delete(idleCallbacks, id)
901+ return 0
902+ }
903+ return id
904+}
905+
906+// IdleRemove cancels a previously scheduled idle callback.
907+func IdleRemove(handle uintptr) {
908+ if handle == 0 {
909+ return
910+ }
911+ C.remove_idle_callback(C.uintptr_t(handle))
912+ delete(idleCallbacks, handle)
913+}
914+
915+// IterateMainLoop processes a single GTK main loop iteration and reports if work was handled.
916+func IterateMainLoop() bool {
917+ return C.iterate_main_loop() == 1
526 }918 }
527 919
528 //export goIdleCallback920 //export goIdleCallback
@@ -546,3 +938,304 @@ func goHoverCallback(handle C.uintptr_t) {
546 fn()938 fn()
547 }939 }
548 }940 }
941+
942+// GtkBox functions for stacked panes
943+
944+// NewBox creates a new GtkBox widget with the specified orientation and spacing.
945+func NewBox(orientation Orientation, spacing int) uintptr {
946+ box := C.box_new(C.GtkOrientation(orientation), C.int(spacing))
947+ return uintptr(unsafe.Pointer(box))
948+}
949+
950+// BoxAppend adds a child widget to the end of a GtkBox.
951+func BoxAppend(box uintptr, child uintptr) {
952+ if box == 0 || child == 0 {
953+ return
954+ }
955+ log.Printf("[workspace] BoxAppend box=%#x child=%#x", box, child)
956+ C.box_append((*C.GtkWidget)(unsafe.Pointer(box)), (*C.GtkWidget)(unsafe.Pointer(child)))
957+}
958+
959+// BoxPrepend adds a child widget to the beginning of a GtkBox.
960+func BoxPrepend(box uintptr, child uintptr) {
961+ if box == 0 || child == 0 {
962+ return
963+ }
964+ log.Printf("[workspace] BoxPrepend box=%#x child=%#x", box, child)
965+ C.box_prepend((*C.GtkWidget)(unsafe.Pointer(box)), (*C.GtkWidget)(unsafe.Pointer(child)))
966+}
967+
968+// BoxRemove removes a child widget from a GtkBox.
969+func BoxRemove(box uintptr, child uintptr) {
970+ if box == 0 || child == 0 {
971+ return
972+ }
973+ log.Printf("[workspace] BoxRemove box=%#x child=%#x", box, child)
974+ C.box_remove((*C.GtkWidget)(unsafe.Pointer(box)), (*C.GtkWidget)(unsafe.Pointer(child)))
975+}
976+
977+// BoxInsertChildAfter inserts a child widget after a sibling in a GtkBox.
978+func BoxInsertChildAfter(box uintptr, child uintptr, sibling uintptr) {
979+ if box == 0 || child == 0 {
980+ return
981+ }
982+ log.Printf("[workspace] BoxInsertChildAfter box=%#x child=%#x sibling=%#x", box, child, sibling)
983+ C.box_insert_child_after((*C.GtkWidget)(unsafe.Pointer(box)), (*C.GtkWidget)(unsafe.Pointer(child)), (*C.GtkWidget)(unsafe.Pointer(sibling)))
984+}
985+
986+// Widget visibility functions for stacked panes
987+
988+// WidgetSetVisible sets the visibility state of a widget.
989+func WidgetSetVisible(widget uintptr, visible bool) {
990+ if widget == 0 {
991+ return
992+ }
993+ log.Printf("[workspace] WidgetSetVisible widget=%#x visible=%v", widget, visible)
994+ C.widget_set_visible((*C.GtkWidget)(unsafe.Pointer(widget)), goBool(visible))
995+}
996+
997+// WidgetGetVisible returns the visibility state of a widget.
998+func WidgetGetVisible(widget uintptr) bool {
999+ if widget == 0 {
1000+ return false
1001+ }
1002+ return C.widget_get_visible((*C.GtkWidget)(unsafe.Pointer(widget))) != 0
1003+}
1004+
1005+// WidgetHide hides a widget.
1006+func WidgetHide(widget uintptr) {
1007+ if widget == 0 {
1008+ return
1009+ }
1010+ log.Printf("[workspace] WidgetHide widget=%#x", widget)
1011+ C.widget_hide((*C.GtkWidget)(unsafe.Pointer(widget)))
1012+}
1013+
1014+// Label functions for title bars
1015+
1016+// NewLabel creates a new GtkLabel widget with the specified text.
1017+func NewLabel(text string) uintptr {
1018+ cstr := C.CString(text)
1019+ defer C.free(unsafe.Pointer(cstr))
1020+ label := C.label_new(cstr)
1021+ return uintptr(unsafe.Pointer(label))
1022+}
1023+
1024+// LabelSetText sets the text of a GtkLabel.
1025+func LabelSetText(label uintptr, text string) {
1026+ if label == 0 {
1027+ return
1028+ }
1029+ cstr := C.CString(text)
1030+ defer C.free(unsafe.Pointer(cstr))
1031+ C.label_set_text((*C.GtkWidget)(unsafe.Pointer(label)), cstr)
1032+}
1033+
1034+// LabelGetText returns the text of a GtkLabel.
1035+func LabelGetText(label uintptr) string {
1036+ if label == 0 {
1037+ return ""
1038+ }
1039+ cstr := C.label_get_text((*C.GtkWidget)(unsafe.Pointer(label)))
1040+ if cstr == nil {
1041+ return ""
1042+ }
1043+ return C.GoString(cstr)
1044+}
1045+
1046+// EllipsizeMode represents PangoEllipsizeMode values.
1047+type EllipsizeMode int
1048+
1049+const (
1050+ EllipsizeNone EllipsizeMode = 0 // PANGO_ELLIPSIZE_NONE
1051+ EllipsizeStart EllipsizeMode = 1 // PANGO_ELLIPSIZE_START
1052+ EllipsizeMiddle EllipsizeMode = 2 // PANGO_ELLIPSIZE_MIDDLE
1053+ EllipsizeEnd EllipsizeMode = 3 // PANGO_ELLIPSIZE_END
1054+)
1055+
1056+// LabelSetEllipsize sets the ellipsization mode for a GtkLabel.
1057+func LabelSetEllipsize(label uintptr, mode EllipsizeMode) {
1058+ if label == 0 {
1059+ return
1060+ }
1061+ C.label_set_ellipsize((*C.GtkWidget)(unsafe.Pointer(label)), C.int(mode))
1062+}
1063+
1064+// LabelSetMaxWidthChars sets the maximum width in characters for a GtkLabel.
1065+func LabelSetMaxWidthChars(label uintptr, nChars int) {
1066+ if label == 0 {
1067+ return
1068+ }
1069+ C.label_set_max_width_chars((*C.GtkWidget)(unsafe.Pointer(label)), C.int(nChars))
1070+}
1071+
1072+// WidgetWaitForDraw is deprecated - GTK4 handles widget operations automatically
1073+// This function is kept for compatibility but does nothing in production
1074+func WidgetWaitForDraw(widget uintptr) {
1075+ // GTK4 documentation states: never force a redraw
1076+ // Widget operations are handled automatically by the GTK4 main loop
1077+ // This function is a no-op in production code
1078+}
1079+
1080+// WidgetQueueDraw safely queues a redraw operation (GTK4 recommended approach)
1081+func WidgetQueueDraw(widget uintptr) {
1082+ if widget == 0 || !widgetIsValid(widget) {
1083+ return
1084+ }
1085+ C.gtk_widget_queue_draw((*C.GtkWidget)(unsafe.Pointer(widget)))
1086+}
1087+
1088+// WidgetHasCSSClass checks if a widget has a specific CSS class
1089+func WidgetHasCSSClass(widget uintptr, class string) bool {
1090+ if widget == 0 || class == "" || !widgetIsValid(widget) {
1091+ return false
1092+ }
1093+ cstr := C.CString(class)
1094+ defer C.free(unsafe.Pointer(cstr))
1095+ result := C.gtk_widget_has_css_class((*C.GtkWidget)(unsafe.Pointer(widget)), cstr)
1096+ return result != 0
1097+}
1098+
1099+// Widget margin functions for creating visual borders
1100+
1101+// WidgetSetMargin sets all margins (top, right, bottom, left) to the same value
1102+func WidgetSetMargin(widget uintptr, margin int) {
1103+ if widget == 0 || !widgetIsValid(widget) {
1104+ return
1105+ }
1106+ w := (*C.GtkWidget)(unsafe.Pointer(widget))
1107+ C.gtk_widget_set_margin_top(w, C.int(margin))
1108+ C.gtk_widget_set_margin_bottom(w, C.int(margin))
1109+ C.gtk_widget_set_margin_start(w, C.int(margin))
1110+ C.gtk_widget_set_margin_end(w, C.int(margin))
1111+}
1112+
1113+// WidgetSetMarginTop sets the top margin
1114+func WidgetSetMarginTop(widget uintptr, margin int) {
1115+ if widget == 0 || !widgetIsValid(widget) {
1116+ return
1117+ }
1118+ C.gtk_widget_set_margin_top((*C.GtkWidget)(unsafe.Pointer(widget)), C.int(margin))
1119+}
1120+
1121+// WidgetSetMarginBottom sets the bottom margin
1122+func WidgetSetMarginBottom(widget uintptr, margin int) {
1123+ if widget == 0 || !widgetIsValid(widget) {
1124+ return
1125+ }
1126+ C.gtk_widget_set_margin_bottom((*C.GtkWidget)(unsafe.Pointer(widget)), C.int(margin))
1127+}
1128+
1129+// WidgetSetMarginStart sets the start (left in LTR) margin
1130+func WidgetSetMarginStart(widget uintptr, margin int) {
1131+ if widget == 0 || !widgetIsValid(widget) {
1132+ return
1133+ }
1134+ C.gtk_widget_set_margin_start((*C.GtkWidget)(unsafe.Pointer(widget)), C.int(margin))
1135+}
1136+
1137+// WidgetSetMarginEnd sets the end (right in LTR) margin
1138+func WidgetSetMarginEnd(widget uintptr, margin int) {
1139+ if widget == 0 || !widgetIsValid(widget) {
1140+ return
1141+ }
1142+ C.gtk_widget_set_margin_end((*C.GtkWidget)(unsafe.Pointer(widget)), C.int(margin))
1143+}
1144+
1145+// GTK4 Focus Controller functions for focus state machine
1146+
1147+// WidgetAddFocusController registers focus callbacks for the given widget and
1148+// returns a token that can be used to detach it later.
1149+func WidgetAddFocusController(widget uintptr, onEnter, onLeave func()) uintptr {
1150+ if widget == 0 || !widgetIsValid(widget) {
1151+ return 0
1152+ }
1153+
1154+ focusMu.Lock()
1155+ token := nextFocusID
1156+ nextFocusID++
1157+
1158+ callbacks := FocusCallbacks{
1159+ OnEnter: onEnter,
1160+ OnLeave: onLeave,
1161+ }
1162+ focusCallbacks[token] = callbacks
1163+ focusMu.Unlock()
1164+
1165+ controller := C.widget_add_focus_controller((*C.GtkWidget)(unsafe.Pointer(widget)), C.uintptr_t(token))
1166+ if controller == nil {
1167+ focusMu.Lock()
1168+ delete(focusCallbacks, token)
1169+ focusMu.Unlock()
1170+ return 0
1171+ }
1172+
1173+ focusMu.Lock()
1174+ focusControllers[token] = uintptr(unsafe.Pointer(controller))
1175+ focusMu.Unlock()
1176+ return token
1177+}
1178+
1179+// WidgetRemoveFocusController removes a previously registered focus controller.
1180+func WidgetRemoveFocusController(widget uintptr, token uintptr) {
1181+ if token == 0 {
1182+ return
1183+ }
1184+
1185+ focusMu.Lock()
1186+ controller := focusControllers[token]
1187+ delete(focusCallbacks, token)
1188+ delete(focusControllers, token)
1189+ focusMu.Unlock()
1190+
1191+ if controller == 0 {
1192+ return
1193+ }
1194+
1195+ if widget == 0 || !widgetIsValid(widget) {
1196+ return
1197+ }
1198+
1199+ C.widget_remove_focus_controller((*C.GtkWidget)(unsafe.Pointer(widget)), (*C.GtkEventController)(unsafe.Pointer(controller)))
1200+}
1201+
1202+//export goFocusEnterCallback
1203+func goFocusEnterCallback(handle C.uintptr_t) {
1204+ token := uintptr(handle)
1205+ focusMu.Lock()
1206+ callbacks := focusCallbacks[token]
1207+ focusMu.Unlock()
1208+
1209+ if callbacks.OnEnter != nil {
1210+ callbacks.OnEnter()
1211+ }
1212+}
1213+
1214+//export goFocusLeaveCallback
1215+func goFocusLeaveCallback(handle C.uintptr_t) {
1216+ token := uintptr(handle)
1217+ focusMu.Lock()
1218+ callbacks := focusCallbacks[token]
1219+ focusMu.Unlock()
1220+
1221+ if callbacks.OnLeave != nil {
1222+ callbacks.OnLeave()
1223+ }
1224+}
1225+
1226+// WidgetGetAllocation gets the bounds (position and size) of a widget using modern GTK4 API
1227+func WidgetGetAllocation(widget uintptr) WidgetAllocation {
1228+ var x, y, width, height C.int
1229+
1230+ C.widget_get_allocation_modern(
1231+ (*C.GtkWidget)(unsafe.Pointer(widget)),
1232+ &x, &y, &width, &height,
1233+ )
1234+
1235+ return WidgetAllocation{
1236+ X: int(x),
1237+ Y: int(y),
1238+ Width: int(width),
1239+ Height: int(height),
1240+ }
1241+}
M pkg/webkit/workspace_widgets_stub.go +429 -12
@@ -20,12 +20,29 @@ type WidgetBounds struct {
20 Height float6420 Height float64
21 }21 }
22 22
23+// WidgetAllocation represents a widget's allocation (position and size) in stub builds
24+type WidgetAllocation struct {
25+ X int
26+ Y int
27+ Width int
28+ Height int
29+}
30+
23 type widgetStub struct {31 type widgetStub struct {
24 startChild uintptr32 startChild uintptr
25 endChild uintptr33 endChild uintptr
26 bounds WidgetBounds34 bounds WidgetBounds
27 hasBounds bool35 hasBounds bool
28 hover map[uintptr]func()36 hover map[uintptr]func()
37+ // GTK4 lifecycle simulation
38+ parent uintptr
39+ isDestroyed bool
40+ refCount uint
41+ widgetType string // Track widget type for IsPaned, etc.
42+ // GTK focus management simulation
43+ hasFocus bool
44+ canFocus bool
45+ focusedChild uintptr
29 }46 }
30 47
31 var (48 var (
@@ -33,6 +50,10 @@ var (
33 widgetState = map[uintptr]*widgetStub{}50 widgetState = map[uintptr]*widgetStub{}
34 nextWidgetID uintptr = 151 nextWidgetID uintptr = 1
35 nextHoverToken uintptr = 152 nextHoverToken uintptr = 1
53+ // GTK focus management simulation
54+ globalFocusedWidget uintptr
55+ focusWarningEnabled bool = true
56+ gtkTestInitOnce sync.Once
36 )57 )
37 58
38 func newWidgetHandle() uintptr {59 func newWidgetHandle() uintptr {
@@ -40,7 +61,10 @@ func newWidgetHandle() uintptr {
40 defer widgetMu.Unlock()61 defer widgetMu.Unlock()
41 id := nextWidgetID62 id := nextWidgetID
42 nextWidgetID++63 nextWidgetID++
43- widgetState[id] = &widgetStub{}64+ widgetState[id] = &widgetStub{
65+ refCount: 1, // GTK widgets start with ref count 1
66+ canFocus: true, // Most widgets can receive focus
67+ }
44 return id68 return id
45 }69 }
46 70
@@ -70,22 +94,101 @@ func ResetWidgetStubsForTesting() {
70 }94 }
71 95
72 func NewPaned(orientation Orientation) uintptr {96 func NewPaned(orientation Orientation) uintptr {
73- return newWidgetHandle()97+ widgetMu.Lock()
98+ defer widgetMu.Unlock()
99+ id := nextWidgetID
100+ nextWidgetID++
101+ widgetState[id] = &widgetStub{
102+ refCount: 1,
103+ canFocus: true,
104+ widgetType: "paned", // Mark as paned widget
105+ }
106+ return id
74 }107 }
75 108
76 func PanedSetStartChild(paned uintptr, child uintptr) {109 func PanedSetStartChild(paned uintptr, child uintptr) {
77 widgetMu.Lock()110 widgetMu.Lock()
78 defer widgetMu.Unlock()111 defer widgetMu.Unlock()
79- if stub, ok := widgetState[paned]; ok {112+
80- stub.startChild = child113+ // GTK4 validation: paned must be a valid widget
114+ if paned == 0 {
115+ panic("GTK-CRITICAL simulation: gtk_widget_get_parent: assertion 'GTK_IS_WIDGET (widget)' failed - paned is NULL")
116+ }
117+
118+ stub, ok := widgetState[paned]
119+ if !ok || stub.isDestroyed {
120+ panic("GTK-CRITICAL simulation: gtk_widget_get_parent: assertion 'GTK_IS_WIDGET (widget)' failed - paned is invalid")
121+ }
122+
123+ // GTK4 validation: if child is not 0, it must be a valid widget
124+ if child != 0 {
125+ childStub, childExists := widgetState[child]
126+ if !childExists || childStub.isDestroyed {
127+ panic("GTK-CRITICAL simulation: gtk_widget_insert_before: assertion 'GTK_IS_WIDGET (widget)' failed - child is invalid")
128+ }
129+ }
130+
131+ // GTK4 behavior: setting child to 0 automatically unparents the old child
132+ if stub.startChild != 0 && child == 0 {
133+ if oldChild, exists := widgetState[stub.startChild]; exists {
134+ oldChild.parent = 0 // Automatically unparent
135+ }
136+ }
137+ // Set new child and establish parent relationship
138+ stub.startChild = child
139+ if child != 0 {
140+ if childStub, exists := widgetState[child]; exists {
141+ // Simulate the exact GTK focus management issue from production
142+ // When reparenting a widget with focus hierarchy to a new parent,
143+ // GTK's focus management calls gtk_paned_set_focus_child with (nil)
144+ if focusWarningEnabled && (childStub.hasFocus || childStub.focusedChild != 0) {
145+ panic("GTK-WARNING simulation: Error finding last focus widget of GtkPaned, gtk_paned_set_focus_child was called on widget (nil) which is not child")
146+ }
147+ childStub.parent = paned
148+ }
81 }149 }
82 }150 }
83 151
84 func PanedSetEndChild(paned uintptr, child uintptr) {152 func PanedSetEndChild(paned uintptr, child uintptr) {
85 widgetMu.Lock()153 widgetMu.Lock()
86 defer widgetMu.Unlock()154 defer widgetMu.Unlock()
87- if stub, ok := widgetState[paned]; ok {155+
88- stub.endChild = child156+ // GTK4 validation: paned must be a valid widget
157+ if paned == 0 {
158+ panic("GTK-CRITICAL simulation: gtk_widget_get_parent: assertion 'GTK_IS_WIDGET (widget)' failed - paned is NULL")
159+ }
160+
161+ stub, ok := widgetState[paned]
162+ if !ok || stub.isDestroyed {
163+ panic("GTK-CRITICAL simulation: gtk_widget_get_parent: assertion 'GTK_IS_WIDGET (widget)' failed - paned is invalid")
164+ }
165+
166+ // GTK4 validation: if child is not 0, it must be a valid widget
167+ if child != 0 {
168+ childStub, childExists := widgetState[child]
169+ if !childExists || childStub.isDestroyed {
170+ panic("GTK-CRITICAL simulation: gtk_widget_insert_before: assertion 'GTK_IS_WIDGET (widget)' failed - child is invalid")
171+ }
172+ }
173+
174+ // GTK4 behavior: setting child to 0 automatically unparents the old child
175+ if stub.endChild != 0 && child == 0 {
176+ if oldChild, exists := widgetState[stub.endChild]; exists {
177+ oldChild.parent = 0 // Automatically unparent
178+ }
179+ }
180+ // Set new child and establish parent relationship
181+ stub.endChild = child
182+ if child != 0 {
183+ if childStub, exists := widgetState[child]; exists {
184+ // Simulate the exact GTK focus management issue from production
185+ // When reparenting a widget with focus hierarchy to a new parent,
186+ // GTK's focus management calls gtk_paned_set_focus_child with (nil)
187+ if focusWarningEnabled && (childStub.hasFocus || childStub.focusedChild != 0) {
188+ panic("GTK-WARNING simulation: Error finding last focus widget of GtkPaned, gtk_paned_set_focus_child was called on widget (nil) which is not child")
189+ }
190+ childStub.parent = paned
191+ }
89 }192 }
90 }193 }
91 194
@@ -96,17 +199,103 @@ func PanedSetResizeEnd(paned uintptr, resize bool) {}
96 // PanedSetPosition is a no-op in stub builds.199 // PanedSetPosition is a no-op in stub builds.
97 func PanedSetPosition(paned uintptr, pos int) {}200 func PanedSetPosition(paned uintptr, pos int) {}
98 201
99-func WidgetUnparent(widget uintptr) {}202+// PanedGetPosition gets the current position of the divider in a GtkPaned (stub)
203+func PanedGetPosition(paned uintptr) int { return 0 }
204+
205+func WidgetUnparent(widget uintptr) {
206+ widgetMu.Lock()
207+ defer widgetMu.Unlock()
208+ if stub, ok := widgetState[widget]; ok {
209+ // GTK4 CRITICAL simulation: unparenting widget with no parent is illegal
210+ if stub.parent == 0 {
211+ // In real GTK4, this would cause: gtk_widget_get_parent: assertion 'GTK_IS_WIDGET (widget)' failed
212+ // We'll simulate this by marking the widget as destroyed
213+ stub.isDestroyed = true
214+ panic("GTK-CRITICAL simulation: gtk_widget_unparent called on widget with no parent")
215+ }
216+ // Remove from parent's child list
217+ if parentStub, exists := widgetState[stub.parent]; exists {
218+ if parentStub.startChild == widget {
219+ parentStub.startChild = 0
220+ }
221+ if parentStub.endChild == widget {
222+ parentStub.endChild = 0
223+ }
224+ }
225+ stub.parent = 0
226+ }
227+}
100 228
101 func WidgetSetHExpand(widget uintptr, expand bool) {}229 func WidgetSetHExpand(widget uintptr, expand bool) {}
102 230
103 func WidgetSetVExpand(widget uintptr, expand bool) {}231 func WidgetSetVExpand(widget uintptr, expand bool) {}
104 232
105-func WidgetGetParent(widget uintptr) uintptr { return 0 }233+func WidgetGetParent(widget uintptr) uintptr {
234+ widgetMu.Lock()
235+ defer widgetMu.Unlock()
236+ if stub, ok := widgetState[widget]; ok && !stub.isDestroyed {
237+ return stub.parent
238+ }
239+ return 0
240+}
106 241
107 func WidgetShow(widget uintptr) {}242 func WidgetShow(widget uintptr) {}
108 243
109-func WidgetGrabFocus(widget uintptr) {}244+func WidgetGrabFocus(widget uintptr) {
245+ widgetMu.Lock()
246+ defer widgetMu.Unlock()
247+
248+ if widget == 0 {
249+ return
250+ }
251+
252+ stub, ok := widgetState[widget]
253+ if !ok || stub.isDestroyed || !stub.canFocus {
254+ return
255+ }
256+
257+ // Clear previous focus
258+ if globalFocusedWidget != 0 {
259+ if prevStub, exists := widgetState[globalFocusedWidget]; exists {
260+ prevStub.hasFocus = false
261+ }
262+ }
263+
264+ // Set new focus
265+ stub.hasFocus = true
266+ globalFocusedWidget = widget
267+
268+ // Simulate focus in parent hierarchy - each parent should point to its direct focused child
269+ currentChild := widget
270+ parent := stub.parent
271+ for parent != 0 {
272+ if parentStub, exists := widgetState[parent]; exists {
273+ parentStub.focusedChild = currentChild // Direct child that contains focus
274+ currentChild = parent // Move up the hierarchy
275+ parent = parentStub.parent
276+ } else {
277+ break
278+ }
279+ }
280+}
281+
282+// WidgetSetFocusChild sets or clears the focus child of a widget.
283+// Pass 0 as child to clear the focus child (prevents GTK focus chain warnings during reparenting).
284+func WidgetSetFocusChild(widget uintptr, child uintptr) {
285+ widgetMu.Lock()
286+ defer widgetMu.Unlock()
287+
288+ if widget == 0 {
289+ return
290+ }
291+
292+ stub, ok := widgetState[widget]
293+ if !ok || stub.isDestroyed {
294+ return
295+ }
296+
297+ stub.focusedChild = child
298+}
110 299
111 func WidgetRef(widget uintptr) bool {300 func WidgetRef(widget uintptr) bool {
112 widgetMu.Lock()301 widgetMu.Lock()
@@ -121,13 +310,50 @@ func WidgetQueueAllocate(widget uintptr) {}
121 310
122 func WidgetRealizeInContainer(widget uintptr) {}311 func WidgetRealizeInContainer(widget uintptr) {}
123 312
313+func WidgetSetSizeRequest(widget uintptr, width, height int) {}
314+
315+func WidgetResetSizeRequest(widget uintptr) {}
316+
317+func WidgetQueueResize(widget uintptr) {}
318+
124 func WidgetHookDestroy(widget uintptr) {}319 func WidgetHookDestroy(widget uintptr) {}
125 320
126 func WidgetIsValid(widget uintptr) bool {321 func WidgetIsValid(widget uintptr) bool {
127 widgetMu.Lock()322 widgetMu.Lock()
128 defer widgetMu.Unlock()323 defer widgetMu.Unlock()
129- _, ok := widgetState[widget]324+ if widget == 0 {
130- return ok && widget != 0325+ return false
326+ }
327+ if stub, ok := widgetState[widget]; ok {
328+ return !stub.isDestroyed
329+ }
330+ return false
331+}
332+
333+// IsPaned checks if a widget is a GtkPaned container.
334+func IsPaned(widget uintptr) bool {
335+ widgetMu.Lock()
336+ defer widgetMu.Unlock()
337+ if widget == 0 {
338+ return false
339+ }
340+ if stub, ok := widgetState[widget]; ok && !stub.isDestroyed {
341+ return stub.widgetType == "paned"
342+ }
343+ return false
344+}
345+
346+// IsBox checks if a widget is a GtkBox container.
347+func IsBox(widget uintptr) bool {
348+ widgetMu.Lock()
349+ defer widgetMu.Unlock()
350+ if widget == 0 {
351+ return false
352+ }
353+ if stub, ok := widgetState[widget]; ok && !stub.isDestroyed {
354+ return stub.widgetType == "box"
355+ }
356+ return false
131 }357 }
132 358
133 func WidgetRefCount(widget uintptr) uint {359 func WidgetRefCount(widget uintptr) uint {
@@ -175,10 +401,35 @@ func WidgetGetBounds(widget uintptr) (WidgetBounds, bool) {
175 return stub.bounds, true401 return stub.bounds, true
176 }402 }
177 403
178-func IdleAdd(fn func() bool) {404+// WidgetWaitForDraw is a no-op validation stub in this build.
405+// It does NOT provide any synchronization or guarantee that widget draw operations are complete.
406+// In the stub, it only validates the widget state and does not wait for any operations.
407+// In real GTK builds, this would wait for all pending draw operations to complete.
408+func WidgetWaitForDraw(widget uintptr) {
409+ gtkTestInitOnce.Do(func() {})
410+ widgetMu.Lock()
411+ defer widgetMu.Unlock()
412+
413+ // Validate widget is still valid after any recent operations
414+ if widget != 0 {
415+ stub, ok := widgetState[widget]
416+ if !ok || stub.isDestroyed {
417+ panic("GTK-CRITICAL simulation: gtk_test_widget_wait_for_draw: widget is invalid")
418+ }
419+ }
420+}
421+
422+// IdleAdd simulates GTK's idle callback system - DEPRECATED, use WidgetWaitForDraw instead
423+func IdleAdd(fn func() bool) uintptr {
179 if fn != nil {424 if fn != nil {
180 fn()425 fn()
181 }426 }
427+ return 0
428+}
429+
430+// IdleRemove cancels a simulated idle callback (no-op in stub builds).
431+func IdleRemove(handle uintptr) {
432+ _ = handle
182 }433 }
183 434
184 func AddCSSProvider(css string) {}435 func AddCSSProvider(css string) {}
@@ -186,3 +437,169 @@ func AddCSSProvider(css string) {}
186 func WidgetAddCSSClass(widget uintptr, class string) {}437 func WidgetAddCSSClass(widget uintptr, class string) {}
187 438
188 func WidgetRemoveCSSClass(widget uintptr, class string) {}439 func WidgetRemoveCSSClass(widget uintptr, class string) {}
440+
441+func WidgetHasCSSClass(widget uintptr, class string) bool {
442+ // In stub mode, we don't track CSS classes
443+ return false
444+}
445+
446+// Widget margin functions for creating visual borders (stub implementations)
447+
448+func WidgetSetMargin(widget uintptr, margin int) {}
449+
450+func WidgetSetMarginTop(widget uintptr, margin int) {}
451+
452+func WidgetSetMarginBottom(widget uintptr, margin int) {}
453+
454+func WidgetSetMarginStart(widget uintptr, margin int) {}
455+
456+func WidgetSetMarginEnd(widget uintptr, margin int) {}
457+
458+func WidgetQueueDraw(widget uintptr) {
459+ // Stub implementation - no-op
460+}
461+
462+// Focus controller functions for GTK4 EventControllerFocus (stub implementations)
463+func WidgetAddFocusController(widget uintptr, onEnter, onLeave func()) uintptr {
464+ // Stub implementation - return a unique token
465+ token := nextHoverToken
466+ nextHoverToken++
467+ return token
468+}
469+
470+func WidgetRemoveFocusController(widget uintptr, token uintptr) {
471+ // Stub implementation - no-op
472+}
473+
474+// SimulateFocusForTesting gives focus to a widget for test scenarios
475+func SimulateFocusForTesting(widget uintptr) {
476+ WidgetGrabFocus(widget)
477+}
478+
479+// GtkBox functions for stacked panes (stub implementations)
480+
481+func NewBox(orientation Orientation, spacing int) uintptr {
482+ widgetMu.Lock()
483+ defer widgetMu.Unlock()
484+ id := nextWidgetID
485+ nextWidgetID++
486+ widgetState[id] = &widgetStub{
487+ refCount: 1,
488+ canFocus: true,
489+ widgetType: "box", // Mark as box widget
490+ }
491+ return id
492+}
493+
494+func BoxAppend(box uintptr, child uintptr) {
495+ widgetMu.Lock()
496+ defer widgetMu.Unlock()
497+
498+ if box == 0 || child == 0 {
499+ return
500+ }
501+
502+ boxStub, ok := widgetState[box]
503+ if !ok || boxStub.isDestroyed {
504+ panic("GTK-CRITICAL simulation: gtk_box_append: box is invalid")
505+ }
506+
507+ childStub, ok := widgetState[child]
508+ if !ok || childStub.isDestroyed {
509+ panic("GTK-CRITICAL simulation: gtk_box_append: child is invalid")
510+ }
511+
512+ if childStub.parent != 0 && childStub.parent != box {
513+ panic("GTK-CRITICAL simulation: gtk_box_append: child already has a different parent")
514+ }
515+ childStub.parent = box
516+}
517+
518+func BoxPrepend(box uintptr, child uintptr) {
519+ BoxAppend(box, child)
520+}
521+
522+func BoxRemove(box uintptr, child uintptr) {
523+ widgetMu.Lock()
524+ defer widgetMu.Unlock()
525+
526+ if box == 0 || child == 0 {
527+ return
528+ }
529+
530+ childStub, ok := widgetState[child]
531+ if !ok || childStub.isDestroyed {
532+ panic("GTK-CRITICAL simulation: gtk_box_remove: child is invalid")
533+ }
534+
535+ if childStub.parent != box {
536+ panic("GTK-CRITICAL simulation: gtk_box_remove: child is not parented to the specified box")
537+ }
538+
539+ childStub.parent = 0
540+}
541+
542+func BoxInsertChildAfter(box uintptr, child uintptr, sibling uintptr) {
543+ BoxAppend(box, child)
544+}
545+
546+// Widget visibility functions for stacked panes (stub implementations)
547+
548+func WidgetSetVisible(widget uintptr, visible bool) {}
549+
550+func WidgetGetVisible(widget uintptr) bool { return true }
551+
552+func WidgetHide(widget uintptr) {}
553+
554+// Label functions for title bars (stub implementations)
555+
556+func NewLabel(text string) uintptr {
557+ return newWidgetHandle()
558+}
559+
560+func LabelSetText(label uintptr, text string) {}
561+
562+func LabelGetText(label uintptr) string { return "" }
563+
564+// EllipsizeMode represents PangoEllipsizeMode values in stub builds.
565+type EllipsizeMode int
566+
567+const (
568+ EllipsizeNone EllipsizeMode = 0
569+ EllipsizeStart EllipsizeMode = 1
570+ EllipsizeMiddle EllipsizeMode = 2
571+ EllipsizeEnd EllipsizeMode = 3
572+)
573+
574+func LabelSetEllipsize(label uintptr, mode EllipsizeMode) {}
575+
576+func LabelSetMaxWidthChars(label uintptr, nChars int) {}
577+
578+// SetTestContainer is a testing helper to set the container field for WebView in stub builds
579+func (w *WebView) SetTestContainer(container uintptr) {
580+ w.container = container
581+}
582+
583+// WidgetGetAllocation returns a stub allocation for the widget
584+func WidgetGetAllocation(widget uintptr) WidgetAllocation {
585+ widgetMu.Lock()
586+ defer widgetMu.Unlock()
587+
588+ // Return stub allocation based on widget bounds if available
589+ if stub, ok := widgetState[widget]; ok && stub.hasBounds {
590+ return WidgetAllocation{
591+ X: int(stub.bounds.X),
592+ Y: int(stub.bounds.Y),
593+ Width: int(stub.bounds.Width),
594+ Height: int(stub.bounds.Height),
595+ }
596+ }
597+
598+ // Default stub allocation for testing
599+ return WidgetAllocation{
600+ X: 0,
601+ Y: 0,
602+ Width: 400,
603+ Height: 300,
604+ }
605+}

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