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
6666
6767 # Node/TypeScript related
6868 node_modules/
69+.pnpm-store/
70+
6971
7072 # Development/tooling files
7173 CLAUDE.md
7274 .claude/
7375 memory/
76+notes/
7477 scripts/
7578 !scripts/generate-types.js
7679 specs/
7780 templates/
7881 .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.
66
77 ### Added
88 - **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
910
1011 ### Changed
1112 - **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.
1516 ### Fixed
1617 - **Mobile URL overflow**: Fixed long URLs breaking layout on narrow screens by adding proper width constraints and CSS fixes
1718 - **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
1825
1926 ## [0.10.0] - 2025-09-24
2027
M README.md +6 -2
@@ -59,10 +59,12 @@ Dumber is a minimalist browser and launcher companion focused on speed and simpl
5959 | **←** / **L** (in pane mode) | Split Left | Create new pane to the left |
6060 | **↑** / **U** (in pane mode) | Split Up | Create new pane above |
6161 | **↓** / **D** (in pane mode) | Split Down | Create new pane below |
62+| **S** (in pane mode) | Stack Pane | Create stacked pane (Zellij-style) |
6263 | **X** (in pane mode) | Close Pane | Close current pane |
6364 | **Enter** (in pane mode) | Confirm Action | Confirm pane operation |
6465 | **Escape** (in pane mode) | Exit Pane Mode | Return to normal navigation |
6566 | **Alt+Arrow Keys** | Navigate Panes | Move focus between panes |
67+| **Alt+Up/Down** | Navigate Stack | Navigate between stacked panes |
6668
6769 ### Mouse Controls
6870 | Action | Result | Notes |
@@ -159,16 +161,18 @@ Dumber features a Zellij-inspired pane management system that allows you to spli
159161
160162 ### How it Works
161163 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:
163165 - `→` or `R` - Split right
164166 - `←` or `L` - Split left
165167 - `↑` or `U` - Split up
166168 - `↓` or `D` - Split down
167-3. **Navigate**: Use `Alt+Arrow Keys` to move focus between panes
169+ - `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
168171 4. **Close**: Press `X` in pane mode to close the current pane
169172
170173 ### Features
171174 - **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
172176 - **Focus management**: Visual borders, hover-to-focus, and enhanced focus throttling (100ms) for intuitive navigation
173177 - **Independent sessions**: Each pane maintains its own browsing session, history, and zoom level
174178 - **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 {
1313 type WorkspaceConfigPayload,
1414 type WorkspaceRuntime,
1515 } from "./modules/workspace";
16-import { initializeWindowOpenInterceptor } from "./modules/window-open";
1716 import { keyboardService, type KeyboardService } from "$lib/keyboard";
1817 import type { Suggestion } from "../components/omnibox/types";
1918 // Note: color-scheme module is loaded separately at document-start by WebKit
@@ -156,9 +155,7 @@ if (!window.__dumber_gui_ready) {
156155 whenDOMReady(async () => {
157156 try {
158157 // Page-world bridge is injected by WebKit; avoid duplicate injection here
159-
160- // Initialize window.open interceptor first (must be early)
161- initializeWindowOpenInterceptor();
158+ // window.open interceptor is now in main-world.ts (page-world context)
162159
163160 // Initialize toast system first
164161 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 = {
8080 l: "split-left",
8181 u: "split-up",
8282 d: "split-down",
83+ s: "stack-pane",
8384 x: "close-pane",
8485 enter: "confirm",
8586 escape: "cancel",
@@ -254,7 +255,6 @@ class WorkspaceController implements WorkspaceRuntime {
254255 clearTimeout(this.paneModeTimer);
255256 this.paneModeTimer = null;
256257 }
257- this.emitWorkspaceEvent("pane-mode-exited", { action: reason });
258258 this.bridge({ event: "pane-mode-exited", reason });
259259 }
260260
@@ -308,7 +308,7 @@ class WorkspaceController implements WorkspaceRuntime {
308308 direction: "right",
309309 config: this.config,
310310 });
311- this.bridge({ event: "pane-split", direction: "right" });
311+ this.bridge({ event: "pane-split", direction: "right", action: "split-right" });
312312 this.showToast("Split pane to the right");
313313 this.exitPaneMode("split-right");
314314 break;
@@ -317,7 +317,7 @@ class WorkspaceController implements WorkspaceRuntime {
317317 direction: "left",
318318 config: this.config,
319319 });
320- this.bridge({ event: "pane-split", direction: "left" });
320+ this.bridge({ event: "pane-split", direction: "left", action: "split-left" });
321321 this.showToast("Split pane to the left");
322322 this.exitPaneMode("split-left");
323323 break;
@@ -326,7 +326,7 @@ class WorkspaceController implements WorkspaceRuntime {
326326 direction: "up",
327327 config: this.config,
328328 });
329- this.bridge({ event: "pane-split", direction: "up" });
329+ this.bridge({ event: "pane-split", direction: "up", action: "split-up" });
330330 this.showToast("Split pane upwards");
331331 this.exitPaneMode("split-up");
332332 break;
@@ -335,10 +335,15 @@ class WorkspaceController implements WorkspaceRuntime {
335335 direction: "down",
336336 config: this.config,
337337 });
338- this.bridge({ event: "pane-split", direction: "down" });
338+ this.bridge({ event: "pane-split", direction: "down", action: "split-down" });
339339 this.showToast("Split pane downwards");
340340 this.exitPaneMode("split-down");
341341 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;
342347 case "close-pane":
343348 this.emitWorkspaceEvent("pane-closed", { key, config: this.config });
344349 this.bridge({ event: "pane-close", action });
M gui/src/pages/Homepage.svelte +18 -37
@@ -16,7 +16,6 @@
1616 interface Shortcut {
1717 key: string;
1818 description: string;
19- action: () => void;
2019 }
2120
2221 interface HistoryStats {
@@ -95,95 +94,77 @@
9594 // Browser shortcuts
9695 {
9796 key: 'Ctrl+L',
98- description: 'Focus address bar',
99- action: () => console.log('Ctrl+L triggered')
97+ description: 'Focus address bar'
10098 },
10199 {
102100 key: 'Ctrl+F',
103- description: 'Find in page',
104- action: () => console.log('Ctrl+F triggered')
101+ description: 'Find in page'
105102 },
106103 {
107104 key: 'Ctrl+Shift+C',
108- description: 'Copy URL',
109- action: () => console.log('Ctrl+Shift+C triggered')
105+ description: 'Copy URL'
110106 },
111107 {
112108 key: 'Ctrl+R / F5',
113- description: 'Reload page',
114- action: () => window.location.reload()
109+ description: 'Reload page'
115110 },
116111 {
117112 key: 'Ctrl+Shift+R',
118- description: 'Hard reload',
119- action: () => console.log('Ctrl+Shift+R triggered')
113+ description: 'Hard reload'
120114 },
121115 {
122116 key: 'F12',
123- description: 'DevTools',
124- action: () => console.log('F12 triggered')
117+ description: 'DevTools'
125118 },
126119 {
127120 key: 'Ctrl+← / Ctrl+→',
128- description: 'Navigate back/forward',
129- action: () => console.log('Ctrl+Arrow triggered')
121+ description: 'Navigate back/forward'
130122 },
131123 {
132124 key: 'Ctrl+0',
133- description: 'Reset zoom',
134- action: () => console.log('Ctrl+0 triggered')
125+ description: 'Reset zoom'
135126 },
136127 {
137128 key: 'Ctrl++ / Ctrl+-',
138- description: 'Zoom in/out',
139- action: () => console.log('Ctrl+Zoom triggered')
129+ description: 'Zoom in/out'
140130 },
141131 // Workspace pane mode
142132 {
143133 key: 'Ctrl+P',
144- description: 'Enter pane mode',
145- action: () => console.log('Ctrl+P triggered')
134+ description: 'Enter pane mode'
146135 },
147136 {
148137 key: '→ / R (pane mode)',
149- description: 'Split pane right',
150- action: () => console.log('Split right triggered')
138+ description: 'Split pane right'
151139 },
152140 {
153141 key: '← / L (pane mode)',
154- description: 'Split pane left',
155- action: () => console.log('Split left triggered')
142+ description: 'Split pane left'
156143 },
157144 {
158145 key: '↑ / U (pane mode)',
159- description: 'Split pane up',
160- action: () => console.log('Split up triggered')
146+ description: 'Split pane up'
161147 },
162148 {
163149 key: '↓ / D (pane mode)',
164- description: 'Split pane down',
165- action: () => console.log('Split down triggered')
150+ description: 'Split pane down'
166151 },
167152 {
168153 key: 'X (pane mode)',
169- description: 'Close current pane',
170- action: () => console.log('Close pane triggered')
154+ description: 'Close current pane'
171155 },
172156 {
173157 key: 'Enter (pane mode)',
174- description: 'Confirm action',
175- action: () => console.log('Confirm triggered')
158+ description: 'Confirm action'
176159 },
177160 {
178161 key: 'Escape (pane mode)',
179- description: 'Exit pane mode',
180- action: () => console.log('Exit pane mode triggered')
162+ description: 'Exit pane mode'
181163 },
182164 // Workspace navigation
183165 {
184166 key: 'Alt+Arrow Keys',
185- description: 'Navigate between panes',
186- action: () => console.log('Navigate panes triggered')
167+ description: 'Navigate between panes'
187168 }
188169 ];
189170 shortcutsLoading = false;
M internal/app/browser/browser.go +8 -0
@@ -110,6 +110,7 @@ func (app *BrowserApp) Initialize() error {
110110 log.Printf("Warning: failed to initialize WebKit log capture: %v", err)
111111 } else {
112112 defer webkit.StopWebKitLogCapture()
113+ webkit.StartWebKitOutputCapture()
113114 }
114115 }
115116
@@ -220,6 +221,13 @@ func (app *BrowserApp) cleanup() {
220221 // setupOutputCapture initializes stdout/stderr capture if configured
221222 func (app *BrowserApp) setupOutputCapture() error {
222223 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+
223231 log.Printf("Warning: stdout/stderr capture is experimental and may interfere with normal operations")
224232 if logger := logging.GetLogger(); logger != nil {
225233 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() {
125125 p.guiComponents = nil
126126 p.hasGUI = false
127127 }
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 {
4444
4545 // For related popups, they're active if parent chain contains the active node
4646 if node.isRelated && node.parentPane != nil {
47- activeNode := s.app.workspace.active
47+ activeNode := s.app.workspace.GetActiveNode()
4848 cur := node
4949 for cur != nil {
5050 if cur == activeNode {
@@ -56,7 +56,7 @@ func (s *ShortcutHandler) isActivePane() bool {
5656 }
5757
5858 // Independent panes: equal to active node
59- return node == s.app.workspace.active
59+ return node == s.app.workspace.GetActiveNode()
6060 }
6161
6262 // 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) {
157157 if url == "" {
158158 return
159159 }
160+
161+ // Update database with new title
160162 go func(url, title string) {
161163 ctx := context.Background()
162164 if err := app.browserService.UpdatePageTitle(ctx, url, title); err != nil {
163165 log.Printf("Warning: failed to update page title: %v", err)
164166 }
165167 }(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+ }
166173 })
167174
168175 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
@@ -1,49 +1,30 @@
1+// workspace_manager.go - Core workspace management and coordination
12 package browser
23
34 import (
45 "errors"
56 "fmt"
67 "log"
7- "math"
88 "strings"
99 "sync"
10+ "sync/atomic"
1011 "time"
1112
1213 "github.com/bnema/dumber/internal/app/messaging"
13- "github.com/bnema/dumber/internal/config"
1414 "github.com/bnema/dumber/pkg/webkit"
1515 )
1616
17-type paneNode struct {
18- pane *BrowserPane
19- parent *paneNode
20- left *paneNode
21- right *paneNode
22- container uintptr // GtkPaned for branch nodes, stable WebView container for leaves
23- orientation webkit.Orientation
24- isLeaf bool
25- isPopup bool // Deprecated: use windowType instead
26- // Window type tracking
27- windowType webkit.WindowType // Tab or Popup
28- windowFeatures *webkit.WindowFeatures // Features if popup
29- isRelated bool // Shares context
30- parentPane *paneNode // Parent for related views
31- autoClose bool // Auto-close on OAuth success
32- hoverToken uintptr
33-}
34-
3517 // WorkspaceManager coordinates Zellij-style pane operations.
3618 type WorkspaceManager struct {
3719 app *BrowserApp
3820 window *webkit.Window
3921 root *paneNode
40- active *paneNode
4122 mainPane *paneNode
4223 viewToNode map[*webkit.WebView]*paneNode
4324 lastSplitMsg map[*webkit.WebView]time.Time
4425 lastExitMsg map[*webkit.WebView]time.Time
4526 paneModeActive bool
46- splitting bool
27+ splitting int32 // atomic: 0=false, 1=true
4728 cssInitialized bool
4829 createWebViewFn func() (*webkit.WebView, error)
4930 createPaneFn func(*webkit.WebView) (*BrowserPane, error)
@@ -57,28 +38,74 @@ type WorkspaceManager struct {
5738 lastFocusChange time.Time // When focus was last changed
5839 focusThrottleMutex sync.Mutex // Protects focus throttling state
5940
41+ // Stack operation timing to prevent focus conflicts
42+ lastStackOperation time.Time // When a stack operation was last performed
43+
6044 // NEW: Pane creation deduplicator
6145 paneDeduplicator *messaging.PaneRequestDeduplicator
62-}
6346
64-const (
65- activePaneClass = "workspace-pane-active"
66- basePaneClass = "workspace-pane"
67- multiPaneClass = "workspace-multi-pane"
68-)
47+ // Specialized managers for different pane operations
48+ stackedPaneManager *StackedPaneManager
49+ focusStateMachine *FocusStateMachine
50+
51+ // Focus debouncing to prevent rapid oscillation from any source
52+ lastFocusTime time.Time
53+ lastFocusTarget *paneNode
54+ focusDebounce time.Duration
55+
56+ // Validation and safety systems (debug-only, controlled by DUMBER_DEBUG_WORKSPACE env)
57+ treeValidator *TreeValidator // Debug-only tree validation
58+ treeRebalancer *TreeRebalancer // Rebalance after close operations
59+ geometryValidator *GeometryValidator // Validate split constraints
60+ stackLifecycleManager *StackLifecycleManager // Stack pane lifecycle
61+
62+ // Debug instrumentation helpers
63+ debugLevel DebugLevel
64+ debugPaneClose bool
65+ diagnostics *WorkspaceDiagnostics
66+
67+ // Cleanup tracking
68+ cleanupCounter uint
69+ pendingIdle map[uintptr][]*paneNode
70+}
6971
7072 // Workspace navigation shortcuts are now handled globally by WindowShortcutHandler
7173
7274 // NewWorkspaceManager builds a workspace manager rooted at the provided pane.
7375 func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManager {
76+ debugLevel := getDebugLevel()
77+
7478 manager := &WorkspaceManager{
7579 app: app,
7680 window: rootPane.webView.Window(),
7781 viewToNode: make(map[*webkit.WebView]*paneNode),
7882 lastSplitMsg: make(map[*webkit.WebView]time.Time),
7983 lastExitMsg: make(map[*webkit.WebView]time.Time),
80- paneDeduplicator: messaging.NewPaneRequestDeduplicator(), // NEW: Initialize deduplicator
84+ paneDeduplicator: messaging.NewPaneRequestDeduplicator(),
85+ focusDebounce: 150 * time.Millisecond,
86+ debugLevel: debugLevel,
87+ }
88+
89+ // Initialize validation components (opt-in based on debug level)
90+ if debugLevel >= DebugBasic {
91+ manager.treeValidator = NewTreeValidator(true, debugLevel == DebugFull)
92+ manager.geometryValidator = NewGeometryValidator()
93+ manager.geometryValidator.SetDebugMode(debugLevel == DebugFull)
94+ } else {
95+ // Production mode: disable validators
96+ manager.treeValidator = NewTreeValidator(false, false)
97+ manager.geometryValidator = NewGeometryValidator()
8198 }
99+
100+ // Initialize tree rebalancer (always needed for proper close operations)
101+ manager.treeRebalancer = NewTreeRebalancer(manager, manager.treeValidator)
102+
103+ // Initialize stack lifecycle manager
104+ manager.stackLifecycleManager = NewStackLifecycleManager(manager, manager.treeValidator)
105+
106+ // Initialize existing specialized managers (now enhanced with bulletproof components)
107+ manager.stackedPaneManager = NewStackedPaneManager(manager)
108+ manager.focusStateMachine = NewFocusStateMachine(manager)
82109 manager.createWebViewFn = func() (*webkit.WebView, error) {
83110 if manager.app == nil {
84111 return nil, errors.New("workspace manager missing app reference")
@@ -87,9 +114,11 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag
87114 if err != nil {
88115 return nil, err
89116 }
90- // Keep CreateWindow = true for popup WebViews to ensure proper window features initialization
91- // This prevents WindowFeatures optional crashes when WebKit accesses popup properties
92- cfg.CreateWindow = true
117+ // Pane WebViews are embedded inside the main application window, so they do not
118+ // need their own toplevel GtkWindow. Avoid creating per-pane windows to keep GTK
119+ // ownership simple (Epiphany follows the same pattern) and to prevent premature
120+ // swapchain destruction when panes are closed.
121+ cfg.CreateWindow = false
93122 return webkit.NewWebView(cfg)
94123 }
95124 manager.createPaneFn = func(view *webkit.WebView) (*BrowserPane, error) {
@@ -98,16 +127,17 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag
98127 }
99128 return manager.app.createPaneForView(view)
100129 }
101- manager.ensureStyles()
130+ manager.ensureWorkspaceStyles()
102131
103132 rootContainer := rootPane.webView.RootWidget()
104133 root := &paneNode{
105- pane: rootPane,
106- container: rootContainer,
107- isLeaf: true,
134+ pane: rootPane,
135+ isLeaf: true,
108136 }
137+ // Initialize widgets properly
138+ manager.initializePaneWidgets(root, rootContainer)
139+
109140 manager.root = root
110- manager.active = root
111141 manager.mainPane = root
112142 manager.viewToNode[rootPane.webView] = root
113143 manager.ensureHover(root)
@@ -117,10 +147,29 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag
117147 }
118148
119149 app.workspace = manager
120- manager.focusNode(root)
121- // Workspace navigation shortcuts are now handled globally by WindowShortcutHandler
122- // Ensure initial CSS classes are applied
123- manager.ensurePaneBaseClasses()
150+
151+ // Configure debug settings from app config
152+ if app.config != nil {
153+ manager.focusStateMachine.ConfigureDebug(
154+ app.config.Debug.EnableFocusDebug,
155+ false, // CSS debug removed
156+ app.config.Debug.EnableFocusMetrics,
157+ )
158+
159+ manager.debugPaneClose = app.config.Debug.EnablePaneCloseDebug
160+ }
161+
162+ manager.diagnostics = NewWorkspaceDiagnostics(manager.debugPaneClose)
163+
164+ // Initialize focus state machine after all setup is complete
165+ if err := manager.focusStateMachine.Initialize(); err != nil {
166+ log.Printf("[workspace] Failed to initialize focus state machine: %v", err)
167+ }
168+
169+ if manager.debugPaneClose {
170+ manager.dumpTreeState("workspace_init")
171+ }
172+
124173 return manager
125174 }
126175
@@ -156,7 +205,7 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa
156205 wm.paneModeActive = true
157206 wm.paneModeSource = source
158207 wm.lastPaneModeEntry = time.Now()
159- wm.focusNode(node)
208+ wm.SetActivePane(node, SourceProgrammatic)
160209 case "pane-confirmed", "pane-cancelled", "pane-mode-exited":
161210 // Debounce pane-mode-exited events to prevent duplicate focus calls
162211 if event == "pane-mode-exited" {
@@ -174,7 +223,7 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa
174223 wm.paneModeSource = nil
175224 wm.paneMutex.Unlock()
176225
177- wm.focusNode(node)
226+ wm.focusAfterPaneMode(node)
178227 case "pane-close":
179228 if !wm.paneModeActive {
180229 log.Printf("[workspace] close requested outside pane mode; ignoring")
@@ -189,7 +238,7 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa
189238 // Don't focus the node that's about to be closed - closeCurrentPane() will handle focus
190239 wm.closeCurrentPane()
191240 case "pane-split":
192- wm.focusNode(node)
241+ wm.SetActivePane(node, SourceProgrammatic)
193242 direction := strings.ToLower(msg.Direction)
194243 if direction == "" {
195244 direction = "right"
@@ -200,20 +249,70 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa
200249 return
201250 }
202251 }
203- if wm.splitting {
252+
253+ // CRITICAL FIX: Use atomic operations for splitting flag to prevent race conditions
254+ if !atomic.CompareAndSwapInt32(&wm.splitting, 0, 1) {
204255 log.Printf("[workspace] split ignored: operation already in progress")
205256 return
206257 }
207- wm.splitting = true
258+
259+ // CRITICAL FIX: Always clear splitting flag on exit (panic recovery)
260+ defer func() {
261+ atomic.StoreInt32(&wm.splitting, 0)
262+ if r := recover(); r != nil {
263+ log.Printf("[workspace] split operation panicked, flag cleared: %v", r)
264+ panic(r) // Re-panic to maintain error handling
265+ }
266+ }()
267+
208268 wm.lastSplitMsg[source] = time.Now()
209- newNode, err := wm.splitNode(node, direction)
269+
270+ newNode, err := wm.SplitPane(node, direction)
210271 if err != nil {
211272 log.Printf("[workspace] split failed: %v", err)
212- wm.splitting = false
273+ // CRITICAL FIX: Pump GTK events after validation failure to clear pending operations
274+ if webkit.IsMainThread() {
275+ webkit.IterateMainLoop()
276+ }
277+ return
278+ }
279+ wm.clonePaneState(node, newNode)
280+ case "pane-stack":
281+ wm.SetActivePane(node, SourceProgrammatic)
282+ if last, ok := wm.lastSplitMsg[source]; ok {
283+ if time.Since(last) < 200*time.Millisecond {
284+ log.Printf("[workspace] stack ignored: debounce (%.0fms)", time.Since(last).Seconds()*1000)
285+ return
286+ }
287+ }
288+
289+ // CRITICAL FIX: Use atomic operations for splitting flag
290+ if !atomic.CompareAndSwapInt32(&wm.splitting, 0, 1) {
291+ log.Printf("[workspace] stack ignored: operation already in progress")
292+ return
293+ }
294+
295+ // CRITICAL FIX: Always clear splitting flag on exit (panic recovery)
296+ defer func() {
297+ atomic.StoreInt32(&wm.splitting, 0)
298+ if r := recover(); r != nil {
299+ log.Printf("[workspace] stack operation panicked, flag cleared: %v", r)
300+ panic(r) // Re-panic to maintain error handling
301+ }
302+ }()
303+
304+ wm.lastSplitMsg[source] = time.Now()
305+
306+ newNode, err := wm.stackedPaneManager.StackPane(node)
307+ if err != nil {
308+ log.Printf("[workspace] stack failed: %v", err)
309+ // CRITICAL FIX: Pump GTK events after stack failure
310+ if webkit.IsMainThread() {
311+ webkit.IterateMainLoop()
312+ }
213313 return
214314 }
215315 wm.clonePaneState(node, newNode)
216- wm.splitting = false
217316 case "create-pane":
218317 log.Printf("[workspace] create-pane requested: url=%s action=%s requestId=%s", msg.URL, msg.Action, msg.RequestID)
219318
@@ -298,1947 +397,140 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa
298397
299398 log.Printf("[workspace] Closing popup pane due to %s", msg.Reason)
300399
301- // Close the popup pane
302- if err := wm.closePane(targetNode); err != nil {
303- log.Printf("[workspace] Failed to close popup pane: %v", err)
304- } else {
305- log.Printf("[workspace] Successfully closed popup pane: %s", msg.WebViewID)
306- }
307- default:
308- log.Printf("[workspace] unhandled workspace event: %s", msg.Event)
309- }
310-}
311-
312-// GetActiveNode returns the currently active pane node
313-func (wm *WorkspaceManager) GetActiveNode() *paneNode {
314- return wm.active
315-}
316-
317-// GetNodeForWebView returns the pane node associated with a WebView
318-func (wm *WorkspaceManager) GetNodeForWebView(webView *webkit.WebView) *paneNode {
319- return wm.viewToNode[webView]
320-}
321-
322-// RegisterNavigationHandler sets up navigation handling for a webview (simplified)
323-func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) {
324- if webView == nil {
325- return
326- }
327-
328- log.Printf("[workspace] Registered navigation handler for webview: %s", webView.ID())
329-}
330-
331-// DispatchPaneFocusEvent sends a workspace focus event to a pane's webview
332-func (wm *WorkspaceManager) DispatchPaneFocusEvent(node *paneNode, active bool) {
333- if node == nil || node.pane == nil || node.pane.webView == nil {
334- return
335- }
336-
337- detail := map[string]any{
338- "active": active,
339- "webview": fmt.Sprintf("%p", node.pane.webView),
340- "webviewId": node.pane.webView.ID(),
341- "hasGUI": node.pane.HasGUI(),
342- "timestamp": time.Now().UnixMilli(),
343- }
344-
345- if err := node.pane.webView.DispatchCustomEvent("dumber:workspace-focus", detail); err != nil {
346- log.Printf("[workspace] failed to dispatch focus event: %v", err)
347- } else if wm.app.config != nil && wm.app.config.Debug.EnableWorkspaceDebug {
348- log.Printf("[workspace] dispatched focus event for webview %s (active=%v)", node.pane.webView.ID(), active)
349- }
350-}
351-
352-func (wm *WorkspaceManager) focusNode(node *paneNode) {
353- if node == nil || !node.isLeaf || node.pane == nil || node.pane.webView == nil {
354- return
355- }
356-
357- // Don't focus a node that's not in our viewToNode map (likely destroyed)
358- if _, exists := wm.viewToNode[node.pane.webView]; !exists {
359- log.Printf("[workspace] focusNode: skipping destroyed/unknown webView=%p", node.pane.webView)
360- return
361- }
362-
363- previous := wm.active
364- var previousContainer uintptr
365- var previousWebView *webkit.WebView
366- if previous != nil && previous != node && previous.pane != nil && previous.pane.webView != nil {
367- previousContainer = previous.container
368- previousWebView = previous.pane.webView
369- wm.DispatchPaneFocusEvent(previous, false)
370- }
371-
372- // Set previously active WebView as inactive
373- if previousWebView != nil && previousWebView != node.pane.webView {
374- previousWebView.SetActive(false)
375- }
376-
377- wm.active = node
378- wm.app.activePane = node.pane
379- wm.app.webView = node.pane.webView
380- wm.app.zoomController = node.pane.zoomController
381- wm.app.navigationController = node.pane.navigationController
382- wm.app.clipboardController = node.pane.clipboardController
383- wm.app.messageHandler = node.pane.messageHandler
384- wm.app.shortcutHandler = node.pane.shortcutHandler
385-
386- // Set this WebView as active
387- if node.pane != nil && node.pane.webView != nil {
388- node.pane.webView.SetActive(true)
389- }
390-
391- // Shortcuts are registered once during webView creation, no need to re-register on focus
392-
393- if handler := node.pane.messageHandler; handler != nil {
394- handler.SetWorkspaceObserver(wm)
395- }
396-
397- if wm.app.browserService != nil {
398- wm.app.browserService.AttachWebView(node.pane.webView)
399- }
400-
401- // Ensure all panes have proper base classes
402- wm.ensurePaneBaseClasses()
403-
404- container := node.container
405- viewWidget := node.pane.webView.Widget()
406- if container != 0 && container != previousContainer {
407- // Remove active class from previous container to ensure only one pane is visually active
408- if previousContainer != 0 {
409- webkit.WidgetRemoveCSSClass(previousContainer, activePaneClass)
410- }
411-
412- // Add active class to current container (border visibility controlled by CSS multi-pane rules)
413- webkit.WidgetAddCSSClass(container, activePaneClass)
414- if !webkit.WidgetIsValid(container) {
415- log.Printf("[workspace] focus aborted: container invalid widget=%#x", container)
416- return
417- }
418- webkit.IdleAdd(func() bool {
419- if !webkit.WidgetIsValid(container) {
420- log.Printf("[workspace] focus aborted during idle: container invalid widget=%#x", container)
421- return false
422- }
423- parent := webkit.WidgetGetParent(container)
424- if parent != 0 {
425- webkit.WidgetRealizeInContainer(container)
426- if viewWidget != 0 {
427- webkit.WidgetGrabFocus(viewWidget)
400+ // Remove this popup from parent's activePopupChildren before closing
401+ if targetNode.parentPane != nil {
402+ parentNode := targetNode.parentPane
403+ for i, childID := range parentNode.activePopupChildren {
404+ if childID == msg.WebViewID {
405+ parentNode.activePopupChildren = append(parentNode.activePopupChildren[:i], parentNode.activePopupChildren[i+1:]...)
406+ log.Printf("[workspace] Removed popup %s from parent's activePopupChildren (remaining: %d)", msg.WebViewID, len(parentNode.activePopupChildren))
407+ break
428408 }
429- // Consolidated focus operation log
430- if wm.app.config != nil && wm.app.config.Debug.EnableWorkspaceDebug {
431- log.Printf("[workspace] focus operations completed: container=%#x parent=%#x viewWidget=%#x", container, parent, viewWidget)
432- }
433- } else {
434- log.Printf("[workspace] focus deferred: widget not parented")
435409 }
436- return false // Remove idle callback
437- })
438- }
439-
440- if node.pane != nil && node.pane.webView != nil {
441- // Update pane focus time
442- node.pane.UpdateLastFocus()
443410
444- // Always dispatch focus event to ensure JavaScript side knows the active pane
445- // This fixes the issue where navigation within the same pane doesn't update JS state
446- wm.DispatchPaneFocusEvent(node, true)
411+ // Clean up ALL localStorage entries for this popup in parent WebView
412+ // This includes popup_<requestId>_parent_info, popup_<requestId>_parent_action, etc.
413+ if parentNode.pane != nil && parentNode.pane.WebView() != nil && targetNode.requestID != "" {
414+ parentWebViewID := parentNode.pane.WebView().ID()
415+ requestID := targetNode.requestID
416+
417+ cleanupScript := fmt.Sprintf(`
418+ try {
419+ // Clean up popup_mapping_<parentId> (from Go code)
420+ localStorage.removeItem('popup_mapping_%s');
421+
422+ // Clean up popup_<requestId>_* entries (from JavaScript)
423+ const requestId = '%s';
424+ const keysToRemove = [
425+ 'popup_' + requestId + '_parent_info',
426+ 'popup_' + requestId + '_parent_action',
427+ 'popup_' + requestId + '_message_to_popup',
428+ 'popup_' + requestId + '_message_to_parent',
429+ 'oauth_callback_' + '%s'
430+ ];
431+
432+ keysToRemove.forEach(key => {
433+ try {
434+ localStorage.removeItem(key);
435+ } catch(e) {}
436+ });
437+
438+ console.log('[workspace] Cleaned up popup localStorage for requestId:', requestId);
439+ } catch(e) {
440+ console.warn('[workspace] Failed to clean popup localStorage:', e);
441+ }
442+ `, parentWebViewID, requestID, msg.WebViewID)
447443
448- // Lazy-load GUI components if first focus
449- if !node.pane.HasGUI() {
450- wm.ensureGUIInPane(node.pane)
444+ if err := parentNode.pane.WebView().InjectScript(cleanupScript); err != nil {
445+ log.Printf("[workspace] Failed to inject localStorage cleanup script: %v", err)
446+ } else {
447+ log.Printf("[workspace] Cleaned up localStorage for popup requestId=%s webviewId=%s", requestID, msg.WebViewID)
448+ }
449+ }
451450 }
452- }
453-}
454-
455-// generateActivePaneCSS generates the CSS for workspace panes based on config
456-func (wm *WorkspaceManager) generateActivePaneCSS() string {
457- cfg := config.Get()
458- styling := cfg.Workspace.Styling
459-
460- // Get appropriate border colors based on GTK theme preference
461- var inactiveBorderColor, windowBackgroundColor string
462- isDark := webkit.PrefersDarkTheme()
463- if isDark {
464- inactiveBorderColor = "#333333" // Dark border for dark theme
465- windowBackgroundColor = "#2b2b2b" // Dark window background
466- } else {
467- inactiveBorderColor = "#dddddd" // Light border for light theme
468- windowBackgroundColor = "#ffffff" // Light window background
469- }
470-
471- // Log the border color values for debugging
472- log.Printf("[workspace] GTK prefers dark: %v, inactive border color: %s", isDark, inactiveBorderColor)
473-
474- css := fmt.Sprintf(`window {
475- background-color: %s;
476- }
477451
478- .workspace-pane {
479- background-color: %s;
480- border: %dpx solid %s;
481- transition: border-color %dms ease-in-out;
482- border-radius: %dpx;
483- }
484-
485- .workspace-pane.workspace-multi-pane {
486- border: %dpx solid %s;
487- border-radius: %dpx;
488- }
489-
490- .workspace-pane.workspace-multi-pane.workspace-pane-active {
491- border-color: %s;
492- }`,
493- windowBackgroundColor,
494- windowBackgroundColor,
495- styling.BorderWidth,
496- inactiveBorderColor,
497- styling.TransitionDuration,
498- styling.BorderRadius,
499- styling.BorderWidth,
500- inactiveBorderColor,
501- styling.BorderRadius,
502- styling.BorderColor,
503- )
504-
505- // Log the actual CSS being generated
506- log.Printf("[workspace] Generated CSS: %s", css)
507-
508- return css
509-}
452+ // Close the popup pane
453+ if err := wm.ClosePane(targetNode); err != nil {
454+ log.Printf("[workspace] Failed to close popup pane: %v", err)
455+ } else {
456+ log.Printf("[workspace] Successfully closed popup pane: %s", msg.WebViewID)
510457
511-func (wm *WorkspaceManager) ensureStyles() {
512- if wm == nil || wm.cssInitialized {
513- return
514- }
515- activePaneCSS := wm.generateActivePaneCSS()
516- webkit.AddCSSProvider(activePaneCSS)
517- wm.cssInitialized = true
518-}
458+ // Fix GTK rendering for parent pane after popup closes
459+ // The parent pane's rendering can become corrupted after popup closes
460+ if targetNode.parentPane != nil {
461+ parentNode := targetNode.parentPane
462+ if parentNode.container != 0 && webkit.WidgetIsValid(parentNode.container) {
463+ log.Printf("[workspace] Fixing parent pane rendering after popup close")
519464
520-// hasMultiplePanes returns true if there are multiple panes in the workspace
521-func (wm *WorkspaceManager) hasMultiplePanes() bool {
522- return wm != nil && wm.app != nil && len(wm.app.panes) > 1
523-}
465+ // CRITICAL: Hide parent container to disconnect WebKitGTK rendering pipeline
466+ webkit.WidgetHide(parentNode.container)
524467
525-// ensurePaneBaseClasses ensures all panes have the proper base CSS classes
526-func (wm *WorkspaceManager) ensurePaneBaseClasses() {
527- if wm == nil {
528- return
529- }
468+ // Schedule showing and forcing GTK to reconnect rendering
469+ wm.scheduleIdleGuarded(func() bool {
470+ if parentNode == nil || !parentNode.widgetValid || parentNode.container == 0 {
471+ return false
472+ }
473+ // Show widget to reconnect WebKit rendering pipeline
474+ webkit.WidgetShow(parentNode.container)
475+ // Force GTK to recalculate size and recreate rendering surface
476+ webkit.WidgetQueueResize(parentNode.container)
477+ webkit.WidgetQueueDraw(parentNode.container)
478+
479+ // Also queue resize+draw on the WebView widget itself
480+ if parentNode.pane != nil && parentNode.pane.WebView() != nil {
481+ webViewWidget := parentNode.pane.WebView().Widget()
482+ if webViewWidget != 0 && webkit.WidgetIsValid(webViewWidget) {
483+ webkit.WidgetQueueResize(webViewWidget)
484+ webkit.WidgetQueueDraw(webViewWidget)
485+ log.Printf("[workspace] Queued resize+draw for parent WebView widget")
486+ }
487+ }
530488
531- leaves := wm.collectLeaves()
532- for _, leaf := range leaves {
533- if leaf != nil && leaf.container != 0 {
534- webkit.WidgetAddCSSClass(leaf.container, basePaneClass)
535- if wm.hasMultiplePanes() {
536- webkit.WidgetAddCSSClass(leaf.container, multiPaneClass)
537- } else {
538- webkit.WidgetRemoveCSSClass(leaf.container, multiPaneClass)
489+ log.Printf("[workspace] Shown and queued resize+draw for parent pane after popup close")
490+ return false
491+ }, parentNode)
492+ }
539493 }
540494 }
495+ default:
496+ log.Printf("[workspace] unhandled workspace event: %s", msg.Event)
541497 }
542498 }
543499
544-func (wm *WorkspaceManager) focusByView(view *webkit.WebView) {
545- if wm == nil || view == nil {
546- return
547- }
548-
549- // Throttle focus changes to prevent infinite loops
550- wm.focusThrottleMutex.Lock()
551- const focusThrottleInterval = 100 * time.Millisecond
552- if time.Since(wm.lastFocusChange) < focusThrottleInterval {
553- wm.focusThrottleMutex.Unlock()
554- return
555- }
556- wm.lastFocusChange = time.Now()
557- wm.focusThrottleMutex.Unlock()
558-
559- if node, ok := wm.viewToNode[view]; ok {
560- if wm.active != node {
561- wm.focusNode(node)
562- }
563- }
500+// GetActiveNode returns the currently active pane node
501+func (wm *WorkspaceManager) GetActiveNode() *paneNode {
502+ return wm.focusStateMachine.GetActivePane()
564503 }
565504
566-func (wm *WorkspaceManager) ensureHover(node *paneNode) {
567- if wm == nil || node == nil || !node.isLeaf {
568- return
569- }
570- if node.container == 0 || node.hoverToken != 0 {
571- return
572- }
573-
574- token := webkit.WidgetAddHoverHandler(node.container, func() {
575- if wm == nil {
576- return
577- }
578- wm.focusNode(node)
579- })
580- node.hoverToken = token
581- if token == 0 {
582- log.Printf("[workspace] failed to attach hover handler: widget=%#x", node.container)
583- }
584-}
505+// SetActivePane requests focus change through the focus state machine with debouncing
506+func (wm *WorkspaceManager) SetActivePane(node *paneNode, source FocusSource) {
507+ now := time.Now()
585508
586-func (wm *WorkspaceManager) detachHover(node *paneNode) {
587- if wm == nil || node == nil || node.hoverToken == 0 {
509+ // Check if we should debounce this focus request (except for urgent system events)
510+ if source != SourceSystem && now.Sub(wm.lastFocusTime) < wm.focusDebounce && wm.lastFocusTarget == node {
511+ // Too soon and same target - ignore this request to prevent rapid oscillation
588512 return
589513 }
590- webkit.WidgetRemoveHoverHandler(node.container, node.hoverToken)
591- node.hoverToken = 0
592-}
593-
594-// FocusNeighbor moves focus to the nearest pane in the requested direction using the
595-// actual widget geometry to determine adjacency.
596-func (wm *WorkspaceManager) FocusNeighbor(direction string) bool {
597- if wm == nil {
598- return false
599- }
600- switch strings.ToLower(direction) {
601- case "left", "right", "up", "down":
602- return wm.focusAdjacent(strings.ToLower(direction))
603- default:
604- return false
605- }
606-}
607-
608-const focusEpsilon = 1e-3
609-
610-func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
611- if wm.active == nil || !wm.active.isLeaf || wm.active.container == 0 {
612- return false
613- }
614-
615- if neighbor := wm.structuralNeighbor(wm.active, direction); neighbor != nil {
616- wm.focusNode(neighbor)
617- return true
618- }
619-
620- currentBounds, ok := webkit.WidgetGetBounds(wm.active.container)
621- if !ok {
622- log.Printf("[workspace] unable to compute bounds for active pane")
623- return false
624- }
625-
626- cx := currentBounds.X + currentBounds.Width/2.0
627- cy := currentBounds.Y + currentBounds.Height/2.0
628-
629- leaves := wm.collectLeaves()
630- bestScore := math.MaxFloat64
631- var best *paneNode
632- var debugCandidates []string
633-
634- for _, candidate := range leaves {
635- if candidate == nil || candidate == wm.active || candidate.container == 0 {
636- continue
637- }
638- bounds, ok := webkit.WidgetGetBounds(candidate.container)
639- if !ok {
640- continue
641- }
642- tx := bounds.X + bounds.Width/2.0
643- ty := bounds.Y + bounds.Height/2.0
644-
645- dx := tx - cx
646- dy := ty - cy
647-
648- var score float64
649- switch direction {
650- case "left":
651- if dx >= -focusEpsilon {
652- continue
653- }
654- score = math.Abs(dx)*1000 + math.Abs(dy)
655- case "right":
656- if dx <= focusEpsilon {
657- continue
658- }
659- score = math.Abs(dx)*1000 + math.Abs(dy)
660- case "up":
661- if dy >= -focusEpsilon {
662- continue
663- }
664- score = math.Abs(dy)*1000 + math.Abs(dx)
665- case "down":
666- if dy <= focusEpsilon {
667- continue
668- }
669- score = math.Abs(dy)*1000 + math.Abs(dx)
670- }
671-
672- if direction == "up" || direction == "down" {
673- debugCandidates = append(debugCandidates, fmt.Sprintf("cand=%#x dx=%.2f dy=%.2f score=%.2f", candidate.container, dx, dy, score))
674- }
675-
676- if score < bestScore {
677- bestScore = score
678- best = candidate
679- }
680- }
681-
682- if best != nil {
683- wm.focusNode(best)
684- return true
685- }
686514
687- if len(debugCandidates) > 0 {
688- log.Printf("[workspace] focusAdjacent no candidate direction=%s current=%#x candidates=%s", direction, wm.active.container, strings.Join(debugCandidates, "; "))
689- }
690- return false
691-}
692-
693-func (wm *WorkspaceManager) structuralNeighbor(node *paneNode, direction string) *paneNode {
694- if node == nil || node.container == 0 {
695- return nil
696- }
697-
698- refBounds, ok := webkit.WidgetGetBounds(node.container)
699- if !ok {
700- return nil
701- }
702- cx := refBounds.X + refBounds.Width/2.0
703- cy := refBounds.Y + refBounds.Height/2.0
704- axisVertical := direction == "up" || direction == "down"
705-
706- for parent := node.parent; parent != nil; parent = parent.parent {
707- switch direction {
708- case "up":
709- if axisVertical && parent.orientation == webkit.OrientationVertical && parent.right == node {
710- if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil {
711- return leaf
712- }
713- }
714- case "down":
715- if axisVertical && parent.orientation == webkit.OrientationVertical && parent.left == node {
716- if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil {
717- return leaf
718- }
719- }
720- case "left":
721- if !axisVertical && parent.orientation == webkit.OrientationHorizontal && parent.right == node {
722- if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil {
723- return leaf
724- }
725- }
726- case "right":
727- if !axisVertical && parent.orientation == webkit.OrientationHorizontal && parent.left == node {
728- if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil {
729- return leaf
730- }
731- }
732- }
733- node = parent
734- }
735- return nil
736-}
515+ // Update tracking and proceed with focus change
516+ wm.lastFocusTime = now
517+ wm.lastFocusTarget = node
737518
738-func (wm *WorkspaceManager) closestLeafFromSubtree(node *paneNode, cx, cy float64, direction string) *paneNode {
739- leaves := wm.collectLeavesFrom(node)
740- bestScore := math.MaxFloat64
741- var best *paneNode
742- for _, leaf := range leaves {
743- if leaf == nil || leaf.container == 0 {
744- continue
745- }
746- bounds, ok := webkit.WidgetGetBounds(leaf.container)
747- if !ok {
748- continue
749- }
750- tx := bounds.X + bounds.Width/2.0
751- ty := bounds.Y + bounds.Height/2.0
752- dx := tx - cx
753- dy := ty - cy
754- var score float64
755- switch direction {
756- case "left":
757- if dx >= -focusEpsilon {
758- continue
759- }
760- score = math.Abs(dx)*1000 + math.Abs(dy)
761- case "right":
762- if dx <= focusEpsilon {
763- continue
764- }
765- score = math.Abs(dx)*1000 + math.Abs(dy)
766- case "up":
767- if dy >= -focusEpsilon {
768- continue
769- }
770- score = math.Abs(dy)*1000 + math.Abs(dx)
771- case "down":
772- if dy <= focusEpsilon {
773- continue
774- }
775- score = math.Abs(dy)*1000 + math.Abs(dx)
776- default:
777- continue
778- }
779- if score < bestScore {
780- bestScore = score
781- best = leaf
782- }
519+ if err := wm.focusStateMachine.RequestFocus(node, source); err != nil {
520+ log.Printf("[workspace] Focus request failed: %v", err)
783521 }
784- if best == nil {
785- return wm.boundaryFallback(node, direction)
786- }
787- return best
788522 }
789523
790-func (wm *WorkspaceManager) boundaryFallback(node *paneNode, direction string) *paneNode {
791- return wm.boundaryFallbackWithDepth(node, direction, 0)
524+// GetNodeForWebView returns the pane node associated with a WebView
525+func (wm *WorkspaceManager) GetNodeForWebView(webView *webkit.WebView) *paneNode {
526+ return wm.viewToNode[webView]
792527 }
793528
794-func (wm *WorkspaceManager) boundaryFallbackWithDepth(node *paneNode, direction string, depth int) *paneNode {
795- // Prevent infinite recursion - max tree depth should be reasonable
796- const maxDepth = 50
797- if depth > maxDepth {
798- log.Printf("[workspace] boundaryFallback: max depth exceeded, possible tree corruption")
799- return nil
800- }
801-
802- if node == nil {
803- return nil
804- }
805- if node.isLeaf {
806- return node
807- }
808- switch direction {
809- case "up":
810- if leaf := wm.boundaryFallbackWithDepth(node.right, direction, depth+1); leaf != nil {
811- return leaf
812- }
813- return wm.boundaryFallbackWithDepth(node.left, direction, depth+1)
814- case "down":
815- if leaf := wm.boundaryFallbackWithDepth(node.left, direction, depth+1); leaf != nil {
816- return leaf
817- }
818- return wm.boundaryFallbackWithDepth(node.right, direction, depth+1)
819- case "left":
820- if leaf := wm.boundaryFallbackWithDepth(node.right, direction, depth+1); leaf != nil {
821- return leaf
822- }
823- return wm.boundaryFallbackWithDepth(node.left, direction, depth+1)
824- case "right":
825- if leaf := wm.boundaryFallbackWithDepth(node.left, direction, depth+1); leaf != nil {
826- return leaf
827- }
828- return wm.boundaryFallbackWithDepth(node.right, direction, depth+1)
829- default:
830- return nil
529+// RegisterNavigationHandler sets up navigation handling for a webview (simplified)
530+func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) {
531+ if webView == nil {
532+ return
831533 }
832-}
833-
834-func (wm *WorkspaceManager) collectLeaves() []*paneNode {
835- return wm.collectLeavesFrom(wm.root)
836-}
837-
838-func (wm *WorkspaceManager) collectLeavesFrom(node *paneNode) []*paneNode {
839- var leaves []*paneNode
840- visited := make(map[*paneNode]bool)
841534
842- var walk func(*paneNode, int)
843- walk = func(n *paneNode, depth int) {
844- // Prevent infinite recursion and cycles
845- const maxDepth = 50
846- if n == nil || depth > maxDepth {
847- return
848- }
849- if visited[n] {
850- log.Printf("[workspace] collectLeavesFrom: cycle detected in tree")
851- return
852- }
853- visited[n] = true
854-
855- if n.isLeaf {
856- leaves = append(leaves, n)
857- return
858- }
859- walk(n.left, depth+1)
860- walk(n.right, depth+1)
861- }
862- walk(node, 0)
863- return leaves
864-}
865-
866-func (wm *WorkspaceManager) createWebView() (*webkit.WebView, error) {
867- if wm == nil || wm.createWebViewFn == nil {
868- return nil, errors.New("workspace manager missing webview factory")
869- }
870- return wm.createWebViewFn()
871-}
872-
873-func (wm *WorkspaceManager) createPane(view *webkit.WebView) (*BrowserPane, error) {
874- if wm == nil || wm.createPaneFn == nil {
875- return nil, errors.New("workspace manager missing pane factory")
876- }
877- return wm.createPaneFn(view)
878-}
879-
880-func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*paneNode, error) {
881- if target == nil || !target.isLeaf || target.pane == nil {
882- return nil, errors.New("split target must be a leaf pane")
883- }
884-
885- newView, err := wm.createWebView()
886- if err != nil {
887- return nil, err
888- }
889-
890- newPane, err := wm.createPane(newView)
891- if err != nil {
892- return nil, err
893- }
894-
895- if handler := newPane.MessageHandler(); handler != nil {
896- handler.SetWorkspaceObserver(wm)
897- }
898-
899- newContainer := newPane.webView.RootWidget()
900- if newContainer == 0 {
901- return nil, errors.New("new pane missing container")
902- }
903- webkit.WidgetSetHExpand(newContainer, true)
904- webkit.WidgetSetVExpand(newContainer, true)
905- webkit.WidgetRealizeInContainer(newContainer)
906-
907- existingContainer := target.container
908- if existingContainer == 0 {
909- return nil, errors.New("existing pane missing container")
910- }
911- webkit.WidgetSetHExpand(existingContainer, true)
912- webkit.WidgetSetVExpand(existingContainer, true)
913- webkit.WidgetRealizeInContainer(existingContainer)
914-
915- orientation, existingFirst := mapDirection(direction)
916- log.Printf("[workspace] splitting direction=%s orientation=%v existingFirst=%v target.parent=%p", direction, orientation, existingFirst, target.parent)
917-
918- paned := webkit.NewPaned(orientation)
919- if paned == 0 {
920- return nil, errors.New("failed to create GtkPaned")
921- }
922- webkit.WidgetSetHExpand(paned, true)
923- webkit.WidgetSetVExpand(paned, true)
924- webkit.PanedSetResizeStart(paned, true)
925- webkit.PanedSetResizeEnd(paned, true)
926-
927- newLeaf := &paneNode{
928- pane: newPane,
929- container: newContainer,
930- isLeaf: true,
931- }
932- split := &paneNode{
933- parent: target.parent,
934- container: paned,
935- orientation: orientation,
936- isLeaf: false,
937- }
938-
939- parent := split.parent
940-
941- // Detach existing container from its current GTK parent before inserting into new paned.
942- if parent == nil {
943- // Target is the root - remove it from the window and unparent it
944- log.Printf("[workspace] removing existing container=%#x from window", existingContainer)
945- if wm.window != nil {
946- wm.window.SetChild(0)
947- }
948- } else if parent.container != 0 {
949- // Target has a parent paned - unparent it from there
950- log.Printf("[workspace] unparenting existing container=%#x from parent paned=%#x", existingContainer, parent.container)
951- if parent.left == target {
952- webkit.PanedSetStartChild(parent.container, 0)
953- } else if parent.right == target {
954- webkit.PanedSetEndChild(parent.container, 0)
955- }
956- }
957-
958- // Properly unparent the widget from GTK's perspective
959- webkit.WidgetUnparent(existingContainer)
960- if parent != nil && parent.container != 0 {
961- webkit.WidgetQueueAllocate(parent.container)
962- }
963-
964- // Set up the tree structure first
965- if existingFirst {
966- split.left = target
967- split.right = newLeaf
968- } else {
969- split.left = newLeaf
970- split.right = target
971- }
972-
973- target.parent = split
974- newLeaf.parent = split
975-
976- // Update tree root/parent references
977- if parent == nil {
978- wm.root = split
979- } else {
980- if parent.left == target {
981- parent.left = split
982- } else if parent.right == target {
983- parent.right = split
984- }
985- }
986-
987- // Use idle callback to ensure widget unparenting is complete before re-parenting
988- webkit.IdleAdd(func() bool {
989- // Add both containers to the new paned
990- if existingFirst {
991- webkit.PanedSetStartChild(paned, existingContainer)
992- webkit.PanedSetEndChild(paned, newContainer)
993- log.Printf("[workspace] added existing=%#x as start child, new=%#x as end child", existingContainer, newContainer)
994- } else {
995- webkit.PanedSetStartChild(paned, newContainer)
996- webkit.PanedSetEndChild(paned, existingContainer)
997- log.Printf("[workspace] added new=%#x as start child, existing=%#x as end child", newContainer, existingContainer)
998- }
999-
1000- // Attach the new paned to its parent
1001- if parent == nil {
1002- if wm.window != nil {
1003- wm.window.SetChild(paned)
1004- webkit.WidgetQueueAllocate(paned)
1005- log.Printf("[workspace] paned set as window child: paned=%#x", paned)
1006- }
1007- } else {
1008- if parent.left == split {
1009- webkit.PanedSetStartChild(parent.container, paned)
1010- } else if parent.right == split {
1011- webkit.PanedSetEndChild(parent.container, paned)
1012- }
1013- webkit.WidgetQueueAllocate(parent.container)
1014- webkit.WidgetQueueAllocate(paned)
1015- log.Printf("[workspace] paned inserted into parent=%#x", parent.container)
1016- }
1017-
1018- webkit.WidgetShow(paned)
1019- return false // Run once
1020- })
1021-
1022- wm.viewToNode[newPane.webView] = newLeaf
1023- wm.ensureHover(newLeaf)
1024- wm.ensureHover(target)
1025- wm.app.panes = append(wm.app.panes, newPane)
1026- if newPane.zoomController != nil {
1027- newPane.zoomController.ApplyInitialZoom()
1028- }
1029-
1030- // Update CSS classes for all panes now that we have multiple panes
1031- wm.ensurePaneBaseClasses()
1032-
1033- webkit.IdleAdd(func() bool {
1034- if newContainer != 0 {
1035- webkit.WidgetShow(newContainer)
1036- }
1037- wm.focusNode(newLeaf)
1038- return false
1039- })
1040-
1041- return newLeaf, nil
1042-}
1043-
1044-func (wm *WorkspaceManager) clonePaneState(_ *paneNode, target *paneNode) {
1045- if wm == nil || target == nil {
1046- return
1047- }
1048- if target.pane == nil || target.pane.webView == nil {
1049- return
1050- }
1051-
1052- const blankURL = "about:blank"
1053-
1054- if err := target.pane.webView.LoadURL(blankURL); err != nil {
1055- log.Printf("[workspace] failed to prepare blank pane: %v", err)
1056- }
1057-
1058- // Wait for about:blank to load before opening omnibox
1059- target.pane.webView.RegisterURIChangedHandler(func(uri string) {
1060- if uri == blankURL {
1061- // Defer omnibox opening to allow page to fully initialize
1062- webkit.IdleAdd(func() bool {
1063- if injErr := target.pane.webView.InjectScript("window.__dumber_omnibox?.open('omnibox');"); injErr != nil {
1064- log.Printf("[workspace] failed to open omnibox: %v", injErr)
1065- }
1066- return false // Remove idle callback
1067- })
1068- }
1069- })
1070-}
1071-
1072-func (wm *WorkspaceManager) closeCurrentPane() {
1073- if wm == nil || wm.active == nil {
1074- return
1075- }
1076- if err := wm.closePane(wm.active); err != nil {
1077- log.Printf("[workspace] close current pane failed: %v", err)
1078- }
1079-}
1080-
1081-func (wm *WorkspaceManager) closePane(node *paneNode) error {
1082- if wm == nil || node == nil || !node.isLeaf {
1083- return errors.New("close target must be a leaf pane")
1084- }
1085- if node.pane == nil || node.pane.webView == nil {
1086- return errors.New("close target missing webview")
1087- }
1088-
1089- if node.parent != nil && node.parent.container != 0 {
1090- if node.parent.left == node {
1091- webkit.PanedSetStartChild(node.parent.container, 0)
1092- } else if node.parent.right == node {
1093- webkit.PanedSetEndChild(node.parent.container, 0)
1094- }
1095- }
1096-
1097- if node.container != 0 {
1098- webkit.WidgetUnparent(node.container)
1099- }
1100-
1101- remaining := len(wm.app.panes)
1102- willBeLastPane := remaining <= 1
1103- paneCleaned := false
1104- ensureCleanup := func() {
1105- if paneCleaned {
1106- return
1107- }
1108- node.pane.Cleanup()
1109- paneCleaned = true
1110- }
1111-
1112- if remaining == 2 {
1113- var sibling *paneNode
1114- if node.parent != nil {
1115- if node.parent.left == node {
1116- sibling = node.parent.right
1117- } else if node.parent.right == node {
1118- sibling = node.parent.left
1119- }
1120- }
1121- if sibling != nil && sibling.isLeaf && sibling.pane != nil && sibling.pane.webView != nil {
1122- if sibling.isPopup && !node.isPopup {
1123- parentNode := node.parent
1124- parentContainer := uintptr(0)
1125- if parentNode != nil {
1126- parentContainer = parentNode.container
1127- }
1128-
1129- // Detach remaining GTK widgets before destroying the WebViews so GTK
1130- // does not attempt to dispose still-parented children.
1131- if parentContainer != 0 {
1132- if parentNode.left == sibling {
1133- webkit.PanedSetStartChild(parentContainer, 0)
1134- } else if parentNode.right == sibling {
1135- webkit.PanedSetEndChild(parentContainer, 0)
1136- }
1137- }
1138- if sibling.container != 0 {
1139- webkit.WidgetUnparent(sibling.container)
1140- }
1141- if parentContainer != 0 && wm.window != nil {
1142- wm.window.SetChild(0)
1143- }
1144-
1145- log.Printf("[workspace] closing primary pane with related popup present; exiting")
1146- ensureCleanup()
1147- sibling.pane.Cleanup()
1148-
1149- wm.detachHover(node)
1150- wm.detachHover(sibling)
1151-
1152- delete(wm.viewToNode, node.pane.webView)
1153- delete(wm.viewToNode, sibling.pane.webView)
1154- delete(wm.lastSplitMsg, node.pane.webView)
1155- delete(wm.lastSplitMsg, sibling.pane.webView)
1156- delete(wm.lastExitMsg, node.pane.webView)
1157- delete(wm.lastExitMsg, sibling.pane.webView)
1158-
1159- for i := 0; i < len(wm.app.panes); i++ {
1160- if wm.app.panes[i] == node.pane || wm.app.panes[i] == sibling.pane {
1161- wm.app.panes = append(wm.app.panes[:i], wm.app.panes[i+1:]...)
1162- i--
1163- }
1164- }
1165-
1166- if node.pane.webView != nil {
1167- if err := node.pane.webView.Destroy(); err != nil {
1168- log.Printf("[workspace] failed to destroy primary webview: %v", err)
1169- }
1170- }
1171- if sibling.pane.webView != nil {
1172- if err := sibling.pane.webView.Destroy(); err != nil {
1173- log.Printf("[workspace] failed to destroy popup webview: %v", err)
1174- }
1175- }
1176-
1177- wm.root = nil
1178- wm.active = nil
1179- wm.mainPane = nil
1180-
1181- webkit.QuitMainLoop()
1182- return nil
1183- }
1184- }
1185- }
1186- if willBeLastPane && wm.root == node {
1187- log.Printf("[workspace] closing final pane; exiting browser")
1188- ensureCleanup()
1189- wm.detachHover(node)
1190- if err := node.pane.webView.Destroy(); err != nil {
1191- log.Printf("[workspace] failed to destroy webview: %v", err)
1192- }
1193- webkit.QuitMainLoop()
1194- return nil
1195- }
1196-
1197- parent := node.parent
1198- if parent == nil {
1199- // This is the root pane (no parent in tree structure)
1200- if node != wm.root {
1201- return errors.New("inconsistent state: node has no parent but is not root")
1202- }
1203-
1204- // Check if we can find a replacement root
1205- replacement := wm.findReplacementRoot(node)
1206- if replacement == nil {
1207- // No other panes exist, this is the final pane
1208- log.Printf("[workspace] closing final pane; exiting browser")
1209- ensureCleanup()
1210- wm.detachHover(node)
1211- if err := node.pane.webView.Destroy(); err != nil {
1212- log.Printf("[workspace] failed to destroy webview: %v", err)
1213- }
1214- webkit.QuitMainLoop()
1215- return nil
1216- }
1217-
1218- // We have a replacement - delegate root status and close this pane
1219- log.Printf("[workspace] delegating root status from node=%#x to replacement=%#x", node.container, replacement.container)
1220-
1221- // Clean up references first
1222- delete(wm.viewToNode, node.pane.webView)
1223- delete(wm.lastSplitMsg, node.pane.webView)
1224- delete(wm.lastExitMsg, node.pane.webView)
1225-
1226- for i, pane := range wm.app.panes {
1227- if pane == node.pane {
1228- wm.app.panes = append(wm.app.panes[:i], wm.app.panes[i+1:]...)
1229- break
1230- }
1231- }
1232-
1233- if wm.mainPane == node {
1234- wm.mainPane = nil
1235- }
1236-
1237- // Clear current active if it's the node being closed
1238- if wm.active == node {
1239- wm.active = nil
1240- }
1241-
1242- // Set replacement as new root
1243- wm.root = replacement
1244- replacement.parent = nil
1245-
1246- // Replace window child directly (GTK handles reparenting automatically)
1247- if wm.window != nil {
1248- wm.window.SetChild(replacement.container)
1249- if replacement.container != 0 {
1250- webkit.WidgetQueueAllocate(replacement.container)
1251- webkit.WidgetShow(replacement.container)
1252- }
1253- }
1254-
1255- // Focus a suitable pane
1256- focusTarget := wm.leftmostLeaf(replacement)
1257- if focusTarget != nil {
1258- wm.focusNode(focusTarget)
1259- }
1260-
1261- // Destroy the webview and detach hover AFTER rearranging hierarchy
1262- // Only destroy the webview if this is the final pane, otherwise just clean up
1263- ensureCleanup()
1264- wm.detachHover(node)
1265- switch {
1266- case node.isPopup:
1267- if err := node.pane.webView.Destroy(); err != nil {
1268- log.Printf("[workspace] failed to destroy popup webview: %v", err)
1269- }
1270- case willBeLastPane:
1271- // This is the last pane, safe to destroy completely
1272- if err := node.pane.webView.Destroy(); err != nil {
1273- log.Printf("[workspace] failed to destroy webview: %v", err)
1274- }
1275- default:
1276- // Multiple panes remain, don't destroy the window - just clean up the webview
1277- log.Printf("[workspace] skipping webview destruction to preserve window (panes remaining: %d)", remaining-1)
1278- // TODO: Add a method to destroy just the webview without the window
1279- }
1280-
1281- wm.updateMainPane()
1282- // Update CSS classes after pane count changes
1283- wm.ensurePaneBaseClasses()
1284- log.Printf("[workspace] root pane closed and delegated; panes remaining=%d", len(wm.app.panes))
1285- return nil
1286- }
1287-
1288- var sibling *paneNode
1289- if parent.left == node {
1290- sibling = parent.right
1291- } else {
1292- sibling = parent.left
1293- }
1294- if sibling == nil {
1295- return errors.New("pane close failed: missing sibling")
1296- }
1297-
1298- log.Printf("[workspace] closing pane: target=%#x parent=%#x sibling=%#x remaining=%d", node.container, parent.container, sibling.container, remaining)
1299-
1300- // Find focus target before modifying the tree structure
1301- focusTarget := wm.leftmostLeaf(sibling)
1302- if focusTarget == nil {
1303- focusTarget = wm.leftmostLeaf(wm.root)
1304- }
1305-
1306- // Clean up references first
1307- delete(wm.viewToNode, node.pane.webView)
1308- delete(wm.lastSplitMsg, node.pane.webView)
1309- delete(wm.lastExitMsg, node.pane.webView)
1310-
1311- for i, pane := range wm.app.panes {
1312- if pane == node.pane {
1313- wm.app.panes = append(wm.app.panes[:i], wm.app.panes[i+1:]...)
1314- break
1315- }
1316- }
1317-
1318- if wm.mainPane == node {
1319- wm.mainPane = nil
1320- }
1321-
1322- // Clear current active if it's the node being closed
1323- if wm.active == node {
1324- wm.active = nil
1325- }
1326-
1327- grand := parent.parent
1328- if grand == nil {
1329- // Parent is the root node. Promote sibling to become the new root.
1330- // The sibling can be either a leaf (when only 2 panes total) or a branch (when more panes exist)
1331- log.Printf("[workspace] promoting sibling to root: container=%#x, isLeaf=%v", sibling.container, sibling.isLeaf)
1332- wm.root = sibling
1333- sibling.parent = nil
1334-
1335- // Unparent the sibling from the paned first, then set it as window child
1336- if wm.window != nil {
1337- // GTK requires explicit unparenting before reparenting
1338- if parent.left == sibling {
1339- webkit.PanedSetStartChild(parent.container, 0)
1340- } else {
1341- webkit.PanedSetEndChild(parent.container, 0)
1342- }
1343- // Now we can safely set it as window child
1344- wm.window.SetChild(sibling.container)
1345- if sibling.container != 0 {
1346- webkit.WidgetQueueAllocate(sibling.container)
1347- }
1348- }
1349- } else {
1350- // Parent has a grandparent, so promote sibling to take parent's place
1351- log.Printf("[workspace] promoting sibling to parent's position: sibling=%#x grand=%#x", sibling.container, grand.container)
1352-
1353- // First unparent the sibling from its current parent to avoid GTK-CRITICAL errors
1354- if parent.container != 0 && !parent.isLeaf {
1355- if parent.left == sibling {
1356- webkit.PanedSetStartChild(parent.container, 0)
1357- } else if parent.right == sibling {
1358- webkit.PanedSetEndChild(parent.container, 0)
1359- }
1360- }
1361-
1362- // Now safely reparent the sibling to the grandparent
1363- sibling.parent = grand
1364- if grand.container != 0 && !grand.isLeaf {
1365- if grand.left == parent {
1366- grand.left = sibling
1367- webkit.PanedSetStartChild(grand.container, sibling.container)
1368- } else if grand.right == parent {
1369- grand.right = sibling
1370- webkit.PanedSetEndChild(grand.container, sibling.container)
1371- }
1372- webkit.WidgetQueueAllocate(grand.container)
1373- }
1374- }
1375-
1376- // Keep sibling subtree visible
1377- if sibling.container != 0 {
1378- webkit.WidgetShow(sibling.container)
1379- }
1380-
1381- // Clean up parent node references
1382- parent.left = nil
1383- parent.right = nil
1384- node.parent = nil
1385-
1386- // Focus the target pane
1387- if focusTarget != nil && focusTarget != node {
1388- wm.focusNode(focusTarget)
1389- }
1390-
1391- // Destroy the webview and detach hover AFTER all hierarchy changes are complete
1392- ensureCleanup()
1393- wm.detachHover(node)
1394- switch {
1395- case node.isPopup:
1396- if err := node.pane.webView.Destroy(); err != nil {
1397- log.Printf("[workspace] failed to destroy popup webview: %v", err)
1398- }
1399- case willBeLastPane:
1400- // This is the last pane, safe to destroy completely
1401- if err := node.pane.webView.Destroy(); err != nil {
1402- log.Printf("[workspace] failed to destroy webview: %v", err)
1403- }
1404- default:
1405- // Multiple panes remain, don't destroy the window - just clean up the webview
1406- log.Printf("[workspace] skipping webview destruction to preserve window (panes remaining: %d)", remaining-1)
1407- // TODO: Add a method to destroy just the webview without the window
1408- }
1409-
1410- wm.updateMainPane()
1411- // Update CSS classes after pane count changes
1412- wm.ensurePaneBaseClasses()
1413- log.Printf("[workspace] pane closed; panes remaining=%d", len(wm.app.panes))
1414- return nil
1415-}
1416-
1417-func (wm *WorkspaceManager) leftmostLeaf(node *paneNode) *paneNode {
1418- for node != nil && !node.isLeaf {
1419- if node.left != nil {
1420- node = node.left
1421- continue
1422- }
1423- node = node.right
1424- }
1425- return node
1426-}
1427-
1428-// findReplacementRoot finds a suitable replacement when closing the current root pane
1429-func (wm *WorkspaceManager) findReplacementRoot(excludeNode *paneNode) *paneNode {
1430- if wm == nil || wm.root == nil {
1431- return nil
1432- }
1433-
1434- // If root is being closed and there are other panes, find a replacement
1435- leaves := wm.collectLeaves()
1436- for _, leaf := range leaves {
1437- if leaf != excludeNode && leaf != nil && leaf.isLeaf {
1438- // Find the topmost ancestor that's not the current root
1439- current := leaf
1440- for current.parent != nil && current.parent != wm.root {
1441- current = current.parent
1442- }
1443-
1444- // If this leaf is a direct child of root, or root only has one subtree,
1445- // we can promote this subtree to be the new root
1446- if current.parent == wm.root {
1447- // If the sibling is being excluded, promote this subtree
1448- var sibling *paneNode
1449- if wm.root.left == current {
1450- sibling = wm.root.right
1451- } else {
1452- sibling = wm.root.left
1453- }
1454-
1455- if sibling == excludeNode {
1456- // The sibling is being closed, so promote this subtree
1457- return current
1458- }
1459- }
1460-
1461- // Otherwise, return the first suitable leaf
1462- return leaf
1463- }
1464- }
1465-
1466- return nil
1467-}
1468-
1469-func (wm *WorkspaceManager) updateMainPane() {
1470- if len(wm.app.panes) == 1 {
1471- if leaf := wm.viewToNode[wm.app.panes[0].webView]; leaf != nil {
1472- wm.mainPane = leaf
1473- }
1474- return
1475- }
1476-
1477- if wm.mainPane == nil || !wm.mainPane.isLeaf {
1478- if wm.active != nil && wm.active.isLeaf {
1479- wm.mainPane = wm.active
1480- }
1481- }
1482-}
1483-
1484-func (wm *WorkspaceManager) HandlePopup(source *webkit.WebView, url string) *webkit.WebView {
1485- log.Printf("[workspace] HandlePopup called for URL: %s", url)
1486-
1487- // Check for frame type markers added by WebKit layer
1488- isBlankTarget := strings.HasSuffix(url, "#__dumber_frame_blank")
1489- isPopupTarget := strings.HasSuffix(url, "#__dumber_frame_popup")
1490-
1491- // Clean the URL by removing our markers
1492- if isBlankTarget || isPopupTarget {
1493- if isBlankTarget {
1494- url = strings.TrimSuffix(url, "#__dumber_frame_blank")
1495- log.Printf("[workspace] Detected _blank target - will create regular pane for: %s", url)
1496- } else {
1497- url = strings.TrimSuffix(url, "#__dumber_frame_popup")
1498- log.Printf("[workspace] Detected popup target - will create popup pane for: %s", url)
1499- }
1500- }
1501-
1502- if wm == nil || source == nil {
1503- log.Printf("[workspace] HandlePopup: nil workspace manager or source - allowing native popup")
1504- return nil
1505- }
1506-
1507- node := wm.viewToNode[source]
1508- if node == nil {
1509- log.Printf("[workspace] popup from unknown webview - allowing native popup")
1510- return nil
1511- }
1512-
1513- // Note: HandlePopup is now obsolete - window.open is handled directly via JavaScript bypass
1514-
1515- cfg := wm.app.config
1516- if cfg == nil {
1517- log.Printf("[workspace] HandlePopup: nil config - allowing native popup")
1518- return nil
1519- }
1520-
1521- popCfg := cfg.Workspace.Popups
1522- log.Printf("[workspace] Popup config - OpenInNewPane: %v, Placement: %s", popCfg.OpenInNewPane, popCfg.Placement)
1523-
1524- if !popCfg.OpenInNewPane {
1525- log.Printf("[workspace] Popup creation disabled in config - allowing native popup")
1526- return nil
1527- }
1528-
1529- // Smart detection path: create temporary view and decide placement once type is known
1530- if popCfg.EnableSmartDetection {
1531- webkitCfg, err := wm.app.buildWebkitConfig()
1532- if err != nil {
1533- log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
1534- return nil
1535- }
1536- webkitCfg.CreateWindow = false
1537- // Create as related to avoid WindowFeatures crash; we'll decide final placement later
1538- newView, err := webkit.NewWebViewWithRelated(webkitCfg, source)
1539- if err != nil {
1540- log.Printf("[workspace] failed to create temp WebView: %v - allowing native popup", err)
1541- return nil
1542- }
1543-
1544- // Register detection callback
1545- newView.OnWindowTypeDetected(func(t webkit.WindowType, feat *webkit.WindowFeatures) {
1546- wm.RunOnUI(func() {
1547- wm.handleDetectedWindowType(node, newView, url, t, feat)
1548- })
1549- })
1550-
1551- // Fallback: if detection never fires, treat as popup as before
1552- go func() {
1553- time.Sleep(1500 * time.Millisecond)
1554- if newView != nil {
1555- wm.RunOnUI(func() {
1556- if wm.viewToNode[newView] == nil { // not yet placed
1557- wm.handleDetectedWindowType(node, newView, url, webkit.WindowTypePopup, nil)
1558- }
1559- })
1560- }
1561- }()
1562-
1563- return newView
1564- }
1565-
1566- // Legacy path preserved
1567- webkitCfg, err := wm.app.buildWebkitConfig()
1568- if err != nil {
1569- log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
1570- return nil
1571- }
1572- webkitCfg.CreateWindow = false
1573- newView, err := webkit.NewWebViewWithRelated(webkitCfg, source)
1574- if err != nil {
1575- log.Printf("[workspace] failed to create placeholder WebView: %v - allowing native popup", err)
1576- return nil
1577- }
1578-
1579- // Workspace navigation shortcuts are now handled globally by WindowShortcutHandler
1580-
1581- // Create a pane for the new WebView
1582- newPane, err := wm.createPane(newView)
1583- if err != nil {
1584- log.Printf("[workspace] failed to create popup pane: %v - allowing native popup", err)
1585- return nil
1586- }
1587-
1588- // Determine placement direction
1589- direction := strings.ToLower(popCfg.Placement)
1590- if direction == "" {
1591- direction = "right"
1592- }
1593-
1594- // Determine target node for splitting
1595- target := node
1596- if !popCfg.FollowPaneContext && wm.active != nil {
1597- target = wm.active
1598- }
1599-
1600- // Add the popup pane to the workspace using manual pane insertion
1601- if err := wm.insertPopupPane(target, newPane, direction); err != nil {
1602- log.Printf("[workspace] popup pane insertion failed: %v - allowing native popup", err)
1603- return nil
1604- }
1605-
1606- // Apply different behavior based on target type
1607- if isBlankTarget {
1608- log.Printf("[workspace] Treating _blank target as regular pane - no auto-close behavior")
1609- // For _blank targets, just ensure GUI - no popup-specific behavior
1610- } else {
1611- log.Printf("[workspace] Treating as popup pane - applying popup-specific behavior")
1612- // Mark as popup for auto-close handling (OAuth flows, etc.)
1613- newNode := wm.viewToNode[newView]
1614- if newNode != nil {
1615- newNode.isPopup = true
1616- log.Printf("[workspace] Marked pane as popup for auto-close handling")
1617-
1618- // Register close handler for popup auto-close on window.close()
1619- newView.RegisterCloseHandler(func() {
1620- log.Printf("[workspace] Popup requesting close via window.close()")
1621- // Look up the node at close time
1622- if node := wm.viewToNode[newView]; node != nil && node.isPopup {
1623- log.Printf("[workspace] Closing popup pane")
1624- // Brief delay to allow any final redirects to complete
1625- time.AfterFunc(200*time.Millisecond, func() {
1626- webkit.IdleAdd(func() bool {
1627- if err := wm.closePane(node); err != nil {
1628- log.Printf("[workspace] Failed to close popup pane: %v", err)
1629- }
1630- return false
1631- })
1632- })
1633- } else {
1634- log.Printf("[workspace] Could not find popup node for close handler")
1635- }
1636- })
1637- } else {
1638- log.Printf("[workspace] Warning: could not find node for popup WebView in viewToNode map")
1639- }
1640- }
1641-
1642- // Ensure GUI components are available in the new pane
1643- wm.ensureGUIInPane(newPane)
1644-
1645- // Inject GUI components into the popup pane
1646- wm.ensureGUIInPane(newPane)
1647-
1648- // Load the URL if provided
1649- if url != "" {
1650- paneType := "popup"
1651- if isBlankTarget {
1652- paneType = "_blank target"
1653- }
1654- log.Printf("[webkit] LoadURL (%s): %s", paneType, url)
1655- if err := newView.LoadURL(url); err != nil {
1656- log.Printf("[workspace] failed to load %s URL: %v", paneType, err)
1657- }
1658- // Ensure the WebView is visible after loading URL
1659- if err := newView.Show(); err != nil {
1660- log.Printf("[workspace] failed to show popup WebView: %v", err)
1661- }
1662- }
1663-
1664- if isBlankTarget {
1665- log.Printf("[workspace] Created regular pane for _blank target URL: %s", url)
1666- } else {
1667- log.Printf("[workspace] Created popup pane for URL: %s", url)
1668- }
1669- return newView
1670-}
1671-
1672-// registerOAuthAutoClose sets up OAuth auto-close functionality for popups
1673-// Note: OAuth detection is now handled by the main-world.js injection script
1674-func (wm *WorkspaceManager) registerOAuthAutoClose(view *webkit.WebView, url string) {
1675- log.Printf("[workspace] OAuth auto-close enabled for popup with URL: %s", url)
1676- log.Printf("[workspace] OAuth detection will be handled by main-world.js injection script")
1677-}
1678-
1679-func (wm *WorkspaceManager) applyWindowFeatures(view *webkit.WebView, intent *messaging.WindowIntent, isPopup bool) {
1680- if intent == nil {
1681- return
1682- }
1683-
1684- features := &webkit.WindowFeatures{}
1685-
1686- // Apply dimensions if specified
1687- if intent.Width != nil {
1688- features.Width = *intent.Width
1689- }
1690- if intent.Height != nil {
1691- features.Height = *intent.Height
1692- }
1693-
1694- // Apply visibility features based on window type
1695- defaultToolbar := !isPopup
1696- defaultLocation := !isPopup
1697- defaultMenubar := !isPopup
1698-
1699- if intent.Toolbar != nil {
1700- features.ToolbarVisible = *intent.Toolbar
1701- } else {
1702- features.ToolbarVisible = defaultToolbar
1703- }
1704-
1705- if intent.Location != nil {
1706- features.LocationbarVisible = *intent.Location
1707- } else {
1708- features.LocationbarVisible = defaultLocation
1709- }
1710-
1711- if intent.Menubar != nil {
1712- features.MenubarVisible = *intent.Menubar
1713- } else {
1714- features.MenubarVisible = defaultMenubar
1715- }
1716-
1717- if intent.Resizable != nil {
1718- features.Resizable = *intent.Resizable
1719- } else {
1720- features.Resizable = true // Usually resizable unless explicitly disabled
1721- }
1722-
1723- view.SetWindowFeatures(features)
1724- windowTypeStr := "tab"
1725- if isPopup {
1726- windowTypeStr = "popup"
1727- }
1728- log.Printf("[workspace] Applied %s window features from intent: size=%dx%d, toolbar=%t, location=%t, menubar=%t, resizable=%t",
1729- windowTypeStr, features.Width, features.Height, features.ToolbarVisible, features.LocationbarVisible, features.MenubarVisible, features.Resizable)
1730-}
1731-
1732-func (wm *WorkspaceManager) handleIntentAsTab(sourceNode *paneNode, url string, intent *messaging.WindowIntent) *webkit.WebView {
1733- log.Printf("[workspace] Handling intent as tab: %s", url)
1734-
1735- webkitCfg, err := wm.app.buildWebkitConfig()
1736- if err != nil {
1737- log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
1738- return nil
1739- }
1740- webkitCfg.CreateWindow = false
1741-
1742- newView, err := webkit.NewWebView(webkitCfg)
1743- if err != nil {
1744- log.Printf("[workspace] failed to create tab WebView: %v - allowing native popup", err)
1745- return nil
1746- }
1747-
1748- newPane, err := wm.createPane(newView)
1749- if err != nil {
1750- log.Printf("[workspace] failed to create tab pane: %v - allowing native popup", err)
1751- return nil
1752- }
1753-
1754- direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
1755- if direction == "" {
1756- direction = "right"
1757- }
1758-
1759- if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil {
1760- log.Printf("[workspace] tab pane insertion failed: %v - allowing native popup", err)
1761- return nil
1762- }
1763-
1764- node := wm.viewToNode[newView]
1765- if node != nil {
1766- node.windowType = webkit.WindowTypeTab
1767- node.isRelated = false
1768-
1769- // Apply window features from JavaScript intent
1770- wm.applyWindowFeatures(newView, intent, false)
1771- }
1772-
1773- wm.ensureGUIInPane(newPane)
1774-
1775- if url != "" {
1776- if err := newView.LoadURL(url); err != nil {
1777- log.Printf("[workspace] failed to load tab URL: %v", err)
1778- }
1779- if err := newView.Show(); err != nil {
1780- log.Printf("[workspace] failed to show tab WebView: %v", err)
1781- }
1782- }
1783-
1784- log.Printf("[workspace] Created tab pane for URL: %s", url)
1785- return newView
1786-}
1787-
1788-// handleIntentAsPopup creates a related popup pane based on window.open intent
1789-func (wm *WorkspaceManager) handleIntentAsPopup(sourceNode *paneNode, url string, intent *messaging.WindowIntent) *webkit.WebView {
1790- log.Printf("[workspace] Handling intent as popup: %s", url)
1791-
1792- webkitCfg, err := wm.app.buildWebkitConfig()
1793- if err != nil {
1794- log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
1795- return nil
1796- }
1797- webkitCfg.CreateWindow = false
1798-
1799- newView, err := webkit.NewWebViewWithRelated(webkitCfg, sourceNode.pane.webView)
1800- if err != nil {
1801- log.Printf("[workspace] failed to create popup WebView: %v - allowing native popup", err)
1802- return nil
1803- }
1804-
1805- // Log the parent-popup WebView ID relationship for OAuth auto-close
1806- parentWebViewID := sourceNode.pane.webView.ID()
1807- popupWebViewID := newView.ID()
1808- log.Printf("[workspace] Created popup WebView: parentID=%s popupID=%s url=%s", parentWebViewID, popupWebViewID, url)
1809-
1810- // Store popup WebView ID in parent's localStorage for OAuth callback lookup
1811- storeScript := fmt.Sprintf(`
1812- try {
1813- const parentWebViewId = '%s';
1814- const popupWebViewId = '%s';
1815- const popupMapping = {
1816- parentId: parentWebViewId,
1817- popupId: popupWebViewId,
1818- timestamp: Date.now(),
1819- url: '%s'
1820- };
1821- localStorage.setItem('popup_mapping_' + parentWebViewId, JSON.stringify(popupMapping));
1822- console.log('[workspace] Stored popup mapping:', popupMapping);
1823- } catch(e) {
1824- console.warn('[workspace] Failed to store popup mapping:', e);
1825- }
1826- `, parentWebViewID, popupWebViewID, url)
1827-
1828- // Inject into parent WebView so it can find its popup later
1829- if err := sourceNode.pane.webView.InjectScript(storeScript); err != nil {
1830- log.Printf("[workspace] Failed to inject popup mapping script into parent: %v", err)
1831- }
1832-
1833- newPane, err := wm.createPane(newView)
1834- if err != nil {
1835- log.Printf("[workspace] failed to create popup pane: %v - allowing native popup", err)
1836- return nil
1837- }
1838-
1839- direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
1840- if direction == "" {
1841- direction = "right"
1842- }
1843-
1844- if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil {
1845- log.Printf("[workspace] popup pane insertion failed: %v - allowing native popup", err)
1846- return nil
1847- }
1848-
1849- node := wm.viewToNode[newView]
1850- var requestID string
1851- if node != nil {
1852- node.windowType = webkit.WindowTypePopup
1853- node.isRelated = true
1854- node.parentPane = sourceNode
1855- node.isPopup = true
1856- node.autoClose = wm.shouldAutoClose(url)
1857-
1858- // Store requestID for deduplication cleanup
1859- if intent != nil {
1860- requestID = intent.RequestID
1861- }
1862-
1863- // Apply window features from JavaScript intent
1864- wm.applyWindowFeatures(newView, intent, true)
1865- }
1866-
1867- // Register close handler for popup auto-close
1868- newView.RegisterCloseHandler(func() {
1869- log.Printf("[workspace] Popup requesting close via window.close()")
1870-
1871- // Clear the RequestID from deduplicator to allow new popups with same ID
1872- if requestID != "" && wm.paneDeduplicator != nil {
1873- wm.paneDeduplicator.ClearRequestID(requestID)
1874- }
1875-
1876- if n := wm.viewToNode[newView]; n != nil && n.isPopup {
1877- time.AfterFunc(200*time.Millisecond, func() {
1878- webkit.IdleAdd(func() bool {
1879- if err := wm.closePane(n); err != nil {
1880- log.Printf("[workspace] Failed to close popup pane: %v", err)
1881- }
1882- return false
1883- })
1884- })
1885- }
1886- })
1887-
1888- // URL-based auto-close for OAuth popups
1889- if node != nil && node.isPopup && node.autoClose {
1890- wm.registerOAuthAutoClose(newView, url)
1891- }
1892-
1893- wm.ensureGUIInPane(newPane)
1894-
1895- if url != "" {
1896- if err := newView.LoadURL(url); err != nil {
1897- log.Printf("[workspace] failed to load popup URL: %v", err)
1898- }
1899- if err := newView.Show(); err != nil {
1900- log.Printf("[workspace] failed to show popup WebView: %v", err)
1901- }
1902- }
1903-
1904- log.Printf("[workspace] Created popup pane for URL: %s", url)
1905- return newView
1906-}
1907-
1908-// insertIndependentPane inserts a new independent pane next to the source
1909-func (wm *WorkspaceManager) insertIndependentPane(sourceNode *paneNode, webView *webkit.WebView, url string) error {
1910- newPane, err := wm.createPane(webView)
1911- if err != nil {
1912- return err
1913- }
1914- direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
1915- if direction == "" {
1916- direction = "right"
1917- }
1918- if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil { // reuse insertion primitive
1919- return err
1920- }
1921- node := wm.viewToNode[webView]
1922- if node != nil {
1923- node.windowType = webkit.WindowTypeTab
1924- node.isRelated = false
1925- }
1926- if url != "" {
1927- _ = webView.LoadURL(url)
1928- }
1929- return nil
1930-}
1931-
1932-// configureRelatedPopup creates a related view and inserts it
1933-func (wm *WorkspaceManager) configureRelatedPopup(sourceNode *paneNode, webView *webkit.WebView, url string, feat *webkit.WindowFeatures) {
1934- // Use the WebView that was already created and returned to WebKit
1935- related := webView
1936- newPane, err := wm.createPane(related)
1937- if err != nil {
1938- log.Printf("[workspace] failed to create related popup pane: %v", err)
1939- return
1940- }
1941- direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
1942- if direction == "" {
1943- direction = "right"
1944- }
1945- if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil {
1946- log.Printf("[workspace] failed to insert related popup pane: %v", err)
1947- return
1948- }
1949- node := wm.viewToNode[related]
1950- if node != nil {
1951- node.windowType = webkit.WindowTypePopup
1952- node.windowFeatures = feat
1953- node.isRelated = true
1954- node.parentPane = sourceNode
1955- node.isPopup = true
1956- // Heuristic + config for auto-close intent
1957- node.autoClose = wm.shouldAutoClose(url)
1958- }
1959- // Pipe into existing auto-close flow only for popups (confirmed by detection)
1960- related.RegisterCloseHandler(func() {
1961- log.Printf("[workspace] Popup requesting close via window.close()")
1962- if n := wm.viewToNode[related]; n != nil && n.isPopup {
1963- time.AfterFunc(200*time.Millisecond, func() {
1964- webkit.IdleAdd(func() bool {
1965- if err := wm.closePane(n); err != nil {
1966- log.Printf("[workspace] Failed to close popup pane: %v", err)
1967- }
1968- return false
1969- })
1970- })
1971- }
1972- })
1973-
1974- // URL-based fallback: if providers don't call window.close(), auto-close on OAuth callback URLs
1975- if node != nil && node.isPopup && node.autoClose {
1976- wm.registerOAuthAutoClose(related, url)
1977- }
1978- if url != "" {
1979- _ = related.LoadURL(url)
1980- }
1981-}
1982-
1983-// shouldAutoClose checks simple OAuth-like URL patterns and config flag
1984-func (wm *WorkspaceManager) shouldAutoClose(url string) bool {
1985- log.Printf("[workspace] shouldAutoClose called for URL: %s", url)
1986-
1987- if wm == nil || wm.app == nil || wm.app.config == nil {
1988- log.Printf("[workspace] shouldAutoClose: missing config, returning true")
1989- return true
1990- }
1991- if !wm.app.config.Workspace.Popups.OAuthAutoClose {
1992- log.Printf("[workspace] shouldAutoClose: OAuthAutoClose disabled in config, returning false")
1993- return false
1994- }
1995-
1996- u := strings.ToLower(url)
1997- log.Printf("[workspace] shouldAutoClose: checking lowercase URL: %s", u)
1998-
1999- // RFC 6749 compliant OAuth 2.0 URL patterns
2000- oauthPatterns := []string{
2001- // Standard OAuth endpoints
2002- "oauth", "authorize", "authorization",
2003- // Standard callback/redirect patterns
2004- "callback", "redirect", "auth/callback",
2005- // OpenID Connect patterns
2006- "oidc", "openid",
2007- // Common OAuth parameter indicators
2008- "response_type=", "client_id=", "redirect_uri=", "scope=", "state=",
2009- // Standard OAuth response parameters
2010- "code=", "access_token=", "id_token=", "token_type=",
2011- // Error response parameters
2012- "error=", "error_description=",
2013- }
2014-
2015- // Check for OAuth patterns in URL
2016- for _, pattern := range oauthPatterns {
2017- if strings.Contains(u, pattern) {
2018- log.Printf("[workspace] shouldAutoClose: MATCHED pattern '%s' in URL, returning true", pattern)
2019- return true
2020- }
2021- }
2022-
2023- log.Printf("[workspace] shouldAutoClose: no OAuth patterns matched, returning false")
2024- return false
2025-}
2026-
2027-// isPopupVerificationPage determines if a URL is a popup verification page that should redirect instead of close
2028-
2029-// RunOnUI schedules a function; here simply executes inline as GTK main loop is single-threaded
2030-func (wm *WorkspaceManager) RunOnUI(fn func()) {
2031- if fn != nil {
2032- fn()
2033- }
2034-}
2035-
2036-// handleDetectedWindowType handles window type detection from smart detection path
2037-func (wm *WorkspaceManager) handleDetectedWindowType(sourceNode *paneNode, webView *webkit.WebView, url string, windowType webkit.WindowType, features *webkit.WindowFeatures) {
2038- if wm.viewToNode[webView] != nil {
2039- return // Already placed
2040- }
2041-
2042- log.Printf("[workspace] Smart detection result: type=%d url=%s", windowType, url)
2043-
2044- switch windowType {
2045- case webkit.WindowTypeTab:
2046- // For tabs, create a NEW independent WebView (can't use the related one)
2047- webkitCfg, err := wm.app.buildWebkitConfig()
2048- if err != nil {
2049- log.Printf("[workspace] failed to build webkit config for tab: %v", err)
2050- return
2051- }
2052- webkitCfg.CreateWindow = false
2053-
2054- // Create independent WebView like handleIntentAsTab does
2055- independentView, err := webkit.NewWebView(webkitCfg)
2056- if err != nil {
2057- log.Printf("[workspace] failed to create independent tab WebView: %v", err)
2058- return
2059- }
2060-
2061- // The related webView was just for detection - we don't use it for tabs
2062- // Insert the new independent view as a tab
2063- if err := wm.insertIndependentPane(sourceNode, independentView, url); err != nil {
2064- log.Printf("[workspace] Failed to insert independent pane: %v", err)
2065- }
2066-
2067- case webkit.WindowTypePopup:
2068- // For popups, use the related WebView we already created
2069- wm.configureRelatedPopup(sourceNode, webView, url, features)
2070- default:
2071- // Fallback to popup behavior
2072- wm.configureRelatedPopup(sourceNode, webView, url, features)
2073- }
2074-}
2075-
2076-// insertPopupPane inserts a pre-created popup pane into the workspace
2077-func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPane, direction string) error {
2078- if target == nil || !target.isLeaf || target.pane == nil {
2079- return errors.New("insert target must be a leaf pane")
2080- }
2081-
2082- if newPane == nil || newPane.webView == nil {
2083- return errors.New("new pane missing webview")
2084- }
2085-
2086- if handler := newPane.MessageHandler(); handler != nil {
2087- handler.SetWorkspaceObserver(wm)
2088- }
2089-
2090- newContainer := newPane.webView.RootWidget()
2091- if newContainer == 0 {
2092- return errors.New("new pane missing container")
2093- }
2094-
2095- webkit.WidgetSetHExpand(newContainer, true)
2096- webkit.WidgetSetVExpand(newContainer, true)
2097- webkit.WidgetRealizeInContainer(newContainer)
2098-
2099- // Also realize the WebView widget itself for proper popup rendering
2100- webViewWidget := newPane.webView.Widget()
2101- if webViewWidget != 0 {
2102- webkit.WidgetRealizeInContainer(webViewWidget)
2103- }
2104-
2105- existingContainer := target.container
2106- if existingContainer == 0 {
2107- return errors.New("existing pane missing container")
2108- }
2109-
2110- orientation, existingFirst := mapDirection(direction)
2111- log.Printf("[workspace] inserting popup direction=%s orientation=%v existingFirst=%v target.parent=%p", direction, orientation, existingFirst, target.parent)
2112-
2113- paned := webkit.NewPaned(orientation)
2114- if paned == 0 {
2115- return errors.New("failed to create GtkPaned")
2116- }
2117- webkit.WidgetSetHExpand(paned, true)
2118- webkit.WidgetSetVExpand(paned, true)
2119- webkit.PanedSetResizeStart(paned, true)
2120- webkit.PanedSetResizeEnd(paned, true)
2121-
2122- newLeaf := &paneNode{
2123- pane: newPane,
2124- container: newContainer,
2125- isLeaf: true,
2126- }
2127- split := &paneNode{
2128- parent: target.parent,
2129- container: paned,
2130- orientation: orientation,
2131- isLeaf: false,
2132- }
2133-
2134- parent := split.parent
2135-
2136- // Detach existing container from its current GTK parent before inserting into new paned.
2137- if parent == nil {
2138- // Target is the root - remove it from the window
2139- log.Printf("[workspace] removing existing container=%#x from window", existingContainer)
2140- if wm.window != nil {
2141- wm.window.SetChild(0)
2142- }
2143- } else if parent.container != 0 {
2144- // Target has a parent paned - unparent it from there
2145- log.Printf("[workspace] unparenting existing container=%#x from parent paned=%#x", existingContainer, parent.container)
2146- if parent.left == target {
2147- webkit.PanedSetStartChild(parent.container, 0)
2148- } else if parent.right == target {
2149- webkit.PanedSetEndChild(parent.container, 0)
2150- }
2151- webkit.WidgetQueueAllocate(parent.container)
2152- }
2153-
2154- // Add both containers to the new paned
2155- if existingFirst {
2156- split.left = target
2157- split.right = newLeaf
2158- webkit.PanedSetStartChild(paned, existingContainer)
2159- webkit.PanedSetEndChild(paned, newContainer)
2160- log.Printf("[workspace] added existing=%#x as start child, new=%#x as end child", existingContainer, newContainer)
2161- } else {
2162- split.left = newLeaf
2163- split.right = target
2164- webkit.PanedSetStartChild(paned, newContainer)
2165- webkit.PanedSetEndChild(paned, existingContainer)
2166- log.Printf("[workspace] added new=%#x as start child, existing=%#x as end child", newContainer, existingContainer)
2167- }
2168-
2169- target.parent = split
2170- newLeaf.parent = split
2171-
2172- if parent == nil {
2173- wm.root = split
2174- if wm.window != nil {
2175- wm.window.SetChild(paned)
2176- webkit.WidgetQueueAllocate(paned)
2177- log.Printf("[workspace] paned set as window child: paned=%#x", paned)
2178- }
2179- } else {
2180- if parent.left == target {
2181- parent.left = split
2182- webkit.PanedSetStartChild(parent.container, paned)
2183- } else if parent.right == target {
2184- parent.right = split
2185- webkit.PanedSetEndChild(parent.container, paned)
2186- }
2187- webkit.WidgetQueueAllocate(parent.container)
2188- webkit.WidgetQueueAllocate(paned)
2189- log.Printf("[workspace] paned inserted into parent=%#x", parent.container)
2190- }
2191-
2192- webkit.WidgetShow(paned)
2193-
2194- wm.viewToNode[newPane.webView] = newLeaf
2195- wm.ensureHover(newLeaf)
2196- wm.ensureHover(target)
2197- wm.app.panes = append(wm.app.panes, newPane)
2198- if newPane.zoomController != nil {
2199- newPane.zoomController.ApplyInitialZoom()
2200- }
2201-
2202- // Update CSS classes for all panes now that we have multiple panes
2203- wm.ensurePaneBaseClasses()
2204-
2205- webkit.IdleAdd(func() bool {
2206- if newContainer != 0 {
2207- webkit.WidgetShow(newContainer)
2208- }
2209- wm.focusNode(newLeaf)
2210- return false
2211- })
2212-
2213- return nil
2214-}
2215-
2216-// ensureGUIInPane lazily loads GUI components into a pane when it gains focus
2217-// ensureGUIInPane is now a no-op since GUI is injected globally by WebKit
2218-// This prevents duplicate GUI injection that was causing duplicate log messages
2219-func (wm *WorkspaceManager) ensureGUIInPane(pane *BrowserPane) {
2220- if pane == nil {
2221- return
2222- }
2223-
2224- // GUI is already injected globally via WebKit's enableUserContentManager
2225- // Just mark the pane as having GUI to prevent unnecessary calls
2226- if !pane.HasGUI() {
2227- pane.SetHasGUI(true)
2228- pane.SetGUIComponent("manager", true)
2229- pane.SetGUIComponent("omnibox", true)
2230- }
2231-}
2232-
2233-func mapDirection(direction string) (webkit.Orientation, bool) {
2234- switch direction {
2235- case "left":
2236- return webkit.OrientationHorizontal, false
2237- case "up":
2238- return webkit.OrientationVertical, false
2239- case "down":
2240- return webkit.OrientationVertical, true
2241- default:
2242- return webkit.OrientationHorizontal, true
2243- }
535+ log.Printf("[workspace] Registered navigation handler for webview: %s", webView.ID())
2244536 }
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) {
355355 } else {
356356 // Prefer unified page-world API; fallback to legacy global function
357357 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")
359359 if injErr := h.webView.InjectScript(script); injErr != nil {
360360 log.Printf("[ERROR] Failed to inject suggestions script: %v", injErr)
361361 } else {
M internal/config/config.go +26 -0
@@ -215,6 +215,18 @@ type DebugConfig struct {
215215
216216 // Enable workspace navigation and focus debug logs
217217 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"`
218230 }
219231
220232 // APISecurityConfig holds optional API key protection for dumb://api endpoints
@@ -634,6 +646,20 @@ func (m *Manager) setDefaults() {
634646 m.viper.SetDefault("logging.debug_file", defaults.Logging.DebugFile)
635647 m.viper.SetDefault("logging.verbose_webkit", defaults.Logging.VerboseWebKit)
636648
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+
637663 // Appearance defaults
638664 m.viper.SetDefault("appearance.sans_font", defaults.Appearance.SansFont)
639665 m.viper.SetDefault("appearance.serif_font", defaults.Appearance.SerifFont)
M internal/config/defaults.go +5 -0
@@ -151,6 +151,11 @@ func DefaultConfig() *Config {
151151 LogWebKitCrashes: true, // Always log crashes
152152 EnableScriptDebug: false,
153153 EnableGeneralDebug: false,
154+ EnableWorkspaceDebug: false,
155+ EnableFocusDebug: false,
156+ EnableCSSDebug: false,
157+ EnableFocusMetrics: false,
158+ EnablePaneCloseDebug: false,
154159 },
155160 APISecurity: APISecurityConfig{
156161 Token: "",
M internal/services/browser.go +0 -3
@@ -589,7 +589,6 @@ func zoomKeyFromURL(raw string) string {
589589 // ZoomKeyForLog exposes the derived zoom key (host or raw URL) for logging from other packages.
590590 func ZoomKeyForLog(raw string) string { return zoomKeyFromURL(raw) }
591591
592-
593592 // handleFaviconChanged processes favicon data received from WebKit (legacy binary data method)
594593 func (s *BrowserService) handleFaviconChanged(pageURL string, pngData []byte) {
595594 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
663662 faviconNullString := sql.NullString{String: faviconURI, Valid: true}
664663 if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {
665664 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)
668665 }
669666 }
M pkg/webkit/build_flags_stub.go +15 -0
@@ -5,3 +5,18 @@ package webkit
55 // IsNativeAvailable reports whether the native WebKit2GTK backend is compiled in.
66 // In non-CGO builds, this returns false and WebView methods are logical no-ops.
77 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 {
2727 recycleThreshold int
2828 monitoringInterval time.Duration
2929 stopMonitoring chan struct{}
30+ lastLoggedMemory int64
3031 }
3132
3233 // NewWebViewMemoryManager creates a new memory manager
@@ -105,9 +106,19 @@ func (mgr *WebViewMemoryManager) checkMemoryUsage() {
105106 totalMemory += proc.VmRSS
106107 }
107108
109+ // Only log if memory usage changed by more than 10MB (10240 KB)
110+ const memoryChangeThreshold = 10240 // 10MB in KB
108111 if mgr.enableMonitoring {
109- log.Printf("[webkit] Memory usage: %d processes, total %.1f MB RSS",
110- len(processes), float64(totalMemory)/1024.0)
112+ memoryDiff := totalMemory - mgr.lastLoggedMemory
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+ }
111122 }
112123
113124 // 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 {
3232 uriHandler func(uri string)
3333 faviconHandler func(data []byte)
3434 faviconURIHandler func(pageURI, faviconURI string)
35- zoomHandler func(level float64)
36- popupHandler func(string) *WebView
37- closeHandler func()
38- useDomZoom bool
39- domZoomSeed float64
40- container uintptr
41- id uintptr // WebView unique identifier
35+ zoomHandler func(level float64)
36+ popupHandler func(string) *WebView
37+ closeHandler func()
38+ navigationPolicyHandler func(url string, isUserGesture bool) bool
39+ useDomZoom bool
40+ domZoomSeed float64
41+ container uintptr
42+ id uintptr // WebView unique identifier
4243
4344 // Window type fields (no-op in stub)
4445 windowType WindowType
@@ -125,6 +126,9 @@ func (w *WebView) Window() *Window { return w.window }
125126 // GetCurrentURL returns the last requested URL (non-CGO build approximation).
126127 func (w *WebView) GetCurrentURL() string { return w.url }
127128
129+// GetTitle returns a stub page title.
130+func (w *WebView) GetTitle() string { return "New Tab" }
131+
128132 // GoBack is not supported in the non-CGO stub.
129133 func (w *WebView) GoBack() error { return ErrNotImplemented }
130134
@@ -153,6 +157,10 @@ func (w *WebView) RegisterPopupHandler(cb func(string) *WebView) { w.popupHandle
153157
154158 func (w *WebView) RegisterCloseHandler(cb func()) { w.closeHandler = cb }
155159
160+func (w *WebView) RegisterNavigationPolicyHandler(cb func(url string, isUserGesture bool) bool) {
161+ w.navigationPolicyHandler = cb
162+}
163+
156164 func (w *WebView) dispatchScriptMessage(payload string) { //nolint:unused // Called from CGO WebKit callbacks
157165 if w != nil && w.msgHandler != nil {
158166 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) {
8080 }
8181
8282
83-// Handle decide-policy signal for new window requests - more robust than create signal
83+// 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
8487 static gboolean on_decide_policy(WebKitWebView *web_view,
8588 WebKitPolicyDecision *decision,
8689 WebKitPolicyDecisionType type,
@@ -90,6 +93,32 @@ static gboolean on_decide_policy(WebKitWebView *web_view,
9093 const char* type_name = (type >= 0 && type < 3) ? type_names[type] : "UNKNOWN";
9194 printf("[webkit-policy] Policy decision: type=%s (%d)\n", type_name, type);
9295
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+
93122 if (type == WEBKIT_POLICY_DECISION_TYPE_NEW_WINDOW_ACTION) {
94123 WebKitNavigationPolicyDecision *nav_decision =
95124 WEBKIT_NAVIGATION_POLICY_DECISION(decision);
@@ -111,7 +140,6 @@ static gboolean on_decide_policy(WebKitWebView *web_view,
111140 guint mouse_button = webkit_navigation_action_get_mouse_button(action);
112141
113142 // Call Go handler for popup creation
114- unsigned long view_id = (unsigned long)user_data;
115143 gboolean handled = goHandleNewWindowPolicy(view_id, (char*)uri, nav_type, (char*)frame_name,
116144 is_user_gesture, modifiers, mouse_button);
117145
@@ -248,9 +276,9 @@ static GtkWidget* new_webview_related(WebKitWebView* related_view, WebKitUserCon
248276
249277 static void connect_policy_handler(WebKitWebView* web_view, unsigned long id) {
250278 if (!web_view) return;
251- // Create signal is now handled in window_handler_cgo.go
252- // Use ConnectCreateSignal from Go to connect the signal
253- printf("[webkit-debug] Policy handler connected (create signal handled separately)\n");
279+ // Connect decide-policy signal for navigation control
280+ g_signal_connect_data(G_OBJECT(web_view), "decide-policy", G_CALLBACK(on_decide_policy), (gpointer)id, NULL, 0);
281+ printf("[webkit-debug] Policy handler connected for WebView id: %lu\n", id);
254282 }
255283
256284 // Connect close signal to a WebView for popup auto-close functionality
@@ -987,12 +1015,13 @@ type WebView struct {
9871015 wasReparented bool // Track if this WebView has been reparented
9881016
9891017 // Window type detection and relationships
990- windowType WindowType
991- windowFeatures *WindowFeatures
992- windowTypeCallback func(WindowType, *WindowFeatures)
993- parentWebView *WebView
994- isRelated bool
995- isActive bool // Track if this WebView is currently active/focused
1018+ windowType WindowType
1019+ windowFeatures *WindowFeatures
1020+ windowTypeCallback func(WindowType, *WindowFeatures)
1021+ parentWebView *WebView
1022+ isRelated bool
1023+ 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
9961025 }
9971026
9981027 // NewWebView constructs a new WebView instance with native WebKit2GTK widgets.
@@ -1002,6 +1031,8 @@ func NewWebView(cfg *Config) (*WebView, error) {
10021031 return nil, errors.New("failed to initialize GTK")
10031032 }
10041033
1034+ InitMainThread()
1035+
10051036 if cfg == nil {
10061037 cfg = &Config{}
10071038 }
@@ -1458,6 +1489,8 @@ func NewWebViewWithRelated(cfg *Config, relatedView *WebView) (*WebView, error)
14581489 return nil, errors.New("failed to initialize GTK")
14591490 }
14601491
1492+ InitMainThread()
1493+
14611494 if cfg == nil {
14621495 cfg = &Config{}
14631496 }
@@ -1626,21 +1659,26 @@ func (w *WebView) Destroy() error {
16261659 return ErrNotImplemented
16271660 }
16281661
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)
16291663 // Stop periodic GC if running
16301664 w.stopPeriodicGC()
1665+ log.Printf("[webkit-destroy] periodic GC stopped: id=%d", w.id)
16311666
16321667 // Clean up memory pressure settings
16331668 if w.memStats != nil && w.memStats.memoryPressureSettings != nil {
16341669 // WebKit will clean up the memory pressure settings when context is destroyed
16351670 w.memStats.memoryPressureSettings = nil
1671+ log.Printf("[webkit-destroy] cleared memory pressure settings: id=%d", w.id)
16361672 }
16371673
16381674 // Unregister from memory manager
16391675 if globalMemoryManager != nil {
16401676 globalMemoryManager.UnregisterWebView(w.id)
1677+ log.Printf("[webkit-destroy] unregistered from memory manager: id=%d", w.id)
16411678 }
16421679
16431680 if w.native != nil && w.native.ucm != nil && w.domBridgeScript != nil {
1681+ log.Printf("[webkit-destroy] removing DOM bridge script: id=%d", w.id)
16441682 C.webkit_user_content_manager_remove_script(w.native.ucm, w.domBridgeScript)
16451683 C.webkit_user_script_unref(w.domBridgeScript)
16461684 w.domBridgeScript = nil
@@ -1648,6 +1686,7 @@ func (w *WebView) Destroy() error {
16481686
16491687 // GTK4: destroy windows via gtk_window_destroy if we created one
16501688 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)
16511690 C.gtk_window_destroy((*C.GtkWindow)(unsafe.Pointer(w.native.win)))
16521691 w.native.win = nil
16531692 if w.window != nil {
@@ -1656,13 +1695,17 @@ func (w *WebView) Destroy() error {
16561695 log.Printf("[webkit] Destroy window")
16571696 }
16581697
1698+ log.Printf("[webkit-destroy] releasing native widgets: id=%d", w.id)
16591699 w.releaseNativeWidgets()
1700+ log.Printf("[webkit-destroy] native widgets released: id=%d", w.id)
16601701 w.destroyed = true
16611702
16621703 // Unregister from dynamic protection updates
16631704 UnregisterActiveWebView(w)
1705+ log.Printf("[webkit-destroy] unregistered active view: id=%d", w.id)
16641706
16651707 unregisterView(w.id)
1708+ log.Printf("[webkit-destroy] destroy complete: id=%d", w.id)
16661709 return nil
16671710 }
16681711
@@ -1696,6 +1739,18 @@ func (w *WebView) GetCurrentURL() string {
16961739 return C.GoString((*C.char)(unsafe.Pointer(uri)))
16971740 }
16981741
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+
16991754 func (w *WebView) GoBack() error {
17001755 if w == nil || w.native == nil || w.native.wv == nil {
17011756 return ErrNotImplemented
@@ -1743,6 +1798,11 @@ func (w *WebView) RegisterCloseHandler(cb func()) {
17431798 }
17441799 }
17451800
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+
17461806 // EnablePrintInterception connects the print signal to intercept Ctrl+P
17471807 func (w *WebView) EnablePrintInterception() {
17481808 if w.native != nil && w.native.wv != nil {
@@ -2344,6 +2404,37 @@ func goHandleConsoleMessage(message *C.char) {
23442404 logging.CaptureWebKitLog(messageText)
23452405 }
23462406
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+
23472438 // showTLSWarningDialog displays a warning dialog for TLS certificate errors with storage capability
23482439 func (w *WebView) showTLSWarningDialog(hostname, uri string, errorFlags int, certificateInfo string) bool {
23492440 if w == nil || w.native == nil || w.native.win == nil {
M pkg/webkit/window.go +35 -1
@@ -2,10 +2,14 @@
22
33 package webkit
44
5+import "sync"
6+
57 // Window represents a top-level application window hosting a WebView.
68 // Stub for future GTK application window management.
79 type Window struct {
810 Title string
11+ child uintptr
12+ mu sync.Mutex
913 }
1014
1115 // NewWindow constructs a Window. In real implementation, this would create a GTK window.
@@ -20,6 +24,36 @@ func (w *Window) SetTitle(title string) {
2024 }
2125
2226 // SetChild is a stubbed window child setter for non-CGO builds.
27+// Simulates GTK4 behavior where setting child to 0 unparents the current child.
2328 func (w *Window) SetChild(child uintptr) {
24- _ = child
29+ 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
2559 }
M pkg/webkit/window_cgo.go +2 -0
@@ -27,6 +27,8 @@ func NewWindow(title string) (*Window, error) {
2727 if C.gtk_init_check() == 0 {
2828 return nil, ErrNotImplemented
2929 }
30+
31+ InitMainThread()
3032 // GTK4: gtk_window_new() returns GtkWindow*, no toplevel enum
3133 w := (*C.GtkWidget)(unsafe.Pointer(C.gtk_window_new()))
3234 if w == nil {
M pkg/webkit/workspace_widgets_cgo.go +707 -14
@@ -5,26 +5,50 @@ package webkit
55 /*
66 #cgo pkg-config: gtk4
77 #include <gtk/gtk.h>
8+#include <gtk/gtktestutils.h>
9+#include <gtk/gtkbox.h>
10+#include <gtk/gtkpaned.h>
811 #include <gdk/gdk.h>
912 #include <glib.h>
1013 #include <glib-object.h>
1114 #include <graphene.h>
15+#include <pthread.h>
1216 #include <stdint.h>
1317 #include <stdlib.h>
1418
1519 // Logging helper for diagnostics
1620 #define WORKSPACE_LOG(fmt, ...) g_printerr("[workspace-cgo] " fmt "\n", ##__VA_ARGS__)
1721
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+
1831 // Callback function for idle handling following the existing pattern
1932 extern gboolean goIdleCallback(uintptr_t handle);
2033 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();
2141
2242 static gboolean idle_callback_wrapper(gpointer data) {
2343 return goIdleCallback((uintptr_t)data);
2444 }
2545
26-static void add_idle_callback(uintptr_t handle) {
27- g_idle_add_full(G_PRIORITY_DEFAULT_IDLE, idle_callback_wrapper, (gpointer)handle, NULL);
46+static guint add_idle_callback(uintptr_t handle) {
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);
2852 }
2953
3054 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) {
78102 return widget != NULL && GTK_IS_WIDGET(widget);
79103 }
80104
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+
81192 static GtkWidget* paned_get_start_child(GtkWidget* paned) {
82193 if (!paned) return NULL;
83194 return gtk_paned_get_start_child(GTK_PANED(paned));
@@ -147,6 +258,116 @@ static void widget_remove_css_class(GtkWidget* widget, const char* name) {
147258 if (!widget || !name) return;
148259 gtk_widget_remove_css_class(widget, name);
149260 }
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+}
150371 */
151372 import "C"
152373
@@ -164,13 +385,52 @@ type WidgetBounds struct {
164385 Height float64
165386 }
166387
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+
167402 var (
168403 hoverMu sync.Mutex
169404 hoverCallbacks = make(map[uintptr]func())
170405 hoverControllers = make(map[uintptr]uintptr)
171406 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
172416 )
173417
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+
174434 func widgetIsValid(widget uintptr) bool {
175435 if widget == 0 {
176436 return false
@@ -190,6 +450,22 @@ func WidgetIsValid(widget uintptr) bool {
190450 return widgetIsValid(widget)
191451 }
192452
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+
193469 // WidgetRefCount exposes the widget reference count for debugging.
194470 func WidgetRefCount(widget uintptr) uint {
195471 return widgetRefCount(widget)
@@ -314,24 +590,65 @@ func PanedSetPosition(paned uintptr, pos int) {
314590 C.gtk_paned_set_position((*C.GtkPaned)(unsafe.Pointer(paned)), C.int(pos))
315591 }
316592
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+
317604 // WidgetUnparent detaches a widget from its current parent.
318605 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 {
319621 if widget == 0 {
320- return
622+ log.Printf("[workspace] WidgetUnparentChecked: widget=0")
623+ return false
321624 }
625+
322626 if !widgetIsValid(widget) {
323- log.Printf("[workspace] WidgetUnparent skipped invalid widget=%#x", widget)
324- return
627+ log.Printf("[workspace] WidgetUnparentChecked: widget=%#x invalid before unparent", widget)
628+ return false
325629 }
326- before := widgetRefCount(widget)
327- log.Printf("[workspace] WidgetUnparent widget=%#x ref_before=%d", widget, before)
630+
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+
328640 C.gtk_widget_unparent((*C.GtkWidget)(unsafe.Pointer(widget)))
329- if widgetIsValid(widget) {
330- after := widgetRefCount(widget)
331- log.Printf("[workspace] WidgetUnparent widget=%#x ref_after=%d", widget, after)
332- } else {
333- log.Printf("[workspace] WidgetUnparent widget=%#x now invalid", widget)
641+
642+ if !widgetIsValid(widget) {
643+ log.Printf("[workspace] WidgetUnparentChecked: widget=%#x invalid after unparent", widget)
644+ return true
334645 }
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
335652 }
336653
337654 // WidgetSetHExpand configures horizontal expand for a widget.
@@ -366,6 +683,24 @@ func WidgetSetVExpand(widget uintptr, expand bool) {
366683 C.gtk_widget_set_vexpand((*C.GtkWidget)(unsafe.Pointer(widget)), goBool(expand))
367684 }
368685
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+
369704 // WidgetShow makes the widget visible.
370705 func WidgetShow(widget uintptr) {
371706 if widget == 0 {
@@ -388,6 +723,28 @@ func WidgetGrabFocus(widget uintptr) {
388723 C.gtk_widget_grab_focus((*C.GtkWidget)(unsafe.Pointer(widget)))
389724 }
390725
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+
391748 // WidgetRef increments the reference count to keep the widget alive during reparenting.
392749 func WidgetRef(widget uintptr) bool {
393750 if widget == 0 {
@@ -434,6 +791,20 @@ func WidgetQueueAllocate(widget uintptr) {
434791 C.gtk_widget_queue_allocate((*C.GtkWidget)(unsafe.Pointer(widget)))
435792 }
436793
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+
437808 // WidgetRealizeInContainer ensures a widget is properly realized within a container
438809 func WidgetRealizeInContainer(widget uintptr) {
439810 if widget == 0 {
@@ -518,11 +889,32 @@ var (
518889 )
519890
520891 // 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+ }
522896 id := nextIdleID
523897 nextIdleID++
524898 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
526918 }
527919
528920 //export goIdleCallback
@@ -546,3 +938,304 @@ func goHoverCallback(handle C.uintptr_t) {
546938 fn()
547939 }
548940 }
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 {
2020 Height float64
2121 }
2222
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+
2331 type widgetStub struct {
2432 startChild uintptr
2533 endChild uintptr
2634 bounds WidgetBounds
2735 hasBounds bool
2836 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
2946 }
3047
3148 var (
@@ -33,6 +50,10 @@ var (
3350 widgetState = map[uintptr]*widgetStub{}
3451 nextWidgetID uintptr = 1
3552 nextHoverToken uintptr = 1
53+ // GTK focus management simulation
54+ globalFocusedWidget uintptr
55+ focusWarningEnabled bool = true
56+ gtkTestInitOnce sync.Once
3657 )
3758
3859 func newWidgetHandle() uintptr {
@@ -40,7 +61,10 @@ func newWidgetHandle() uintptr {
4061 defer widgetMu.Unlock()
4162 id := nextWidgetID
4263 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+ }
4468 return id
4569 }
4670
@@ -70,22 +94,101 @@ func ResetWidgetStubsForTesting() {
7094 }
7195
7296 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
74107 }
75108
76109 func PanedSetStartChild(paned uintptr, child uintptr) {
77110 widgetMu.Lock()
78111 defer widgetMu.Unlock()
79- if stub, ok := widgetState[paned]; ok {
80- stub.startChild = child
112+
113+ // 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+ }
81149 }
82150 }
83151
84152 func PanedSetEndChild(paned uintptr, child uintptr) {
85153 widgetMu.Lock()
86154 defer widgetMu.Unlock()
87- if stub, ok := widgetState[paned]; ok {
88- stub.endChild = child
155+
156+ // 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+ }
89192 }
90193 }
91194
@@ -96,17 +199,103 @@ func PanedSetResizeEnd(paned uintptr, resize bool) {}
96199 // PanedSetPosition is a no-op in stub builds.
97200 func PanedSetPosition(paned uintptr, pos int) {}
98201
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+}
100228
101229 func WidgetSetHExpand(widget uintptr, expand bool) {}
102230
103231 func WidgetSetVExpand(widget uintptr, expand bool) {}
104232
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+}
106241
107242 func WidgetShow(widget uintptr) {}
108243
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+}
110299
111300 func WidgetRef(widget uintptr) bool {
112301 widgetMu.Lock()
@@ -121,13 +310,50 @@ func WidgetQueueAllocate(widget uintptr) {}
121310
122311 func WidgetRealizeInContainer(widget uintptr) {}
123312
313+func WidgetSetSizeRequest(widget uintptr, width, height int) {}
314+
315+func WidgetResetSizeRequest(widget uintptr) {}
316+
317+func WidgetQueueResize(widget uintptr) {}
318+
124319 func WidgetHookDestroy(widget uintptr) {}
125320
126321 func WidgetIsValid(widget uintptr) bool {
127322 widgetMu.Lock()
128323 defer widgetMu.Unlock()
129- _, ok := widgetState[widget]
130- return ok && widget != 0
324+ if widget == 0 {
325+ 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
131357 }
132358
133359 func WidgetRefCount(widget uintptr) uint {
@@ -175,10 +401,35 @@ func WidgetGetBounds(widget uintptr) (WidgetBounds, bool) {
175401 return stub.bounds, true
176402 }
177403
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 {
179424 if fn != nil {
180425 fn()
181426 }
427+ return 0
428+}
429+
430+// IdleRemove cancels a simulated idle callback (no-op in stub builds).
431+func IdleRemove(handle uintptr) {
432+ _ = handle
182433 }
183434
184435 func AddCSSProvider(css string) {}
@@ -186,3 +437,169 @@ func AddCSSProvider(css string) {}
186437 func WidgetAddCSSClass(widget uintptr, class string) {}
187438
188439 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