detached d7d1748

fix(workspace): simplify closePane to prevent GTK crashes and let GTK auto-manage parenting

Major changes:
- Simplified closePane() to let GTK4 auto-handle widget unparenting/reparenting
  instead of manual manipulation which caused use-after-free crashes
- Key principle: PanedSetStartChild/EndChild and window.SetChild automatically
  unparent widgets from their current parents - no need for manual detachment
- Unparent closed pane BEFORE promoting sibling to avoid paned with single child
- Remove complex manual GTK widget hierarchy manipulation that caused crashes

Other improvements:
- Simplify insertPopupPane to use uintptr directly instead of WidgetContainer
- Add IdleGuarded for omnibox opening to prevent accessing destroyed widgets
- Cleanup stacked panes and workspace utility functions
- Remove obsolete widget.go and documentation files

Known issues:
- OAuth popup windows still compress parent pane - needs WidgetSetSizeRequest
  and WidgetQueueResize GTK methods to force proper layout recalculation
commit
d7d17483ffdd2891baecec85ae32331393b8f53b
author
Brice · · 2025-10-03 20:29 UTC
parent
efcc10f
signature
unsigned
28 files · +1291 -5801
A config.yaml +214 -0
@@ -0,0 +1,214 @@
1+apisecurity:
2+ requiretoken: false
3+ token: ''
4+appearance:
5+ default_font_size: 16
6+ defaultfontsize: 16
7+ monospace_font: Fira Code
8+ monospacefont: Fira Code
9+ sans_font: Fira Sans
10+ sansfont: Fira Sans
11+ serif_font: Fira Sans
12+ seriffont: Fira Sans
13+codec_preferences:
14+ av1_hardware_only: false
15+ av1_max_resolution: 1080p
16+ block_vp8: true
17+ block_vp9: false
18+ custom_user_agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like
19+ Gecko) Chrome/120.0.0.0 Safari/537.36
20+ disable_twitch_codec_control: true
21+ disable_vp9_hardware: false
22+ force_av1: false
23+ preferred_codecs: av1,h264
24+ queue_buffer_time_sec: 10
25+ video_buffer_size_mb: 16
26+codecpreferences:
27+ av1hardwareonly: false
28+ av1maxresolution: 1080p
29+ blockvp8: true
30+ blockvp9: false
31+ customuseragent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like
32+ Gecko) Chrome/120.0.0.0 Safari/537.36
33+ disabletwitchcodeccontrol: true
34+ disablevp9hardware: false
35+ forceav1: false
36+ preferredcodecs: av1,h264
37+ queuebuffertimesec: 10
38+ videobuffersizemb: 16
39+database:
40+ max_connections: 1
41+ max_idle_time: 300000000000
42+ maxconnections: 1
43+ maxidletime: 300000000000
44+ path: ''
45+ query_timeout: 30000000000
46+ querytimeout: 30000000000
47+debug:
48+ enable_css_debug: false
49+ enable_filtering_debug: false
50+ enable_focus_debug: false
51+ enable_focus_metrics: false
52+ enable_general_debug: false
53+ enable_pane_close_debug: false
54+ enable_script_debug: false
55+ enable_webkit_debug: false
56+ enable_webview_debug: false
57+ enable_workspace_debug: false
58+ enablecssdebug: false
59+ enablefilteringdebug: false
60+ enablefocusdebug: false
61+ enablefocusmetrics: false
62+ enablegeneraldebug: false
63+ enablepaneclosedebug: false
64+ enablescriptdebug: false
65+ enablewebkitdebug: false
66+ enablewebviewdebug: false
67+ enableworkspacedebug: false
68+ logwebkitcrashes: true
69+ webkitdebugcategories: Network:preconnectTo,ContentFilters
70+dmenu:
71+ date_format: 2006-01-02 15:04
72+ dateformat: 2006-01-02 15:04
73+ history_prefix: "\U0001F552"
74+ historyprefix: "\U0001F552"
75+ max_history_items: 20
76+ maxhistoryitems: 20
77+ shortcut_prefix: "\U0001F50D"
78+ shortcutprefix: "\U0001F50D"
79+ show_last_visited: true
80+ show_visit_count: true
81+ showlastvisited: true
82+ showvisitcount: true
83+ sort_by_visit_count: true
84+ sortbyvisitcount: true
85+ url_prefix: "\U0001F310"
86+ urlprefix: "\U0001F310"
87+history:
88+ cleanup_interval_days: 1
89+ cleanupintervaldays: 1
90+ max_entries: 10000
91+ maxentries: 10000
92+ retention_period_days: 365
93+ retentionperioddays: 365
94+logging:
95+ capture_c_output: false
96+ capture_stderr: false
97+ capture_stdout: false
98+ captureconsole: false
99+ capturecoutput: false
100+ capturestderr: false
101+ capturestdout: false
102+ compress: true
103+ debug_file: debug.log
104+ debugfile: debug.log
105+ enable_file_log: true
106+ enablefilelog: true
107+ filename: ''
108+ format: text
109+ level: info
110+ log_dir: /home/brice/.local/state/dumber/logs
111+ logdir: /home/brice/.local/state/dumber/logs
112+ max_age: 7
113+ max_backups: 3
114+ max_size: 100
115+ maxage: 7
116+ maxbackups: 3
117+ maxsize: 100
118+ verbose_webkit: false
119+ verbosewebkit: false
120+rendering_mode: gpu
121+renderingmode: gpu
122+search_shortcuts:
123+ g:
124+ description: Google search
125+ url: https://www.google.com/search?q=%s
126+ gh:
127+ description: GitHub search
128+ url: https://github.com/search?q=%s
129+ w:
130+ description: Wikipedia search
131+ url: https://en.wikipedia.org/wiki/%s
132+ yt:
133+ description: YouTube search
134+ url: https://www.youtube.com/results?search_query=%s
135+searchshortcuts:
136+ g:
137+ description: Google search
138+ url: https://www.google.com/search?q=%s
139+ gh:
140+ description: GitHub search
141+ url: https://github.com/search?q=%s
142+ w:
143+ description: Wikipedia search
144+ url: https://en.wikipedia.org/wiki/%s
145+ yt:
146+ description: YouTube search
147+ url: https://www.youtube.com/results?search_query=%s
148+use_dom_zoom: false
149+usedomzoom: false
150+video_acceleration:
151+ auto_detect_gpu: true
152+ enable_all_drivers: true
153+ enable_vaapi: true
154+ legacy_vaapi: false
155+ vaapi_driver_name: vdpau
156+videoacceleration:
157+ autodetectgpu: true
158+ enablealldrivers: true
159+ enablevaapi: true
160+ legacyvaapi: false
161+ vaapidrivername: ''
162+webkit_memory:
163+ cache_model: web_browser
164+ conservative_threshold: 0.4
165+ enable_gc_interval: 120
166+ enable_memory_monitoring: true
167+ enable_page_cache: true
168+ kill_threshold: 0.8
169+ memory_limit_mb: 0
170+ poll_interval_seconds: 45
171+ process_recycle_threshold: 50
172+ strict_threshold: 0.6
173+webkitmemory:
174+ cachemodel: web_browser
175+ conservativethreshold: 0.4
176+ enablegcinterval: 120
177+ enablememorymonitoring: true
178+ enablepagecache: true
179+ killthreshold: 0.8
180+ memorylimitmb: 0
181+ pollintervalseconds: 45
182+ processrecyclethreshold: 50
183+ strictthreshold: 0.6
184+workspace:
185+ enable_zellij_controls: true
186+ pane_mode:
187+ action_bindings:
188+ arrowdown: split-down
189+ arrowleft: split-left
190+ arrowright: split-right
191+ arrowup: split-up
192+ d: split-down
193+ enter: confirm
194+ escape: cancel
195+ l: split-left
196+ r: split-right
197+ u: split-up
198+ x: close-pane
199+ activation_shortcut: cmdorctrl+p
200+ timeout_ms: 3000
201+ popups:
202+ follow_pane_context: true
203+ open_in_new_pane: true
204+ placement: right
205+ styling:
206+ border_color: '@theme_selected_bg_color'
207+ border_radius: 0
208+ border_width: 2
209+ transition_duration: 120
210+ tabs:
211+ close_tab: cmdorctrl+w
212+ new_tab: cmdorctrl+t
213+ next_tab: cmdorctrl+tab
214+ previous_tab: cmdorctrl+shift+tab
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 docs/GO_CLEANUP_PLAN.md +0 -216
@@ -1,216 +0,0 @@
1-# Go-Idiomatic Cleanup Plan (2025 Standards)
2-
3-## Overview
4-This document outlines the plan to refactor the browser package to follow Go best practices and idioms as of 2025, focusing on simplicity and maintainability.
5-
6-## Core Principles (Based on Current Go Best Practices)
7-1. **"Accept interfaces, return structs"** - Define interfaces where they're used
8-2. **Small, focused interfaces** - 1-3 methods max (like io.Reader, io.Writer)
9-3. **No "manager" suffix** - Go prefers descriptive type names
10-4. **Avoid premature abstraction** - Only create interfaces when needed
11-5. **Keep it simple** - Go values simplicity over clever abstractions
12-
13-## Phase 1: Simplify File Structure (Following stdlib patterns)
14-
15-### Current Problem:
16-- 15 `workspace_*` files (Java-style naming)
17-- Too many abstractions without clear benefit
18-- "Manager" suffix everywhere (not idiomatic)
19-
20-### New Structure (12 files total):
21-```
22-browser/
23-├── doc.go # Package documentation
24-├── browser.go # BrowserApp and main types
25-├── pane.go # paneNode, BrowserPane types
26-├── tree.go # Tree operations (split, close, navigation)
27-├── focus.go # Focus handling (simplified)
28-├── widget.go # Widget wrapping and safety
29-├── stack.go # Stacked pane operations
30-├── validation.go # Tree and geometry validation
31-├── state.go # State capture/restore
32-├── shortcuts.go # Keyboard shortcuts
33-├── webview.go # WebView helpers
34-└── popup.go # Popup handling
35-```
36-
37-## Phase 2: Define Minimal Interfaces (Only Where Needed)
38-
39-### In pane.go (consumer-defined):
40-```go
41-// Viewer is what a pane needs from a webview
42-type Viewer interface {
43- LoadURL(string) error
44- Widget() uintptr
45-}
46-
47-// Focusable is what can receive focus
48-type Focusable interface {
49- Focus() error
50- IsFocused() bool
51-}
52-```
53-
54-### In tree.go (for testing):
55-```go
56-// TreeWalker visits nodes in the tree
57-type TreeWalker interface {
58- Visit(*paneNode) error
59-}
60-```
61-
62-**NO MEGA-INTERFACES** - Avoid TreeManager, FocusManager, etc.
63-
64-## Phase 3: Rename Types (Remove "Manager" Suffix)
65-
66-### Renames:
67-- `WorkspaceManager` → `Workspace`
68-- `FocusStateMachine` → `FocusController`
69-- `StackedPaneManager` → `StackController`
70-- `TreeValidator` → `Validator`
71-- `GeometryValidator` → (merge into Validator)
72-- `WidgetTransactionManager` → `WidgetTx`
73-- `StateTombstoneManager` → `StateCapture`
74-
75-## Phase 4: Consolidate by Functionality
76-
77-### tree.go (~1500 lines):
78-Merge:
79-- workspace_pane_ops.go (core operations)
80-- workspace_tree_rebalancer.go (rebalancing)
81-- workspace_navigation.go (tree navigation)
82-
83-### focus.go (~600 lines):
84-Simplify focus_state_machine.go:
85-- Remove complex state machine
86-- Use simple focused pointer + validation
87-- Move CSS to widget.go
88-
89-### widget.go (~700 lines):
90-Merge:
91-- Current widget.go
92-- workspace_widget_transaction.go
93-- Widget parts from workspace_utils.go
94-
95-### validation.go (~800 lines):
96-Merge:
97-- workspace_tree_validator.go
98-- workspace_geometry_validator.go
99-- Validation from workspace_bulletproof_operations.go
100-
101-### state.go (~600 lines):
102-Simplify workspace_state_tombstone.go:
103-- Remove complex tombstone system
104-- Simple state snapshots
105-
106-## Phase 5: Simplify Workspace Type
107-
108-### Current WorkspaceManager (too complex):
109-```go
110-type WorkspaceManager struct {
111- // 20+ fields, multiple mutexes, nested managers
112-}
113-```
114-
115-### New Workspace (simplified):
116-```go
117-type Workspace struct {
118- app *BrowserApp
119- root *paneNode
120- focus *paneNode // Currently focused pane
121- views map[*webkit.WebView]*paneNode
122-
123- mu sync.RWMutex // Single mutex
124-}
125-
126-// Methods directly on Workspace, no delegation
127-func (w *Workspace) Split(target *paneNode, dir Direction) (*paneNode, error)
128-func (w *Workspace) Close(target *paneNode) error
129-func (w *Workspace) Focus(target *paneNode) error
130-```
131-
132-## Phase 6: Remove Overengineering
133-
134-### Remove:
135-- Complex state machine for focus (use simple pointer)
136-- Transaction system (use defer for cleanup)
137-- Concurrency controller (use sync.Mutex)
138-- Multiple validation layers (validate once)
139-- Bulletproof wrappers (handle errors normally)
140-
141-### Simplify to:
142-```go
143-// Instead of complex transactions
144-func (w *Workspace) Split(target *paneNode, dir Direction) (*paneNode, error) {
145- w.mu.Lock()
146- defer w.mu.Unlock()
147-
148- // Validate once
149- if err := w.validate(); err != nil {
150- return nil, err
151- }
152-
153- // Do the split
154- newNode := w.doSplit(target, dir)
155-
156- // Update focus
157- w.focus = newNode
158-
159- return newNode, nil
160-}
161-```
162-
163-## Benefits of This Approach:
164-
165-1. **Go-idiomatic**: Follows stdlib patterns
166-2. **Simpler**: 40% less code, easier to understand
167-3. **No over-abstraction**: Interfaces only where needed
168-4. **Clear ownership**: Each file has single responsibility
169-5. **Testable**: Small interfaces easy to mock
170-6. **Maintainable**: Less indirection, clearer flow
171-
172-## What We're NOT Doing:
173-- Creating interface for everything
174-- Using "Manager" suffix
175-- Complex dependency injection
176-- Abstracting GTK (until actually needed)
177-- Multiple layers of validation
178-
179-## Implementation Order:
180-1. Rename files (remove workspace_ prefix)
181-2. Merge related files by functionality
182-3. Simplify types (remove Manager suffix)
183-4. Remove unnecessary abstractions
184-5. Test that everything still works
185-
186-This follows Go's philosophy: **"Clear is better than clever"**
187-
188-## File Consolidation Map
189-
190-### Current → New:
191-```
192-workspace_manager.go → browser.go (merge with existing)
193-workspace_types.go → pane.go (merge with existing)
194-workspace_pane_ops.go → tree.go
195-workspace_tree_rebalancer.go → tree.go (merge)
196-workspace_navigation.go → tree.go (merge)
197-focus_state_machine.go → focus.go (simplified)
198-widget.go → widget.go (keep, expand)
199-workspace_widget_transaction.go → widget.go (merge)
200-workspace_utils.go → widget.go (merge widget parts)
201-workspace_tree_validator.go → validation.go
202-workspace_geometry_validator.go → validation.go (merge)
203-workspace_bulletproof_operations.go → validation.go (merge)
204-workspace_state_tombstone.go → state.go (simplified)
205-workspace_stack_lifecycle.go → state.go (merge)
206-stacked_panes.go → stack.go (rename)
207-workspace_css.go → widget.go (merge CSS parts)
208-workspace_popup.go → popup.go (rename)
209-workspace_concurrency.go → REMOVE (use standard sync)
210-shortcuts.go → shortcuts.go (keep)
211-window_shortcuts.go → shortcuts.go (merge)
212-webview.go → webview.go (keep)
213-interfaces.go → REMOVE (define interfaces where used)
214-```
215-
216-### Result: 24 files → 12 files (50% reduction)⏎̸
D docs/PANE_ARCHITECTURE.md +0 -742
@@ -1,742 +0,0 @@
1-# Binary Tree / Pane Handling Architecture Documentation
2-
3-This document provides an in-depth analysis of the binary tree and pane handling system in the Dumber browser application, based on thorough examination of the actual codebase.
4-
5-## Table of Contents
6-
7-1. [Overall Architecture](#overall-architecture)
8-2. [Core Data Structures](#core-data-structures)
9-3. [Component Hierarchy](#component-hierarchy)
10-4. [Binary Tree Operations](#binary-tree-operations)
11-5. [Widget Layer Integration](#widget-layer-integration)
12-6. [Focus Management System](#focus-management-system)
13-7. [Safety and Validation Systems](#safety-and-validation-systems)
14-8. [Operation Flow Diagrams](#operation-flow-diagrams)
15-
16-## Overall Architecture
17-
18-The pane handling system implements a sophisticated binary tree structure for managing browser panes (similar to tmux/Zellij), with multiple layers of abstraction and safety systems.
19-
20-```mermaid
21-graph TB
22- subgraph "Application Layer"
23- BrowserApp[BrowserApp]
24- BrowserPane[BrowserPane]
25- end
26-
27- subgraph "Workspace Management Layer"
28- WorkspaceManager[WorkspaceManager]
29- paneNode[paneNode - Binary Tree Node]
30- StackedPaneManager[StackedPaneManager]
31- FocusStateMachine[FocusStateMachine]
32- end
33-
34- subgraph "Safety Layer"
35- TreeValidator[TreeValidator]
36- GeometryValidator[GeometryValidator]
37- WidgetTxManager[WidgetTransactionManager]
38- ConcurrencyController[ConcurrencyController]
39- TreeRebalancer[TreeRebalancer]
40- StateTombstoneManager[StateTombstoneManager]
41- end
42-
43- subgraph "Widget Layer"
44- SafeWidget[SafeWidget Wrapper]
45- WidgetRegistry[WidgetRegistry]
46- end
47-
48- subgraph "GTK/WebKit Layer"
49- GtkPaned[GtkPaned - Split Container]
50- GtkBox[GtkBox - Stack Container]
51- WebView[WebKitWebView]
52- GTKWindow[GtkWindow]
53- end
54-
55- BrowserApp --> WorkspaceManager
56- WorkspaceManager --> paneNode
57- WorkspaceManager --> StackedPaneManager
58- WorkspaceManager --> FocusStateMachine
59- WorkspaceManager --> TreeValidator
60- WorkspaceManager --> GeometryValidator
61- paneNode --> SafeWidget
62- SafeWidget --> GtkPaned
63- SafeWidget --> GtkBox
64- SafeWidget --> WebView
65- BrowserPane --> WebView
66-```
67-
68-## Core Data Structures
69-
70-### paneNode Structure
71-
72-The `paneNode` is the fundamental building block of the binary tree:
73-
74-```mermaid
75-classDiagram
76- class paneNode {
77- +BrowserPane pane
78- +paneNode parent
79- +paneNode left
80- +paneNode right
81- +SafeWidget container
82- +Orientation orientation
83- +bool isLeaf
84- +bool isPopup
85- +WindowType windowType
86- +WindowFeatures windowFeatures
87- +bool isRelated
88- +paneNode parentPane
89- +bool autoClose
90- +uintptr hoverToken
91- +uintptr focusControllerToken
92- +bool pendingHoverReattach
93- +bool pendingFocusReattach
94- +bool isStacked // TRUE: stack container, FALSE: regular pane or pane within stack
95- +[]*paneNode stackedPanes // Non-empty only if isStacked=true
96- +int activeStackIndex
97- +SafeWidget titleBar
98- +SafeWidget stackWrapper
99- }
100-
101- class BrowserPane {
102- +WebView webView
103- +MessageHandler messageHandler
104- +ZoomController zoomController
105- +Cleanup()
106- +CleanupFromWorkspace()
107- }
108-
109- class SafeWidget {
110- +uintptr ptr
111- +string typeInfo
112- +bool valid
113- +Execute(func(uintptr) error)
114- +Invalidate()
115- +IsValid() bool
116- }
117-
118- paneNode --> BrowserPane
119- paneNode --> SafeWidget
120-```
121-
122-### WorkspaceManager Structure
123-
124-```mermaid
125-classDiagram
126- class WorkspaceManager {
127- +BrowserApp app
128- +Window window
129- +paneNode root
130- +paneNode mainPane
131- +map[WebView]paneNode viewToNode
132- +map[WebView]time.Time lastSplitMsg
133- +map[WebView]time.Time lastExitMsg
134- +bool paneModeActive
135- +int32 splitting
136- +bool cssInitialized
137- +func createWebViewFn
138- +func createPaneFn
139- +WebView paneModeSource
140- +time.Time lastPaneModeEntry
141- +sync.Mutex paneMutex
142- +StackedPaneManager stackedPaneManager
143- +FocusStateMachine focusStateMachine
144- +sync.Mutex widgetMutex
145- +WidgetRegistry widgetRegistry
146- +TreeValidator treeValidator
147- +WidgetTransactionManager widgetTxManager
148- +ConcurrencyController concurrencyController
149- +TreeRebalancer treeRebalancer
150- +GeometryValidator geometryValidator
151- +StackLifecycleManager stackLifecycleManager
152- +StateTombstoneManager stateTombstoneManager
153- }
154-```
155-
156-## Component Hierarchy
157-
158-The system follows a clear hierarchical structure:
159-
160-```mermaid
161-graph TD
162- subgraph "Tree Structure"
163- Root[Root paneNode]
164- Split1[Split paneNode<br/>GtkPaned Horizontal]
165- Split2[Split paneNode<br/>GtkPaned Vertical]
166- Stack1[Stack paneNode<br/>GtkBox Container]
167- Leaf1[Leaf paneNode<br/>WebView 1]
168- Leaf2[Leaf paneNode<br/>WebView 2]
169- Leaf3[Leaf paneNode<br/>WebView 3]
170- Leaf4[Leaf paneNode<br/>WebView 4]
171- Leaf5[Leaf paneNode<br/>WebView 5]
172- end
173-
174- Root --> Split1
175- Root --> Leaf1
176- Split1 --> Split2
177- Split1 --> Stack1
178- Split2 --> Leaf2
179- Split2 --> Leaf3
180- Stack1 --> Leaf4
181- Stack1 --> Leaf5
182-
183- style Root fill:#e1f5fe
184- style Split1 fill:#f3e5f5
185- style Split2 fill:#f3e5f5
186- style Stack1 fill:#e8f5e8
187- style Leaf1 fill:#fff3e0
188- style Leaf2 fill:#fff3e0
189- style Leaf3 fill:#fff3e0
190- style Leaf4 fill:#fff3e0
191- style Leaf5 fill:#fff3e0
192-```
193-
194-## Binary Tree Operations
195-
196-### Stacked Pane Node Semantics
197-
198-**Critical Implementation Detail**: The `isStacked` flag has precise semantics that are essential for correct operation:
199-
200-- **Stack Container Node**: `isStacked=true`, `isLeaf=false`, `stackedPanes` contains the individual panes
201-- **Panes Within Stack**: `isStacked=false`, `isLeaf=true`, `parent` points to the stack container
202-
203-Individual panes within a stack are **NOT** marked as stacked themselves. Only the container node managing the stack has `isStacked=true`. This allows:
204-- Split validation to accept individual panes within stacks
205-- Split operations to automatically target the stack container (not the individual pane)
206-- Proper tree traversal and focus management
207-
208-**Splitting from Stacked Panes**: When splitting from a pane inside a stack, the system automatically:
209-1. Detects `target.parent.isStacked == true`
210-2. Promotes the split target to the stack container
211-3. Splits around the **entire stack**, not the individual pane
212-4. Creates a new sibling to the stack container in the tree
213-
214-This ensures stacks remain cohesive units in the tree structure.
215-
216-### Split Operation Flow
217-
218-```mermaid
219-sequenceDiagram
220- participant User
221- participant WM as WorkspaceManager
222- participant BP as BulletproofOps
223- participant TV as TreeValidator
224- participant GV as GeometryValidator
225- participant GTK as GTK Layer
226-
227- User->>WM: Request split (target, direction)
228- WM->>BP: BulletproofSplitNode()
229- BP->>TV: ValidateTree(before_split)
230- TV-->>BP: Validation result
231- BP->>GV: ValidateSplit(target, direction)
232- GV-->>BP: Geometry validation
233-
234- alt Validation passes
235- BP->>WM: splitNode(target, direction)
236- WM->>GTK: Create GtkPaned
237- GTK-->>WM: Paned widget
238- WM->>GTK: Reparent existing widget
239- WM->>GTK: Add new WebView
240- WM->>WM: Update tree structure
241- WM-->>BP: New pane node
242- BP->>TV: ValidateTree(after_split)
243- BP-->>User: Success + new pane
244- else Validation fails
245- BP-->>User: Error message
246- end
247-```
248-
249-### Close Operation Flow
250-
251-```mermaid
252-sequenceDiagram
253- participant User
254- participant WM as WorkspaceManager
255- participant BP as BulletproofOps
256- participant STM as StateTombstoneManager
257- participant TV as TreeValidator
258- participant TR as TreeRebalancer
259- participant GTK as GTK Layer
260-
261- User->>WM: Request close (target)
262- WM->>BP: BulletproofClosePane()
263- BP->>STM: CaptureState("close")
264- STM-->>BP: State tombstone
265- BP->>TV: ValidateTree(before_close)
266- TV-->>BP: Validation result
267-
268- alt Has sibling
269- BP->>WM: closePane(target)
270- WM->>WM: Find sibling/replacement node
271- WM->>GTK: Unparent target widget
272- WM->>WM: swapContainers (skips attachRoot if TreeRebalancer enabled)
273- WM->>GTK: Promote sibling in tree structure
274- WM->>WM: Update tree structure
275- WM-->>BP: Return promoted node
276- BP->>TV: ValidateTree(after_close)
277- BP->>TR: RebalanceAfterClose(closed, promoted)
278- TR->>TR: Execute promotion transaction
279- TR->>GTK: WidgetResetSizeRequest(promoted)
280- TR->>GTK: Attach to window/parent (handles double-attachment prevention)
281- TR->>GTK: Queue allocation on ancestors
282- TR->>TR: Validate geometry
283- TR-->>BP: Promotion completed
284- BP-->>User: Success
285- else Last pane
286- WM->>GTK: QuitMainLoop()
287- WM-->>BP: Return nil (app exiting)
288- else Error occurs
289- BP->>STM: RestoreState(tombstone)
290- BP-->>User: Error + rollback
291- end
292-```
293-
294-### Stack Operation
295-
296-```mermaid
297-sequenceDiagram
298- participant User
299- participant WM as WorkspaceManager
300- participant SPM as StackedPaneManager
301- participant GTK as GTK Layer
302-
303- User->>WM: Request stack (target)
304- WM->>SPM: StackPane(target)
305- SPM->>GTK: Create GtkBox container
306- GTK-->>SPM: Box widget
307- SPM->>GTK: Create title bar
308- SPM->>GTK: Add target to stack
309- SPM->>GTK: Create new WebView
310- SPM->>GTK: Add new pane to stack
311- SPM->>SPM: Update stack navigation
312- SPM-->>WM: New stacked node
313- WM-->>User: Success
314-```
315-
316-## Widget Layer Integration
317-
318-### SafeWidget Architecture
319-
320-```mermaid
321-graph TB
322- subgraph "SafeWidget System"
323- SW[SafeWidget]
324- WR[WidgetRegistry]
325- WT[WidgetTransaction]
326- end
327-
328- subgraph "GTK Widgets"
329- GP[GtkPaned]
330- GB[GtkBox]
331- WV[WebKitWebView]
332- GL[GtkLabel]
333- end
334-
335- SW --> WR
336- SW --> WT
337- SW --> GP
338- SW --> GB
339- SW --> WV
340- SW --> GL
341-
342- WR -.-> |"Track lifetimes"| GP
343- WR -.-> |"Track lifetimes"| GB
344- WR -.-> |"Track lifetimes"| WV
345-
346- WT -.-> |"Atomic operations"| GP
347- WT -.-> |"Atomic operations"| GB
348- WT -.-> |"Atomic operations"| WV
349-```
350-
351-### Widget Lifecycle Management
352-
353-```mermaid
354-stateDiagram-v2
355- [*] --> Created
356- Created --> Registered: Register with WidgetRegistry
357- Registered --> Realized: WidgetRealizeInContainer()
358- Realized --> Parented: Add to parent widget
359- Parented --> Visible: WidgetShow()
360- Visible --> Processing: Normal operation
361- Processing --> Reparenting: During splits/closes
362- Reparenting --> Unparented: WidgetUnparent()
363- Unparented --> Parented: New parent assigned
364- Processing --> Invalidated: Widget destroyed
365- Invalidated --> [*]
366-
367- Reparenting --> ValidationFailed: Widget becomes invalid
368- ValidationFailed --> Invalidated
369-```
370-
371-## Focus Management System
372-
373-### Focus State Machine
374-
375-```mermaid
376-stateDiagram-v2
377- [*] --> Initializing
378- Initializing --> Idle: System ready
379- Idle --> Transitioning: Focus request received
380- Transitioning --> Focused: Focus applied successfully
381- Focused --> Idle: Focus settled
382- Transitioning --> Reconciling: Focus conflict detected
383- Reconciling --> Focused: Conflict resolved
384- Reconciling --> Idle: Fallback to previous state
385-
386- note right of Transitioning: Priority queue processes requests
387- note right of Reconciling: CSS class synchronization
388- note right of Focused: GTK focus + CSS classes applied
389-```
390-
391-### Focus Request Processing
392-
393-```mermaid
394-graph TB
395- subgraph "Focus Request Flow"
396- FR[Focus Request]
397- DD[Request Deduplicator]
398- PQ[Priority Queue]
399- FSM[Focus State Machine]
400- GC[GTK Controllers]
401- CSS[CSS Classes]
402- end
403-
404- FR --> DD
405- DD --> |"Not duplicate"| PQ
406- DD --> |"Duplicate"| Discard[Discard]
407- PQ --> FSM
408- FSM --> GC
409- FSM --> CSS
410-
411- subgraph "Priority Levels"
412- PU[Urgent: 100]
413- PH[High: 90]
414- PN[Normal: 50]
415- PL[Low: 10]
416- end
417-
418- PQ --> PU
419- PQ --> PH
420- PQ --> PN
421- PQ --> PL
422-```
423-
424-## Safety and Validation Systems
425-
426-### Tree Validation
427-
428-```mermaid
429-graph TB
430- subgraph "Tree Validation System"
431- TV[TreeValidator]
432- VS[Validation Suite]
433-
434- subgraph "Validation Checks"
435- PC[Parent-Child Consistency]
436- RC[Root Node Validation]
437- LC[Leaf Node Validation]
438- SC[Stack Consistency]
439- WC[Widget Consistency]
440- end
441- end
442-
443- TV --> VS
444- VS --> PC
445- VS --> RC
446- VS --> LC
447- VS --> SC
448- VS --> WC
449-
450- PC -.-> |"Bidirectional links"| TreeStructure[Tree Structure]
451- RC -.-> |"Single root"| TreeStructure
452- LC -.-> |"Valid panes"| TreeStructure
453- SC -.-> |"Stack integrity"| TreeStructure
454- WC -.-> |"Widget pointers"| TreeStructure
455-```
456-
457-### Bulletproof Operation Wrapper
458-
459-```mermaid
460-sequenceDiagram
461- participant Client
462- participant BP as BulletproofOps
463- participant STM as StateTombstone
464- participant GV as GeometryValidator
465- participant TV as TreeValidator
466- participant CC as ConcurrencyController
467- participant TR as TreeRebalancer
468- participant Core as CoreOperation
469-
470- Client->>BP: Operation request
471- BP->>STM: Capture state tombstone
472- BP->>GV: Validate geometry constraints
473- BP->>TV: Validate tree before operation
474-
475- alt On GTK main thread
476- BP->>Core: Execute directly
477- else Off main thread
478- BP->>CC: Submit to concurrency controller
479- CC->>Core: Execute on main thread
480- end
481-
482- Core-->>BP: Operation result
483-
484- alt Success
485- BP->>TV: Validate tree after operation
486- BP->>TR: Rebalance if needed
487- BP-->>Client: Success
488- else Failure
489- BP->>STM: Restore state from tombstone
490- BP-->>Client: Error + rollback
491- end
492-```
493-
494-## Operation Flow Diagrams
495-
496-### Complete Split Operation
497-
498-```mermaid
499-flowchart TD
500- Start([Split Request]) --> V1{Tree Valid?}
501- V1 -->|No| Error1[Return Error]
502- V1 -->|Yes| V2{Geometry Valid?}
503- V2 -->|No| Error2[Return Error]
504- V2 -->|Yes| Capture[Capture State Tombstone]
505-
506- Capture --> Thread{On GTK Thread?}
507- Thread -->|Yes| Direct[Execute Directly]
508- Thread -->|No| Queue[Submit to Concurrency Controller]
509- Queue --> Execute[Execute on Main Thread]
510- Direct --> Execute
511-
512- Execute --> CreatePaned[Create GtkPaned Widget]
513- CreatePaned --> CreateWebView[Create New WebView]
514- CreateWebView --> DetachControllers[Safely Detach GTK Controllers]
515- DetachControllers --> UnparentTarget[Unparent Target Widget]
516- UnparentTarget --> SetupPaned[Configure Paned Properties]
517- SetupPaned --> AddChildren[Add Both Widgets to Paned]
518- AddChildren --> UpdateTree[Update Tree Structure]
519- UpdateTree --> AttachPaned[Attach Paned to Parent/Window]
520- AttachPaned --> ReattachControllers[Reattach GTK Controllers]
521- ReattachControllers --> ShowWidgets[Show All Widgets]
522- ShowWidgets --> UpdateCSS[Update CSS Classes]
523- UpdateCSS --> SetFocus[Set Focus to New Pane]
524- SetFocus --> Validate[Validate Tree Structure]
525- Validate --> Rebalance[Tree Rebalancing Check]
526- Rebalance --> Success([Return New Pane])
527-
528- Execute -->|Error| Rollback[Restore from Tombstone]
529- Rollback --> Error3[Return Error]
530-
531- style Start fill:#e1f5fe
532- style Success fill:#e8f5e8
533- style Error1 fill:#ffebee
534- style Error2 fill:#ffebee
535- style Error3 fill:#ffebee
536-```
537-
538-### Complete Close Operation
539-
540-```mermaid
541-flowchart TD
542- Start([Close Request]) --> Stack{In Stack?}
543- Stack -->|Yes| StackClose[Use Stack Lifecycle Manager]
544- Stack -->|No| ValidateTree[Validate Tree Structure]
545-
546- ValidateTree --> Capture[Capture State Tombstone]
547- Capture --> CheckLast{Last Pane?}
548-
549- CheckLast -->|Yes| QuitApp[Quit Application - Return nil]
550- CheckLast -->|No| CheckRoot{Is Root?}
551-
552- CheckRoot -->|Yes| FindReplacement[Find Replacement Root]
553- CheckRoot -->|No| FindSibling[Find Sibling Node]
554-
555- FindReplacement --> HasReplacement{Has Replacement?}
556- HasReplacement -->|No| QuitApp
557- HasReplacement -->|Yes| PromoteRoot[Promote Replacement to Root]
558-
559- FindSibling --> ValidateWidgets[Validate Widget State]
560- ValidateWidgets --> CheckGrand{Has Grandparent?}
561-
562- CheckGrand -->|Yes| PromoteToParent[Promote Sibling to Parent Position]
563- CheckGrand -->|No| PromoteToRoot
564-
565- PromoteRoot --> TrackPromoted[Track Promoted Node]
566- PromoteToParent --> TrackPromoted
567-
568- TrackPromoted --> UnparentFromCurrent[Unparent from Current Parent]
569- UnparentFromCurrent --> AttachToNew[Attach to New Parent/Window]
570- AttachToNew --> UpdateTreeRefs[Update Tree References]
571- UpdateTreeRefs --> CleanupOld[Cleanup Old Parent Node]
572- CleanupOld --> FindFocus[Find Focus Target]
573- FindFocus --> SetFocus[Set Focus]
574- SetFocus --> CleanupPane[Cleanup Closed Pane]
575- CleanupPane --> DestroyWebView[Destroy WebView]
576- DestroyWebView --> UpdateMain[Update Main Pane Reference]
577- UpdateMain --> ReturnPromoted[Return Promoted Node]
578- ReturnPromoted --> PromotionTx[Execute Promotion Transaction]
579- PromotionTx --> ResetSize[WidgetResetSizeRequest]
580- ResetSize --> ReattachWidget[Reattach with Expansion]
581- ReattachWidget --> QueueAllocation[Queue Allocation on Ancestors]
582- QueueAllocation --> ValidateGeometry[Validate Final Geometry]
583- ValidateGeometry --> Success([Success with Promoted Node])
584-
585- StackClose --> Success
586- QuitApp --> SuccessNil([Success - App Exiting])
587-
588- style Start fill:#e1f5fe
589- style Success fill:#e8f5e8
590- style SuccessNil fill:#fff3e0
591- style TrackPromoted fill:#e3f2fd
592- style PromotionTx fill:#f3e5f5
593-```
594-
595-## CSS Class Management
596-
597-The system uses CSS classes for visual feedback and state indication:
598-
599-```mermaid
600-graph TB
601- subgraph "CSS Class System"
602- Base[.workspace-pane]
603- Multi[.workspace-multi-pane]
604- Active[.workspace-pane-active]
605- Outline[.workspace-pane-active-outline]
606- Stack[.stacked-pane-container]
607- end
608-
609- subgraph "Application Logic"
610- Single[Single Pane] --> Base
611- Multiple[Multiple Panes] --> Base
612- Multiple --> Multi
613- Focused[Focused Pane] --> Active
614- Focused --> Outline
615- StackContainer[Stack Container] --> Stack
616- end
617-
618- subgraph "Visual Effects"
619- Base --> BasicStyling[Basic pane styling]
620- Multi --> BorderStyling[Pane borders in multi-pane mode]
621- Active --> FocusHighlight[Focus highlight]
622- Outline --> FocusOutline[Focus outline border]
623- Stack --> StackStyling[Stack container styling]
624- end
625-```
626-
627-## Enhanced Promotion System
628-
629-The system now includes a sophisticated promotion transaction system that ensures proper widget layout after pane closures:
630-
631-### Promotion Transaction Flow
632-
633-```mermaid
634-sequenceDiagram
635- participant BP as BulletproofOps
636- participant WM as WorkspaceManager
637- participant TR as TreeRebalancer
638- participant WT as WidgetTransaction
639- participant GTK as GTK Layer
640-
641- BP->>WM: closePane(node)
642- WM->>WM: Identify promoted node
643- WM->>GTK: Update tree structure
644- WM-->>BP: Return promoted node
645-
646- BP->>TR: RebalanceAfterClose(closed, promoted)
647- TR->>WT: Begin promotion transaction
648-
649- Note over TR,WT: Widget size constraint reset
650- TR->>WT: Add WidgetResetSizeRequest operation
651-
652- Note over TR,WT: Widget reattachment with expansion
653- TR->>WT: Add reattachment operation
654-
655- Note over TR,WT: Ancestor allocation propagation
656- TR->>WT: Add allocation queue operations
657-
658- Note over TR,WT: Geometry validation
659- TR->>WT: Add validation operation
660-
661- WT->>GTK: Execute all operations atomically
662- GTK-->>WT: Operations completed
663- WT->>WT: Commit transaction
664- WT-->>TR: Transaction success
665- TR-->>BP: Promotion completed
666-```
667-
668-### Key Promotion Features
669-
670-- **Size Constraint Reset**: `WidgetResetSizeRequest()` clears stale size constraints
671-- **Proper Expansion**: Ensures promoted widgets can expand to fill available space
672-- **Ancestor Propagation**: Queues allocation updates up the widget hierarchy
673-- **Geometry Validation**: Validates final widget bounds after promotion
674-- **Transaction Safety**: All operations are atomic with rollback capability
675-
676-## Key Files and Their Responsibilities
677-
678-| File | Purpose | Key Components |
679-|------|---------|----------------|
680-| `workspace_types.go:8` | Core data structures | `paneNode`, CSS constants |
681-| `workspace_manager.go:18` | Main coordinator | `WorkspaceManager`, initialization |
682-| `workspace_pane_ops.go:707` | Tree operations | Split, close, stack operations with promoted node tracking |
683-| `workspace_tree_rebalancer.go:92` | Tree balancing | Rebalancing algorithms, promotion transactions |
684-| `workspace_bulletproof_operations.go:158` | Safety wrapper | Bulletproof operation wrappers with promotion handling |
685-| `workspace_concurrency.go:454` | Async operations | Concurrency controller with promoted node results |
686-| `focus_state_machine.go:186` | Focus management | State machine, priority queue |
687-| `workspace_widgets_cgo.go:633` | GTK integration | Widget operations, WidgetResetSizeRequest |
688-
689-## Performance Characteristics
690-
691-- **Tree Height**: O(log n) for balanced trees, O(n) worst case
692-- **Split Operation**: O(log n) average case with rebalancing
693-- **Close Operation**: O(1) tree updates + O(k) widget operations where k = ancestor depth
694-- **Focus Changes**: O(1) with priority queue and deduplication
695-- **Widget Operations**: Atomic transactions prevent partial state
696-- **Promotion Transactions**: Batched GTK operations minimize UI thread overhead
697-- **Memory Usage**: Each pane node ~200 bytes + GTK widget overhead
698-
699-### Recent Performance Improvements
700-
701-- **Promoted Node Tracking**: Eliminates redundant tree traversals during rebalancing
702-- **Batched Widget Operations**: Ancestor allocation updates processed in single transaction
703-- **Size Constraint Management**: Explicit constraint reset prevents GTK layout conflicts
704-- **Async Consistency**: Identical behavior between sync and async operation paths
705-
706-### Widget Safety Enhancements
707-
708-Recent improvements prevent GTK assertion failures and widget corruption:
709-
710-- **CSS Class Safety**: All CSS class operations verify `WidgetIsValid()` before modification to prevent GTK bloom filter corruption when widgets are destroyed mid-operation
711-- **Paned Child Clearing**: Before destroying paned widgets, verifies `WidgetGetParent(child) == paned_ptr` to avoid unparenting already-reparented children
712-- **Double-Attachment Prevention**: `swapContainers` skips `attachRoot` when `TreeRebalancer` is enabled, delegating widget attachment to the promotion transaction
713-- **Unparent Verification**: Stack container creation includes explicit unparent checks and logging for freshly-created WebView widgets
714-
715-## Thread Safety
716-
717-The system implements comprehensive thread safety:
718-
719-- **Widget Operations**: All GTK operations marshaled to main thread
720-- **Focus Management**: Queue-based serialization with priority
721-- **State Changes**: Mutex protection for critical sections
722-- **Tree Modifications**: Atomic operations with rollback capability
723-
724-## Known Constraints and Design Decisions
725-
726-### Stacked Pane Management
727-
728-1. **Individual panes within stacks have `isStacked=false`**: Only the container node has `isStacked=true`. This enables split validation and proper tree operations.
729-
730-2. **Splitting targets the stack container**: When splitting from a pane inside a stack, the operation automatically promotes to split the entire stack container, not the individual pane.
731-
732-3. **Widget attachment coordination**: When `TreeRebalancer` is enabled, `closePane` delegates widget attachment to the rebalancer's promotion transaction to prevent double-attachment race conditions.
733-
734-### GTK Widget Lifecycle
735-
736-1. **Reparenting invalidates container references**: When widgets are reparented via `PanedSetStartChild/EndChild` or `window.SetChild`, GTK automatically unparents them from the old container. Code must check `WidgetGetParent(child) == old_parent` before attempting cleanup.
737-
738-2. **CSS operations require validity checks**: CSS class manipulation on destroyed widgets corrupts GTK's internal bloom filter. All CSS operations verify `WidgetIsValid()` before proceeding.
739-
740-3. **WebView containers may have implicit parents**: Freshly created WebView widgets may have internal GTK parent references that require explicit unparenting before box/paned insertion.
741-
742-This architecture provides a robust, efficient, and safe pane management system that can handle complex window layouts while maintaining excellent performance and user experience.⏎̸
D docs/PANE_CLOSE_REFACTORING_PLAN.md +0 -491
@@ -1,491 +0,0 @@
1-# Detailed Pane Close Refactoring Plan
2-
3-## Problem Analysis Summary
4-
5-The current implementation in `internal/app/browser/workspace_pane_ops.go:707`+ exhibits **three interlocked failure modes**:
6-1. **Multiple Pane Closure**: The promotion branch (around `workspace_pane_ops.go:990`) iterates over invalid parent/child references, causing both siblings to be detached when `wm.app.panes` still counts them. This yields double-close behaviour during rapid shortcuts.
7-2. **Invalid Widget Promotion**: The root promotion path (roughly `workspace_pane_ops.go:878-948`) manipulates widgets after `Cleanup()` invalidates their `SafeWidget` guard, which leads to sporadic `GTK_IS_WIDGET` assertions.
8-3. **Race Conditions**: Cleanup (`ensureCleanup`) and hover-detach run while asynchronous `IdleAdd` callbacks still reference the old nodes, so hover/focus reattachment runs against freed memory during reproduction of issue #1429.
9-
10-> Baseline reproduction: trigger repeated `closePane` via Ctrl+W on a layout with nested root + stack; log spew shows `[workspace] close aborted` alongside panics in `ensureSafeWidget`.
11-
12-### Environment Constraints
13-- `stackedPaneManager.CloseStackedPane` is tightly coupled to `wm.stackedPaneManager`, so any refactor must either reimplement its logic or provide compatibility shims.
14-- `BulletproofClosePane` wraps `closePane`, retrying on specific errors; changes to panic/zero-return semantics must preserve that contract.
15-- Hover/focus controller lifecycles (`wm.ensureHover`, `focusStateMachine.attachGTKController`) must remain intact after tree restructuring.
16-
17-### Preconditions & Guardrails
18-1. Capture baseline behaviour with `go test ./tests/workspace/...` (currently incomplete; we will add targeted tests in Phase 5).
19-2. Add temporary structured logging with `log.Printf("[pane-close] ...")` gated behind an environment toggle to avoid production noise.
20-3. Ensure unit test scaffolding can construct `WorkspaceManager` with fake `webkit` bindings (see `tests/browser/fakes`).
21-
22-## PHASE 0: Stabilise & Instrument
23-
24-1. Wrap `BulletproofClosePane` with structured logging (log key: `pane-close-stage`) guarded by `wm.debugInstrumentation` flag.
25-2. Add `WorkspaceDiagnostics` helper capturing tree snapshots (`wm.dumpTreeState`) before and after `closePane`.
26-3. Add regression harness in `tests/browser/workspace_close_test.go` that constructs a representative tree and exercises edge cases (root-only, nested split, stack + split).
27-4. Verify reproduction steps still fail prior to refactor to ensure harness is meaningful.
28-
29-## PHASE 1: New Simplified Architecture
30-
31-### 1.1 Keep File Layout, Replace Implementation In-Place
32-```
33-internal/app/browser/workspace_pane_ops.go # Replace existing closePane implementation with new logic
34-internal/app/browser/workspace_types.go # Extend paneNode metadata
35-```
36-
37-### 1.2 Core Data Structure Changes
38-```go
39-// Add to paneNode struct
40-type paneNode struct {
41- // ... existing fields ...
42-
43- widgetValid bool // Guard flagged before GTK destruction
44- cleanupGeneration uint // Helps assert that asynchronous callbacks skip stale nodes
45-}
46-
47-// Add to WorkspaceManager
48-type WorkspaceManager struct {
49- // ... existing fields ...
50-
51- cleanupCounter uint
52-}
53-```
54-
55-## PHASE 2: Detailed New closePane Implementation
56-
57-### 2.1 Main Close Function (60-80 lines total)
58-```go
59-func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) {
60- ctx := wm.beginClose(node)
61- defer ctx.finish()
62-
63- // STEP 1: Basic validation (quick fail)
64- if ctx.err != nil {
65- return nil, ctx.err
66- }
67-
68- // STEP 2: Handle stacked panes via compatibility shim
69- if node.parent != nil && node.parent.isStacked {
70- return wm.closeStackedPaneCompat(node)
71- }
72-
73- // STEP 3: Handle trivial exit cases
74- if ctx.remaining == 1 {
75- return wm.cleanupAndExit(node)
76- }
77- if node == wm.root {
78- return wm.promoteNewRoot(ctx, node)
79- }
80-
81- // STEP 4: Promote sibling in-place
82- parent := node.parent
83- sibling := wm.getSibling(node)
84- grandparent := parent.parent
85- wm.ensureWidgets(grandparent, parent, sibling)
86-
87- wm.promoteSibling(grandparent, parent, sibling)
88-
89- // STEP 5: GTK updates leverage auto-unparenting
90- wm.swapContainers(grandparent, sibling)
91-
92- // STEP 6: Cleanup & focus
93- wm.cleanupPane(node, ctx.Generation())
94- wm.decommissionParent(parent, ctx.Generation())
95- wm.setFocusToLeaf(sibling)
96-
97- return sibling, nil
98-}
99-```
100-
101-### 2.2 Helper Functions with Exact Implementation
102-
103-#### getSibling - CORRECTED LOGIC
104-```go
105-func (wm *WorkspaceManager) getSibling(node *paneNode) *paneNode {
106- if node.parent == nil {
107- return nil
108- }
109- // FIXED: Simple, clear sibling identification
110- if node.parent.left == node {
111- return node.parent.right
112- }
113- return node.parent.left
114-}
115-```
116-
117-#### beginClose / closeContext
118-```go
119-type closeContext struct {
120- wm *WorkspaceManager
121- target *paneNode
122- remaining int
123- err error
124- generation uint
125-}
126-
127-func (wm *WorkspaceManager) beginClose(node *paneNode) closeContext {
128- ctx := closeContext{wm: wm, target: node, generation: wm.nextCleanupGeneration()}
129- switch {
130- case wm == nil:
131- ctx.err = errors.New("workspace manager nil")
132- case node == nil || !node.isLeaf:
133- ctx.err = errors.New("invalid close target")
134- case node.pane == nil || node.pane.webView == nil:
135- ctx.err = errors.New("close target missing webview")
136- default:
137- ctx.remaining = len(wm.app.panes)
138- }
139- return ctx
140-}
141-
142-func (ctx closeContext) Generation() uint { return ctx.generation }
143-
144-func (ctx closeContext) finish() {
145- if ctx.err == nil {
146- ctx.wm.updateMainPane()
147- }
148-}
149-```
150-
151-#### ensureWidgets
152-```go
153-func (wm *WorkspaceManager) ensureWidgets(nodes ...*paneNode) {
154- for _, n := range nodes {
155- if n == nil || n.container == nil {
156- continue
157- }
158- if n.container.IsValid() {
159- continue
160- }
161- ptr := n.container.Ptr()
162- n.container = wm.widgetRegistry.Recover(ptr, n.container.typeInfo)
163- }
164-}
165-```
166-
167-#### promoteSibling
168-```go
169-func (wm *WorkspaceManager) promoteSibling(grand *paneNode, parent *paneNode, sibling *paneNode) {
170- if grand == nil {
171- wm.root = sibling
172- sibling.parent = nil
173- return
174- }
175- sibling.parent = grand
176- if grand.left == parent {
177- grand.left = sibling
178- } else {
179- grand.right = sibling
180- }
181-}
182-```
183-
184-#### swapContainers
185-```go
186-func (wm *WorkspaceManager) swapContainers(grand *paneNode, sibling *paneNode) {
187- if grand == nil {
188- wm.attachRoot(sibling)
189- return
190- }
191- grand.container.Execute(func(gPtr uintptr) error {
192- sibling.container.Execute(func(sPtr uintptr) error {
193- if grand.left == sibling {
194- webkit.PanedSetStartChild(gPtr, sPtr)
195- } else {
196- webkit.PanedSetEndChild(gPtr, sPtr)
197- }
198- return nil
199- })
200- return nil
201- })
202-}
203-```
204-
205-#### decommissionParent
206-```go
207-func (wm *WorkspaceManager) decommissionParent(parent *paneNode, generation uint) {
208- if parent == nil {
209- return
210- }
211- wm.cleanupPane(parent, generation)
212-}
213-```
214-
215-#### nextCleanupGeneration
216-```go
217-func (wm *WorkspaceManager) nextCleanupGeneration() uint {
218- wm.cleanupCounter++
219- return wm.cleanupCounter
220-}
221-```
222-
223-#### attachRoot
224-```go
225-func (wm *WorkspaceManager) attachRoot(root *paneNode) {
226- if root == nil || root.container == nil || wm.window == nil {
227- return
228- }
229- root.container.Execute(func(ptr uintptr) error {
230- wm.window.SetChild(ptr)
231- webkit.WidgetQueueAllocate(ptr)
232- webkit.WidgetShow(ptr)
233- return nil
234- })
235-}
236-```
237-
238-#### promoteNewRoot - Handle Root Replacement
239-```go
240-func (wm *WorkspaceManager) promoteNewRoot(ctx closeContext, oldRoot *paneNode) (*paneNode, error) {
241- candidate := wm.findReplacementRoot(oldRoot)
242- if candidate == nil {
243- return wm.cleanupAndExit(oldRoot)
244- }
245-
246- sibling := wm.getSibling(candidate)
247- if sibling != nil {
248- wm.promoteSibling(candidate.parent.parent, candidate.parent, sibling)
249- }
250-
251- candidate.parent = nil
252- wm.root = candidate
253-
254- wm.attachRoot(candidate)
255-
256- wm.cleanupPane(oldRoot, ctx.Generation())
257- return candidate, nil
258-}
259-```
260-
261-#### cleanupPane - Safe Cleanup
262-```go
263-func (wm *WorkspaceManager) cleanupPane(node *paneNode, generation uint) {
264- if node == nil {
265- return
266- }
267- if !node.widgetValid {
268- return
269- }
270-
271- node.widgetValid = false
272- node.cleanupGeneration = generation
273-
274- if node.pane != nil {
275- node.pane.CleanupFromWorkspace(wm)
276- if node.pane.webView != nil {
277- node.pane.webView.Destroy()
278- }
279- }
280-
281- if node.container != nil {
282- node.container.Invalidate()
283- node.container = nil
284- }
285-
286- node.parent = nil
287- node.left = nil
288- node.right = nil
289-}
290-```
291-
292-## PHASE 3: Stack Container Simplification
293-
294-### 3.1 Compatibility Layer for Stacked Panes
295-```go
296-func (wm *WorkspaceManager) closeStackedPaneCompat(node *paneNode) (*paneNode, error) {
297- stack := node.parent
298-
299- // Find index
300- index := -1
301- for i, pane := range stack.stackedPanes {
302- if pane == node {
303- index = i
304- break
305- }
306- }
307-
308- if index == -1 {
309- return nil, errors.New("pane not in stack")
310- }
311-
312- // Remove from array
313- stack.stackedPanes = append(
314- stack.stackedPanes[:index],
315- stack.stackedPanes[index+1:]...
316- )
317-
318- // If only one pane left, unstack it
319- if len(stack.stackedPanes) == 1 {
320- remaining := stack.stackedPanes[0]
321-
322- // Replace stack with remaining pane in tree
323- remaining.parent = stack.parent
324- if stack.parent != nil {
325- if stack.parent.left == stack {
326- stack.parent.left = remaining
327- } else {
328- stack.parent.right = remaining
329- }
330-
331- // Update GTK widgets
332- stack.parent.container.Execute(func(pPtr uintptr) error {
333- remaining.container.Execute(func(rPtr uintptr) error {
334- if stack.parent.left == remaining {
335- webkit.PanedSetStartChild(pPtr, rPtr)
336- } else {
337- webkit.PanedSetEndChild(pPtr, rPtr)
338- }
339- return nil
340- })
341- return nil
342- })
343- } else {
344- // Stack was root
345- wm.root = remaining
346- remaining.container.Execute(func(ptr uintptr) error {
347- wm.window.SetChild(ptr)
348- return nil
349- })
350- }
351-
352- // Cleanup stack container
353- wm.cleanupPane(stack, wm.nextCleanupGeneration())
354-
355- return remaining, nil
356- }
357-
358- // Update active index if needed
359- if stack.activeStackIndex >= len(stack.stackedPanes) {
360- stack.activeStackIndex = len(stack.stackedPanes) - 1
361- }
362-
363- // Remove widget from GTK box
364- stack.container.Execute(func(boxPtr uintptr) error {
365- node.container.Execute(func(nodePtr uintptr) error {
366- webkit.BoxRemove(boxPtr, nodePtr)
367- return nil
368- })
369- return nil
370- })
371-
372- // Show new active pane
373- if stack.activeStackIndex >= 0 {
374- active := stack.stackedPanes[stack.activeStackIndex]
375- active.container.Execute(func(ptr uintptr) error {
376- webkit.WidgetShow(ptr)
377- return nil
378- })
379- }
380-
381- // Cleanup closed pane
382- wm.cleanupPane(node, wm.nextCleanupGeneration())
383-
384- return stack, nil
385-}
386-```
387-
388-## PHASE 4: GTK4 Widget Lifecycle Rules
389-
390-### 4.1 Key GTK4 Rules to Follow
391-1. **Auto-unparenting**: When calling `gtk_paned_set_start_child(paned, new_child)`, GTK4 automatically:
392- - Unparents the old child (if any)
393- - Unparents new_child from its current parent (if any)
394- - Sets new_child as the start child
395-
396-2. **Widget Validity**: Always check `GTK_IS_WIDGET(widget)` before operations
397-
398-3. **Reference Counting**: GTK4 uses reference counting - widgets are destroyed when ref count reaches 0
399-
400-### 4.2 Safe Widget Operations Pattern
401-```go
402-// NEVER do this:
403-webkit.WidgetUnparent(widget) // Manual unparent
404-webkit.PanedSetStartChild(paned, widget) // Then set
405-
406-// ALWAYS do this:
407-webkit.PanedSetStartChild(paned, widget) // GTK handles unparenting
408-
409-// For boxes:
410-webkit.BoxRemove(box, widget) // Removes and unparents
411-// OR
412-webkit.BoxAppend(box, widget) // Auto-unparents from old parent
413-```
414-
415-## PHASE 5: Migration Strategy
416-
417-### 5.1 Step-by-Step Migration
418-1. Land instrumentation (`beginClose` logging, `dumpTreeState`) behind `DebugPaneClose` flag.
419-2. Extend data structures (`widgetValid`, `cleanupGeneration`, `cleanupCounter`).
420-3. Introduce helper scaffolding (`closeContext`, `ensureWidgets`, `promoteSibling`, `swapContainers`).
421-4. Replace core `closePane` logic and keep compatibility helpers stubbed.
422-5. Rewrite stacked close handling to call `closeStackedPaneCompat` while keeping `StackedPaneManager` API surface intact.
423-6. Add regression tests + golden tree dumps; run `go test ./tests/browser/...` and `make build`.
424-7. Remove verbose logging, keep assertions + generation checks.
425-
426-## PHASE 6: Validation & Safety
427-
428-### 6.1 Add Assertions
429-```go
430-// Add to every widget operation:
431-if !webkit.WidgetIsValid(ptr) {
432- return fmt.Errorf("invalid widget %#x", ptr)
433-}
434-
435-// Before tree operations:
436-if err := wm.validateTreeConsistency(); err != nil {
437- return nil, fmt.Errorf("tree inconsistent before close: %w", err)
438-}
439-```
440-
441-### 6.2 Comprehensive Logging
442-```go
443-// Before operation:
444-log.Printf("[pane-close] start node=%p parent=%p sibling=%p",
445- node, node.parent, sibling)
446-
447-// After tree update:
448-log.Printf("[pane-close] tree updated new_root=%p promoted=%p gen=%d",
449- wm.root, promoted, ctx.Generation())
450-
451-// After GTK update:
452-log.Printf("[pane-close] gtk updated widget=%#x parent=%#x",
453- widgetPtr, parentPtr)
454-```
455-
456-## Expected Results
457-
458-### Before (Current Issues):
459-- 400+ lines of defensive code
460-- Segfaults on complex layouts
461-- Multiple panes closed incorrectly
462-- Widget corruption errors
463-
464-### After (New Implementation):
465-- ≤200 lines of cohesive logic
466-- Clean GTK4 lifecycle adherence
467-- Predictable single-pane closure
468-- Deterministic hover/focus cleanup using generations
469-
470-## Implementation Order
471-1. Land instrumentation + regression harness (Phase 0)
472-2. Introduce data-structure additions (`widgetValid`, generation counter)
473-3. Implement new `closePane` + helper set in-place (Phase 2)
474-4. Wire stacked pane compatibility shim (Phase 3)
475-5. Run tests (`go test ./tests/browser/...`) and `make build`
476-6. Trim temporary logging once confidence is regained
477-
478-## Key Files to Modify
479-
480-1. `internal/app/browser/workspace_types.go` - Add `widgetValid` and `cleanupGeneration`
481-2. `internal/app/browser/workspace_manager.go` - Introduce `cleanupCounter` + helper
482-3. `internal/app/browser/workspace_pane_ops.go` - Replace `closePane`, add helpers, call into compat shim
483-4. `internal/app/browser/stacked_panes.go` - Point manager to `closeStackedPaneCompat`
484-5. `tests/browser/workspace_close_test.go` - Add regression scenarios
485-
486-## Critical Success Metrics
487-
488-1. **Stability**: No segfaults in any close scenario
489-2. **Correctness**: Only requested pane closes (never multiple)
490-3. **Performance**: Close operation completes in <50ms
491-4. **Maintainability**: Code is <200 lines and easily understood
D docs/WORKSPACE_SIMPLIFICATION_PLAN.md +0 -448
@@ -1,448 +0,0 @@
1-# Workspace Architecture Simplification Plan
2-
3-**Status**: Approved for implementation
4-**Date**: 2025-09-30
5-**Goal**: Remove over-engineering, simplify architecture, improve naming
6-
7-## Why Simplify
8-
9-Current architecture has ~2000 lines of defensive code that add complexity without measurable benefit:
10-- "Bulletproof" terminology is confusing
11-- ConcurrencyController bypassed 90% of the time (operations already on main thread)
12-- Widget transaction batching adds overhead GTK4 already handles
13-- Validation runs on every operation despite rarely catching issues
14-- 5-layer call chain for simple operations
15-
16-**This is unreleased code** - we can refactor cleanly without backward compatibility concerns.
17-
18----
19-
20-## Goals
21-
22-✅ **40-50% less code** (~2000 lines removed)
23-✅ **Clearer architecture** (3 layers instead of 5)
24-✅ **Better naming** (SplitPane not BulletproofSplitNode)
25-✅ **Faster operations** (remove overhead)
26-✅ **Same correctness** (keep core algorithms)
27-
28----
29-
30-## Phase 1: Rename Everything 🏷️
31-
32-### Method Renaming
33-```go
34-// Public API (used by application code)
35-BulletproofSplitNode → SplitPane
36-BulletproofClosePane → ClosePane
37-BulletproofStackPane → StackPane
38-
39-// Internal implementations (unchanged)
40-splitNode → splitNode (stays private)
41-closePane → closePane (stays private)
42-```
43-
44-### File Renaming
45-```
46-workspace_bulletproof_operations.go → workspace_operations.go
47-workspace_tree_rebalancer.go → workspace_layout.go
48-```
49-
50-### Call Sites to Update
51-- `workspace_manager.go:270` - split operation
52-- `workspace_manager.go:401` - close operation
53-- `workspace_pane_ops.go:182` - close current pane
54-- `workspace_popup.go:158,412,498` - popup close operations
55-
56----
57-
58-## Phase 2: Delete ConcurrencyController 🔥
59-
60-### Why Delete
61-- **Reality**: 90% of operations already on GTK main thread
62-- **Check cost**: `webkit.IsMainThread()` is 1 CPU instruction (pthread_self)
63-- **Complexity**: 454 lines for edge case marshalling
64-- **Better approach**: Direct IdleAdd for off-thread calls
65-
66-### Replacement Pattern
67-```go
68-func (wm *WorkspaceManager) SplitPane(target *paneNode, direction string) (*paneNode, error) {
69- if !webkit.IsMainThread() {
70- return wm.marshalToMainThread(func() (*paneNode, error) {
71- return wm.splitPaneImpl(target, direction)
72- })
73- }
74- return wm.splitPaneImpl(target, direction)
75-}
76-
77-func marshalToMainThread[T any](fn func() (T, error)) (T, error) {
78- var result T
79- var err error
80- done := make(chan struct{})
81-
82- webkit.IdleAdd(func() bool {
83- result, err = fn()
84- close(done)
85- return false
86- })
87-
88- <-done
89- return result, err
90-}
91-```
92-
93-### Delete
94-- **File**: `workspace_concurrency.go` (454 lines)
95-- **Fields**: Remove from WorkspaceManager:
96- - `concurrencyController *ConcurrencyController`
97-- **References**: Update all operation calls
98-
99----
100-
101-## Phase 3: Delete WidgetTransactionManager 🎯
102-
103-### Why Delete
104-- **GTK4 already batches**: No need to manually batch widget ops
105-- **Overhead**: Closure allocation, operation queuing, commit phase
106-- **Direct calls work**: GTK4 docs say direct calls are fine
107-
108-### Simplified Pattern
109-```go
110-// Old (complex):
111-tx := wm.widgetTxManager.BeginTransaction("promotion")
112-tx.AddOperation(&WidgetOperation{
113- Execute: func() error {
114- webkit.WidgetResetSizeRequest(ptr)
115- webkit.WidgetSetHExpand(ptr, true)
116- return nil
117- },
118-})
119-tx.Execute()
120-tx.Commit()
121-
122-// New (direct):
123-webkit.WidgetResetSizeRequest(ptr)
124-webkit.WidgetSetHExpand(ptr, true)
125-webkit.WidgetQueueAllocate(ptr)
126-```
127-
128-### Delete
129-- **File**: `workspace_widget_transaction.go` (400 lines)
130-- **Fields**: Remove from WorkspaceManager:
131- - `widgetTxManager *WidgetTransactionManager`
132-- **Dependencies**: Update TreeRebalancer to call GTK directly
133-
134----
135-
136-## Phase 4: Simplify TreeRebalancer 🌲
137-
138-### Current Problem
139-230-line promotion transaction for what should be 5 GTK calls
140-
141-### New Implementation
142-```go
143-func (wm *WorkspaceManager) promoteNodeAfterClose(node *paneNode) error {
144- if node == nil || node.container == nil {
145- return errors.New("invalid promotion target")
146- }
147-
148- ptr := node.container.Ptr()
149- if !webkit.WidgetIsValid(ptr) {
150- return errors.New("widget destroyed during promotion")
151- }
152-
153- // Step 1: Clear stale size constraints
154- webkit.WidgetResetSizeRequest(ptr)
155- webkit.WidgetSetHExpand(ptr, true)
156- webkit.WidgetSetVExpand(ptr, true)
157-
158- // Step 2: Attach to new parent
159- if node.parent == nil {
160- wm.attachToWindow(ptr)
161- } else {
162- wm.attachToPaned(node.parent.container.Ptr(), ptr, node.parent.left == node)
163- }
164-
165- // Step 3: Request layout recalculation
166- webkit.WidgetQueueAllocate(ptr)
167- if node.parent != nil {
168- webkit.WidgetQueueAllocate(node.parent.container.Ptr())
169- }
170-
171- return nil
172-}
173-```
174-
175-### Changes to TreeRebalancer
176-- Remove `executePromotion` (230 lines of transaction code)
177-- Replace with direct GTK calls (~30 lines)
178-- Keep tree metrics calculation (useful for debugging)
179-- Keep rotation logic (not used yet, but correct for future)
180-
181----
182-
183-## Phase 5: Make Validation Opt-In 📊
184-
185-### Debug Mode Levels
186-```go
187-type DebugLevel int
188-
189-const (
190- DebugOff DebugLevel = iota // Production: no validation
191- DebugBasic // Development: basic checks
192- DebugFull // Testing: full validation
193-)
194-
195-// Read from environment
196-func getDebugLevel() DebugLevel {
197- switch os.Getenv("DUMBER_DEBUG_WORKSPACE") {
198- case "off", "0":
199- return DebugOff
200- case "basic", "1":
201- return DebugBasic
202- case "full", "2":
203- return DebugFull
204- default:
205- return DebugBasic // Safe default for development
206- }
207-}
208-```
209-
210-### Conditional Validation
211-```go
212-func (wm *WorkspaceManager) SplitPane(target *paneNode, direction string) (*paneNode, error) {
213- // Quick validation (always run - cheap)
214- if target == nil || !target.isLeaf {
215- return nil, errors.New("split target must be leaf pane")
216- }
217-
218- // Expensive validation (debug only)
219- if wm.debugLevel >= DebugBasic {
220- if err := wm.geometryValidator.ValidateSplit(target, direction); err != nil {
221- return nil, fmt.Errorf("geometry validation: %w", err)
222- }
223- }
224-
225- if wm.debugLevel == DebugFull {
226- wm.treeValidator.ValidateTree(wm.root, "before_split")
227- }
228-
229- // Actual operation
230- newNode, err := wm.splitPaneImpl(target, direction)
231-
232- if wm.debugLevel == DebugFull {
233- wm.treeValidator.ValidateTree(wm.root, "after_split")
234- }
235-
236- return newNode, err
237-}
238-```
239-
240-### Keep Components (Made Optional)
241-- `TreeValidator` - Useful for debugging tree corruption
242-- `GeometryValidator` - Catches size constraint issues
243-- `StateTombstoneManager` - Useful for rollback during development
244-
245----
246-
247-## Phase 6: Simplify SafeWidget 🔧
248-
249-### Current Problem
250-Every widget operation wrapped in closure:
251-```go
252-node.container.Execute(func(ptr uintptr) error {
253- webkit.WidgetShow(ptr)
254- return nil
255-})
256-```
257-
258-### Simplified Approach
259-```go
260-// SafeWidget keeps validity tracking but removes closure overhead
261-type SafeWidget struct {
262- ptr uintptr
263- typeInfo string
264- valid atomic.Bool
265-}
266-
267-func (sw *SafeWidget) Ptr() uintptr {
268- if sw.valid.Load() {
269- return sw.ptr
270- }
271- return 0
272-}
273-
274-func (sw *SafeWidget) IsValid() bool {
275- return sw.valid.Load() && webkit.WidgetIsValid(sw.ptr)
276-}
277-
278-// Usage becomes direct:
279-if ptr := node.container.Ptr(); ptr != 0 {
280- webkit.WidgetShow(ptr)
281-}
282-```
283-
284-### Migration
285-- Remove `Execute()` method from SafeWidget
286-- Update all call sites to use Ptr() + direct calls
287-- Keep Invalidate() for cleanup tracking
288-
289----
290-
291-## Phase 7: File Consolidation 📁
292-
293-### Delete These Files
294-1. `workspace_concurrency.go` - 454 lines
295-2. `workspace_widget_transaction.go` - 400 lines
296-3. `workspace_stack_lifecycle.go` - Merge into pane_ops (not heavily used)
297-
298-### Rename These Files
299-1. `workspace_bulletproof_operations.go` → `workspace_operations.go`
300-2. `workspace_tree_rebalancer.go` → `workspace_layout.go`
301-
302-### Final Structure (14 files, -2000 lines)
303-```
304-Core:
305- workspace_types.go - Data structures
306- workspace_manager.go - Manager initialization
307- workspace_operations.go - Public API (SplitPane, ClosePane, StackPane)
308- workspace_pane_ops.go - Private implementations
309- workspace_layout.go - Tree rebalancing (simplified)
310-
311-Features:
312- workspace_stacked_panes.go - Stack management
313- workspace_focus.go - Focus state machine
314- workspace_popup.go - Popup handling
315- workspace_navigation.go - Keyboard navigation
316-
317-Utilities:
318- workspace_utils.go - Helper functions
319- workspace_css.go - CSS class management
320-
321-Debug (opt-in):
322- workspace_tree_validator.go - Tree validation
323- workspace_geometry_validator.go - Geometry checks
324- workspace_state_tombstone.go - State snapshots
325- workspace_debug.go - Debug helpers
326- workspace_diagnostics.go - Diagnostics
327-```
328-
329----
330-
331-## Implementation Order
332-
333-### Day 1: Safe Renames (2-3 hours)
334-1. ✅ Phase 1: Rename methods and files
335-2. ✅ Update all call sites
336-3. ✅ Update docs (PANE_ARCHITECTURE.md)
337-4. ✅ Commit: "refactor(workspace): rename bulletproof to standard names"
338-
339-### Day 2: Remove Layers (4-5 hours)
340-5. ✅ Phase 2: Delete ConcurrencyController
341-6. ✅ Phase 3: Delete WidgetTransactionManager
342-7. ✅ Phase 6: Simplify SafeWidget
343-8. ✅ Commit: "refactor(workspace): remove transaction overhead"
344-
345-### Day 3: Simplify Logic (3-4 hours)
346-9. ✅ Phase 4: Simplify TreeRebalancer promotion
347-10. ✅ Phase 5: Make validation opt-in
348-11. ✅ Commit: "refactor(workspace): simplify promotion and validation"
349-
350-### Day 4: Cleanup (2 hours)
351-12. ✅ Phase 7: Consolidate files
352-13. ✅ Update imports everywhere
353-14. ✅ Commit: "refactor(workspace): consolidate files"
354-
355-### Day 5: Test & Validate (3-4 hours)
356-15. ✅ Run with DUMBER_DEBUG_WORKSPACE=full
357-16. ✅ Test all split/close/stack operations
358-17. ✅ Verify no GTK warnings
359-18. ✅ Check geometry with debug tools
360-19. ✅ Commit: "test(workspace): validate simplification"
361-
362-**Total: 14-18 hours over 5 sessions**
363-
364----
365-
366-## Testing Strategy
367-
368-### Environment Setup
369-```bash
370-# Full validation during refactoring
371-export DUMBER_DEBUG_WORKSPACE=full
372-
373-# After validation passes, test production mode
374-export DUMBER_DEBUG_WORKSPACE=off
375-```
376-
377-### Test Cases
378-1. **Splits**: All 4 directions (left/right/up/down)
379-2. **Closes**:
380- - Non-root with sibling promotion
381- - Root pane replacement
382- - Last pane (app exit)
383- - Pane within stack
384-3. **Stacks**: Create, navigate tabs, close tabs
385-4. **Stress Tests**:
386- - Rapid split/close cycles
387- - Split from stacked pane
388- - Deep nesting (8+ levels)
389- - Focus changes during operations
390-
391-### Success Criteria
392-- ✅ All operations work identically to before
393-- ✅ No GTK warnings/errors in logs
394-- ✅ Widget sizes correct after promotion
395-- ✅ Tree structure valid (DebugFull validation passes)
396-- ✅ ~2000 lines removed
397-- ✅ Faster operation latency (measure with debug timers)
398-
399----
400-
401-## Risk Mitigation
402-
403-### Branch Strategy
404-```bash
405-git checkout -b refactor/simplify-workspace
406-# Commit after each phase
407-# Squash before merge to main
408-```
409-
410-### If Issues Found
411-1. **Broken operation**: Revert specific phase commit
412-2. **GTK crash**: Enable DebugFull, check validation logs
413-3. **Widget sizing wrong**: Check promotion logic, compare with old TreeRebalancer
414-4. **Focus loss**: Check focus state machine integration
415-
416-### No Rollback Code Needed
417-- This is unreleased software
418-- Clean refactor is better than feature flags
419-- Git history provides rollback if critical issue found
420-
421----
422-
423-## Expected Outcome
424-
425-### Code Quality
426-- **Before**: 19 files, ~8000 lines, 5-layer architecture
427-- **After**: 14 files, ~6000 lines, 3-layer architecture
428-- **Complexity**: -40% (measured by cyclomatic complexity)
429-
430-### Performance
431-- **Operation latency**: -20% (remove transaction/validation overhead)
432-- **Memory usage**: -15% (fewer allocations)
433-- **Code clarity**: Massively improved (direct call chains)
434-
435-### Maintainability
436-- ✅ New developers understand flow in 30 minutes vs 3 hours
437-- ✅ Adding features requires touching fewer files
438-- ✅ Debugging shows clear stack traces
439-- ✅ No mysterious "bulletproof" terminology
440-
441----
442-
443-## Notes
444-
445-- Keep all tree algorithms identical (they work correctly)
446-- GTK4 bindings are solid - trust them
447-- Validation is development tool, not production requirement
448-- Direct is better than defensive when APIs are stable⏎̸
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)
M internal/app/browser/focus_state_machine.go +6 -10
@@ -529,17 +529,11 @@ func (fsm *FocusStateMachine) findTopLeftPane(leaves []*paneNode) *paneNode {
529529 var bestScore float64 = 1e9
530530
531531 for _, leaf := range leaves {
532- if leaf.container == nil {
532+ if leaf.container == 0 || !webkit.WidgetIsValid(leaf.container) {
533533 continue
534534 }
535535
536- var bounds webkit.WidgetBounds
537- var ok bool
538- leaf.container.Execute(func(containerPtr uintptr) error {
539- bounds, ok = webkit.WidgetGetBounds(containerPtr)
540- return nil
541- })
542-
536+ bounds, ok := webkit.WidgetGetBounds(leaf.container)
543537 if !ok {
544538 continue
545539 }
@@ -570,7 +564,8 @@ func (fsm *FocusStateMachine) applyInitialFocus(node *paneNode) error {
570564
571565 // Apply initial visual border
572566 if fsm.wm != nil {
573- fsm.wm.applyActivePaneBorder(node)
567+ ctx := fsm.wm.determineBorderContext(node)
568+ fsm.wm.applyActivePaneBorder(ctx)
574569 }
575570
576571 // Update state
@@ -736,7 +731,8 @@ func (fsm *FocusStateMachine) executeFocusChange(request FocusRequest) error {
736731 fsm.wm.removeActivePaneBorder(oldPane)
737732 }
738733 // Add border to new pane
739- fsm.wm.applyActivePaneBorder(newPane)
734+ ctx := fsm.wm.determineBorderContext(newPane)
735+ fsm.wm.applyActivePaneBorder(ctx)
740736 }
741737
742738 // Update state
M internal/app/browser/stacked_panes.go +190 -201
@@ -130,13 +130,10 @@ func (spm *StackedPaneManager) addPaneToExistingStack(target, newLeaf *paneNode)
130130 stackNode.stackedPanes[insertIndex] = newLeaf // Insert new pane
131131
132132 // Unparent the new container before adding it to the stack (only if it has a parent)
133- if newLeaf.container != nil {
134- newLeaf.container.Execute(func(containerPtr uintptr) error {
135- if webkit.WidgetGetParent(containerPtr) != 0 {
136- webkit.WidgetUnparent(containerPtr)
137- }
138- return nil
139- })
133+ if newLeaf.container != 0 && webkit.WidgetIsValid(newLeaf.container) {
134+ if webkit.WidgetGetParent(newLeaf.container) != 0 {
135+ webkit.WidgetUnparent(newLeaf.container)
136+ }
140137 }
141138
142139 // Insert widgets at correct position for Zellij-style layout
@@ -147,28 +144,28 @@ func (spm *StackedPaneManager) addPaneToExistingStack(target, newLeaf *paneNode)
147144 if insertIndex > 0 {
148145 // Insert after the previous pane's container
149146 prevPane := stackNode.stackedPanes[insertIndex-1]
150- if prevPane.container != nil {
151- insertAfterWidget = prevPane.container.Ptr()
147+ if prevPane.container != 0 {
148+ insertAfterWidget = prevPane.container
152149 }
153150 }
154151
155152 if insertAfterWidget != 0 {
156153 // Insert titleBar after the previous pane's container
157- if stackNode.stackWrapper != nil && newLeaf.titleBar != nil {
158- webkit.BoxInsertChildAfter(stackNode.stackWrapper.Ptr(), newLeaf.titleBar.Ptr(), insertAfterWidget)
154+ if stackNode.stackWrapper != 0 && newLeaf.titleBar != 0 {
155+ webkit.BoxInsertChildAfter(stackNode.stackWrapper, newLeaf.titleBar, insertAfterWidget)
159156 }
160157 // Insert container after the newly inserted titleBar
161- if stackNode.stackWrapper != nil && newLeaf.container != nil && newLeaf.titleBar != nil {
162- webkit.BoxInsertChildAfter(stackNode.stackWrapper.Ptr(), newLeaf.container.Ptr(), newLeaf.titleBar.Ptr())
158+ if stackNode.stackWrapper != 0 && newLeaf.container != 0 && newLeaf.titleBar != 0 {
159+ webkit.BoxInsertChildAfter(stackNode.stackWrapper, newLeaf.container, newLeaf.titleBar)
163160 }
164161 log.Printf("[workspace] inserted widgets at position %d (after widget %#x)", insertIndex, insertAfterWidget)
165162 } else {
166163 // Insert at the beginning (insertIndex = 0)
167- if stackNode.stackWrapper != nil && newLeaf.container != nil {
168- webkit.BoxPrepend(stackNode.stackWrapper.Ptr(), newLeaf.container.Ptr())
164+ if stackNode.stackWrapper != 0 && newLeaf.container != 0 {
165+ webkit.BoxPrepend(stackNode.stackWrapper, newLeaf.container)
169166 }
170- if stackNode.stackWrapper != nil && newLeaf.titleBar != nil {
171- webkit.BoxPrepend(stackNode.stackWrapper.Ptr(), newLeaf.titleBar.Ptr())
167+ if stackNode.stackWrapper != 0 && newLeaf.titleBar != 0 {
168+ webkit.BoxPrepend(stackNode.stackWrapper, newLeaf.titleBar)
172169 }
173170 log.Printf("[workspace] prepended widgets at position 0")
174171 }
@@ -218,8 +215,8 @@ func (spm *StackedPaneManager) convertToStackedContainer(target, newLeaf *paneNo
218215
219216 // Build the complete stack structure with hidden widgets
220217 webkit.BoxAppend(stackInternalBox, titleBar)
221- if existingContainer != nil {
222- webkit.BoxAppend(stackInternalBox, existingContainer.Ptr())
218+ if existingContainer != 0 {
219+ webkit.BoxAppend(stackInternalBox, existingContainer)
223220 }
224221
225222 // Immediately reattach the stack wrapper to minimize visibility gap
@@ -267,27 +264,24 @@ func (spm *StackedPaneManager) convertToStackedContainer(target, newLeaf *paneNo
267264
268265 // Unparent the new container before adding it to the stack
269266 // This is critical: even freshly created WebViews might have internal parent refs
270- if newLeaf.container != nil {
271- newLeaf.container.Execute(func(containerPtr uintptr) error {
272- parent := webkit.WidgetGetParent(containerPtr)
273- if parent != 0 {
274- log.Printf("[workspace] unparenting new pane container %#x from parent %#x before stack append", containerPtr, parent)
275- webkit.WidgetUnparent(containerPtr)
276- // Verify unparent succeeded
277- if finalParent := webkit.WidgetGetParent(containerPtr); finalParent != 0 {
278- log.Printf("[workspace] WARNING: container %#x still has parent %#x after unparent", containerPtr, finalParent)
279- }
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)
280275 }
281- return nil
282- })
276+ }
283277 }
284278
285279 // Add the new widgets to the internal stack box
286- if stackNode.stackWrapper != nil && newLeaf.titleBar != nil {
287- webkit.BoxAppend(stackNode.stackWrapper.Ptr(), newLeaf.titleBar.Ptr())
280+ if stackNode.stackWrapper != 0 && newLeaf.titleBar != 0 {
281+ webkit.BoxAppend(stackNode.stackWrapper, newLeaf.titleBar)
288282 }
289- if stackNode.stackWrapper != nil && newLeaf.container != nil {
290- webkit.BoxAppend(stackNode.stackWrapper.Ptr(), newLeaf.container.Ptr())
283+ if stackNode.stackWrapper != 0 && newLeaf.container != 0 {
284+ webkit.BoxAppend(stackNode.stackWrapper, newLeaf.container)
291285 }
292286
293287 return stackNode, insertIndex, nil
@@ -301,24 +295,18 @@ func (spm *StackedPaneManager) detachFromParent(target *paneNode, parent *paneNo
301295 spm.wm.window.SetChild(0)
302296 }
303297 // Unparent if it has a GTK parent
304- if target.container != nil {
305- target.container.Execute(func(containerPtr uintptr) error {
306- if webkit.WidgetGetParent(containerPtr) != 0 {
307- webkit.WidgetUnparent(containerPtr)
308- }
309- return nil
310- })
298+ if target.container != 0 && webkit.WidgetIsValid(target.container) {
299+ if webkit.WidgetGetParent(target.container) != 0 {
300+ webkit.WidgetUnparent(target.container)
301+ }
311302 }
312- } else if parent.container != nil {
303+ } else if parent.container != 0 && webkit.WidgetIsValid(parent.container) {
313304 // Target has a parent paned - remove it (automatically unparents in GTK4)
314- parent.container.Execute(func(panedPtr uintptr) error {
315- if parent.left == target {
316- webkit.PanedSetStartChild(panedPtr, 0)
317- } else if parent.right == target {
318- webkit.PanedSetEndChild(panedPtr, 0)
319- }
320- return nil
321- })
305+ if parent.left == target {
306+ webkit.PanedSetStartChild(parent.container, 0)
307+ } else if parent.right == target {
308+ webkit.PanedSetEndChild(parent.container, 0)
309+ }
322310 }
323311 }
324312
@@ -328,15 +316,12 @@ func (spm *StackedPaneManager) reattachToParent(container uintptr, target *paneN
328316 if spm.wm.window != nil {
329317 spm.wm.window.SetChild(container)
330318 }
331- } else if parent.container != nil {
332- parent.container.Execute(func(panedPtr uintptr) error {
333- if parent.left == target {
334- webkit.PanedSetStartChild(panedPtr, container)
335- } else if parent.right == target {
336- webkit.PanedSetEndChild(panedPtr, container)
337- }
338- return nil
339- })
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+ }
340325 }
341326 }
342327
@@ -357,7 +342,7 @@ func (spm *StackedPaneManager) finalizeStackCreation(stackNode, newLeaf *paneNod
357342 currentActiveIndex := stackNode.activeStackIndex
358343 if currentActiveIndex >= 0 && currentActiveIndex < len(stackNode.stackedPanes) && currentActiveIndex != insertIndex {
359344 currentlyActivePane := stackNode.stackedPanes[currentActiveIndex]
360- if currentlyActivePane.pane != nil && currentlyActivePane.pane.webView != nil && currentlyActivePane.titleBar != nil {
345+ if currentlyActivePane.pane != nil && currentlyActivePane.pane.webView != nil && currentlyActivePane.titleBar != 0 {
361346 currentTitle := currentlyActivePane.pane.webView.GetTitle()
362347 if currentTitle != "" {
363348 spm.updateTitleBarLabel(currentlyActivePane, currentTitle)
@@ -410,20 +395,20 @@ func (spm *StackedPaneManager) UpdateStackVisibility(stackNode *paneNode) {
410395 for i, pane := range stackNode.stackedPanes {
411396 if i == activeIndex {
412397 // Active pane: show container, NEVER show title bar
413- if pane.container != nil {
414- webkit.WidgetSetVisible(pane.container.Ptr(), true)
398+ if pane.container != 0 {
399+ webkit.WidgetSetVisible(pane.container, true)
415400 }
416- if pane.titleBar != nil {
417- webkit.WidgetSetVisible(pane.titleBar.Ptr(), false) // ABSOLUTE RULE: never visible for active pane
401+ if pane.titleBar != 0 {
402+ webkit.WidgetSetVisible(pane.titleBar, false) // ABSOLUTE RULE: never visible for active pane
418403 }
419404 log.Printf("[workspace] active pane %d: container=visible, titleBar=HIDDEN", i)
420405 } else {
421406 // Inactive panes: hide container, show title bar
422- if pane.container != nil {
423- webkit.WidgetSetVisible(pane.container.Ptr(), false)
407+ if pane.container != 0 {
408+ webkit.WidgetSetVisible(pane.container, false)
424409 }
425- if pane.titleBar != nil {
426- webkit.WidgetSetVisible(pane.titleBar.Ptr(), true)
410+ if pane.titleBar != 0 {
411+ webkit.WidgetSetVisible(pane.titleBar, true)
427412 }
428413 }
429414 }
@@ -486,7 +471,7 @@ func (spm *StackedPaneManager) NavigateStack(direction string) bool {
486471 // Update title bar for the pane transitioning from ACTIVE to INACTIVE
487472 // This ensures the collapsed pane shows its current page title (Zellij-style layout)
488473 currentActivePane := stackNode.stackedPanes[currentIndex]
489- if currentActivePane.pane != nil && currentActivePane.pane.webView != nil && currentActivePane.titleBar != nil {
474+ if currentActivePane.pane != nil && currentActivePane.pane.webView != nil && currentActivePane.titleBar != 0 {
490475 currentTitle := currentActivePane.pane.webView.GetTitle()
491476 if currentTitle != "" {
492477 spm.updateTitleBarLabel(currentActivePane, currentTitle)
@@ -561,7 +546,7 @@ func (spm *StackedPaneManager) UpdateTitleBar(webView *webkit.WebView, title str
561546 }
562547
563548 // Check if this pane is in a stack and has a title bar
564- if node.parent != nil && node.parent.isStacked && node.titleBar != nil {
549+ if node.parent != nil && node.parent.isStacked && node.titleBar != 0 {
565550 // CRITICAL: Only update title bar for INACTIVE panes
566551 // Active panes should never show their title bar
567552 stackNode := node.parent
@@ -589,7 +574,7 @@ func (spm *StackedPaneManager) UpdateTitleBar(webView *webkit.WebView, title str
589574
590575 // updateTitleBarLabel updates the label widget within a title bar by recreating it
591576 func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string) {
592- if node == nil || node.titleBar == nil || node.parent == nil || !node.parent.isStacked {
577+ if node == nil || node.titleBar == 0 || node.parent == nil || !node.parent.isStacked {
593578 return
594579 }
595580
@@ -607,7 +592,7 @@ func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string)
607592 }
608593
609594 // Replace the old title bar in the stack
610- if node.parent != nil && node.parent.isStacked && node.parent.stackWrapper != nil {
595+ if node.parent != nil && node.parent.isStacked && node.parent.stackWrapper != 0 {
611596 // Find the correct insertion position based on the pane's index in the stack
612597 paneIndex := -1
613598 for i, stackedPane := range node.parent.stackedPanes {
@@ -618,11 +603,8 @@ func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string)
618603 }
619604
620605 // Remove old title bar
621- if node.titleBar != nil {
622- node.titleBar.Execute(func(titleBarPtr uintptr) error {
623- webkit.WidgetUnparent(titleBarPtr)
624- return nil
625- })
606+ if node.titleBar != 0 && webkit.WidgetIsValid(node.titleBar) {
607+ webkit.WidgetUnparent(node.titleBar)
626608 }
627609
628610 // Update the node's title bar reference
@@ -631,14 +613,14 @@ func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string)
631613 // Insert the new title bar at the correct position
632614 if paneIndex == 0 {
633615 // First pane - insert at the beginning
634- if node.parent.stackWrapper != nil {
635- webkit.BoxPrepend(node.parent.stackWrapper.Ptr(), newTitleBar)
616+ if node.parent.stackWrapper != 0 {
617+ webkit.BoxPrepend(node.parent.stackWrapper, newTitleBar)
636618 }
637619 } else {
638620 // Insert after the previous pane's container
639621 prevPane := node.parent.stackedPanes[paneIndex-1]
640- if node.parent.stackWrapper != nil && prevPane.container != nil {
641- webkit.BoxInsertChildAfter(node.parent.stackWrapper.Ptr(), newTitleBar, prevPane.container.Ptr())
622+ if node.parent.stackWrapper != 0 && prevPane.container != 0 {
623+ webkit.BoxInsertChildAfter(node.parent.stackWrapper, newTitleBar, prevPane.container)
642624 }
643625 }
644626
@@ -674,15 +656,18 @@ func (spm *StackedPaneManager) createTitleBarWithTitle(title string) uintptr {
674656 return titleBox
675657 }
676658
677-// CloseStackedPane handles closing a pane that is part of a stack
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.
678663 func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
679- if node.parent == nil || !node.parent.isStacked {
664+ if node == nil || node.parent == nil || !node.parent.isStacked {
680665 return errors.New("node is not part of a stacked pane")
681666 }
682667
683668 stackNode := node.parent
684669
685- // Find the index of the node to be closed
670+ // Locate the pane inside the stack slice.
686671 nodeIndex := -1
687672 for i, stackedPane := range stackNode.stackedPanes {
688673 if stackedPane == node {
@@ -690,155 +675,162 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
690675 break
691676 }
692677 }
693-
694678 if nodeIndex == -1 {
695679 return errors.New("node not found in stack")
696680 }
697681
698682 log.Printf("[workspace] closing stacked pane: index=%d stackSize=%d", nodeIndex, len(stackNode.stackedPanes))
699683
700- // Clean up the pane
701- spm.wm.detachHover(node)
702- node.pane.CleanupFromWorkspace(spm.wm)
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+ }
703690
704- // Remove the pane's widgets from the stack container
705- var stackBox *SafeWidget = stackNode.stackWrapper
706- if stackBox == nil {
691+ // Remove the pane widgets from the stack container before mutating the tree.
692+ stackBox := stackNode.stackWrapper
693+ if stackBox == 0 {
707694 stackBox = stackNode.container
708695 }
709-
710- if stackBox != nil {
711- if node.titleBar != nil {
712- webkit.BoxRemove(stackBox.Ptr(), node.titleBar.Ptr())
696+ if stackBox != 0 {
697+ if node.titleBar != 0 {
698+ webkit.BoxRemove(stackBox, node.titleBar)
713699 }
714- if node.container != nil {
715- webkit.BoxRemove(stackBox.Ptr(), node.container.Ptr())
700+ if node.container != 0 {
701+ webkit.BoxRemove(stackBox, node.container)
716702 }
717703 }
718704
719- // Clean up the pane
720- node.pane.Cleanup()
721-
722- // Remove from the stack
705+ // Remove the pane from the stack slice.
723706 stackNode.stackedPanes = append(stackNode.stackedPanes[:nodeIndex], stackNode.stackedPanes[nodeIndex+1:]...)
724707
725- // Handle the remaining panes in the stack
726- if len(stackNode.stackedPanes) == 0 {
727- // No panes left in stack - this should not happen normally
728- log.Printf("[workspace] warning: empty stack after closing pane")
729- return errors.New("stack became empty")
730- } else if len(stackNode.stackedPanes) == 1 {
731- // Only one pane left - convert back to a regular pane
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.
732766 lastPane := stackNode.stackedPanes[0]
733767 parent := stackNode.parent
734768 lastPaneContainer := lastPane.container
735769
736- // Remove the title bar since we're going back to single pane
737- if lastPane.titleBar != nil && stackBox != nil {
738- webkit.BoxRemove(stackBox.Ptr(), lastPane.titleBar.Ptr())
770+ if lastPane.titleBar != 0 && stackBox != 0 {
771+ webkit.BoxRemove(stackBox, lastPane.titleBar)
739772 }
740-
741- // Unparent the pane container from stack wrapper
742- if stackBox != nil && lastPaneContainer != nil {
743- webkit.BoxRemove(stackBox.Ptr(), lastPaneContainer.Ptr())
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+ }
744780 }
745781
746- // CRITICAL FIX: Replace the stackNode completely with lastPane
747- // Update parent child references FIRST
748782 if parent == nil {
749783 spm.wm.root = lastPane
750- if spm.wm.window != nil && lastPaneContainer != nil {
751- spm.wm.window.SetChild(lastPaneContainer.Ptr())
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)
752789 }
753790 } else {
754791 if parent.left == stackNode {
755792 parent.left = lastPane
756- if parent.container != nil && lastPaneContainer != nil {
757- parent.container.Execute(func(panedPtr uintptr) error {
758- webkit.PanedSetStartChild(panedPtr, lastPaneContainer.Ptr())
759- return nil
760- })
761- }
762793 } else if parent.right == stackNode {
763794 parent.right = lastPane
764- if parent.container != nil && lastPaneContainer != nil {
765- parent.container.Execute(func(panedPtr uintptr) error {
766- webkit.PanedSetEndChild(panedPtr, lastPaneContainer.Ptr())
767- return nil
768- })
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)
769802 }
803+ webkit.WidgetQueueAllocate(parent.container)
804+ }
805+ if lastPaneContainer != 0 {
806+ webkit.WidgetSetVisible(lastPaneContainer, true)
770807 }
771808 }
772809
773- // Convert lastPane back to regular pane
774- lastPane.parent = parent
775810 lastPane.isStacked = false
776811 lastPane.stackedPanes = nil
777- lastPane.titleBar = nil
778-
779- // CRITICAL: Ensure the container is visible after reparenting
780- // The container may have been hidden during stack operations
781- if lastPaneContainer != nil {
782- webkit.WidgetSetVisible(lastPaneContainer.Ptr(), true)
783- }
784-
785- // Update the viewToNode mapping to point to the lastPane, not stackNode
812+ lastPane.titleBar = 0
786813 spm.wm.viewToNode[lastPane.pane.webView] = lastPane
787814
788- // FIXED: Focus the remaining pane if any pane from this stack was currently focused
789- // Check if currentlyFocused is part of this stack (including the pane being closed)
790- shouldFocus := false
791- currentlyFocused := spm.wm.GetActiveNode()
792- if currentlyFocused != nil {
793- log.Printf("[workspace] DEBUG: currentlyFocused=%p, node being closed=%p, lastPane=%p",
794- currentlyFocused, node, lastPane)
795- // Check if currentlyFocused is the pane being closed
796- if currentlyFocused == node {
797- shouldFocus = true
798- log.Printf("[workspace] DEBUG: shouldFocus=true (closing focused pane)")
799- } else if currentlyFocused.parent == stackNode {
800- // Check if currentlyFocused is another pane in this stack
801- shouldFocus = true
802- log.Printf("[workspace] DEBUG: shouldFocus=true (focused pane in same stack)")
803- }
804- }
805-
806- if shouldFocus {
807- log.Printf("[workspace] focusing remaining pane after stack conversion")
815+ generation := spm.wm.nextCleanupGeneration()
816+ spm.wm.cleanupPane(node, generation)
808817
809- // Log widget state before focus operations
810- if lastPaneWidget := lastPane.pane.webView.Widget(); lastPaneWidget != 0 {
811- visible := webkit.WidgetGetVisible(lastPaneWidget)
812- log.Printf("[workspace] Widget state before focus: widget=%#x visible=%v",
813- lastPaneWidget, visible)
814- }
818+ spm.wm.SetActivePane(lastPane, SourceClose)
819+ if lastPaneWidget := lastPane.pane.webView.Widget(); lastPaneWidget != 0 {
820+ webkit.WidgetShow(lastPaneWidget)
821+ webkit.WidgetGrabFocus(lastPaneWidget)
822+ }
815823
816- // Mirror the exact logic from finalizeStackCreation that works perfectly:
817- // 1. First call SetActivePane (this does all the focus work)
818- // 2. Then set currentlyFocused
819- spm.wm.SetActivePane(lastPane, SourceClose)
820-
821- // CRITICAL: Ensure widget visibility and focus after reparenting
822- // The widget may need explicit show/focus calls after being reparented
823- if lastPaneWidget := lastPane.pane.webView.Widget(); lastPaneWidget != 0 {
824- log.Printf("[workspace] Ensuring widget visibility after reparenting")
825- webkit.WidgetShow(lastPaneWidget)
826- webkit.WidgetGrabFocus(lastPaneWidget)
827-
828- // Log widget state after focus operations
829- visible := webkit.WidgetGetVisible(lastPaneWidget)
830- log.Printf("[workspace] Widget state after focus: widget=%#x visible=%v",
831- lastPaneWidget, visible)
832- }
824+ stackNode.parent = nil
825+ stackNode.container = 0
826+ stackNode.stackWrapper = 0
827+ stackNode.stackedPanes = nil
833828
834- log.Printf("[workspace] DEBUG: applied finalizeStackCreation focus logic in reverse")
835- } else {
836- log.Printf("[workspace] DEBUG: not focusing remaining pane (shouldFocus=false)")
837- }
829+ log.Printf("[workspace] converted stack back to regular pane")
830+ return nil
838831
839- log.Printf("[workspace] converted single-pane stack back to regular pane")
840- } else {
841- // Multiple panes remain in stack - adjust active index
832+ default:
833+ // Still multiple panes in stack — adjust active index and visibility.
842834 if stackNode.activeStackIndex >= nodeIndex && stackNode.activeStackIndex > 0 {
843835 stackNode.activeStackIndex--
844836 }
@@ -846,20 +838,17 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
846838 stackNode.activeStackIndex = len(stackNode.stackedPanes) - 1
847839 }
848840
849- // Update visibility for remaining panes
850841 spm.UpdateStackVisibility(stackNode)
851-
852- // Focus the new active pane if we closed the currently active one
853842 if spm.wm.GetActiveNode() == node {
854- newActivePaneInStack := stackNode.stackedPanes[stackNode.activeStackIndex]
855- spm.wm.SetActivePane(newActivePaneInStack, SourceClose)
843+ newActivePane := stackNode.stackedPanes[stackNode.activeStackIndex]
844+ spm.wm.SetActivePane(newActivePane, SourceClose)
856845 }
857846
847+ generation := spm.wm.nextCleanupGeneration()
848+ spm.wm.cleanupPane(node, generation)
849+
858850 log.Printf("[workspace] closed pane from stack: remaining=%d activeIndex=%d",
859851 len(stackNode.stackedPanes), stackNode.activeStackIndex)
852+ return nil
860853 }
861-
862- log.Printf("[workspace] stacked pane closed; panes remaining=%d", len(spm.wm.app.panes))
863-
864- return nil
865854 }
D internal/app/browser/widget.go +0 -257
@@ -1,257 +0,0 @@
1-package browser
2-
3-import (
4- "context"
5- "fmt"
6- "sync"
7- "time"
8-
9- "github.com/bnema/dumber/pkg/webkit"
10-)
11-
12-// SafeWidget provides thread-safe access to GTK widgets with validation
13-type SafeWidget struct {
14- ptr uintptr
15- typeInfo string
16- registry *WidgetRegistry
17- isInvalidated bool
18- mu sync.RWMutex
19-}
20-
21-// Execute safely executes a function with the widget pointer, ensuring thread safety and validation
22-// CRITICAL FIX: Don't hold mutex during GTK operations to prevent deadlock
23-func (w *SafeWidget) Execute(fn func(uintptr) error) error {
24- if w == nil || w.registry == nil {
25- return fmt.Errorf("SafeWidget is nil or has no registry")
26- }
27-
28- w.mu.RLock()
29- if w.isInvalidated {
30- w.mu.RUnlock()
31- return fmt.Errorf("widget %s permanently invalidated", w.String())
32- }
33- ptr := w.ptr
34- w.mu.RUnlock()
35-
36- if !w.IsValid() {
37- return fmt.Errorf("widget %s is no longer valid", w.String())
38- }
39-
40- if err := fn(ptr); err != nil {
41- w.markInvalidIfNeeded(ptr)
42- return err
43- }
44-
45- if !w.IsValid() {
46- w.markInvalidIfNeeded(ptr)
47- return fmt.Errorf("widget %s became invalid during execution", w.String())
48- }
49-
50- return nil
51-}
52-
53-// ExecuteWithTimeout executes a function with timeout to prevent indefinite blocking
54-func (w *SafeWidget) ExecuteWithTimeout(fn func(uintptr) error, timeout time.Duration) error {
55- if w == nil || w.registry == nil {
56- return fmt.Errorf("SafeWidget is nil or has no registry")
57- }
58-
59- // Create context with timeout
60- ctx, cancel := context.WithTimeout(context.Background(), timeout)
61- defer cancel()
62-
63- // Channel for result
64- resultChan := make(chan error, 1)
65-
66- w.mu.RLock()
67- if w.isInvalidated {
68- w.mu.RUnlock()
69- return fmt.Errorf("widget %s permanently invalidated", w.String())
70- }
71- ptr := w.ptr
72- w.mu.RUnlock()
73-
74- if !w.IsValid() {
75- return fmt.Errorf("widget %s is no longer valid", w.String())
76- }
77-
78- // Phase 2: Execute function in goroutine with timeout
79- go func() {
80- resultChan <- fn(ptr)
81- }()
82-
83- // Wait for result or timeout
84- select {
85- case err := <-resultChan:
86- if err != nil {
87- w.markInvalidIfNeeded(ptr)
88- return err
89- }
90-
91- if !w.IsValid() {
92- w.markInvalidIfNeeded(ptr)
93- return fmt.Errorf("widget %s became invalid during execution", w.String())
94- }
95-
96- return nil
97- case <-ctx.Done():
98- return fmt.Errorf("widget operation timed out after %v", timeout)
99- }
100-}
101-
102-// IsValid checks if the underlying GTK widget is still valid
103-func (w *SafeWidget) IsValid() bool {
104- if w == nil || w.ptr == 0 {
105- return false
106- }
107-
108- w.mu.RLock()
109- if w.isInvalidated {
110- w.mu.RUnlock()
111- return false
112- }
113- ptr := w.ptr
114- w.mu.RUnlock()
115-
116- if !webkit.WidgetIsValid(ptr) {
117- w.Invalidate()
118- return false
119- }
120-
121- return true
122-}
123-
124-// Invalidate marks the widget as permanently invalid
125-func (w *SafeWidget) Invalidate() {
126- if w == nil {
127- return
128- }
129-
130- w.mu.Lock()
131- defer w.mu.Unlock()
132-
133- w.isInvalidated = true
134-}
135-
136-// markInvalidIfNeeded cleans up registry state when a widget becomes invalid during execution
137-func (w *SafeWidget) markInvalidIfNeeded(ptr uintptr) {
138- if w == nil {
139- return
140- }
141-
142- if w.IsValid() {
143- return
144- }
145-
146- w.Invalidate()
147- if w.registry != nil {
148- w.registry.Unregister(ptr)
149- }
150-}
151-
152-// String returns a string representation of the SafeWidget for debugging
153-func (w *SafeWidget) String() string {
154- if w == nil {
155- return "SafeWidget(nil)"
156- }
157-
158- w.mu.RLock()
159- typeInfo := w.typeInfo
160- ptr := w.ptr
161- w.mu.RUnlock()
162-
163- return fmt.Sprintf("SafeWidget(%s@%#x)", typeInfo, ptr)
164-}
165-
166-// Ptr returns the raw uintptr to the GTK widget
167-func (w *SafeWidget) Ptr() uintptr {
168- if w == nil {
169- return 0
170- }
171-
172- w.mu.RLock()
173- defer w.mu.RUnlock()
174-
175- return w.ptr
176-}
177-
178-// WidgetRegistry manages a collection of SafeWidget instances with thread safety
179-type WidgetRegistry struct {
180- mu sync.RWMutex
181- widgets map[uintptr]*SafeWidget
182-}
183-
184-// NewWidgetRegistry creates a new widget registry
185-func NewWidgetRegistry() *WidgetRegistry {
186- return &WidgetRegistry{
187- widgets: make(map[uintptr]*SafeWidget),
188- }
189-}
190-
191-// Register creates a new SafeWidget and registers it in the registry
192-func (r *WidgetRegistry) Register(ptr uintptr, typeInfo string) *SafeWidget {
193- if r == nil || ptr == 0 {
194- return nil
195- }
196-
197- r.mu.Lock()
198- defer r.mu.Unlock()
199-
200- // Check if widget is already registered
201- if existing, exists := r.widgets[ptr]; exists {
202- return existing
203- }
204-
205- // Create new SafeWidget
206- widget := &SafeWidget{
207- ptr: ptr,
208- typeInfo: typeInfo,
209- registry: r,
210- }
211-
212- r.widgets[ptr] = widget
213- return widget
214-}
215-
216-// Unregister removes a widget from the registry
217-func (r *WidgetRegistry) Unregister(ptr uintptr) {
218- if r == nil || ptr == 0 {
219- return
220- }
221-
222- r.mu.Lock()
223- defer r.mu.Unlock()
224-
225- if widget, exists := r.widgets[ptr]; exists {
226- widget.Invalidate()
227- delete(r.widgets, ptr)
228- }
229-}
230-
231-// Cleanup removes all invalid widgets from the registry
232-func (r *WidgetRegistry) Cleanup() {
233- if r == nil {
234- return
235- }
236-
237- r.mu.Lock()
238- defer r.mu.Unlock()
239-
240- for ptr, widget := range r.widgets {
241- if !widget.IsValid() {
242- delete(r.widgets, ptr)
243- }
244- }
245-}
246-
247-// Count returns the number of registered widgets
248-func (r *WidgetRegistry) Count() int {
249- if r == nil {
250- return 0
251- }
252-
253- r.mu.RLock()
254- defer r.mu.RUnlock()
255-
256- return len(r.widgets)
257-}
M internal/app/browser/workspace_diagnostics.go +2 -2
@@ -134,8 +134,8 @@ func describePaneNode(node *paneNode) string {
134134 parts = append(parts, fmt.Sprintf("split orientation=%d", node.orientation))
135135 }
136136
137- if node.container != nil {
138- parts = append(parts, fmt.Sprintf("widget=%#x", node.container.Ptr()))
137+ if node.container != 0 {
138+ parts = append(parts, fmt.Sprintf("widget=%#x", node.container))
139139 }
140140
141141 if node.parent != nil {
M internal/app/browser/workspace_geometry_validator.go +54 -441
@@ -1,19 +1,18 @@
1-// workspace_geometry_validator.go - Geometry validation for split operations inspired by Zellij
1+// workspace_geometry_validator.go - Simplified geometry validation for split operations
22 package browser
33
44 import (
55 "fmt"
66 "log"
7- "time"
87
98 "github.com/bnema/dumber/pkg/webkit"
109 )
1110
1211 // GeometryValidator validates pane geometry constraints for splits and operations
1312 type GeometryValidator struct {
14- minPaneWidth int // Minimum width for a pane in pixels
15- minPaneHeight int // Minimum height for a pane in pixels
16- debugMode bool // Enable debug logging
13+ minPaneWidth int
14+ minPaneHeight int
15+ debugMode bool
1716 }
1817
1918 // PaneGeometry represents the geometry of a pane
@@ -25,285 +24,145 @@ type PaneGeometry struct {
2524 IsValid bool
2625 }
2726
28-// SplitConstraints represents constraints for split operations
29-type SplitConstraints struct {
30- MinResultingWidth int
31- MinResultingHeight int
32- MaxSplitRatio float64 // Maximum ratio for uneven splits (e.g., 0.8 = 80/20 split)
33- MinSplitRatio float64 // Minimum ratio for even splits (e.g., 0.3 = 30/70 split)
34-}
35-
3627 // ValidationResult represents the result of a geometry validation
3728 type ValidationResult struct {
3829 IsValid bool
3930 Reason string
40- Constraints SplitConstraints
4131 Geometry PaneGeometry
42- RequiresRevalidation bool // True if validation should be retried after widget allocation
32+ RequiresRevalidation bool
4333 }
4434
45-// NewGeometryValidator creates a new geometry validator with Zellij-inspired constraints
35+// NewGeometryValidator creates a new geometry validator
4636 func NewGeometryValidator() *GeometryValidator {
4737 return &GeometryValidator{
48- minPaneWidth: 300, // Inspired by Zellij's MIN_TERMINAL_WIDTH * character_width
49- minPaneHeight: 200, // Inspired by Zellij's MIN_TERMINAL_HEIGHT * character_height
38+ minPaneWidth: 300, // Minimum width for usable pane
39+ minPaneHeight: 200, // Minimum height for usable pane
5040 debugMode: false,
5141 }
5242 }
5343
54-// SetMinimumDimensions sets the minimum pane dimensions
55-func (gv *GeometryValidator) SetMinimumDimensions(width, height int) {
56- gv.minPaneWidth = width
57- gv.minPaneHeight = height
58- log.Printf("[geometry] Updated minimum dimensions: %dx%d", width, height)
59-}
60-
6144 // SetDebugMode enables or disables debug logging
6245 func (gv *GeometryValidator) SetDebugMode(debug bool) {
6346 gv.debugMode = debug
6447 }
6548
6649 // GetPaneGeometry extracts geometry information from a pane node
67-// CRITICAL FIX: Use timeout to prevent indefinite blocking on GTK calls
6850 func (gv *GeometryValidator) GetPaneGeometry(node *paneNode) PaneGeometry {
6951 geometry := PaneGeometry{IsValid: false}
7052
71- if node == nil || node.container == nil {
53+ if node == nil || node.container == 0 {
7254 if gv.debugMode {
7355 log.Printf("[geometry] Invalid node or missing container")
7456 }
7557 return geometry
7658 }
7759
78- // CRITICAL FIX: If not on main thread, marshal GTK calls to prevent deadlock
79- if !webkit.IsMainThread() {
80- // Use IdleAdd to marshal geometry query to main thread
81- resultChan := make(chan PaneGeometry, 1)
82-
83- webkit.IdleAdd(func() bool {
84- // Execute on main thread
85- mainThreadGeometry := gv.getGeometryOnMainThread(node)
86- resultChan <- mainThreadGeometry
87- return false // Remove idle callback
88- })
89-
90- // Wait for result with timeout
91- select {
92- case result := <-resultChan:
93- return result
94- case <-time.After(100 * time.Millisecond):
95- log.Printf("[geometry] Geometry query timed out (100ms), assuming invalid")
96- return geometry
97- }
98- }
99-
100- // We're on main thread, execute directly but with timeout protection
101- return gv.getGeometryWithTimeout(node, 50*time.Millisecond)
102-}
103-
104-// getGeometryOnMainThread executes geometry query on main thread (must be called from main thread)
105-func (gv *GeometryValidator) getGeometryOnMainThread(node *paneNode) PaneGeometry {
106- geometry := PaneGeometry{IsValid: false}
107-
108- // Get geometry from GTK widget (now safe since we're on main thread)
109- err := node.container.Execute(func(ptr uintptr) error {
110- // Get widget allocation
111- allocation := webkit.WidgetGetAllocation(ptr)
112-
113- geometry.X = allocation.X
114- geometry.Y = allocation.Y
115- geometry.Width = allocation.Width
116- geometry.Height = allocation.Height
117- geometry.IsValid = true
118-
119- if gv.debugMode {
120- log.Printf("[geometry] Pane geometry: %dx%d at (%d,%d)",
121- geometry.Width, geometry.Height, geometry.X, geometry.Y)
122- }
123-
124- return nil
125- })
126-
127- if err != nil {
128- log.Printf("[geometry] Failed to get pane geometry: %v", err)
129- geometry.IsValid = false
130- }
131-
132- return geometry
133-}
134-
135-// getGeometryWithTimeout gets geometry with timeout protection
136-func (gv *GeometryValidator) getGeometryWithTimeout(node *paneNode, timeout time.Duration) PaneGeometry {
137- geometry := PaneGeometry{IsValid: false}
138-
139- // Use ExecuteWithTimeout to prevent indefinite blocking
140- err := node.container.ExecuteWithTimeout(func(ptr uintptr) error {
141- // Get widget allocation
142- allocation := webkit.WidgetGetAllocation(ptr)
143-
144- geometry.X = allocation.X
145- geometry.Y = allocation.Y
146- geometry.Width = allocation.Width
147- geometry.Height = allocation.Height
148- geometry.IsValid = true
60+ // Get widget allocation directly - GTK operations are always on main thread
61+ allocation := webkit.WidgetGetAllocation(node.container)
14962
150- if gv.debugMode {
151- log.Printf("[geometry] Pane geometry: %dx%d at (%d,%d)",
152- geometry.Width, geometry.Height, geometry.X, geometry.Y)
153- }
154-
155- return nil
156- }, timeout)
63+ geometry.X = allocation.X
64+ geometry.Y = allocation.Y
65+ geometry.Width = allocation.Width
66+ geometry.Height = allocation.Height
67+ geometry.IsValid = true
15768
158- if err != nil {
159- log.Printf("[geometry] Failed to get pane geometry: %v", err)
160- geometry.IsValid = false
69+ if gv.debugMode {
70+ log.Printf("[geometry] Pane geometry: %dx%d at (%d,%d)",
71+ geometry.Width, geometry.Height, geometry.X, geometry.Y)
16172 }
16273
16374 return geometry
16475 }
16576
166-// ValidateHorizontalSplit validates if a pane can be split horizontally
167-func (gv *GeometryValidator) ValidateHorizontalSplit(node *paneNode) ValidationResult {
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) {
16882 geometry := gv.GetPaneGeometry(node)
169-
170- result := ValidationResult{
171- Geometry: geometry,
172- Constraints: SplitConstraints{
173- MinResultingWidth: gv.minPaneWidth,
174- MinResultingHeight: gv.minPaneHeight,
175- MaxSplitRatio: 0.8,
176- MinSplitRatio: 0.2,
177- },
178- }
83+ result := ValidationResult{Geometry: geometry}
17984
18085 if !geometry.IsValid {
181- // Check if this is a zero allocation during startup
18286 if geometry.Width == 0 && geometry.Height == 0 {
183- // Allow operation but flag for re-validation
18487 result.IsValid = true
185- result.Reason = "geometry pending allocation, allowing horizontal split"
88+ result.Reason = "geometry pending allocation"
18689 result.RequiresRevalidation = true
187- log.Printf("[geometry] Allowing horizontal split with zero allocation - will revalidate after first allocation")
188- return result
18990 } else {
19091 result.IsValid = false
19192 result.Reason = "could not determine pane geometry"
192- return result
19393 }
94+ return geometry, result, false
19495 }
19596
196- // Check if the pane is wide enough to split horizontally
197- // Each resulting pane must be at least minPaneWidth wide
198- requiredWidth := gv.minPaneWidth * 2
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+ }
199106
107+ requiredWidth := gv.minPaneWidth * 2
200108 if geometry.Width < requiredWidth {
201109 result.IsValid = false
202- result.Reason = fmt.Sprintf("pane width %d is less than required %d for horizontal split",
110+ result.Reason = fmt.Sprintf("pane width %d < required %d for horizontal split",
203111 geometry.Width, requiredWidth)
204112 return result
205113 }
206114
207- // Check height constraints
208115 if geometry.Height < gv.minPaneHeight {
209116 result.IsValid = false
210- result.Reason = fmt.Sprintf("pane height %d is less than minimum %d",
117+ result.Reason = fmt.Sprintf("pane height %d < minimum %d",
211118 geometry.Height, gv.minPaneHeight)
212119 return result
213120 }
214121
215122 result.IsValid = true
216123 result.Reason = "horizontal split is valid"
217-
218- if gv.debugMode {
219- log.Printf("[geometry] Horizontal split validation: %s (width=%d, required=%d)",
220- result.Reason, geometry.Width, requiredWidth)
221- }
222-
223124 return result
224125 }
225126
226127 // ValidateVerticalSplit validates if a pane can be split vertically
227128 func (gv *GeometryValidator) ValidateVerticalSplit(node *paneNode) ValidationResult {
228- geometry := gv.GetPaneGeometry(node)
229-
230- result := ValidationResult{
231- Geometry: geometry,
232- Constraints: SplitConstraints{
233- MinResultingWidth: gv.minPaneWidth,
234- MinResultingHeight: gv.minPaneHeight,
235- MaxSplitRatio: 0.8,
236- MinSplitRatio: 0.2,
237- },
238- }
239-
240- if !geometry.IsValid {
241- // Check if this is a zero allocation during startup
242- if geometry.Width == 0 && geometry.Height == 0 {
243- // Allow operation but flag for re-validation
244- result.IsValid = true
245- result.Reason = "geometry pending allocation, allowing vertical split"
246- result.RequiresRevalidation = true
247- log.Printf("[geometry] Allowing vertical split with zero allocation - will revalidate after first allocation")
248- return result
249- } else {
250- result.IsValid = false
251- result.Reason = "could not determine pane geometry"
252- return result
253- }
129+ geometry, result, ok := gv.prepareGeometryResult(node)
130+ if !ok {
131+ return result
254132 }
255133
256- // Check if the pane is tall enough to split vertically
257- // Each resulting pane must be at least minPaneHeight tall
258134 requiredHeight := gv.minPaneHeight * 2
259-
260135 if geometry.Height < requiredHeight {
261136 result.IsValid = false
262- result.Reason = fmt.Sprintf("pane height %d is less than required %d for vertical split",
137+ result.Reason = fmt.Sprintf("pane height %d < required %d for vertical split",
263138 geometry.Height, requiredHeight)
264139 return result
265140 }
266141
267- // Check width constraints
268142 if geometry.Width < gv.minPaneWidth {
269143 result.IsValid = false
270- result.Reason = fmt.Sprintf("pane width %d is less than minimum %d",
144+ result.Reason = fmt.Sprintf("pane width %d < minimum %d",
271145 geometry.Width, gv.minPaneWidth)
272146 return result
273147 }
274148
275149 result.IsValid = true
276150 result.Reason = "vertical split is valid"
277-
278- if gv.debugMode {
279- log.Printf("[geometry] Vertical split validation: %s (height=%d, required=%d)",
280- result.Reason, geometry.Height, requiredHeight)
281- }
282-
283151 return result
284152 }
285153
286-// ValidateSplit validates a split operation based on direction (inspired by Zellij's split logic)
154+// ValidateSplit validates a split operation based on direction
287155 func (gv *GeometryValidator) ValidateSplit(node *paneNode, direction string) ValidationResult {
288156 if node == nil {
289- return ValidationResult{
290- IsValid: false,
291- Reason: "node is nil",
292- }
157+ return ValidationResult{IsValid: false, Reason: "node is nil"}
293158 }
294159
295160 if node.isStacked {
296- return ValidationResult{
297- IsValid: false,
298- Reason: "cannot split stacked pane container directly",
299- }
161+ return ValidationResult{IsValid: false, Reason: "cannot split stacked pane container"}
300162 }
301163
302164 if !node.isLeaf {
303- return ValidationResult{
304- IsValid: false,
305- Reason: "can only split leaf panes",
306- }
165+ return ValidationResult{IsValid: false, Reason: "can only split leaf panes"}
307166 }
308167
309168 switch direction {
@@ -312,285 +171,39 @@ func (gv *GeometryValidator) ValidateSplit(node *paneNode, direction string) Val
312171 case "up", "down":
313172 return gv.ValidateVerticalSplit(node)
314173 default:
315- return ValidationResult{
316- IsValid: false,
317- Reason: fmt.Sprintf("unknown split direction: %s", direction),
318- }
319- }
320-}
321-
322-// DetermineBestSplitDirection determines the best split direction for a pane (like Zellij's algorithm)
323-func (gv *GeometryValidator) DetermineBestSplitDirection(node *paneNode) (string, ValidationResult) {
324- geometry := gv.GetPaneGeometry(node)
325-
326- if !geometry.IsValid {
327- return "", ValidationResult{
328- IsValid: false,
329- Reason: "could not determine pane geometry",
330- }
331- }
332-
333- // Calculate aspect ratio and available space
334- aspectRatio := float64(geometry.Width) / float64(geometry.Height)
335-
336- // Check which splits are possible
337- horizontalValid := gv.ValidateHorizontalSplit(node)
338- verticalValid := gv.ValidateVerticalSplit(node)
339-
340- if gv.debugMode {
341- log.Printf("[geometry] Determining best split: aspect=%.2f, horizontal=%v, vertical=%v",
342- aspectRatio, horizontalValid.IsValid, verticalValid.IsValid)
343- }
344-
345- // If only one direction is valid, use it
346- if horizontalValid.IsValid && !verticalValid.IsValid {
347- return "right", horizontalValid
348- }
349- if verticalValid.IsValid && !horizontalValid.IsValid {
350- return "down", verticalValid
351- }
352-
353- // If neither is valid, return error
354- if !horizontalValid.IsValid && !verticalValid.IsValid {
355- return "", ValidationResult{
356- IsValid: false,
357- Reason: "pane is too small for any split operation",
358- }
359- }
360-
361- // Both directions are valid - choose based on aspect ratio and space
362- // Prefer horizontal split for wide panes, vertical for tall panes
363- const aspectThreshold = 1.5
364-
365- if aspectRatio > aspectThreshold {
366- // Wide pane - prefer horizontal split
367- return "right", horizontalValid
368- } else if aspectRatio < (1.0 / aspectThreshold) {
369- // Tall pane - prefer vertical split
370- return "down", verticalValid
371- } else {
372- // Nearly square - prefer the direction with more available space
373- horizontalSpace := geometry.Width - (gv.minPaneWidth * 2)
374- verticalSpace := geometry.Height - (gv.minPaneHeight * 2)
375-
376- if horizontalSpace > verticalSpace {
377- return "right", horizontalValid
378- } else {
379- return "down", verticalValid
380- }
174+ return ValidationResult{IsValid: false, Reason: fmt.Sprintf("unknown split direction: %s", direction)}
381175 }
382176 }
383177
384178 // ValidateStackOperation validates if a pane can be converted to a stack
385179 func (gv *GeometryValidator) ValidateStackOperation(node *paneNode) ValidationResult {
386- geometry := gv.GetPaneGeometry(node)
387-
388- result := ValidationResult{
389- Geometry: geometry,
390- Constraints: SplitConstraints{
391- MinResultingWidth: gv.minPaneWidth,
392- MinResultingHeight: gv.minPaneHeight + 30, // Extra space for title bar
393- },
394- }
395-
396- if !geometry.IsValid {
397- // Check if this is a zero allocation during startup
398- if geometry.Width == 0 && geometry.Height == 0 {
399- // Allow operation but flag for re-validation
400- result.IsValid = true
401- result.Reason = "geometry pending allocation, allowing stack operation"
402- result.RequiresRevalidation = true
403- log.Printf("[geometry] Allowing stack operation with zero allocation - will revalidate after first allocation")
404- return result
405- } else {
406- result.IsValid = false
407- result.Reason = "could not determine pane geometry"
408- return result
409- }
180+ geometry, result, ok := gv.prepareGeometryResult(node)
181+ if !ok {
182+ return result
410183 }
411184
412- // Check if pane is already stacked
413185 if node.isStacked {
414186 result.IsValid = false
415187 result.Reason = "pane is already stacked"
416188 return result
417189 }
418190
419- // Check if pane is large enough for stacking (needs space for title bars)
420191 minStackHeight := gv.minPaneHeight + 30 // Extra space for title bar
421-
422192 if geometry.Height < minStackHeight {
423193 result.IsValid = false
424- result.Reason = fmt.Sprintf("pane height %d is less than required %d for stacking",
194+ result.Reason = fmt.Sprintf("pane height %d < required %d for stacking",
425195 geometry.Height, minStackHeight)
426196 return result
427197 }
428198
429199 if geometry.Width < gv.minPaneWidth {
430200 result.IsValid = false
431- result.Reason = fmt.Sprintf("pane width %d is less than minimum %d",
201+ result.Reason = fmt.Sprintf("pane width %d < minimum %d",
432202 geometry.Width, gv.minPaneWidth)
433203 return result
434204 }
435205
436206 result.IsValid = true
437207 result.Reason = "stack operation is valid"
438-
439- if gv.debugMode {
440- log.Printf("[geometry] Stack validation: %s", result.Reason)
441- }
442-
443208 return result
444209 }
445-
446-// ValidateWorkspaceLayout validates the overall workspace layout
447-func (gv *GeometryValidator) ValidateWorkspaceLayout(root *paneNode) []ValidationResult {
448- var results []ValidationResult
449-
450- if root == nil {
451- results = append(results, ValidationResult{
452- IsValid: false,
453- Reason: "workspace root is nil",
454- })
455- return results
456- }
457-
458- // Validate each pane in the workspace
459- leaves := gv.collectAllLeaves(root)
460-
461- for i, leaf := range leaves {
462- geometry := gv.GetPaneGeometry(leaf)
463-
464- result := ValidationResult{
465- Geometry: geometry,
466- }
467-
468- if !geometry.IsValid {
469- result.IsValid = false
470- result.Reason = fmt.Sprintf("leaf pane %d has invalid geometry", i)
471- } else if geometry.Width < gv.minPaneWidth {
472- result.IsValid = false
473- result.Reason = fmt.Sprintf("leaf pane %d width %d below minimum %d",
474- i, geometry.Width, gv.minPaneWidth)
475- } else if geometry.Height < gv.minPaneHeight {
476- result.IsValid = false
477- result.Reason = fmt.Sprintf("leaf pane %d height %d below minimum %d",
478- i, geometry.Height, gv.minPaneHeight)
479- } else {
480- result.IsValid = true
481- result.Reason = fmt.Sprintf("leaf pane %d geometry is valid", i)
482- }
483-
484- results = append(results, result)
485- }
486-
487- return results
488-}
489-
490-// collectAllLeaves collects all leaf panes in the workspace
491-func (gv *GeometryValidator) collectAllLeaves(node *paneNode) []*paneNode {
492- if node == nil {
493- return nil
494- }
495-
496- var leaves []*paneNode
497-
498- if node.isLeaf {
499- leaves = append(leaves, node)
500- } else if node.isStacked {
501- // For stacked panes, only include the active pane
502- if node.activeStackIndex >= 0 && node.activeStackIndex < len(node.stackedPanes) {
503- leaves = append(leaves, node.stackedPanes[node.activeStackIndex])
504- }
505- } else {
506- // Branch node - collect from children
507- leaves = append(leaves, gv.collectAllLeaves(node.left)...)
508- leaves = append(leaves, gv.collectAllLeaves(node.right)...)
509- }
510-
511- return leaves
512-}
513-
514-// GetOptimalSplitRatio calculates the optimal split ratio based on available space
515-func (gv *GeometryValidator) GetOptimalSplitRatio(geometry PaneGeometry, direction string) float64 {
516- // Default to 50/50 split
517- defaultRatio := 0.5
518-
519- switch direction {
520- case "left", "right":
521- // For horizontal splits, consider width
522- availableWidth := geometry.Width - (gv.minPaneWidth * 2)
523- if availableWidth <= 0 {
524- return defaultRatio
525- }
526- // If there's plenty of space, use 50/50, otherwise bias toward existing content
527- return defaultRatio
528-
529- case "up", "down":
530- // For vertical splits, consider height
531- availableHeight := geometry.Height - (gv.minPaneHeight * 2)
532- if availableHeight <= 0 {
533- return defaultRatio
534- }
535- // If there's plenty of space, use 50/50, otherwise bias toward existing content
536- return defaultRatio
537-
538- default:
539- return defaultRatio
540- }
541-}
542-
543-// GetGeometryStats returns statistics about the workspace geometry
544-func (gv *GeometryValidator) GetGeometryStats(root *paneNode) map[string]interface{} {
545- results := gv.ValidateWorkspaceLayout(root)
546-
547- totalPanes := len(results)
548- validPanes := 0
549- totalArea := 0
550- minWidth := int(^uint(0) >> 1) // Max int
551- maxWidth := 0
552- minHeight := int(^uint(0) >> 1) // Max int
553- maxHeight := 0
554-
555- for _, result := range results {
556- if result.IsValid {
557- validPanes++
558- }
559-
560- if result.Geometry.IsValid {
561- totalArea += result.Geometry.Width * result.Geometry.Height
562-
563- if result.Geometry.Width < minWidth {
564- minWidth = result.Geometry.Width
565- }
566- if result.Geometry.Width > maxWidth {
567- maxWidth = result.Geometry.Width
568- }
569- if result.Geometry.Height < minHeight {
570- minHeight = result.Geometry.Height
571- }
572- if result.Geometry.Height > maxHeight {
573- maxHeight = result.Geometry.Height
574- }
575- }
576- }
577-
578- if totalPanes == 0 {
579- return map[string]interface{}{
580- "total_panes": 0,
581- }
582- }
583-
584- return map[string]interface{}{
585- "total_panes": totalPanes,
586- "valid_panes": validPanes,
587- "validity_rate": float64(validPanes) / float64(totalPanes),
588- "total_area": totalArea,
589- "min_width": minWidth,
590- "max_width": maxWidth,
591- "min_height": minHeight,
592- "max_height": maxHeight,
593- "min_pane_width": gv.minPaneWidth,
594- "min_pane_height": gv.minPaneHeight,
595- }
596-}
M internal/app/browser/workspace_manager.go +30 -23
@@ -48,54 +48,59 @@ type WorkspaceManager struct {
4848 stackedPaneManager *StackedPaneManager
4949 focusStateMachine *FocusStateMachine
5050
51- // Widget operation synchronization to prevent GTK race conditions
52- widgetMutex sync.Mutex
53- widgetRegistry *WidgetRegistry
54-
5551 // Focus debouncing to prevent rapid oscillation from any source
5652 lastFocusTime time.Time
5753 lastFocusTarget *paneNode
5854 focusDebounce time.Duration
5955
60- // ENHANCED COMPONENTS: Validation and safety systems (opt-in for debugging)
61- treeValidator *TreeValidator
62- treeRebalancer *TreeRebalancer
63- geometryValidator *GeometryValidator
64- stackLifecycleManager *StackLifecycleManager
65- stateTombstoneManager *StateTombstoneManager
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
6661
6762 // Debug instrumentation helpers
63+ debugLevel DebugLevel
6864 debugPaneClose bool
6965 diagnostics *WorkspaceDiagnostics
7066
71- // Enhanced pane close refactoring fields
67+ // Cleanup tracking
7268 cleanupCounter uint
69+ pendingIdle map[uintptr][]*paneNode
7370 }
7471
7572 // Workspace navigation shortcuts are now handled globally by WindowShortcutHandler
7673
7774 // NewWorkspaceManager builds a workspace manager rooted at the provided pane.
7875 func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManager {
76+ debugLevel := getDebugLevel()
77+
7978 manager := &WorkspaceManager{
8079 app: app,
8180 window: rootPane.webView.Window(),
8281 viewToNode: make(map[*webkit.WebView]*paneNode),
8382 lastSplitMsg: make(map[*webkit.WebView]time.Time),
8483 lastExitMsg: make(map[*webkit.WebView]time.Time),
85- paneDeduplicator: messaging.NewPaneRequestDeduplicator(), // NEW: Initialize deduplicator
86- widgetRegistry: NewWidgetRegistry(), // Initialize widget registry
87- focusDebounce: 150 * time.Millisecond, // 150ms focus debouncing for all sources
84+ paneDeduplicator: messaging.NewPaneRequestDeduplicator(),
85+ focusDebounce: 150 * time.Millisecond,
86+ debugLevel: debugLevel,
8887 }
8988
90- // Initialize enhanced components first
91- manager.treeValidator = NewTreeValidator(true, false) // enabled, debug off
92- manager.geometryValidator = NewGeometryValidator()
93- manager.stateTombstoneManager = NewStateTombstoneManager(manager)
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()
98+ }
9499
95- // Initialize tree rebalancer
100+ // Initialize tree rebalancer (always needed for proper close operations)
96101 manager.treeRebalancer = NewTreeRebalancer(manager, manager.treeValidator)
97102
98- // Initialize stack lifecycle manager with enhanced components
103+ // Initialize stack lifecycle manager
99104 manager.stackLifecycleManager = NewStackLifecycleManager(manager, manager.treeValidator)
100105
101106 // Initialize existing specialized managers (now enhanced with bulletproof components)
@@ -109,9 +114,11 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag
109114 if err != nil {
110115 return nil, err
111116 }
112- // Keep CreateWindow = true for popup WebViews to ensure proper window features initialization
113- // This prevents WindowFeatures optional crashes when WebKit accesses popup properties
114- 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
115122 return webkit.NewWebView(cfg)
116123 }
117124 manager.createPaneFn = func(view *webkit.WebView) (*BrowserPane, error) {
M internal/app/browser/workspace_navigation.go +33 -47
@@ -89,19 +89,15 @@ func (wm *WorkspaceManager) ensureHover(node *paneNode) {
8989 if wm == nil || node == nil || !node.isLeaf {
9090 return
9191 }
92- if node.container == nil || node.hoverToken != 0 {
92+ if node.container == 0 || node.hoverToken != 0 {
9393 return
9494 }
9595
96- var token uintptr
97- node.container.Execute(func(containerPtr uintptr) error {
98- token = webkit.WidgetAddHoverHandler(containerPtr, func() {
99- if wm == nil {
100- return
101- }
102- wm.SetActivePane(node, SourceMouse)
103- })
104- return nil
96+ token := webkit.WidgetAddHoverHandler(node.container, func() {
97+ if wm == nil {
98+ return
99+ }
100+ wm.SetActivePane(node, SourceMouse)
105101 })
106102 node.hoverToken = token
107103 if token == 0 {
@@ -115,7 +111,7 @@ func (wm *WorkspaceManager) detachHover(node *paneNode) {
115111 return
116112 }
117113
118- if node.container == nil || !node.container.IsValid() {
114+ if node.container == 0 || !webkit.WidgetIsValid(node.container) {
119115 node.hoverToken = 0
120116 return
121117 }
@@ -123,12 +119,22 @@ func (wm *WorkspaceManager) detachHover(node *paneNode) {
123119 token := node.hoverToken
124120 node.hoverToken = 0
125121
126- node.container.Execute(func(containerPtr uintptr) error {
127- if containerPtr != 0 && webkit.WidgetIsValid(containerPtr) {
128- webkit.WidgetRemoveHoverHandler(containerPtr, token)
129- }
130- return nil
131- })
122+ if webkit.WidgetIsValid(node.container) {
123+ webkit.WidgetRemoveHoverHandler(node.container, token)
124+ }
125+}
126+
127+// detachFocus removes the GTK focus controller attached to a pane, if any.
128+func (wm *WorkspaceManager) detachFocus(node *paneNode) {
129+ if wm == nil || node == nil || node.focusControllerToken == 0 {
130+ return
131+ }
132+
133+ if wm.focusStateMachine != nil {
134+ wm.focusStateMachine.detachGTKController(node, node.focusControllerToken)
135+ }
136+
137+ node.focusControllerToken = 0
132138 }
133139
134140 // FocusNeighbor moves focus to the nearest pane in the requested direction using the
@@ -229,7 +235,7 @@ func (wm *WorkspaceManager) navigateStack(direction string) bool {
229235 // focusAdjacent uses geometry-based calculations to find and focus adjacent panes
230236 func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
231237 currentFocused := wm.GetActiveNode()
232- if currentFocused == nil || !currentFocused.isLeaf || currentFocused.container == nil {
238+ if currentFocused == nil || !currentFocused.isLeaf || currentFocused.container == 0 {
233239 return false
234240 }
235241
@@ -238,12 +244,7 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
238244 return true
239245 }
240246
241- var currentBounds webkit.WidgetBounds
242- var ok bool
243- currentFocused.container.Execute(func(containerPtr uintptr) error {
244- currentBounds, ok = webkit.WidgetGetBounds(containerPtr)
245- return nil
246- })
247+ currentBounds, ok := webkit.WidgetGetBounds(currentFocused.container)
247248 if !ok {
248249 log.Printf("[workspace] unable to compute bounds for active pane")
249250 return false
@@ -258,16 +259,11 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
258259 var debugCandidates []string
259260
260261 for _, candidate := range leaves {
261- if candidate == nil || candidate == currentFocused || candidate.container == nil {
262+ if candidate == nil || candidate == currentFocused || candidate.container == 0 {
262263 continue
263264 }
264- var bounds webkit.WidgetBounds
265- var boundsOk bool
266- candidate.container.Execute(func(containerPtr uintptr) error {
267- bounds, boundsOk = webkit.WidgetGetBounds(containerPtr)
268- return nil
269- })
270- ok := boundsOk
265+
266+ bounds, ok := webkit.WidgetGetBounds(candidate.container)
271267 if !ok {
272268 continue
273269 }
@@ -324,16 +320,11 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
324320
325321 // structuralNeighbor finds neighbors based on the tree structure rather than geometry
326322 func (wm *WorkspaceManager) structuralNeighbor(node *paneNode, direction string) *paneNode {
327- if node == nil || node.container == nil {
323+ if node == nil || node.container == 0 {
328324 return nil
329325 }
330326
331- var refBounds webkit.WidgetBounds
332- var ok bool
333- node.container.Execute(func(containerPtr uintptr) error {
334- refBounds, ok = webkit.WidgetGetBounds(containerPtr)
335- return nil
336- })
327+ refBounds, ok := webkit.WidgetGetBounds(node.container)
337328 if !ok {
338329 return nil
339330 }
@@ -379,16 +370,11 @@ func (wm *WorkspaceManager) closestLeafFromSubtree(node *paneNode, cx, cy float6
379370 bestScore := math.MaxFloat64
380371 var best *paneNode
381372 for _, leaf := range leaves {
382- if leaf == nil || leaf.container == nil {
373+ if leaf == nil || leaf.container == 0 {
383374 continue
384375 }
385- var bounds webkit.WidgetBounds
386- var boundsOk bool
387- leaf.container.Execute(func(containerPtr uintptr) error {
388- bounds, boundsOk = webkit.WidgetGetBounds(containerPtr)
389- return nil
390- })
391- ok := boundsOk
376+
377+ bounds, ok := webkit.WidgetGetBounds(leaf.container)
392378 if !ok {
393379 continue
394380 }
M internal/app/browser/workspace_operations.go +82 -116
@@ -16,44 +16,42 @@ func (wm *WorkspaceManager) SplitPane(target *paneNode, direction string) (*pane
1616
1717 log.Printf("[workspace] Starting split operation: target=%p direction=%s", target, direction)
1818
19- // Step 1: Capture state tombstone for rollback
20- tombstone, err := wm.stateTombstoneManager.CaptureState("split")
21- if err != nil {
22- log.Printf("[workspace] Failed to capture state tombstone: %v", err)
23- // Continue anyway - tombstone is for rollback safety
24- }
25-
26- // Step 2: Validate geometry constraints
27- validation := wm.geometryValidator.ValidateSplit(target, direction)
28- if !validation.IsValid {
29- return nil, fmt.Errorf("split validation failed: %s", validation.Reason)
30- }
19+ // Step 1: Validate geometry constraints (only in DebugBasic+)
20+ if wm.debugLevel >= DebugBasic {
21+ validation := wm.geometryValidator.ValidateSplit(target, direction)
22+ if !validation.IsValid {
23+ if wm.debugLevel == DebugFull {
24+ return nil, fmt.Errorf("split validation failed: %s", validation.Reason)
25+ }
26+ log.Printf("[workspace] WARNING: Split validation failed but allowing operation: %s", validation.Reason)
27+ }
3128
32- // Log if re-validation will be needed due to pending widget allocation
33- if validation.RequiresRevalidation {
34- log.Printf("[workspace] Split validation passed with pending allocation - operation will proceed")
35- }
29+ // Log if re-validation will be needed due to pending widget allocation
30+ if validation.RequiresRevalidation {
31+ log.Printf("[workspace] Split validation passed with pending allocation - operation will proceed")
32+ }
3633
37- // Step 3: Validate tree invariants before operation
38- if err := wm.treeValidator.ValidateTree(wm.root, "before_split"); err != nil {
39- return nil, fmt.Errorf("tree validation failed before split: %w", err)
34+ // Step 3: Validate tree invariants before operation
35+ if err := wm.treeValidator.ValidateTree(wm.root, "before_split"); err != nil {
36+ if wm.debugLevel == DebugFull {
37+ return nil, fmt.Errorf("tree validation failed before split: %w", err)
38+ }
39+ log.Printf("[workspace] WARNING: Tree validation failed before split but allowing operation: %v", err)
40+ }
4041 }
4142
42- // Step 4: Execute directly if we're already on the GTK main thread
43+ // Step 2: Execute directly if we're already on the GTK main thread
4344 if webkit.IsMainThread() {
4445 log.Printf("[workspace] Already on main thread, executing split directly")
4546 newNode, err := wm.splitNode(target, direction)
4647 if err != nil {
47- if tombstone != nil {
48- if rollbackErr := wm.stateTombstoneManager.RestoreState(tombstone.ID); rollbackErr != nil {
49- log.Printf("[workspace] Rollback failed after split failure: %v", rollbackErr)
50- }
51- }
5248 return nil, err
5349 }
5450
55- if err := wm.treeValidator.ValidateTree(wm.root, "after_split"); err != nil {
56- log.Printf("[workspace] Tree validation failed after split: %v", err)
51+ if wm.debugLevel >= DebugBasic {
52+ if err := wm.treeValidator.ValidateTree(wm.root, "after_split"); err != nil {
53+ log.Printf("[workspace] Tree validation failed after split: %v", err)
54+ }
5755 }
5856
5957 // Tree rebalancing is only needed after close promotions. Splits have correct allocation from GTK.
@@ -62,24 +60,20 @@ func (wm *WorkspaceManager) SplitPane(target *paneNode, direction string) (*pane
6260 return newNode, nil
6361 }
6462
65- // Step 5: Not on main thread, marshal via IdleAdd
63+ // Step 3: Not on main thread, marshal via IdleAdd
6664 log.Printf("[workspace] Not on main thread, marshalling split via IdleAdd")
6765 var newNode *paneNode
6866 var splitErr error
6967 done := make(chan struct{})
7068
71- webkit.IdleAdd(func() bool {
69+ _ = webkit.IdleAdd(func() bool {
7270 newNode, splitErr = wm.splitNode(target, direction)
73- if splitErr != nil {
74- if tombstone != nil {
75- if rollbackErr := wm.stateTombstoneManager.RestoreState(tombstone.ID); rollbackErr != nil {
76- log.Printf("[workspace] Rollback failed after split failure: %v", rollbackErr)
71+ if splitErr == nil {
72+ if wm.debugLevel >= DebugBasic {
73+ if verr := wm.treeValidator.ValidateTree(wm.root, "after_split"); verr != nil {
74+ log.Printf("[workspace] Tree validation failed after split: %v", verr)
7775 }
7876 }
79- } else {
80- if verr := wm.treeValidator.ValidateTree(wm.root, "after_split"); verr != nil {
81- log.Printf("[workspace] Tree validation failed after split: %v", verr)
82- }
8377 }
8478 close(done)
8579 return false
@@ -105,15 +99,14 @@ func (wm *WorkspaceManager) ClosePane(node *paneNode) error {
10599 wm.paneCloseLogf("start bulletproof close node=%p", node)
106100 wm.dumpTreeState("before_close")
107101
108- // Step 1: Capture state tombstone for rollback
109- tombstone, err := wm.stateTombstoneManager.CaptureState("close")
110- if err != nil {
111- log.Printf("[workspace] Failed to capture state tombstone: %v", err)
112- }
113-
114- // Step 2: Validate tree invariants before operation
115- if err := wm.treeValidator.ValidateTree(wm.root, "before_close"); err != nil {
116- return fmt.Errorf("tree validation failed before close: %w", err)
102+ // Step 1: Validate tree invariants before operation (only in DebugBasic+)
103+ if wm.debugLevel >= DebugBasic {
104+ if err := wm.treeValidator.ValidateTree(wm.root, "before_close"); err != nil {
105+ if wm.debugLevel == DebugFull {
106+ return fmt.Errorf("tree validation failed before close: %w", err)
107+ }
108+ log.Printf("[workspace] WARNING: Tree validation failed before close but allowing operation: %v", err)
109+ }
117110 }
118111
119112 // Step 3: Check if this is a stacked pane and use enhanced lifecycle management
@@ -131,16 +124,13 @@ func (wm *WorkspaceManager) ClosePane(node *paneNode) error {
131124 if err != nil {
132125 wm.paneCloseLogf("closePane failed node=%p err=%v", node, err)
133126 wm.dumpTreeState("after_close_error")
134- if tombstone != nil {
135- if rollbackErr := wm.stateTombstoneManager.RestoreState(tombstone.ID); rollbackErr != nil {
136- log.Printf("[workspace] Rollback failed after close failure: %v", rollbackErr)
137- }
138- }
139127 return err
140128 }
141129
142- if err := wm.treeValidator.ValidateTree(wm.root, "after_close"); err != nil {
143- log.Printf("[workspace] Tree validation failed after close: %v", err)
130+ if wm.debugLevel >= DebugBasic {
131+ if err := wm.treeValidator.ValidateTree(wm.root, "after_close"); err != nil {
132+ log.Printf("[workspace] Tree validation failed after close: %v", err)
133+ }
144134 }
145135 wm.paneCloseLogf("closePane succeeded node=%p promoted=%p root=%p", node, promoted, wm.root)
146136 wm.dumpTreeState("after_close_success")
@@ -161,20 +151,17 @@ func (wm *WorkspaceManager) ClosePane(node *paneNode) error {
161151 var closeErr error
162152 done := make(chan struct{})
163153
164- webkit.IdleAdd(func() bool {
154+ _ = webkit.IdleAdd(func() bool {
165155 wm.paneCloseLogf("invoking closePane node=%p", node)
166156 promoted, closeErr = wm.closePane(node)
167157 if closeErr != nil {
168158 wm.paneCloseLogf("closePane failed node=%p err=%v", node, closeErr)
169159 wm.dumpTreeState("after_close_error")
170- if tombstone != nil {
171- if rollbackErr := wm.stateTombstoneManager.RestoreState(tombstone.ID); rollbackErr != nil {
172- log.Printf("[workspace] Rollback failed after close failure: %v", rollbackErr)
173- }
174- }
175160 } else {
176- if verr := wm.treeValidator.ValidateTree(wm.root, "after_close"); verr != nil {
177- log.Printf("[workspace] Tree validation failed after close: %v", verr)
161+ if wm.debugLevel >= DebugBasic {
162+ if verr := wm.treeValidator.ValidateTree(wm.root, "after_close"); verr != nil {
163+ log.Printf("[workspace] Tree validation failed after close: %v", verr)
164+ }
178165 }
179166 wm.paneCloseLogf("closePane succeeded node=%p promoted=%p root=%p", node, promoted, wm.root)
180167 wm.dumpTreeState("after_close_success")
@@ -207,67 +194,64 @@ func (wm *WorkspaceManager) StackPane(target *paneNode) (*paneNode, error) {
207194
208195 log.Printf("[workspace] Starting stack operation: target=%p", target)
209196
210- // Step 1: Validate stack operation constraints
211- validation := wm.geometryValidator.ValidateStackOperation(target)
212- if !validation.IsValid {
213- return nil, fmt.Errorf("stack validation failed: %s", validation.Reason)
214- }
215-
216- // Log if re-validation will be needed due to pending widget allocation
217- if validation.RequiresRevalidation {
218- log.Printf("[workspace] Stack validation passed with pending allocation - operation will proceed")
219- }
197+ // Step 1: Validate stack operation constraints (only in DebugBasic+)
198+ if wm.debugLevel >= DebugBasic {
199+ validation := wm.geometryValidator.ValidateStackOperation(target)
200+ if !validation.IsValid {
201+ if wm.debugLevel == DebugFull {
202+ return nil, fmt.Errorf("stack validation failed: %s", validation.Reason)
203+ }
204+ log.Printf("[workspace] WARNING: Stack validation failed but allowing operation: %s", validation.Reason)
205+ }
220206
221- // Step 2: Capture state tombstone for rollback
222- tombstone, err := wm.stateTombstoneManager.CaptureState("stack")
223- if err != nil {
224- log.Printf("[workspace] Failed to capture state tombstone: %v", err)
207+ // Log if re-validation will be needed due to pending widget allocation
208+ if validation.RequiresRevalidation {
209+ log.Printf("[workspace] Stack validation passed with pending allocation - operation will proceed")
210+ }
225211 }
226212
227- // Step 3: Validate tree invariants before operation
228- if err := wm.treeValidator.ValidateTree(wm.root, "before_stack"); err != nil {
229- return nil, fmt.Errorf("tree validation failed before stack: %w", err)
213+ // Step 2: Validate tree invariants before operation (only in DebugBasic+)
214+ if wm.debugLevel >= DebugBasic {
215+ if err := wm.treeValidator.ValidateTree(wm.root, "before_stack"); err != nil {
216+ if wm.debugLevel == DebugFull {
217+ return nil, fmt.Errorf("tree validation failed before stack: %w", err)
218+ }
219+ log.Printf("[workspace] WARNING: Tree validation failed before stack but allowing operation: %v", err)
220+ }
230221 }
231222
232- // Step 4: Execute directly if already on the GTK main thread
223+ // Step 3: Execute directly if already on the GTK main thread
233224 if webkit.IsMainThread() {
234225 log.Printf("[workspace] Already on main thread, executing stack directly")
235226 newNode, err := wm.stackedPaneManager.StackPane(target)
236227 if err != nil {
237- if tombstone != nil {
238- if rollbackErr := wm.stateTombstoneManager.RestoreState(tombstone.ID); rollbackErr != nil {
239- log.Printf("[workspace] Rollback failed after stack failure: %v", rollbackErr)
240- }
241- }
242228 return nil, err
243229 }
244230
245- if err := wm.treeValidator.ValidateTree(wm.root, "after_stack"); err != nil {
246- log.Printf("[workspace] Tree validation failed after stack: %v", err)
231+ if wm.debugLevel >= DebugBasic {
232+ if err := wm.treeValidator.ValidateTree(wm.root, "after_stack"); err != nil {
233+ log.Printf("[workspace] Tree validation failed after stack: %v", err)
234+ }
247235 }
248236
249237 log.Printf("[workspace] Stack operation completed successfully (direct execution): newNode=%p", newNode)
250238 return newNode, nil
251239 }
252240
253- // Step 5: Not on main thread, marshal via IdleAdd
241+ // Step 4: Not on main thread, marshal via IdleAdd
254242 log.Printf("[workspace] Not on main thread, marshalling stack via IdleAdd")
255243 var newNode *paneNode
256244 var stackErr error
257245 done := make(chan struct{})
258246
259- webkit.IdleAdd(func() bool {
247+ _ = webkit.IdleAdd(func() bool {
260248 newNode, stackErr = wm.stackedPaneManager.StackPane(target)
261- if stackErr != nil {
262- if tombstone != nil {
263- if rollbackErr := wm.stateTombstoneManager.RestoreState(tombstone.ID); rollbackErr != nil {
264- log.Printf("[workspace] Rollback failed after stack failure: %v", rollbackErr)
249+ if stackErr == nil {
250+ if wm.debugLevel >= DebugBasic {
251+ if verr := wm.treeValidator.ValidateTree(wm.root, "after_stack"); verr != nil {
252+ log.Printf("[workspace] Tree validation failed after stack: %v", verr)
265253 }
266254 }
267- } else {
268- if verr := wm.treeValidator.ValidateTree(wm.root, "after_stack"); verr != nil {
269- log.Printf("[workspace] Tree validation failed after stack: %v", verr)
270- }
271255 }
272256 close(done)
273257 return false
@@ -324,19 +308,11 @@ func (wm *WorkspaceManager) GetEnhancedStats() map[string]interface{} {
324308 stats["tree_validation"] = wm.treeValidator.GetValidationStats()
325309 }
326310
327-
328-
329311 if wm.treeRebalancer != nil {
330312 stats["tree_rebalancing"] = wm.treeRebalancer.GetRebalancingStats()
331313 }
332314
333- if wm.geometryValidator != nil {
334- stats["geometry"] = wm.geometryValidator.GetGeometryStats(wm.root)
335- }
336-
337- if wm.stateTombstoneManager != nil {
338- stats["tombstones"] = wm.stateTombstoneManager.GetTombstoneStats()
339- }
315+ // Geometry validator no longer tracks stats - simplified
340316
341317 return stats
342318 }
@@ -352,16 +328,7 @@ func (wm *WorkspaceManager) ValidateWorkspaceIntegrity() error {
352328 }
353329 }
354330
355- // Geometry validation
356- if wm.geometryValidator != nil {
357- results := wm.geometryValidator.ValidateWorkspaceLayout(wm.root)
358- for i, result := range results {
359- if !result.IsValid {
360- log.Printf("[workspace] Geometry validation failed for pane %d: %s", i, result.Reason)
361- // Don't fail the entire check for geometry issues
362- }
363- }
364- }
331+ // Geometry validation simplified - ValidateWorkspaceLayout removed
365332
366333 log.Printf("[workspace] Workspace integrity check completed successfully")
367334 return nil
@@ -371,7 +338,6 @@ func (wm *WorkspaceManager) ValidateWorkspaceIntegrity() error {
371338 func (wm *WorkspaceManager) ShutdownEnhancedComponents() {
372339 log.Printf("[workspace] Shutting down enhanced validation components")
373340
374-
375341 // Other components don't require explicit shutdown currently
376342 log.Printf("[workspace] Enhanced validation components shutdown complete")
377343 }
M internal/app/browser/workspace_pane_ops.go +270 -185
@@ -128,12 +128,15 @@ func (wm *WorkspaceManager) clonePaneState(_ *paneNode, target *paneNode) {
128128 target.pane.webView.RegisterURIChangedHandler(func(uri string) {
129129 if uri == blankURL {
130130 // Defer omnibox opening to allow page to fully initialize
131- webkit.IdleAdd(func() bool {
131+ wm.scheduleIdleGuarded(func() bool {
132+ if target == nil || target.pane == nil || target.pane.webView == nil {
133+ return false
134+ }
132135 if injErr := target.pane.webView.InjectScript("window.__dumber_omnibox?.open('omnibox');"); injErr != nil {
133136 log.Printf("[workspace] failed to open omnibox: %v", injErr)
134137 }
135138 return false // Remove idle callback
136- })
139+ }, target)
137140 }
138141 })
139142 }
@@ -325,7 +328,7 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa
325328 webkit.WidgetRealizeInContainer(webViewWidget)
326329 }
327330
328- if target.container == nil {
331+ if target.container == 0 {
329332 return errors.New("existing pane missing container")
330333 }
331334
@@ -360,28 +363,24 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa
360363 wm.safelyDetachControllersBeforeReparent(target)
361364
362365 // Detach existing container from its current GTK parent before inserting into new paned.
363- existingContainer := target.getContainerPtr()
366+ existingContainer := target.container
364367 if parent == nil {
365368 // Target is the root - remove it from the window
366369 log.Printf("[workspace] removing existing container=%#x from window", existingContainer)
367370 if wm.window != nil {
368371 wm.window.SetChild(0)
369372 }
370- } else if parent.container != nil {
373+ } else if parent.container != 0 {
371374 // Target has a parent paned - unparent it from there
372375 log.Printf("[workspace] unparenting existing container from parent paned")
373- err := parent.container.Execute(func(parentPtr uintptr) error {
374- log.Printf("[workspace] executing unparent: parentPtr=%#x", parentPtr)
376+ if webkit.WidgetIsValid(parent.container) {
377+ log.Printf("[workspace] executing unparent: parentPtr=%#x", parent.container)
375378 if parent.left == target {
376- webkit.PanedSetStartChild(parentPtr, 0)
379+ webkit.PanedSetStartChild(parent.container, 0)
377380 } else if parent.right == target {
378- webkit.PanedSetEndChild(parentPtr, 0)
381+ webkit.PanedSetEndChild(parent.container, 0)
379382 }
380- webkit.WidgetQueueAllocate(parentPtr)
381- return nil
382- })
383- if err != nil {
384- log.Printf("[workspace] error during parent unparent: %v", err)
383+ webkit.WidgetQueueAllocate(parent.container)
385384 }
386385 }
387386
@@ -411,22 +410,18 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa
411410 log.Printf("[workspace] paned set as window child: paned=%#x", paned)
412411 }
413412 } else {
414- err := parent.container.Execute(func(parentPtr uintptr) error {
415- log.Printf("[workspace] executing parent reparent: parentPtr=%#x, paned=%#x", parentPtr, paned)
413+ if webkit.WidgetIsValid(parent.container) {
414+ log.Printf("[workspace] executing parent reparent: parentPtr=%#x, paned=%#x", parent.container, paned)
416415 if parent.left == target {
417416 parent.left = split
418- webkit.PanedSetStartChild(parentPtr, paned)
417+ webkit.PanedSetStartChild(parent.container, paned)
419418 } else if parent.right == target {
420419 parent.right = split
421- webkit.PanedSetEndChild(parentPtr, paned)
420+ webkit.PanedSetEndChild(parent.container, paned)
422421 }
423- webkit.WidgetQueueAllocate(parentPtr)
422+ webkit.WidgetQueueAllocate(parent.container)
424423 webkit.WidgetQueueAllocate(paned)
425424 log.Printf("[workspace] paned inserted into parent successfully")
426- return nil
427- })
428- if err != nil {
429- log.Printf("[workspace] error during parent reparent: %v", err)
430425 }
431426 }
432427
@@ -446,9 +441,12 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa
446441 if target != nil && target.isStacked {
447442 reattachTargets = append(reattachTargets, target.stackedPanes...)
448443 }
449-
450- webkit.IdleAdd(func() bool {
451- if newContainer != 0 {
444+ guardNodes := append([]*paneNode{newLeaf}, reattachTargets...)
445+ wm.scheduleIdleGuarded(func() bool {
446+ if newLeaf == nil || !newLeaf.widgetValid {
447+ return false
448+ }
449+ if newContainer != 0 && webkit.WidgetIsValid(newContainer) {
452450 webkit.WidgetShow(newContainer)
453451 }
454452 // Attach GTK focus controller to new pane
@@ -457,7 +455,7 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa
457455 }
458456
459457 for _, candidate := range reattachTargets {
460- if candidate == nil {
458+ if candidate == nil || !candidate.widgetValid {
461459 continue
462460 }
463461 if candidate.pendingHoverReattach {
@@ -476,7 +474,7 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa
476474
477475 wm.SetActivePane(newLeaf, SourceSplit)
478476 return false
479- })
477+ }, guardNodes...)
480478
481479 return nil
482480 }
@@ -499,7 +497,7 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
499497
500498 // Determine what we're actually splitting
501499 var splitTarget *paneNode
502- var splitTargetContainer *SafeWidget
500+ var splitTargetContainer uintptr
503501
504502 if stackContainer != nil {
505503 // Case: splitting from inside a stack - we split around the entire stack
@@ -536,28 +534,26 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
536534 webkit.WidgetRealizeInContainer(newContainer)
537535
538536 // Use the determined split target container
539- if splitTargetContainer == nil {
537+ if splitTargetContainer == 0 {
540538 return nil, errors.New("split target missing container")
541539 }
542540
543541 // Only apply GTK operations if this is a simple widget (leaf pane)
544542 // For stacked containers, we need to be more careful
545543 if splitTarget.isLeaf {
546- splitTargetContainer.Execute(func(containerPtr uintptr) error {
547- webkit.WidgetSetHExpand(containerPtr, true)
548- webkit.WidgetSetVExpand(containerPtr, true)
549- webkit.WidgetRealizeInContainer(containerPtr)
550- return nil
551- })
544+ if webkit.WidgetIsValid(splitTargetContainer) {
545+ webkit.WidgetSetHExpand(splitTargetContainer, true)
546+ webkit.WidgetSetVExpand(splitTargetContainer, true)
547+ webkit.WidgetRealizeInContainer(splitTargetContainer)
548+ }
552549 } else if splitTarget.isStacked {
553550 // Stack containers need the same setup as regular panes for proper splitting
554- splitTargetContainer.Execute(func(containerPtr uintptr) error {
555- webkit.WidgetSetHExpand(containerPtr, true)
556- webkit.WidgetSetVExpand(containerPtr, true)
557- webkit.WidgetRealizeInContainer(containerPtr)
558- log.Printf("[workspace] configured stack wrapper for split: %#x", containerPtr)
559- return nil
560- })
551+ if webkit.WidgetIsValid(splitTargetContainer) {
552+ webkit.WidgetSetHExpand(splitTargetContainer, true)
553+ webkit.WidgetSetVExpand(splitTargetContainer, true)
554+ webkit.WidgetRealizeInContainer(splitTargetContainer)
555+ log.Printf("[workspace] configured stack wrapper for split: %#x", splitTargetContainer)
556+ }
561557 }
562558
563559 orientation, existingFirst := mapDirection(direction)
@@ -588,6 +584,17 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
588584
589585 parent := split.parent
590586
587+ // Clear CSS classes before reparenting to avoid GTK bloom filter corruption
588+ if splitTargetContainer != 0 && webkit.WidgetIsValid(splitTargetContainer) {
589+ // Remove active border class if present (prevents GTK bloom filter corruption during unparent)
590+ if webkit.WidgetHasCSSClass(splitTargetContainer, activePaneClass) {
591+ webkit.WidgetRemoveCSSClass(splitTargetContainer, activePaneClass)
592+ }
593+ }
594+
595+ // Prevent GTK from auto-dropping controllers while the widget is temporarily unparented.
596+ wm.safelyDetachControllersBeforeReparent(splitTarget)
597+
591598 // Detach split target container from its current GTK parent before inserting into new paned.
592599 if parent == nil {
593600 // Split target is the root - remove it from the window
@@ -596,25 +603,23 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
596603 wm.window.SetChild(0)
597604 }
598605 // For root widgets, only unparent if they actually have a GTK parent
599- splitTargetContainer.Execute(func(containerPtr uintptr) error {
600- if webkit.WidgetGetParent(containerPtr) != 0 {
601- webkit.WidgetUnparent(containerPtr)
606+ if webkit.WidgetIsValid(splitTargetContainer) {
607+ if webkit.WidgetGetParent(splitTargetContainer) != 0 {
608+ webkit.WidgetUnparent(splitTargetContainer)
602609 }
603- return nil
604- })
605- } else if parent.container != nil {
610+ }
611+ } else if parent.container != 0 {
606612 // Split target has a parent paned - remove it (automatically unparents in GTK4)
607613 log.Printf("[workspace] unparenting split target container=%#x from parent paned=%#x", splitTargetContainer, parent.container)
608- parent.container.Execute(func(panedPtr uintptr) error {
614+ if webkit.WidgetIsValid(parent.container) {
609615 if parent.left == splitTarget {
610- webkit.PanedSetStartChild(panedPtr, 0)
616+ webkit.PanedSetStartChild(parent.container, 0)
611617 } else if parent.right == splitTarget {
612- webkit.PanedSetEndChild(panedPtr, 0)
618+ webkit.PanedSetEndChild(parent.container, 0)
613619 }
614620 // GTK4 automatically unparents when we set paned child to 0 - no manual unparent needed
615- webkit.WidgetQueueAllocate(panedPtr)
616- return nil
617- })
621+ webkit.WidgetQueueAllocate(parent.container)
622+ }
618623 }
619624
620625 // Set up the tree structure first
@@ -644,19 +649,17 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
644649
645650 // Add both containers to the new paned
646651 if existingFirst {
647- splitTargetContainer.Execute(func(splitPtr uintptr) error {
648- webkit.PanedSetStartChild(paned, splitPtr)
652+ if webkit.WidgetIsValid(splitTargetContainer) {
653+ webkit.PanedSetStartChild(paned, splitTargetContainer)
649654 webkit.PanedSetEndChild(paned, newContainer)
650- log.Printf("[workspace] added splitTarget=%#x as start child, new=%#x as end child", splitPtr, newContainer)
651- return nil
652- })
655+ log.Printf("[workspace] added splitTarget=%#x as start child, new=%#x as end child", splitTargetContainer, newContainer)
656+ }
653657 } else {
654- splitTargetContainer.Execute(func(splitPtr uintptr) error {
658+ if webkit.WidgetIsValid(splitTargetContainer) {
655659 webkit.PanedSetStartChild(paned, newContainer)
656- webkit.PanedSetEndChild(paned, splitPtr)
657- log.Printf("[workspace] added new=%#x as start child, splitTarget=%#x as end child", newContainer, splitPtr)
658- return nil
659- })
660+ webkit.PanedSetEndChild(paned, splitTargetContainer)
661+ log.Printf("[workspace] added new=%#x as start child, splitTarget=%#x as end child", newContainer, splitTargetContainer)
662+ }
660663 }
661664
662665 // Attach the new paned to its parent
@@ -667,17 +670,16 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
667670 log.Printf("[workspace] paned set as window child: paned=%#x", paned)
668671 }
669672 } else {
670- parent.container.Execute(func(parentPtr uintptr) error {
673+ if webkit.WidgetIsValid(parent.container) {
671674 if parent.left == split {
672- webkit.PanedSetStartChild(parentPtr, paned)
675+ webkit.PanedSetStartChild(parent.container, paned)
673676 } else if parent.right == split {
674- webkit.PanedSetEndChild(parentPtr, paned)
677+ webkit.PanedSetEndChild(parent.container, paned)
675678 }
676- webkit.WidgetQueueAllocate(parentPtr)
679+ webkit.WidgetQueueAllocate(parent.container)
677680 webkit.WidgetQueueAllocate(paned)
678- log.Printf("[workspace] paned inserted into parent=%#x", parentPtr)
679- return nil
680- })
681+ log.Printf("[workspace] paned inserted into parent=%#x", parent.container)
682+ }
681683 }
682684
683685 webkit.WidgetShow(paned)
@@ -705,9 +707,12 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
705707 if splitTarget != nil && splitTarget.isStacked {
706708 reattachTargets = append(reattachTargets, splitTarget.stackedPanes...)
707709 }
708-
709- webkit.IdleAdd(func() bool {
710- if newContainer != 0 {
710+ guardNodes := append([]*paneNode{newLeaf}, reattachTargets...)
711+ wm.scheduleIdleGuarded(func() bool {
712+ if newLeaf == nil || !newLeaf.widgetValid {
713+ return false
714+ }
715+ if newContainer != 0 && webkit.WidgetIsValid(newContainer) {
711716 webkit.WidgetShow(newContainer)
712717 }
713718 // Attach GTK focus controller to new pane
@@ -716,7 +721,7 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
716721 }
717722
718723 for _, candidate := range reattachTargets {
719- if candidate == nil {
724+ if candidate == nil || !candidate.widgetValid {
720725 continue
721726 }
722727 if candidate.pendingHoverReattach {
@@ -734,7 +739,7 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
734739 }
735740 wm.SetActivePane(newLeaf, SourceSplit)
736741 return false
737- })
742+ }, guardNodes...)
738743
739744 return newLeaf, nil
740745 }
@@ -758,21 +763,6 @@ func (wm *WorkspaceManager) nextCleanupGeneration() uint {
758763 return wm.cleanupCounter
759764 }
760765
761-// ensureWidgets validates and recovers SafeWidget instances
762-func (wm *WorkspaceManager) ensureWidgets(nodes ...*paneNode) {
763- for _, n := range nodes {
764- if n == nil || n.container == nil {
765- continue
766- }
767- if n.container.IsValid() {
768- continue
769- }
770- ptr := n.container.Ptr()
771- typeInfo := n.container.typeInfo
772- n.container = wm.widgetRegistry.Register(ptr, typeInfo)
773- }
774-}
775-
776766 // promoteSibling promotes a sibling node to replace its parent in the tree
777767 func (wm *WorkspaceManager) promoteSibling(grand *paneNode, parent *paneNode, sibling *paneNode) {
778768 if grand == nil {
@@ -798,53 +788,141 @@ func (wm *WorkspaceManager) swapContainers(grand *paneNode, sibling *paneNode) {
798788 }
799789 return
800790 }
801- grand.container.Execute(func(gPtr uintptr) error {
802- sibling.container.Execute(func(sPtr uintptr) error {
803- // GTK4 auto-unparents when setting paned child
804- // But we should verify the widget is ready for reparenting
805- if parent := webkit.WidgetGetParent(sPtr); parent != 0 && parent != gPtr {
806- log.Printf("[workspace] widget %#x has unexpected parent %#x, expected %#x or 0", sPtr, parent, gPtr)
791+ // Clear CSS classes before reparenting to avoid GTK bloom filter corruption
792+ if sibling.container != 0 && webkit.WidgetIsValid(sibling.container) {
793+ if webkit.WidgetHasCSSClass(sibling.container, activePaneClass) {
794+ webkit.WidgetRemoveCSSClass(sibling.container, activePaneClass)
795+ }
796+ }
797+
798+ if webkit.WidgetIsValid(grand.container) && webkit.WidgetIsValid(sibling.container) {
799+ // GTK4 PanedSetStartChild/EndChild auto-unparent from current parent
800+ // Log current parent for debugging, but this is normal GTK4 behavior
801+ if parent := webkit.WidgetGetParent(sibling.container); parent != 0 && parent != grand.container {
802+ log.Printf("[workspace] widget %#x reparenting from %#x to %#x (GTK4 will auto-unparent)", sibling.container, parent, grand.container)
803+ }
804+
805+ if grand.left == sibling {
806+ webkit.PanedSetStartChild(grand.container, sibling.container)
807+ log.Printf("[workspace] swapContainers: set widget %#x as start child of paned %#x", sibling.container, grand.container)
808+ } else {
809+ webkit.PanedSetEndChild(grand.container, sibling.container)
810+ log.Printf("[workspace] swapContainers: set widget %#x as end child of paned %#x", sibling.container, grand.container)
811+ }
812+ webkit.WidgetQueueAllocate(grand.container)
813+ }
814+}
815+
816+// cascadePromotion handles the case where a paned node has only one child
817+// This happens when closing a pane leaves the grandparent with only one child
818+func (wm *WorkspaceManager) cascadePromotion(singleChildPaned *paneNode) {
819+ if singleChildPaned == nil {
820+ return
821+ }
822+
823+ // Find the single child
824+ var onlyChild *paneNode
825+ if singleChildPaned.left != nil && singleChildPaned.right == nil {
826+ onlyChild = singleChildPaned.left
827+ } else if singleChildPaned.right != nil && singleChildPaned.left == nil {
828+ onlyChild = singleChildPaned.right
829+ } else {
830+ // Either no children or two children - nothing to cascade
831+ return
832+ }
833+
834+ log.Printf("[workspace] Cascading promotion: replacing paned %p with its only child %p", singleChildPaned, onlyChild)
835+
836+ greatGrandparent := singleChildPaned.parent
837+
838+ // Update tree structure
839+ onlyChild.parent = greatGrandparent
840+ if greatGrandparent == nil {
841+ // Single child becomes new root
842+ wm.root = onlyChild
843+ log.Printf("[workspace] Only child %p promoted to root", onlyChild)
844+
845+ // Attach to window
846+ if onlyChild.container != 0 && webkit.WidgetIsValid(onlyChild.container) {
847+ // Unparent from old container if needed
848+ if parent := webkit.WidgetGetParent(onlyChild.container); parent != 0 {
849+ webkit.WidgetUnparent(onlyChild.container)
807850 }
808851
809- if grand.left == sibling {
810- webkit.PanedSetStartChild(gPtr, sPtr)
811- log.Printf("[workspace] swapContainers: set widget %#x as start child of paned %#x", sPtr, gPtr)
852+ wm.window.SetChild(onlyChild.container)
853+ webkit.WidgetSetHExpand(onlyChild.container, true)
854+ webkit.WidgetSetVExpand(onlyChild.container, true)
855+ webkit.WidgetQueueAllocate(onlyChild.container)
856+ webkit.WidgetShow(onlyChild.container)
857+ }
858+
859+ // Cleanup the now-orphaned paned
860+ wm.decommissionParent(singleChildPaned, wm.cleanupCounter)
861+ } else {
862+ // Replace paned in great-grandparent
863+ if greatGrandparent.left == singleChildPaned {
864+ greatGrandparent.left = onlyChild
865+ } else {
866+ greatGrandparent.right = onlyChild
867+ }
868+
869+ log.Printf("[workspace] Only child %p attached to great-grandparent %p", onlyChild, greatGrandparent)
870+
871+ // Reparent widget in GTK
872+ if onlyChild.container != 0 && webkit.WidgetIsValid(onlyChild.container) && greatGrandparent.container != 0 && webkit.WidgetIsValid(greatGrandparent.container) {
873+ // Unparent from old container
874+ if parent := webkit.WidgetGetParent(onlyChild.container); parent != 0 {
875+ webkit.WidgetUnparent(onlyChild.container)
876+ }
877+
878+ // Attach to great-grandparent
879+ if greatGrandparent.left == onlyChild {
880+ webkit.PanedSetStartChild(greatGrandparent.container, onlyChild.container)
812881 } else {
813- webkit.PanedSetEndChild(gPtr, sPtr)
814- log.Printf("[workspace] swapContainers: set widget %#x as end child of paned %#x", sPtr, gPtr)
882+ webkit.PanedSetEndChild(greatGrandparent.container, onlyChild.container)
815883 }
816- webkit.WidgetQueueAllocate(gPtr)
817- return nil
818- })
819- return nil
820- })
884+
885+ webkit.WidgetSetHExpand(onlyChild.container, true)
886+ webkit.WidgetSetVExpand(onlyChild.container, true)
887+ webkit.WidgetQueueAllocate(onlyChild.container)
888+ webkit.WidgetQueueAllocate(greatGrandparent.container)
889+ }
890+
891+ // Cleanup the now-orphaned paned
892+ wm.decommissionParent(singleChildPaned, wm.cleanupCounter)
893+
894+ // Check if great-grandparent now also has only one child (recursive case)
895+ if greatGrandparent != nil && ((greatGrandparent.left == nil) != (greatGrandparent.right == nil)) {
896+ log.Printf("[workspace] Great-grandparent %p also has only one child, continuing cascade", greatGrandparent)
897+ wm.cascadePromotion(greatGrandparent)
898+ }
899+ }
821900 }
822901
823902 // attachRoot attaches a node as the new window root
824903 func (wm *WorkspaceManager) attachRoot(root *paneNode) {
825- if root == nil || root.container == nil || wm.window == nil {
904+ if root == nil || root.container == 0 || wm.window == nil {
826905 return
827906 }
828- root.container.Execute(func(ptr uintptr) error {
907+ if webkit.WidgetIsValid(root.container) {
829908 // Check if widget has a parent and unparent it first
830- if parent := webkit.WidgetGetParent(ptr); parent != 0 {
831- log.Printf("[workspace] unparenting widget %#x from parent %#x before window attach", ptr, parent)
832- webkit.WidgetUnparent(ptr)
909+ if parent := webkit.WidgetGetParent(root.container); parent != 0 {
910+ log.Printf("[workspace] unparenting widget %#x from parent %#x before window attach", root.container, parent)
911+ webkit.WidgetUnparent(root.container)
833912 }
834913
835914 // Now safe to set as window child
836- wm.window.SetChild(ptr)
837- webkit.WidgetQueueAllocate(ptr)
838- webkit.WidgetShow(ptr)
915+ wm.window.SetChild(root.container)
916+ webkit.WidgetQueueAllocate(root.container)
917+ webkit.WidgetShow(root.container)
839918
840919 // Verify attachment succeeded
841- if finalParent := webkit.WidgetGetParent(ptr); finalParent == 0 {
842- log.Printf("[workspace] WARNING: widget %#x has no parent after SetChild", ptr)
920+ if finalParent := webkit.WidgetGetParent(root.container); finalParent == 0 {
921+ log.Printf("[workspace] WARNING: widget %#x has no parent after SetChild", root.container)
843922 } else {
844- log.Printf("[workspace] attachRoot successful: widget %#x now child of %#x", ptr, finalParent)
923+ log.Printf("[workspace] attachRoot successful: widget %#x now child of %#x", root.container, finalParent)
845924 }
846- return nil
847- })
925+ }
848926 }
849927
850928 // cleanupPane safely cleans up a pane node with generation tracking
@@ -856,21 +934,28 @@ func (wm *WorkspaceManager) cleanupPane(node *paneNode, generation uint) {
856934 return
857935 }
858936
937+ wm.cancelIdleHandles(node)
938+
859939 node.widgetValid = false
860940 node.cleanupGeneration = generation
861941
862942 if node.pane != nil {
863- node.pane.CleanupFromWorkspace(wm)
864- if node.pane.webView != nil {
865- node.pane.webView.Destroy()
943+ pane := node.pane
944+ pane.Cleanup()
945+ pane.CleanupFromWorkspace(wm)
946+ if pane.webView != nil {
947+ log.Printf("[workspace] releasing WebView reference: %p", pane.webView)
948+ pane.webView = nil
866949 }
950+ node.pane = nil
867951 }
868952
869- if node.container != nil {
870- node.container.Invalidate()
871- node.container = nil
953+ if node.container != 0 {
954+ // Container widget will be destroyed by GTK when unparented
955+ node.container = 0
872956 }
873957
958+ node.pendingIdleHandles = nil
874959 node.parent = nil
875960 node.left = nil
876961 node.right = nil
@@ -882,30 +967,11 @@ func (wm *WorkspaceManager) decommissionParent(parent *paneNode, generation uint
882967 return
883968 }
884969
885- // For branch nodes (paneds), ensure the GTK widget is destroyed properly
886- if !parent.isLeaf && parent.container != nil {
887- parent.container.Execute(func(ptr uintptr) error {
888- // Clear any remaining children first to ensure clean destruction
889- // Note: Children may already be reparented during promotion, so verify parent relationship
890- if webkit.IsPaned(ptr) {
891- startChild := webkit.PanedGetStartChild(ptr)
892- endChild := webkit.PanedGetEndChild(ptr)
893-
894- // Only clear children that are still valid AND still parented to this paned
895- // If the child was reparented during swapContainers, its parent will be different
896- if startChild != 0 && webkit.WidgetIsValid(startChild) && webkit.WidgetGetParent(startChild) == ptr {
897- webkit.PanedSetStartChild(ptr, 0)
898- }
899- if endChild != 0 && webkit.WidgetIsValid(endChild) && webkit.WidgetGetParent(endChild) == ptr {
900- webkit.PanedSetEndChild(ptr, 0)
901- }
902- log.Printf("[workspace] cleared paned children before destruction: %#x", ptr)
903- }
904- // Hook widget destruction
905- webkit.WidgetHookDestroy(ptr)
906- log.Printf("[workspace] destroyed paned widget: %#x", ptr)
907- return nil
908- })
970+ // For branch nodes (paneds), destroy the widget
971+ // Children have already been unparented during promotion/close, no need to clear them
972+ if !parent.isLeaf && parent.container != 0 && webkit.WidgetIsValid(parent.container) {
973+ webkit.WidgetHookDestroy(parent.container)
974+ log.Printf("[workspace] destroyed paned widget: %#x", parent.container)
909975 }
910976
911977 wm.cleanupPane(parent, generation)
@@ -937,9 +1003,6 @@ func (wm *WorkspaceManager) cleanupAndExit(node *paneNode) (*paneNode, error) {
9371003 log.Printf("[workspace] closing final pane; exiting browser")
9381004 wm.cleanupPane(node, wm.nextCleanupGeneration())
9391005 wm.detachHover(node)
940- if err := node.pane.webView.Destroy(); err != nil {
941- log.Printf("[workspace] failed to destroy webview: %v", err)
942- }
9431006 webkit.QuitMainLoop()
9441007 return nil, nil
9451008 }
@@ -967,6 +1030,10 @@ func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) {
9671030 return wm.closeStackedPaneCompat(node)
9681031 }
9691032
1033+ // Ensure we detach hover/focus controllers before the widget hierarchy changes.
1034+ wm.detachHover(node)
1035+ wm.detachFocus(node)
1036+
9701037 // STEP 3: Handle trivial exit cases
9711038 if ctx.remaining == 1 {
9721039 return wm.cleanupAndExit(node)
@@ -975,21 +1042,49 @@ func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) {
9751042 return wm.promoteNewRoot(ctx, node)
9761043 }
9771044
978- // STEP 4: Promote sibling in-place
1045+ // STEP 4: Unparent closed pane FIRST to avoid leaving paned with single child
9791046 parent := node.parent
9801047 sibling := wm.getSibling(node)
9811048 grandparent := parent.parent
982- wm.ensureWidgets(grandparent, parent, sibling)
9831049
1050+ // Unparent the closed pane's container from the paned BEFORE promoting sibling
1051+ if node.container != 0 && webkit.WidgetIsValid(node.container) {
1052+ if containerParent := webkit.WidgetGetParent(node.container); containerParent == parent.container {
1053+ if parent.left == node {
1054+ webkit.PanedSetStartChild(parent.container, 0)
1055+ } else if parent.right == node {
1056+ webkit.PanedSetEndChild(parent.container, 0)
1057+ }
1058+ log.Printf("[workspace] unparented closed pane container: %#x", node.container)
1059+ }
1060+ }
1061+
1062+ // STEP 5: Promote sibling in-place (tree structure)
9841063 wm.promoteSibling(grandparent, parent, sibling)
9851064
986- // STEP 5: GTK updates leverage auto-unparenting
1065+ // STEP 6: GTK handles all reparenting automatically
1066+ // PanedSetStartChild/EndChild and window.SetChild auto-unparent widgets
9871067 wm.swapContainers(grandparent, sibling)
9881068
989- // STEP 6: Cleanup & focus
1069+ // STEP 7: Check if grandparent now has only one child (cascade promotion needed)
1070+ if grandparent != nil && ((grandparent.left == nil) != (grandparent.right == nil)) {
1071+ // Grandparent has exactly one child - it should be promoted too
1072+ log.Printf("[workspace] Grandparent %p now has only one child, cascading promotion", grandparent)
1073+ wm.cascadePromotion(grandparent)
1074+ }
1075+
1076+ // STEP 8: Cleanup & focus
9901077 wm.cleanupPane(node, ctx.Generation())
9911078 wm.decommissionParent(parent, ctx.Generation())
992- wm.setFocusToLeaf(sibling)
1079+ if sibling != nil {
1080+ wm.scheduleIdleGuarded(func() bool {
1081+ if sibling == nil || !sibling.widgetValid {
1082+ return false
1083+ }
1084+ wm.setFocusToLeaf(sibling)
1085+ return false
1086+ }, sibling)
1087+ }
9931088
9941089 return sibling, nil
9951090 }
@@ -1031,24 +1126,19 @@ func (wm *WorkspaceManager) closeStackedPaneCompat(node *paneNode) (*paneNode, e
10311126 }
10321127
10331128 // Update GTK widgets
1034- stack.parent.container.Execute(func(pPtr uintptr) error {
1035- remaining.container.Execute(func(rPtr uintptr) error {
1036- if stack.parent.left == remaining {
1037- webkit.PanedSetStartChild(pPtr, rPtr)
1038- } else {
1039- webkit.PanedSetEndChild(pPtr, rPtr)
1040- }
1041- return nil
1042- })
1043- return nil
1044- })
1129+ if webkit.WidgetIsValid(stack.parent.container) && webkit.WidgetIsValid(remaining.container) {
1130+ if stack.parent.left == remaining {
1131+ webkit.PanedSetStartChild(stack.parent.container, remaining.container)
1132+ } else {
1133+ webkit.PanedSetEndChild(stack.parent.container, remaining.container)
1134+ }
1135+ }
10451136 } else {
10461137 // Stack was root
10471138 wm.root = remaining
1048- remaining.container.Execute(func(ptr uintptr) error {
1049- wm.window.SetChild(ptr)
1050- return nil
1051- })
1139+ if webkit.WidgetIsValid(remaining.container) {
1140+ wm.window.SetChild(remaining.container)
1141+ }
10521142 }
10531143
10541144 // Cleanup stack container
@@ -1063,21 +1153,16 @@ func (wm *WorkspaceManager) closeStackedPaneCompat(node *paneNode) (*paneNode, e
10631153 }
10641154
10651155 // Remove widget from GTK box
1066- stack.container.Execute(func(boxPtr uintptr) error {
1067- node.container.Execute(func(nodePtr uintptr) error {
1068- webkit.BoxRemove(boxPtr, nodePtr)
1069- return nil
1070- })
1071- return nil
1072- })
1156+ if webkit.WidgetIsValid(stack.container) && webkit.WidgetIsValid(node.container) {
1157+ webkit.BoxRemove(stack.container, node.container)
1158+ }
10731159
10741160 // Show new active pane
10751161 if stack.activeStackIndex >= 0 {
10761162 active := stack.stackedPanes[stack.activeStackIndex]
1077- active.container.Execute(func(ptr uintptr) error {
1078- webkit.WidgetShow(ptr)
1079- return nil
1080- })
1163+ if webkit.WidgetIsValid(active.container) {
1164+ webkit.WidgetShow(active.container)
1165+ }
10811166 }
10821167
10831168 // Cleanup closed pane
M internal/app/browser/workspace_popup.go +15 -6
@@ -154,12 +154,15 @@ func (wm *WorkspaceManager) HandlePopup(source *webkit.WebView, url string) *web
154154 log.Printf("[workspace] Closing popup pane")
155155 // Brief delay to allow any final redirects to complete
156156 time.AfterFunc(200*time.Millisecond, func() {
157- webkit.IdleAdd(func() bool {
157+ wm.scheduleIdleGuarded(func() bool {
158+ if node == nil || !node.widgetValid {
159+ return false
160+ }
158161 if err := wm.ClosePane(node); err != nil {
159162 log.Printf("[workspace] Failed to close popup pane: %v", err)
160163 }
161164 return false
162- })
165+ }, node)
163166 })
164167 } else {
165168 log.Printf("[workspace] Could not find popup node for close handler")
@@ -408,12 +411,15 @@ func (wm *WorkspaceManager) handleIntentAsPopup(sourceNode *paneNode, url string
408411
409412 if n := wm.viewToNode[newView]; n != nil && n.isPopup {
410413 time.AfterFunc(200*time.Millisecond, func() {
411- webkit.IdleAdd(func() bool {
414+ wm.scheduleIdleGuarded(func() bool {
415+ if n == nil || !n.widgetValid {
416+ return false
417+ }
412418 if err := wm.ClosePane(n); err != nil {
413419 log.Printf("[workspace] Failed to close popup pane: %v", err)
414420 }
415421 return false
416- })
422+ }, n)
417423 })
418424 }
419425 })
@@ -494,12 +500,15 @@ func (wm *WorkspaceManager) configureRelatedPopup(sourceNode *paneNode, webView
494500 log.Printf("[workspace] Popup requesting close via window.close()")
495501 if n := wm.viewToNode[related]; n != nil && n.isPopup {
496502 time.AfterFunc(200*time.Millisecond, func() {
497- webkit.IdleAdd(func() bool {
503+ wm.scheduleIdleGuarded(func() bool {
504+ if n == nil || !n.widgetValid {
505+ return false
506+ }
498507 if err := wm.ClosePane(n); err != nil {
499508 log.Printf("[workspace] Failed to close popup pane: %v", err)
500509 }
501510 return false
502- })
511+ }, n)
503512 })
504513 }
505514 })
M internal/app/browser/workspace_stack_lifecycle.go +18 -422
@@ -1,21 +1,19 @@
1-// workspace_stack_lifecycle.go - Enhanced stack container lifecycle management for bulletproof operations
1+// workspace_stack_lifecycle.go - Stack pane lifecycle coordination without SafeWidget wrappers
22 package browser
33
44 import (
55 "errors"
66 "fmt"
77 "log"
8-
9- "github.com/bnema/dumber/pkg/webkit"
108 )
119
12-// StackLifecycleManager manages the complete lifecycle of stack containers
10+// StackLifecycleManager manages the lifecycle hooks around stacked pane operations.
1311 type StackLifecycleManager struct {
1412 wm *WorkspaceManager
1513 treeValidator *TreeValidator
1614 }
1715
18-// NewStackLifecycleManager creates a new stack lifecycle manager
16+// NewStackLifecycleManager creates a new stack lifecycle manager.
1917 func NewStackLifecycleManager(wm *WorkspaceManager, treeValidator *TreeValidator) *StackLifecycleManager {
2018 return &StackLifecycleManager{
2119 wm: wm,
@@ -23,429 +21,44 @@ func NewStackLifecycleManager(wm *WorkspaceManager, treeValidator *TreeValidator
2321 }
2422 }
2523
26-// CloseStackedPaneWithLifecycle handles closing a stacked pane with proper lifecycle management
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.
2727 func (slm *StackLifecycleManager) CloseStackedPaneWithLifecycle(node *paneNode) error {
28- if node.parent == nil || !node.parent.isStacked {
29- return errors.New("node is not part of a stacked pane")
28+ if node == nil {
29+ return errors.New("node is nil")
3030 }
3131
3232 stackNode := node.parent
33+ if stackNode == nil || !stackNode.isStacked {
34+ return errors.New("node is not part of a stacked pane")
35+ }
3336
3437 log.Printf("[stack-lifecycle] Closing stacked pane: node=%p stack=%p stackSize=%d",
3538 node, stackNode, len(stackNode.stackedPanes))
3639
37- // Validate initial state
3840 if err := slm.validateStackState(stackNode, "before close"); err != nil {
3941 return fmt.Errorf("invalid stack state before close: %w", err)
4042 }
4143
42- // Find the index of the node to be closed
43- nodeIndex := -1
44- for i, stackedPane := range stackNode.stackedPanes {
45- if stackedPane == node {
46- nodeIndex = i
47- break
48- }
49- }
50-
51- if nodeIndex == -1 {
52- return errors.New("node not found in stack")
53- }
54-
55- // Determine what to do based on remaining stack size
56- remainingSize := len(stackNode.stackedPanes) - 1
57-
58- switch remainingSize {
59- case 0:
60- // Last pane in stack - remove entire stack container
61- return slm.removeEmptyStackContainer(stackNode, node)
62- case 1:
63- // Two panes, closing one - convert remaining pane back to regular pane
64- return slm.unstackLastPane(stackNode, node, nodeIndex)
65- default:
66- // Multiple panes remain - remove pane from stack
67- return slm.removePaneFromStack(stackNode, node, nodeIndex)
68- }
69-}
70-
71-// removeEmptyStackContainer removes a stack container when the last pane is closed
72-func (slm *StackLifecycleManager) removeEmptyStackContainer(stackNode *paneNode, closingNode *paneNode) error {
73- log.Printf("[stack-lifecycle] Removing empty stack container: stack=%p", stackNode)
74-
75- // Clean up the closing pane first
76- slm.cleanupPaneResources(closingNode)
77-
78- // Remove stack from tree structure
79- if err := slm.removeStackFromTree(stackNode); err != nil {
80- return fmt.Errorf("failed to remove stack from tree: %w", err)
81- }
82-
83- // Validate final state
84- if slm.treeValidator != nil {
85- if err := slm.treeValidator.ValidateTree(slm.wm.root, "remove_empty_stack"); err != nil {
86- log.Printf("[stack-lifecycle] Tree validation failed after removing empty stack: %v", err)
87- }
88- }
89-
90- log.Printf("[stack-lifecycle] Successfully removed empty stack container")
91- return nil
92-}
93-
94-// unstackLastPane converts a two-pane stack back to a regular pane when one is closed
95-func (slm *StackLifecycleManager) unstackLastPane(stackNode *paneNode, closingNode *paneNode, closingIndex int) error {
96- log.Printf("[stack-lifecycle] Unstacking last pane: stack=%p closingIndex=%d", stackNode, closingIndex)
97-
98- // Find the remaining pane
99- var remainingPane *paneNode
100- for i, pane := range stackNode.stackedPanes {
101- if i != closingIndex && pane != closingNode {
102- remainingPane = pane
103- break
104- }
105- }
106-
107- if remainingPane == nil {
108- return errors.New("could not find remaining pane in stack")
109- }
110-
111- // Clean up the closing pane
112- slm.cleanupPaneResources(closingNode)
113-
114- // Convert remaining pane back to regular pane
115- if err := slm.convertStackToRegularPane(stackNode, remainingPane); err != nil {
116- return fmt.Errorf("failed to convert stack to regular pane: %w", err)
117- }
118-
119- // Validate final state
120- if slm.treeValidator != nil {
121- if err := slm.treeValidator.ValidateTree(slm.wm.root, "unstack_last"); err != nil {
122- log.Printf("[stack-lifecycle] Tree validation failed after unstacking: %v", err)
123- }
124- }
125-
126- log.Printf("[stack-lifecycle] Successfully unstacked last pane")
127- return nil
128-}
129-
130-// removePaneFromStack removes a pane from a multi-pane stack
131-func (slm *StackLifecycleManager) removePaneFromStack(stackNode *paneNode, closingNode *paneNode, closingIndex int) error {
132- log.Printf("[stack-lifecycle] Removing pane from stack: pane=%p index=%d", closingNode, closingIndex)
133-
134- // Clean up the closing pane
135- slm.cleanupPaneResources(closingNode)
136-
137- // Remove pane widgets from stack container
138- if err := slm.removePaneWidgetsFromStack(stackNode, closingNode); err != nil {
139- return fmt.Errorf("failed to remove pane widgets: %w", err)
44+ if slm.wm == nil || slm.wm.stackedPaneManager == nil {
45+ return errors.New("stacked pane manager not available")
14046 }
14147
142- // Update stack data structures
143- slm.updateStackAfterRemoval(stackNode, closingIndex)
144-
145- // Update stack visibility after removal
146- if slm.wm.stackedPaneManager != nil {
147- slm.wm.stackedPaneManager.UpdateStackVisibility(stackNode)
48+ if err := slm.wm.stackedPaneManager.CloseStackedPane(node); err != nil {
49+ return err
14850 }
14951
150- // Validate final state
15152 if slm.treeValidator != nil {
152- if err := slm.treeValidator.ValidateTree(slm.wm.root, "remove_from_stack"); err != nil {
153- log.Printf("[stack-lifecycle] Tree validation failed after removing from stack: %v", err)
154- }
155- }
156-
157- log.Printf("[stack-lifecycle] Successfully removed pane from stack")
158- return nil
159-}
160-
161-// removeStackFromTree removes the stack container from the tree structure
162-func (slm *StackLifecycleManager) removeStackFromTree(stackNode *paneNode) error {
163- if stackNode == nil {
164- return errors.New("stack node is nil")
165- }
166-
167- parent := stackNode.parent
168-
169- // Unparent stack container when still valid
170- if stackNode.container != nil {
171- if !stackNode.container.IsValid() {
172- log.Printf("[stack-lifecycle] stack container already invalid, skipping unparent: node=%p", stackNode)
173- slm.invalidateSafeWidget(&stackNode.container)
174- slm.invalidateSafeWidget(&stackNode.stackWrapper)
175- } else {
176- // Directly unparent the widget
177- if err := stackNode.container.Execute(func(ptr uintptr) error {
178- if webkit.WidgetGetParent(ptr) != 0 {
179- webkit.WidgetUnparent(ptr)
180- }
181- return nil
182- }); err != nil {
183- return fmt.Errorf("failed to unparent stack container: %w", err)
184- }
185-
186- // Cleanup stack container and wrapper
187- slm.invalidateSafeWidget(&stackNode.container)
188- slm.invalidateSafeWidget(&stackNode.stackWrapper)
189- }
190- }
191-
192- // Update tree structure
193- if parent == nil {
194- // Stack was root - clear root
195- slm.wm.root = nil
196- slm.wm.mainPane = nil
197- } else {
198- // Remove stack from parent
199- if parent.left == stackNode {
200- parent.left = nil
201- } else if parent.right == stackNode {
202- parent.right = nil
203- }
204-
205- // If parent now has only one child, it needs restructuring
206- if (parent.left == nil) != (parent.right == nil) {
207- // Parent has exactly one child - promote the child
208- var remainingChild *paneNode
209- if parent.left != nil {
210- remainingChild = parent.left
211- } else {
212- remainingChild = parent.right
213- }
214-
215- if err := slm.promoteChildNode(parent, remainingChild); err != nil {
216- return fmt.Errorf("failed to promote child node: %w", err)
217- }
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)
21855 }
21956 }
22057
22158 return nil
22259 }
22360
224-// convertStackToRegularPane converts a stack container to a regular pane
225-func (slm *StackLifecycleManager) convertStackToRegularPane(stackNode *paneNode, remainingPane *paneNode) error {
226- parent := stackNode.parent
227-
228- // Remove remaining pane from stack container
229- if remainingPane.container != nil && remainingPane.container.IsValid() {
230- if err := remainingPane.container.Execute(func(ptr uintptr) error {
231- if webkit.WidgetGetParent(ptr) != 0 {
232- webkit.WidgetUnparent(ptr)
233- }
234- return nil
235- }); err != nil {
236- return fmt.Errorf("failed to unparent remaining pane: %w", err)
237- }
238- }
239-
240- // Update remaining pane properties
241- remainingPane.parent = parent
242- remainingPane.isStacked = false
243-
244- // Clear stack-related fields
245- remainingPane.titleBar = nil
246-
247- // Update tree structure
248- if parent == nil {
249- // Stack was root - make remaining pane the new root
250- slm.wm.root = remainingPane
251- slm.wm.mainPane = remainingPane
252-
253- // Reparent to window
254- if remainingPane.container != nil && remainingPane.container.IsValid() && slm.wm.window != nil {
255- if err := remainingPane.container.Execute(func(ptr uintptr) error {
256- slm.wm.window.SetChild(ptr)
257- webkit.WidgetQueueAllocate(ptr)
258- webkit.WidgetShow(ptr)
259- return nil
260- }); err != nil {
261- return fmt.Errorf("failed to reparent to window: %w", err)
262- }
263- }
264- } else {
265- // Replace stack with remaining pane in parent
266- if parent.left == stackNode {
267- parent.left = remainingPane
268- } else if parent.right == stackNode {
269- parent.right = remainingPane
270- }
271-
272- // Reparent to parent container
273- if remainingPane.container != nil && remainingPane.container.IsValid() && parent.container != nil && parent.container.IsValid() {
274- isStart := parent.left == remainingPane
275- parentPtr := parent.container.Ptr()
276-
277- if err := remainingPane.container.Execute(func(ptr uintptr) error {
278- // Unparent first if needed
279- if webkit.WidgetGetParent(ptr) != 0 {
280- webkit.WidgetUnparent(ptr)
281- }
282-
283- // Reparent to parent
284- if isStart {
285- webkit.PanedSetStartChild(parentPtr, ptr)
286- } else {
287- webkit.PanedSetEndChild(parentPtr, ptr)
288- }
289- return nil
290- }); err != nil {
291- return fmt.Errorf("failed to reparent to parent: %w", err)
292- }
293- }
294- }
295-
296- return nil
297-}
298-
299-// removePaneWidgetsFromStack removes a pane's widgets from the stack container
300-func (slm *StackLifecycleManager) removePaneWidgetsFromStack(stackNode *paneNode, closingNode *paneNode) error {
301- // Remove title bar if it exists
302- if closingNode.titleBar != nil {
303- if !closingNode.titleBar.IsValid() {
304- log.Printf("[stack-lifecycle] title bar already invalid, skipping removal: pane=%p", closingNode)
305- slm.invalidateSafeWidget(&closingNode.titleBar)
306- } else {
307- if err := closingNode.titleBar.Execute(func(ptr uintptr) error {
308- if webkit.WidgetGetParent(ptr) != 0 {
309- webkit.WidgetUnparent(ptr)
310- }
311- return nil
312- }); err != nil {
313- return fmt.Errorf("failed to unparent titlebar: %w", err)
314- }
315- }
316- }
317-
318- // Remove container
319- if closingNode.container != nil {
320- if !closingNode.container.IsValid() {
321- log.Printf("[stack-lifecycle] pane container already invalid, skipping removal: pane=%p", closingNode)
322- slm.invalidateSafeWidget(&closingNode.container)
323- } else {
324- if err := closingNode.container.Execute(func(ptr uintptr) error {
325- if webkit.WidgetGetParent(ptr) != 0 {
326- webkit.WidgetUnparent(ptr)
327- }
328- return nil
329- }); err != nil {
330- return fmt.Errorf("failed to unparent container: %w", err)
331- }
332- }
333- }
334-
335- // Cleanup removed pane widgets
336- slm.invalidateSafeWidget(&closingNode.titleBar)
337- slm.invalidateSafeWidget(&closingNode.container)
338-
339- return nil
340-}
341-
342-// updateStackAfterRemoval updates stack data structures after removing a pane
343-func (slm *StackLifecycleManager) updateStackAfterRemoval(stackNode *paneNode, removedIndex int) {
344- // Remove from stackedPanes slice
345- stackNode.stackedPanes = append(
346- stackNode.stackedPanes[:removedIndex],
347- stackNode.stackedPanes[removedIndex+1:]...,
348- )
349-
350- // Update active index if necessary
351- if stackNode.activeStackIndex >= removedIndex {
352- if stackNode.activeStackIndex > 0 {
353- stackNode.activeStackIndex--
354- }
355- }
356-
357- // Ensure active index is valid
358- if stackNode.activeStackIndex >= len(stackNode.stackedPanes) {
359- stackNode.activeStackIndex = len(stackNode.stackedPanes) - 1
360- }
361- if stackNode.activeStackIndex < 0 && len(stackNode.stackedPanes) > 0 {
362- stackNode.activeStackIndex = 0
363- }
364-
365- log.Printf("[stack-lifecycle] Updated stack: remaining=%d activeIndex=%d",
366- len(stackNode.stackedPanes), stackNode.activeStackIndex)
367-}
368-
369-// promoteChildNode promotes a child node to replace its parent
370-func (slm *StackLifecycleManager) promoteChildNode(parent *paneNode, child *paneNode) error {
371- grandparent := parent.parent
372-
373- // Update child's parent pointer
374- child.parent = grandparent
375-
376- // Update grandparent or root reference
377- if grandparent == nil {
378- // Child becomes new root
379- slm.wm.root = child
380- if child.isLeaf {
381- slm.wm.mainPane = child
382- }
383-
384- // Reparent to window
385- if child.container != nil && child.container.IsValid() && slm.wm.window != nil {
386- if err := child.container.Execute(func(ptr uintptr) error {
387- slm.wm.window.SetChild(ptr)
388- webkit.WidgetQueueAllocate(ptr)
389- webkit.WidgetShow(ptr)
390- return nil
391- }); err != nil {
392- return fmt.Errorf("failed to promote to root: %w", err)
393- }
394- }
395- } else {
396- // Update grandparent's child pointer
397- if grandparent.left == parent {
398- grandparent.left = child
399- } else if grandparent.right == parent {
400- grandparent.right = child
401- }
402-
403- // Reparent widget to grandparent
404- if child.container != nil && child.container.IsValid() && grandparent.container != nil && grandparent.container.IsValid() {
405- isStart := grandparent.left == child
406- grandparentPtr := grandparent.container.Ptr()
407-
408- if err := child.container.Execute(func(ptr uintptr) error {
409- // Unparent first if needed
410- if webkit.WidgetGetParent(ptr) != 0 {
411- webkit.WidgetUnparent(ptr)
412- }
413-
414- // Reparent to grandparent
415- if isStart {
416- webkit.PanedSetStartChild(grandparentPtr, ptr)
417- } else {
418- webkit.PanedSetEndChild(grandparentPtr, ptr)
419- }
420- return nil
421- }); err != nil {
422- return fmt.Errorf("failed to promote child: %w", err)
423- }
424- }
425- }
426-
427- return nil
428-}
429-
430-// cleanupPaneResources cleans up resources associated with a pane
431-func (slm *StackLifecycleManager) cleanupPaneResources(node *paneNode) {
432- // Detach hover
433- if slm.wm != nil {
434- slm.wm.detachHover(node)
435- }
436-
437- // Clean up from workspace
438- if node.pane != nil {
439- node.pane.CleanupFromWorkspace(slm.wm)
440- }
441-
442- // Clear references
443- node.parent = nil
444- node.titleBar = nil
445- node.stackWrapper = nil
446-}
447-
448-// validateStackState validates the current state of a stack
61+// validateStackState validates the current state of a stack before we perform operations.
44962 func (slm *StackLifecycleManager) validateStackState(stackNode *paneNode, operation string) error {
45063 if stackNode == nil {
45164 return errors.New("stack node is nil")
@@ -464,7 +77,6 @@ func (slm *StackLifecycleManager) validateStackState(stackNode *paneNode, operat
46477 stackNode.activeStackIndex, len(stackNode.stackedPanes))
46578 }
46679
467- // Check that all stacked panes have this node as parent
46880 for i, pane := range stackNode.stackedPanes {
46981 if pane == nil {
47082 return fmt.Errorf("stacked pane at index %d is nil", i)
@@ -478,19 +90,3 @@ func (slm *StackLifecycleManager) validateStackState(stackNode *paneNode, operat
47890 operation, len(stackNode.stackedPanes), stackNode.activeStackIndex)
47991 return nil
48092 }
481-
482-// invalidateSafeWidget invalidates and unregisters a widget safely
483-func (slm *StackLifecycleManager) invalidateSafeWidget(widget **SafeWidget) {
484- if widget == nil || *widget == nil {
485- return
486- }
487-
488- ptr := (*widget).Ptr()
489- (*widget).Invalidate()
490-
491- if slm.wm != nil && slm.wm.widgetRegistry != nil && ptr != 0 {
492- slm.wm.widgetRegistry.Unregister(ptr)
493- }
494-
495- *widget = nil
496-}
D internal/app/browser/workspace_state_tombstone.go +0 -705
@@ -1,705 +0,0 @@
1-// workspace_state_tombstone.go - State tombstones for rollback capability inspired by Zellij
2-package browser
3-
4-import (
5- "bytes"
6- "compress/gzip"
7- "encoding/gob"
8- "fmt"
9- "log"
10- "sync"
11- "time"
12-
13- "github.com/bnema/dumber/pkg/webkit"
14-)
15-
16-// StateTombstone captures the complete state of the workspace at a point in time
17-type StateTombstone struct {
18- ID string
19- Timestamp time.Time
20- Operation string
21- TreeState *TombstoneTreeSnapshot
22- WidgetState *WidgetSnapshot
23- FocusState *FocusSnapshot
24- StackState *StackSnapshot
25- Compressed bool
26- Size int
27-}
28-
29-// TombstoneTreeSnapshot captures the tree structure for state tombstones
30-type TombstoneTreeSnapshot struct {
31- RootID string
32- MainPaneID string
33- Nodes map[string]*NodeSnapshot
34- ParentLinks map[string]string // child_id -> parent_id
35- ChildLinks map[string][2]string // parent_id -> [left_id, right_id]
36- NodeOrder []string // Traversal order for reconstruction
37-}
38-
39-// NodeSnapshot captures the state of a single node
40-type NodeSnapshot struct {
41- ID string
42- IsLeaf bool
43- IsStacked bool
44- IsPopup bool
45- WindowType int // webkit.WindowType
46- Orientation int // webkit.Orientation
47- HasPane bool
48- PaneID string
49- StackInfo *StackInfo
50-}
51-
52-// StackInfo captures stack-specific state
53-type StackInfo struct {
54- StackedPaneIDs []string
55- ActiveStackIndex int
56-}
57-
58-// WidgetSnapshot captures widget state information
59-type WidgetSnapshot struct {
60- Widgets map[string]*WidgetInfo
61- Allocations map[string]*AllocationInfo
62- CSSClasses map[string][]string
63- Visibility map[string]bool
64- ParentChild map[string]string // widget_id -> parent_id
65-}
66-
67-// WidgetInfo captures widget information
68-type WidgetInfo struct {
69- ID string
70- Type string
71- Pointer uintptr
72- IsValid bool
73-}
74-
75-// AllocationInfo captures widget allocation
76-type AllocationInfo struct {
77- X int
78- Y int
79- Width int
80- Height int
81-}
82-
83-// FocusSnapshot captures focus state
84-type FocusSnapshot struct {
85- ActiveNodeID string
86- LastFocusTime time.Time
87- FocusSource int // FocusSource
88- HoverTokens map[string]uintptr
89-}
90-
91-// StackSnapshot captures all stack states
92-type StackSnapshot struct {
93- StackContainers map[string]*StackContainerInfo
94-}
95-
96-// StackContainerInfo captures stack container state
97-type StackContainerInfo struct {
98- NodeID string
99- StackedPaneIDs []string
100- ActiveIndex int
101- TitleBars map[string]string // pane_id -> title
102- Visibility map[string]bool // pane_id -> visible
103-}
104-
105-var gobRegisterOnce sync.Once
106-
107-func registerTombstoneGobTypes() {
108- gobRegisterOnce.Do(func() {
109- gob.Register(&TombstoneTreeSnapshot{})
110- gob.Register(&NodeSnapshot{})
111- gob.Register(&WidgetSnapshot{})
112- gob.Register(&FocusSnapshot{})
113- gob.Register(&StackSnapshot{})
114- gob.Register(&StackInfo{})
115- gob.Register(&WidgetInfo{})
116- gob.Register(&AllocationInfo{})
117- gob.Register(&StackContainerInfo{})
118- })
119-}
120-
121-// StateTombstoneManager manages state tombstones for rollback operations
122-type StateTombstoneManager struct {
123- tombstones map[string]*StateTombstone
124- history []*StateTombstone
125- maxTombstones int
126- maxHistory int
127- compress bool
128- mu sync.RWMutex
129- wm *WorkspaceManager
130-}
131-
132-// NewStateTombstoneManager creates a new state tombstone manager
133-func NewStateTombstoneManager(wm *WorkspaceManager) *StateTombstoneManager {
134- // Ensure concrete tombstone types are available for gob encoding/decoding.
135- registerTombstoneGobTypes()
136-
137- return &StateTombstoneManager{
138- tombstones: make(map[string]*StateTombstone),
139- history: make([]*StateTombstone, 0, 50),
140- maxTombstones: 20,
141- maxHistory: 50,
142- compress: true,
143- wm: wm,
144- }
145-}
146-
147-// CaptureState captures the current workspace state as a tombstone
148-func (stm *StateTombstoneManager) CaptureState(operation string) (*StateTombstone, error) {
149- stm.mu.Lock()
150- defer stm.mu.Unlock()
151-
152- tombstoneID := fmt.Sprintf("%s_%d", operation, time.Now().UnixNano())
153-
154- log.Printf("[tombstone] Capturing state for operation: %s", operation)
155-
156- // Capture tree state
157- treeState, err := stm.captureTreeState()
158- if err != nil {
159- return nil, fmt.Errorf("failed to capture tree state: %w", err)
160- }
161-
162- // Capture widget state
163- widgetState, err := stm.captureWidgetState()
164- if err != nil {
165- return nil, fmt.Errorf("failed to capture widget state: %w", err)
166- }
167-
168- // Capture focus state
169- focusState := stm.captureFocusState()
170-
171- // Capture stack state
172- stackState := stm.captureStackState()
173-
174- tombstone := &StateTombstone{
175- ID: tombstoneID,
176- Timestamp: time.Now(),
177- Operation: operation,
178- TreeState: treeState,
179- WidgetState: widgetState,
180- FocusState: focusState,
181- StackState: stackState,
182- Compressed: false,
183- }
184-
185- // Compress if enabled
186- if stm.compress {
187- if err := stm.compressTombstone(tombstone); err != nil {
188- log.Printf("[tombstone] Failed to compress tombstone: %v", err)
189- }
190- }
191-
192- // Calculate size
193- tombstone.Size = stm.calculateTombstoneSize(tombstone)
194-
195- // Store tombstone
196- stm.tombstones[tombstoneID] = tombstone
197-
198- // Add to history
199- stm.history = append(stm.history, tombstone)
200-
201- // Cleanup old tombstones
202- stm.cleanupOldTombstones()
203-
204- log.Printf("[tombstone] Captured state: id=%s, size=%d bytes", tombstoneID, tombstone.Size)
205-
206- return tombstone, nil
207-}
208-
209-// captureTreeState captures the current tree structure
210-func (stm *StateTombstoneManager) captureTreeState() (*TombstoneTreeSnapshot, error) {
211- snapshot := &TombstoneTreeSnapshot{
212- Nodes: make(map[string]*NodeSnapshot),
213- ParentLinks: make(map[string]string),
214- ChildLinks: make(map[string][2]string),
215- NodeOrder: make([]string, 0),
216- }
217-
218- if stm.wm.root != nil {
219- snapshot.RootID = stm.nodeID(stm.wm.root)
220- }
221-
222- if stm.wm.mainPane != nil {
223- snapshot.MainPaneID = stm.nodeID(stm.wm.mainPane)
224- }
225-
226- // Traverse tree and capture all nodes
227- if stm.wm.root != nil {
228- stm.captureNodeRecursive(stm.wm.root, snapshot)
229- }
230-
231- return snapshot, nil
232-}
233-
234-// captureNodeRecursive recursively captures node information
235-func (stm *StateTombstoneManager) captureNodeRecursive(node *paneNode, snapshot *TombstoneTreeSnapshot) {
236- if node == nil {
237- return
238- }
239-
240- nodeID := stm.nodeID(node)
241- snapshot.NodeOrder = append(snapshot.NodeOrder, nodeID)
242-
243- // Capture node information
244- nodeSnap := &NodeSnapshot{
245- ID: nodeID,
246- IsLeaf: node.isLeaf,
247- IsStacked: node.isStacked,
248- IsPopup: node.isPopup,
249- WindowType: int(node.windowType),
250- Orientation: int(node.orientation),
251- }
252-
253- // Capture pane information
254- if node.pane != nil {
255- nodeSnap.HasPane = true
256- nodeSnap.PaneID = stm.paneID(node.pane)
257- }
258-
259- // Capture stack information
260- if node.isStacked {
261- nodeSnap.StackInfo = &StackInfo{
262- StackedPaneIDs: make([]string, len(node.stackedPanes)),
263- ActiveStackIndex: node.activeStackIndex,
264- }
265-
266- for i, stackedPane := range node.stackedPanes {
267- if stackedPane != nil {
268- nodeSnap.StackInfo.StackedPaneIDs[i] = stm.nodeID(stackedPane)
269- }
270- }
271- }
272-
273- snapshot.Nodes[nodeID] = nodeSnap
274-
275- // Capture parent-child relationships
276- if node.parent != nil {
277- snapshot.ParentLinks[nodeID] = stm.nodeID(node.parent)
278- }
279-
280- if node.left != nil || node.right != nil {
281- var leftID, rightID string
282- if node.left != nil {
283- leftID = stm.nodeID(node.left)
284- }
285- if node.right != nil {
286- rightID = stm.nodeID(node.right)
287- }
288- snapshot.ChildLinks[nodeID] = [2]string{leftID, rightID}
289- }
290-
291- // Recurse for children
292- stm.captureNodeRecursive(node.left, snapshot)
293- stm.captureNodeRecursive(node.right, snapshot)
294-
295- // Recurse for stacked panes
296- if node.isStacked {
297- for _, stackedPane := range node.stackedPanes {
298- stm.captureNodeRecursive(stackedPane, snapshot)
299- }
300- }
301-}
302-
303-// captureWidgetState captures widget state information
304-func (stm *StateTombstoneManager) captureWidgetState() (*WidgetSnapshot, error) {
305- snapshot := &WidgetSnapshot{
306- Widgets: make(map[string]*WidgetInfo),
307- Allocations: make(map[string]*AllocationInfo),
308- CSSClasses: make(map[string][]string),
309- Visibility: make(map[string]bool),
310- ParentChild: make(map[string]string),
311- }
312-
313- // Capture widget information from all nodes
314- if stm.wm.root != nil {
315- stm.captureWidgetRecursive(stm.wm.root, snapshot)
316- }
317-
318- return snapshot, nil
319-}
320-
321-// captureWidgetRecursive recursively captures widget information
322-func (stm *StateTombstoneManager) captureWidgetRecursive(node *paneNode, snapshot *WidgetSnapshot) {
323- if node == nil {
324- return
325- }
326-
327- nodeID := stm.nodeID(node)
328-
329- // Capture container widget
330- if node.container != nil {
331- widgetID := fmt.Sprintf("%s_container", nodeID)
332- snapshot.Widgets[widgetID] = &WidgetInfo{
333- ID: widgetID,
334- Type: "container",
335- Pointer: node.container.Ptr(),
336- IsValid: node.container.IsValid(),
337- }
338-
339- // Capture allocation and visibility
340- node.container.Execute(func(ptr uintptr) error {
341- allocation := webkit.WidgetGetAllocation(ptr)
342- snapshot.Allocations[widgetID] = &AllocationInfo{
343- X: allocation.X,
344- Y: allocation.Y,
345- Width: allocation.Width,
346- Height: allocation.Height,
347- }
348-
349- snapshot.Visibility[widgetID] = webkit.WidgetGetVisible(ptr)
350-
351- // Capture CSS classes
352- cssClasses := stm.extractCSSClasses(ptr)
353- if len(cssClasses) > 0 {
354- snapshot.CSSClasses[widgetID] = cssClasses
355- }
356-
357- return nil
358- })
359- }
360-
361- // Capture title bar widget
362- if node.titleBar != nil {
363- widgetID := fmt.Sprintf("%s_titlebar", nodeID)
364- snapshot.Widgets[widgetID] = &WidgetInfo{
365- ID: widgetID,
366- Type: "titlebar",
367- Pointer: node.titleBar.Ptr(),
368- IsValid: node.titleBar.IsValid(),
369- }
370-
371- node.titleBar.Execute(func(ptr uintptr) error {
372- snapshot.Visibility[widgetID] = webkit.WidgetGetVisible(ptr)
373- return nil
374- })
375- }
376-
377- // Capture stack wrapper widget
378- if node.stackWrapper != nil {
379- widgetID := fmt.Sprintf("%s_stackwrapper", nodeID)
380- snapshot.Widgets[widgetID] = &WidgetInfo{
381- ID: widgetID,
382- Type: "stackwrapper",
383- Pointer: node.stackWrapper.Ptr(),
384- IsValid: node.stackWrapper.IsValid(),
385- }
386- }
387-
388- // Recurse for children
389- stm.captureWidgetRecursive(node.left, snapshot)
390- stm.captureWidgetRecursive(node.right, snapshot)
391-
392- // Recurse for stacked panes
393- if node.isStacked {
394- for _, stackedPane := range node.stackedPanes {
395- stm.captureWidgetRecursive(stackedPane, snapshot)
396- }
397- }
398-}
399-
400-// captureFocusState captures the current focus state
401-func (stm *StateTombstoneManager) captureFocusState() *FocusSnapshot {
402- snapshot := &FocusSnapshot{
403- HoverTokens: make(map[string]uintptr),
404- }
405-
406- // Capture active node
407- if activeNode := stm.wm.GetActiveNode(); activeNode != nil {
408- snapshot.ActiveNodeID = stm.nodeID(activeNode)
409- }
410-
411- // Capture focus timing information
412- snapshot.LastFocusTime = stm.wm.lastFocusTime
413- if stm.wm.lastFocusTarget != nil {
414- // Additional focus target info could be captured here
415- }
416-
417- // Capture hover tokens (simplified - in practice you'd need access to hover token map)
418- // This would require extending the workspace manager to expose hover tokens
419-
420- return snapshot
421-}
422-
423-// captureStackState captures all stack container states
424-func (stm *StateTombstoneManager) captureStackState() *StackSnapshot {
425- snapshot := &StackSnapshot{
426- StackContainers: make(map[string]*StackContainerInfo),
427- }
428-
429- // Find all stacked nodes and capture their state
430- if stm.wm.root != nil {
431- stm.captureStackRecursive(stm.wm.root, snapshot)
432- }
433-
434- return snapshot
435-}
436-
437-// captureStackRecursive recursively captures stack states
438-func (stm *StateTombstoneManager) captureStackRecursive(node *paneNode, snapshot *StackSnapshot) {
439- if node == nil {
440- return
441- }
442-
443- if node.isStacked {
444- nodeID := stm.nodeID(node)
445- stackInfo := &StackContainerInfo{
446- NodeID: nodeID,
447- StackedPaneIDs: make([]string, len(node.stackedPanes)),
448- ActiveIndex: node.activeStackIndex,
449- TitleBars: make(map[string]string),
450- Visibility: make(map[string]bool),
451- }
452-
453- // Capture stacked pane IDs
454- for i, stackedPane := range node.stackedPanes {
455- if stackedPane != nil {
456- paneID := stm.nodeID(stackedPane)
457- stackInfo.StackedPaneIDs[i] = paneID
458-
459- // Capture title and visibility
460- if stackedPane.pane != nil && stackedPane.pane.webView != nil {
461- title := stackedPane.pane.webView.GetTitle()
462- stackInfo.TitleBars[paneID] = title
463- }
464-
465- if stackedPane.container != nil {
466- stackedPane.container.Execute(func(ptr uintptr) error {
467- stackInfo.Visibility[paneID] = webkit.WidgetGetVisible(ptr)
468- return nil
469- })
470- }
471- }
472- }
473-
474- snapshot.StackContainers[nodeID] = stackInfo
475- }
476-
477- // Recurse for children
478- stm.captureStackRecursive(node.left, snapshot)
479- stm.captureStackRecursive(node.right, snapshot)
480-
481- // Recurse for stacked panes
482- if node.isStacked {
483- for _, stackedPane := range node.stackedPanes {
484- stm.captureStackRecursive(stackedPane, snapshot)
485- }
486- }
487-}
488-
489-// RestoreState restores the workspace to a previously captured state
490-func (stm *StateTombstoneManager) RestoreState(tombstoneID string) error {
491- stm.mu.RLock()
492- tombstone, exists := stm.tombstones[tombstoneID]
493- stm.mu.RUnlock()
494-
495- if !exists {
496- return fmt.Errorf("tombstone %s not found", tombstoneID)
497- }
498-
499- log.Printf("[tombstone] Restoring state from tombstone: %s (operation: %s)",
500- tombstoneID, tombstone.Operation)
501-
502- // Decompress if needed
503- if tombstone.Compressed {
504- if err := stm.decompressTombstone(tombstone); err != nil {
505- return fmt.Errorf("failed to decompress tombstone: %w", err)
506- }
507- }
508-
509- // This is a complex operation that would require:
510- // 1. Destroying current tree structure
511- // 2. Recreating nodes from snapshot
512- // 3. Restoring widget hierarchy
513- // 4. Restoring focus state
514- // 5. Validating the restored state
515-
516- // For now, we'll implement a basic restoration framework
517- return stm.performRestore(tombstone)
518-}
519-
520-// performRestore performs the actual state restoration
521-func (stm *StateTombstoneManager) performRestore(tombstone *StateTombstone) error {
522- // This is a placeholder for the complex restoration logic
523- // In a full implementation, this would:
524-
525- // 1. Create a transaction for the entire restoration
526- // 2. Clear current state while preserving essential references
527- // 3. Recreate the tree structure from the snapshot
528- // 4. Restore widget hierarchy and properties
529- // 5. Restore focus and stack states
530- // 6. Validate the restored state
531-
532- log.Printf("[tombstone] Restoration framework executed for tombstone %s", tombstone.ID)
533- return fmt.Errorf("state restoration not yet fully implemented")
534-}
535-
536-// Helper methods
537-
538-// nodeID generates a unique ID for a pane node
539-func (stm *StateTombstoneManager) nodeID(node *paneNode) string {
540- if node == nil {
541- return ""
542- }
543- return fmt.Sprintf("node_%p", node)
544-}
545-
546-// paneID generates a unique ID for a browser pane
547-func (stm *StateTombstoneManager) paneID(pane *BrowserPane) string {
548- if pane == nil {
549- return ""
550- }
551- return fmt.Sprintf("pane_%p", pane)
552-}
553-
554-// extractCSSClasses extracts CSS classes from a widget (simplified)
555-func (stm *StateTombstoneManager) extractCSSClasses(ptr uintptr) []string {
556- // This would need to be implemented using GTK introspection
557- // to extract the actual CSS classes from the widget
558- var classes []string
559-
560- // Check for known classes
561- knownClasses := []string{
562- basePaneClass,
563- multiPaneClass,
564- activePaneClass,
565- outlinePaneClass,
566- stackContainerClass,
567- }
568-
569- for _, class := range knownClasses {
570- if webkit.WidgetHasCSSClass(ptr, class) {
571- classes = append(classes, class)
572- }
573- }
574-
575- return classes
576-}
577-
578-// compressTombstone compresses a tombstone to save memory
579-func (stm *StateTombstoneManager) compressTombstone(tombstone *StateTombstone) error {
580- if tombstone.Compressed {
581- return nil
582- }
583-
584- // Serialize the tombstone data
585- var buf bytes.Buffer
586- encoder := gob.NewEncoder(&buf)
587-
588- data := map[string]interface{}{
589- "tree": tombstone.TreeState,
590- "widget": tombstone.WidgetState,
591- "focus": tombstone.FocusState,
592- "stack": tombstone.StackState,
593- }
594-
595- if err := encoder.Encode(data); err != nil {
596- return err
597- }
598-
599- // Compress with gzip
600- var compressed bytes.Buffer
601- gzipWriter := gzip.NewWriter(&compressed)
602- if _, err := gzipWriter.Write(buf.Bytes()); err != nil {
603- return err
604- }
605- if err := gzipWriter.Close(); err != nil {
606- return err
607- }
608-
609- // Store compressed data (this is simplified - in practice you'd store the compressed data)
610- tombstone.Compressed = true
611-
612- log.Printf("[tombstone] Compressed tombstone %s: %d -> %d bytes",
613- tombstone.ID, buf.Len(), compressed.Len())
614-
615- return nil
616-}
617-
618-// decompressTombstone decompresses a tombstone
619-func (stm *StateTombstoneManager) decompressTombstone(tombstone *StateTombstone) error {
620- if !tombstone.Compressed {
621- return nil
622- }
623-
624- // This would decompress the data stored during compression
625- tombstone.Compressed = false
626-
627- log.Printf("[tombstone] Decompressed tombstone %s", tombstone.ID)
628- return nil
629-}
630-
631-// calculateTombstoneSize estimates the size of a tombstone
632-func (stm *StateTombstoneManager) calculateTombstoneSize(tombstone *StateTombstone) int {
633- // This is a rough estimate - in practice you'd calculate more precisely
634- size := 0
635-
636- if tombstone.TreeState != nil {
637- size += len(tombstone.TreeState.Nodes) * 100 // Rough estimate per node
638- }
639-
640- if tombstone.WidgetState != nil {
641- size += len(tombstone.WidgetState.Widgets) * 50 // Rough estimate per widget
642- }
643-
644- return size
645-}
646-
647-// cleanupOldTombstones removes old tombstones to manage memory
648-func (stm *StateTombstoneManager) cleanupOldTombstones() {
649- // Remove excess tombstones
650- if len(stm.tombstones) > stm.maxTombstones {
651- // Remove oldest tombstones first
652- oldestTime := time.Now()
653- var oldestID string
654-
655- for id, tombstone := range stm.tombstones {
656- if tombstone.Timestamp.Before(oldestTime) {
657- oldestTime = tombstone.Timestamp
658- oldestID = id
659- }
660- }
661-
662- if oldestID != "" {
663- delete(stm.tombstones, oldestID)
664- log.Printf("[tombstone] Removed old tombstone: %s", oldestID)
665- }
666- }
667-
668- // Clean up history
669- if len(stm.history) > stm.maxHistory {
670- stm.history = stm.history[len(stm.history)-stm.maxHistory:]
671- }
672-}
673-
674-// GetTombstoneStats returns statistics about tombstones
675-func (stm *StateTombstoneManager) GetTombstoneStats() map[string]interface{} {
676- stm.mu.RLock()
677- defer stm.mu.RUnlock()
678-
679- totalSize := 0
680- compressedCount := 0
681-
682- for _, tombstone := range stm.tombstones {
683- totalSize += tombstone.Size
684- if tombstone.Compressed {
685- compressedCount++
686- }
687- }
688-
689- return map[string]interface{}{
690- "total_tombstones": len(stm.tombstones),
691- "history_length": len(stm.history),
692- "total_size_bytes": totalSize,
693- "compressed_count": compressedCount,
694- "compression_rate": float64(compressedCount) / float64(max(len(stm.tombstones), 1)),
695- "max_tombstones": stm.maxTombstones,
696- "compression_enabled": stm.compress,
697- }
698-}
699-
700-func max(a, b int) int {
701- if a > b {
702- return a
703- }
704- return b
705-}
M internal/app/browser/workspace_tree_rebalancer.go +28 -791
@@ -1,31 +1,24 @@
1-// workspace_tree_rebalancer.go - Tree rebalancing for optimal split pane layout
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.
27 package browser
38
4-import (
5- "fmt"
6- "log"
7- "math"
8- "time"
9-
10- "github.com/bnema/dumber/pkg/webkit"
11-)
12-
13-// TreeRebalancer provides algorithms for rebalancing the binary tree after operations
9+// TreeRebalancer is currently a no-op shim kept for API compatibility.
1410 type TreeRebalancer struct {
15- wm *WorkspaceManager
16- treeValidator *TreeValidator
17- maxImbalance int // Maximum allowed imbalance before rebalancing
18- enabled bool // Whether rebalancing is enabled
11+ enabled bool
1912 }
2013
21-// RebalanceOperation represents a tree rebalancing operation
14+// RebalanceOperation describes an attempted rebalance step (unused in shim).
2215 type RebalanceOperation struct {
2316 Type RebalanceType
2417 Node *paneNode
2518 Description string
2619 }
2720
28-// RebalanceType represents different types of rebalancing operations
21+// RebalanceType enumerates rebalancer operations (kept for completeness).
2922 type RebalanceType int
3023
3124 const (
@@ -35,792 +28,36 @@ const (
3528 RebalanceRestructure
3629 )
3730
38-func (rt RebalanceType) String() string {
39- switch rt {
40- case RebalanceRotateLeft:
41- return "rotate_left"
42- case RebalanceRotateRight:
43- return "rotate_right"
44- case RebalancePromote:
45- return "promote"
46- case RebalanceRestructure:
47- return "restructure"
48- default:
49- return "unknown"
50- }
51-}
52-
53-// TreeMetrics contains metrics about the tree structure
54-type TreeMetrics struct {
55- Height int
56- NodeCount int
57- LeafCount int
58- BalanceFactor int
59- MaxDepth int
60- AvgDepth float64
61- ImbalanceScore int
62-}
63-
64-// NewTreeRebalancer creates a new tree rebalancer
65-func NewTreeRebalancer(wm *WorkspaceManager, treeValidator *TreeValidator) *TreeRebalancer {
66- return &TreeRebalancer{
67- wm: wm,
68- treeValidator: treeValidator,
69- maxImbalance: 2, // Allow up to 2 levels of imbalance
70- enabled: true,
71- }
72-}
73-
74-// SetMaxImbalance sets the maximum allowed imbalance
75-func (tr *TreeRebalancer) SetMaxImbalance(maxImbalance int) {
76- tr.maxImbalance = maxImbalance
77-}
78-
79-// Enable enables tree rebalancing
80-func (tr *TreeRebalancer) Enable() {
81- tr.enabled = true
82- log.Printf("[tree-rebalancer] Tree rebalancing enabled")
83-}
84-
85-// Disable disables tree rebalancing
86-func (tr *TreeRebalancer) Disable() {
87- tr.enabled = false
88- log.Printf("[tree-rebalancer] Tree rebalancing disabled")
89-}
90-
91-// RebalanceAfterClose performs tree rebalancing after a pane close operation
92-func (tr *TreeRebalancer) RebalanceAfterClose(closedNode *paneNode, promotedNode *paneNode) error {
93- if !tr.enabled {
94- return nil
95- }
96-
97- log.Printf("[tree-rebalancer] Analyzing tree for rebalancing after close operation")
98-
99- if promotedNode != nil {
100- if err := tr.executePromotion(promotedNode); err != nil {
101- return fmt.Errorf("promotion execution failed: %w", err)
102- }
103-
104- if tr.wm != nil && tr.wm.geometryValidator != nil {
105- tr.logInitialPromotionGeometry(promotedNode)
106- tr.schedulePromotionValidation(promotedNode)
107- }
108- }
109-
110- // Calculate tree metrics
111- metrics := tr.CalculateTreeMetrics(tr.wm.root)
112- log.Printf("[tree-rebalancer] Tree metrics: height=%d, nodes=%d, balance=%d, imbalance=%d",
113- metrics.Height, metrics.NodeCount, metrics.BalanceFactor, metrics.ImbalanceScore)
114-
115- // Check if rebalancing is needed
116- if metrics.ImbalanceScore <= tr.maxImbalance {
117- log.Printf("[tree-rebalancer] Tree is balanced, no rebalancing needed")
118- return nil
119- }
120-
121- // Find rebalancing operations
122- operations := tr.findRebalancingOperations(tr.wm.root, metrics)
123- if len(operations) == 0 {
124- log.Printf("[tree-rebalancer] No rebalancing operations found")
125- return nil
126- }
127-
128- log.Printf("[tree-rebalancer] Found %d rebalancing operations", len(operations))
129-
130- // Execute rebalancing operations
131- return tr.executeRebalancingOperations(operations)
132-}
133-
134-// CalculateTreeMetrics calculates comprehensive metrics about the tree
135-func (tr *TreeRebalancer) CalculateTreeMetrics(root *paneNode) TreeMetrics {
136- if root == nil {
137- return TreeMetrics{}
138- }
139-
140- metrics := TreeMetrics{}
141-
142- // Calculate basic metrics
143- metrics.Height = tr.calculateHeight(root)
144- metrics.NodeCount = tr.countNodes(root)
145- metrics.LeafCount = tr.countLeaves(root)
146- metrics.MaxDepth = tr.calculateMaxDepth(root)
147- metrics.AvgDepth = tr.calculateAverageDepth(root)
148-
149- // Calculate balance factor
150- leftHeight := tr.calculateHeight(root.left)
151- rightHeight := tr.calculateHeight(root.right)
152- metrics.BalanceFactor = rightHeight - leftHeight
153-
154- // Calculate imbalance score (how far from balanced the tree is)
155- metrics.ImbalanceScore = tr.calculateImbalanceScore(root)
156-
157- return metrics
158-}
159-
160-// calculateHeight calculates the height of a subtree
161-func (tr *TreeRebalancer) calculateHeight(node *paneNode) int {
162- if node == nil {
163- return 0
164- }
165-
166- // Skip stacked nodes for height calculation (they don't affect layout balance)
167- if node.isStacked {
168- // For stacked nodes, height is determined by the active pane
169- if len(node.stackedPanes) > 0 && node.activeStackIndex >= 0 && node.activeStackIndex < len(node.stackedPanes) {
170- return tr.calculateHeight(node.stackedPanes[node.activeStackIndex])
171- }
172- return 1
173- }
174-
175- if node.isLeaf {
176- return 1
177- }
178-
179- leftHeight := tr.calculateHeight(node.left)
180- rightHeight := tr.calculateHeight(node.right)
181- return 1 + max(leftHeight, rightHeight)
182-}
183-
184-// countNodes counts the total number of nodes in the tree
185-func (tr *TreeRebalancer) countNodes(node *paneNode) int {
186- if node == nil {
187- return 0
188- }
189-
190- count := 1
191- if node.isStacked {
192- // Count stacked panes
193- count += len(node.stackedPanes)
194- } else {
195- count += tr.countNodes(node.left)
196- count += tr.countNodes(node.right)
197- }
198-
199- return count
200-}
201-
202-// countLeaves counts the number of leaf nodes
203-func (tr *TreeRebalancer) countLeaves(node *paneNode) int {
204- if node == nil {
205- return 0
206- }
207-
208- if node.isLeaf {
209- return 1
210- }
211-
212- if node.isStacked {
213- return len(node.stackedPanes)
214- }
215-
216- return tr.countLeaves(node.left) + tr.countLeaves(node.right)
217-}
218-
219-// calculateMaxDepth calculates the maximum depth of any leaf
220-func (tr *TreeRebalancer) calculateMaxDepth(node *paneNode) int {
221- return tr.calculateMaxDepthHelper(node, 0)
222-}
223-
224-func (tr *TreeRebalancer) calculateMaxDepthHelper(node *paneNode, currentDepth int) int {
225- if node == nil {
226- return currentDepth
227- }
31+// TreeMetrics represents the structural metrics of the workspace tree.
32+type TreeMetrics struct{}
22833
229- if node.isLeaf || node.isStacked {
230- return currentDepth + 1
231- }
232-
233- leftDepth := tr.calculateMaxDepthHelper(node.left, currentDepth+1)
234- rightDepth := tr.calculateMaxDepthHelper(node.right, currentDepth+1)
235- return max(leftDepth, rightDepth)
34+// NewTreeRebalancer returns a disabled stub implementation.
35+func NewTreeRebalancer(_ *WorkspaceManager, _ *TreeValidator) *TreeRebalancer {
36+ return &TreeRebalancer{}
23637 }
23738
238-// calculateAverageDepth calculates the average depth of all leaves
239-func (tr *TreeRebalancer) calculateAverageDepth(node *paneNode) float64 {
240- if node == nil {
241- return 0
242- }
243-
244- totalDepth, leafCount := tr.calculateTotalDepthAndCount(node, 0)
245- if leafCount == 0 {
246- return 0
247- }
248-
249- return float64(totalDepth) / float64(leafCount)
250-}
251-
252-func (tr *TreeRebalancer) calculateTotalDepthAndCount(node *paneNode, currentDepth int) (int, int) {
253- if node == nil {
254- return 0, 0
255- }
256-
257- if node.isLeaf {
258- return currentDepth + 1, 1
259- }
260-
261- if node.isStacked {
262- // All stacked panes have the same depth
263- return (currentDepth + 1) * len(node.stackedPanes), len(node.stackedPanes)
264- }
39+// SetMaxImbalance is preserved for API compatibility.
40+func (tr *TreeRebalancer) SetMaxImbalance(_ int) {}
26541
266- leftDepth, leftCount := tr.calculateTotalDepthAndCount(node.left, currentDepth+1)
267- rightDepth, rightCount := tr.calculateTotalDepthAndCount(node.right, currentDepth+1)
268-
269- return leftDepth + rightDepth, leftCount + rightCount
270-}
271-
272-// calculateImbalanceScore calculates how imbalanced the tree is
273-func (tr *TreeRebalancer) calculateImbalanceScore(node *paneNode) int {
274- if node == nil {
275- return 0
276- }
277-
278- leftHeight := tr.calculateHeight(node.left)
279- rightHeight := tr.calculateHeight(node.right)
280- currentImbalance := int(math.Abs(float64(leftHeight - rightHeight)))
281-
282- // Recursively calculate imbalance for subtrees
283- leftImbalance := tr.calculateImbalanceScore(node.left)
284- rightImbalance := tr.calculateImbalanceScore(node.right)
285-
286- return max(currentImbalance, max(leftImbalance, rightImbalance))
287-}
288-
289-// findRebalancingOperations identifies operations needed to rebalance the tree
290-func (tr *TreeRebalancer) findRebalancingOperations(root *paneNode, metrics TreeMetrics) []RebalanceOperation {
291- var operations []RebalanceOperation
292-
293- // Find all imbalanced nodes
294- imbalancedNodes := tr.findImbalancedNodes(root)
295-
296- for _, node := range imbalancedNodes {
297- // Determine the best rebalancing operation for this node
298- operation := tr.determineRebalancingOperation(node)
299- if operation != nil {
300- operations = append(operations, *operation)
301- }
302- }
303-
304- return operations
305-}
306-
307-// findImbalancedNodes finds all nodes that are significantly imbalanced
308-func (tr *TreeRebalancer) findImbalancedNodes(node *paneNode) []*paneNode {
309- if node == nil {
310- return nil
311- }
312-
313- var imbalanced []*paneNode
314-
315- leftHeight := tr.calculateHeight(node.left)
316- rightHeight := tr.calculateHeight(node.right)
317- balance := int(math.Abs(float64(leftHeight - rightHeight)))
318-
319- if balance > tr.maxImbalance {
320- imbalanced = append(imbalanced, node)
321- }
322-
323- // Recursively check children
324- imbalanced = append(imbalanced, tr.findImbalancedNodes(node.left)...)
325- imbalanced = append(imbalanced, tr.findImbalancedNodes(node.right)...)
326-
327- return imbalanced
328-}
329-
330-// determineRebalancingOperation determines the best rebalancing operation for a node
331-func (tr *TreeRebalancer) determineRebalancingOperation(node *paneNode) *RebalanceOperation {
332- if node == nil || node.isLeaf {
333- return nil
334- }
42+// Enable toggles the stub on (no functional impact).
43+func (tr *TreeRebalancer) Enable() { tr.enabled = true }
33544
336- leftHeight := tr.calculateHeight(node.left)
337- rightHeight := tr.calculateHeight(node.right)
338-
339- if leftHeight > rightHeight+tr.maxImbalance {
340- // Left-heavy, need right rotation
341- return &RebalanceOperation{
342- Type: RebalanceRotateRight,
343- Node: node,
344- Description: fmt.Sprintf("Right rotation on node %p (left=%d, right=%d)", node, leftHeight, rightHeight),
345- }
346- } else if rightHeight > leftHeight+tr.maxImbalance {
347- // Right-heavy, need left rotation
348- return &RebalanceOperation{
349- Type: RebalanceRotateLeft,
350- Node: node,
351- Description: fmt.Sprintf("Left rotation on node %p (left=%d, right=%d)", node, leftHeight, rightHeight),
352- }
353- }
354-
355- return nil
356-}
357-
358-// executeRebalancingOperations executes a series of rebalancing operations
359-func (tr *TreeRebalancer) executeRebalancingOperations(operations []RebalanceOperation) error {
360- if len(operations) == 0 {
361- return nil
362- }
363-
364- log.Printf("[tree-rebalancer] Executing %d rebalancing operations", len(operations))
365-
366- // Execute each operation
367- for i, operation := range operations {
368- log.Printf("[tree-rebalancer] Executing operation %d/%d: %s", i+1, len(operations), operation.Description)
369-
370- var err error
371- switch operation.Type {
372- case RebalanceRotateLeft:
373- err = tr.executeLeftRotation(operation.Node)
374- case RebalanceRotateRight:
375- err = tr.executeRightRotation(operation.Node)
376- case RebalancePromote:
377- err = tr.executePromotion(operation.Node)
378- case RebalanceRestructure:
379- err = tr.executeRestructure(operation.Node)
380- default:
381- err = fmt.Errorf("unknown rebalance operation type: %s", operation.Type)
382- }
383-
384- if err != nil {
385- log.Printf("[tree-rebalancer] Operation failed: %v", err)
386- return fmt.Errorf("rebalancing operation failed: %w", err)
387- }
388- }
389-
390- // Validate tree after rebalancing
391- if tr.treeValidator != nil {
392- if err := tr.treeValidator.ValidateTree(tr.wm.root, "rebalance"); err != nil {
393- log.Printf("[tree-rebalancer] Tree validation failed after rebalancing: %v", err)
394- }
395- }
396-
397- // Calculate new metrics
398- newMetrics := tr.CalculateTreeMetrics(tr.wm.root)
399- log.Printf("[tree-rebalancer] Rebalancing completed: new imbalance=%d",
400- newMetrics.ImbalanceScore)
45+// Disable toggles the stub off.
46+func (tr *TreeRebalancer) Disable() { tr.enabled = false }
40147
48+// RebalanceAfterClose is a no-op in the stub implementation.
49+func (tr *TreeRebalancer) RebalanceAfterClose(_ *paneNode, _ *paneNode) error {
40250 return nil
40351 }
40452
405-// executeLeftRotation performs a left rotation on the given node
406-func (tr *TreeRebalancer) executeLeftRotation(node *paneNode) error {
407- if node == nil || node.right == nil {
408- return fmt.Errorf("cannot perform left rotation: invalid node structure")
409- }
410-
411- log.Printf("[tree-rebalancer] Performing left rotation on node %p", node)
412-
413- // Store original structure
414- rightChild := node.right
415- rightLeftChild := rightChild.left
416- parent := node.parent
417-
418- // Update tree structure
419- rightChild.left = node
420- node.right = rightLeftChild
421- rightChild.parent = parent
422- node.parent = rightChild
423-
424- if rightLeftChild != nil {
425- rightLeftChild.parent = node
426- }
427-
428- // Update parent's child pointer or root
429- if parent == nil {
430- tr.wm.root = rightChild
431- } else if parent.left == node {
432- parent.left = rightChild
433- } else {
434- parent.right = rightChild
435- }
436-
437- // Update widget hierarchy for the rotation
438- return tr.updateRotationWidgets(node, rightChild, "left")
53+// CalculateTreeMetrics returns empty metrics in the stub.
54+func (tr *TreeRebalancer) CalculateTreeMetrics(_ *paneNode) TreeMetrics {
55+ return TreeMetrics{}
43956 }
44057
441-// executeRightRotation performs a right rotation on the given node
442-func (tr *TreeRebalancer) executeRightRotation(node *paneNode) error {
443- if node == nil || node.left == nil {
444- return fmt.Errorf("cannot perform right rotation: invalid node structure")
445- }
446-
447- log.Printf("[tree-rebalancer] Performing right rotation on node %p", node)
448-
449- // Store original structure
450- leftChild := node.left
451- leftRightChild := leftChild.right
452- parent := node.parent
453-
454- // Update tree structure
455- leftChild.right = node
456- node.left = leftRightChild
457- leftChild.parent = parent
458- node.parent = leftChild
459-
460- if leftRightChild != nil {
461- leftRightChild.parent = node
462- }
463-
464- // Update parent's child pointer or root
465- if parent == nil {
466- tr.wm.root = leftChild
467- } else if parent.left == node {
468- parent.left = leftChild
469- } else {
470- parent.right = leftChild
471- }
472-
473- // Update widget hierarchy for the rotation
474- return tr.updateRotationWidgets(node, leftChild, "right")
475-}
476-
477-// updateRotationWidgets updates widget hierarchy after tree rotation
478-func (tr *TreeRebalancer) updateRotationWidgets(oldRoot, newRoot *paneNode, rotationType string) error {
479- // The widget hierarchy needs to be updated to match the new tree structure
480- // This is complex because GTK widgets need to be reparented correctly
481-
482- if oldRoot.container == nil || newRoot.container == nil {
483- return fmt.Errorf("rotation nodes missing containers")
484- }
485-
486- log.Printf("[tree-rebalancer] Updating widget hierarchy for %s rotation", rotationType)
487-
488- // Force a layout update
489- if newRoot.container != nil {
490- return newRoot.container.Execute(func(ptr uintptr) error {
491- if ptr == 0 || !webkit.WidgetIsValid(ptr) {
492- return fmt.Errorf("rotation widget update: invalid widget pointer")
493- }
494- webkit.WidgetQueueAllocate(ptr)
495- return nil
496- })
497- }
498- return nil
499-}
500-
501-// executePromotion promotes a child node to replace its parent
502-func (tr *TreeRebalancer) executePromotion(node *paneNode) error {
503- if node == nil {
504- return fmt.Errorf("promotion target is nil")
505- }
506- if node.container == nil {
507- return fmt.Errorf("promotion target missing container")
508- }
509- if !node.container.IsValid() {
510- return fmt.Errorf("promotion target container %s invalid", node.container.String())
511- }
512-
513- log.Printf("[tree-rebalancer] Promoting node %p (parent=%p)", node, node.parent)
514- promotionStart := time.Now()
515-
516- // Ensure the promoted widget can expand to occupy available space
517- log.Printf("[tree-rebalancer] promotion expand for %p executing at +%s", node, time.Since(promotionStart).Round(time.Millisecond))
518- if err := node.container.Execute(func(ptr uintptr) error {
519- if ptr == 0 || !webkit.WidgetIsValid(ptr) {
520- return fmt.Errorf("promotion expand: invalid widget pointer")
521- }
522- webkit.WidgetResetSizeRequest(ptr)
523- webkit.WidgetSetHExpand(ptr, true)
524- webkit.WidgetSetVExpand(ptr, true)
525- webkit.WidgetQueueAllocate(ptr)
526- return nil
527- }); err != nil {
528- return fmt.Errorf("failed to expand promoted widget: %w", err)
529- }
530-
531- parent := node.parent
532- if parent == nil {
533- // Attach to window root
534- if tr.wm != nil && tr.wm.window != nil {
535- log.Printf("[tree-rebalancer] promotion window attach for %p executing at +%s", node, time.Since(promotionStart).Round(time.Millisecond))
536- if err := node.container.Execute(func(ptr uintptr) error {
537- if ptr == 0 || !webkit.WidgetIsValid(ptr) {
538- return fmt.Errorf("promotion attach window: invalid widget pointer")
539- }
540-
541- currentParent := webkit.WidgetGetParent(ptr)
542- if currentParent != 0 {
543- // Check if parent is a container widget (paned or box) that we need to unparent from
544- if webkit.IsPaned(currentParent) {
545- log.Printf("[tree-rebalancer] unparenting widget from paned %#x", currentParent)
546- webkit.WidgetUnparent(ptr)
547- } else if webkit.IsBox(currentParent) {
548- log.Printf("[tree-rebalancer] unparenting widget from box (stack) %#x", currentParent)
549- webkit.WidgetUnparent(ptr)
550- } else {
551- // Parent is likely a window or other non-container widget
552- // For safety, check if it's a toplevel window by attempting unparent
553- log.Printf("[tree-rebalancer] widget %#x has non-container parent %#x, checking if unparent needed", ptr, currentParent)
554- if webkit.WidgetIsValid(currentParent) {
555- // If parent is valid but not a container, try unparent for safety
556- webkit.WidgetUnparent(ptr)
557- log.Printf("[tree-rebalancer] unparented widget %#x from non-container parent %#x", ptr, currentParent)
558- }
559- }
560- }
561-
562- // Ensure widget is configured for window child
563- webkit.WidgetSetHExpand(ptr, true)
564- webkit.WidgetSetVExpand(ptr, true)
565-
566- // Set as window child
567- tr.wm.window.SetChild(ptr)
568- webkit.WidgetQueueAllocate(ptr)
569- webkit.WidgetShow(ptr)
570-
571- // Verify the attachment worked
572- finalParent := webkit.WidgetGetParent(ptr)
573- if finalParent == 0 {
574- log.Printf("[tree-rebalancer] WARNING: SetChild failed, widget still has no parent")
575- // Try one more time
576- tr.wm.window.SetChild(ptr)
577- webkit.WidgetQueueAllocate(ptr)
578- finalParent = webkit.WidgetGetParent(ptr)
579- if finalParent == 0 {
580- return fmt.Errorf("failed to attach widget %#x to window after multiple attempts", ptr)
581- }
582- }
583-
584- log.Printf("[tree-rebalancer] promotion window attach successful: widget %#x now has parent %#x", ptr, finalParent)
585- return nil
586- }); err != nil {
587- return fmt.Errorf("failed to attach widget to window: %w", err)
588- }
589- }
590- } else {
591- // Attach to parent container
592- if parent.container == nil {
593- return fmt.Errorf("promotion parent missing container")
594- }
595- if !parent.container.IsValid() {
596- return fmt.Errorf("promotion parent container %s invalid", parent.container.String())
597- }
598-
599- log.Printf("[tree-rebalancer] promotion reparent for %p executing at +%s", node, time.Since(promotionStart).Round(time.Millisecond))
600- if err := parent.container.Execute(func(parentPtr uintptr) error {
601- if parentPtr == 0 || !webkit.WidgetIsValid(parentPtr) {
602- return fmt.Errorf("promotion reparent: invalid parent widget")
603- }
604- return node.container.Execute(func(childPtr uintptr) error {
605- if childPtr == 0 || !webkit.WidgetIsValid(childPtr) {
606- return fmt.Errorf("promotion reparent: invalid child widget")
607- }
608-
609- // Check if widget is already correctly parented (closePane.swapContainers already did the work)
610- currentParent := webkit.WidgetGetParent(childPtr)
611- if currentParent == parentPtr {
612- log.Printf("[tree-rebalancer] widget %#x already correctly parented to %#x, skipping reparent", childPtr, parentPtr)
613- // Just ensure properties are set correctly
614- webkit.WidgetResetSizeRequest(childPtr)
615- webkit.WidgetSetHExpand(childPtr, true)
616- webkit.WidgetSetVExpand(childPtr, true)
617- webkit.WidgetQueueAllocate(childPtr)
618- webkit.WidgetQueueAllocate(parentPtr)
619- return nil
620- }
621-
622- // Widget needs reparenting - unparent from wrong parent if needed
623- if currentParent != 0 {
624- log.Printf("[tree-rebalancer] unparenting widget %#x from incorrect parent %#x", childPtr, currentParent)
625- webkit.WidgetUnparent(childPtr)
626- }
627-
628- // Reattach promoted widget to the GtkPaned
629- if parent.left == node {
630- webkit.PanedSetStartChild(parentPtr, childPtr)
631- } else {
632- webkit.PanedSetEndChild(parentPtr, childPtr)
633- }
634-
635- // Verify attachment succeeded before setting properties
636- finalParent := webkit.WidgetGetParent(childPtr)
637- if finalParent != parentPtr {
638- return fmt.Errorf("failed to attach widget %#x to parent %#x (current parent: %#x)", childPtr, parentPtr, finalParent)
639- }
640-
641- webkit.WidgetResetSizeRequest(childPtr)
642- webkit.WidgetSetHExpand(childPtr, true)
643- webkit.WidgetSetVExpand(childPtr, true)
644- webkit.WidgetQueueAllocate(childPtr)
645- webkit.WidgetQueueAllocate(parentPtr)
646- return nil
647- })
648- }); err != nil {
649- return fmt.Errorf("failed to reparent promoted widget: %w", err)
650- }
651- }
652-
653- // Propagate allocation updates up the ancestor chain so GTK recalculates sizes
654- ancestorPtrs := tr.collectAncestorContainers(node)
655- if len(ancestorPtrs) > 0 {
656- log.Printf("[tree-rebalancer] promotion allocation queue for %p executing at +%s (ancestors=%d)", node, time.Since(promotionStart).Round(time.Millisecond), len(ancestorPtrs))
657- for _, ancestorPtr := range ancestorPtrs {
658- if ancestorPtr == 0 || !webkit.WidgetIsValid(ancestorPtr) {
659- continue
660- }
661- webkit.WidgetQueueAllocate(ancestorPtr)
662- }
663- }
664-
665- // Validate allocation after promotion for debugging purposes
666- log.Printf("[tree-rebalancer] promotion immediate validation for %p executing at +%s", node, time.Since(promotionStart).Round(time.Millisecond))
667- _ = node.container.Execute(func(ptr uintptr) error {
668- if ptr == 0 || !webkit.WidgetIsValid(ptr) {
669- log.Printf("[tree-rebalancer] promotion validate: invalid widget pointer")
670- return nil
671- }
672- bounds, ok := webkit.WidgetGetBounds(ptr)
673- if !ok {
674- log.Printf("[tree-rebalancer] promotion validation: failed to read bounds for %#x", ptr)
675- return nil
676- }
677- if bounds.Width <= 1 || bounds.Height <= 1 {
678- log.Printf("[tree-rebalancer] WARNING: promoted pane %#x has tiny allocation %.1fx%.1f", ptr, bounds.Width, bounds.Height)
679- } else {
680- log.Printf("[tree-rebalancer] Promotion allocation verified for %#x: %.1fx%.1f", ptr, bounds.Width, bounds.Height)
681- }
682- return nil
683- })
684-
685- return nil
686-}
687-
688-// executeRestructure performs a complete restructure of a subtree
689-func (tr *TreeRebalancer) executeRestructure(node *paneNode) error {
690- // This would be used for more complex rebalancing scenarios
691- log.Printf("[tree-rebalancer] Restructuring subtree at node %p", node)
692-
693- // TODO: Implement restructure logic
694- return fmt.Errorf("restructure operation not yet implemented")
695-}
696-
697-func (tr *TreeRebalancer) logInitialPromotionGeometry(node *paneNode) {
698- if tr == nil || tr.wm == nil || tr.wm.geometryValidator == nil || node == nil {
699- return
700- }
701- if node.container == nil || !node.container.IsValid() {
702- log.Printf("[tree-rebalancer] Promotion geometry skipped: container invalid for %p", node)
703- return
704- }
705- tr.ensureRootAttachment(node)
706- geom := tr.wm.geometryValidator.GetPaneGeometry(node)
707- if !geom.IsValid || geom.Width <= 0 || geom.Height <= 0 {
708- log.Printf("[tree-rebalancer] Promotion geometry pending after commit: valid=%v size=%dx%d", geom.IsValid, geom.Width, geom.Height)
709- return
710- }
711- log.Printf("[tree-rebalancer] Promotion geometry validated immediately: %dx%d", geom.Width, geom.Height)
712-}
713-
714-func (tr *TreeRebalancer) schedulePromotionValidation(node *paneNode) {
715- if tr == nil || tr.wm == nil || tr.wm.geometryValidator == nil || node == nil {
716- return
717- }
718-
719- const maxAttempts = 5
720- start := time.Now()
721- attempt := 0
722-
723- var retry func() bool
724- retry = func() bool {
725- attempt++
726- elapsed := time.Since(start).Round(time.Millisecond)
727- if node.container == nil || !node.container.IsValid() {
728- log.Printf("[tree-rebalancer] Deferred promotion geometry aborted after %s (attempt %d): container invalid", elapsed, attempt)
729- return false
730- }
731- tr.ensureRootAttachment(node)
732- geom := tr.wm.geometryValidator.GetPaneGeometry(node)
733- if geom.IsValid && geom.Width > 0 && geom.Height > 0 {
734- log.Printf("[tree-rebalancer] Deferred promotion geometry validated after %s (attempt %d): %dx%d", elapsed, attempt, geom.Width, geom.Height)
735- return false
736- }
737-
738- if attempt >= maxAttempts {
739- log.Printf("[tree-rebalancer] Deferred promotion geometry still invalid after %s (attempt %d): valid=%v size=%dx%d", elapsed, attempt, geom.IsValid, geom.Width, geom.Height)
740- return false
741- }
742-
743- log.Printf("[tree-rebalancer] Promotion geometry still pending after %s (attempt %d); rescheduling", elapsed, attempt)
744- webkit.IdleAdd(retry)
745- return false
746- }
747-
748- webkit.IdleAdd(retry)
749-}
750-
751-func (tr *TreeRebalancer) ensureRootAttachment(node *paneNode) {
752- if tr == nil || tr.wm == nil || node == nil || node.parent != nil {
753- return
754- }
755- if node.container == nil || !node.container.IsValid() {
756- return
757- }
758-
759- _ = node.container.Execute(func(ptr uintptr) error {
760- parent := webkit.WidgetGetParent(ptr)
761- if parent != 0 {
762- // Widget is properly attached, no action needed
763- return nil
764- }
765- if tr.wm.window == nil {
766- log.Printf("[tree-rebalancer] Root pane %#x missing parent but window unavailable", ptr)
767- return nil
768- }
769-
770- // This should not happen if promotion worked correctly - log as error
771- log.Printf("[tree-rebalancer] ERROR: Root pane %#x lost window attachment (this indicates a promotion bug); attempting recovery", ptr)
772-
773- // Recovery attempt
774- tr.wm.window.SetChild(ptr)
775- webkit.WidgetQueueAllocate(ptr)
776- webkit.WidgetShow(ptr)
777-
778- // Verify recovery
779- recoveredParent := webkit.WidgetGetParent(ptr)
780- if recoveredParent == 0 {
781- log.Printf("[tree-rebalancer] CRITICAL: Failed to recover window attachment for %#x", ptr)
782- } else {
783- log.Printf("[tree-rebalancer] Recovery successful: widget %#x now has parent %#x", ptr, recoveredParent)
784- }
785- return nil
786- })
787-}
788-
789-// collectAncestorContainers returns the widget pointers for the promoted node and all ancestors up to the root.
790-func (tr *TreeRebalancer) collectAncestorContainers(node *paneNode) []uintptr {
791- var containers []uintptr
792- visited := make(map[uintptr]bool)
793-
794- current := node
795- for current != nil {
796- if current.container != nil && current.container.IsValid() {
797- ptr := current.container.Ptr()
798- if ptr != 0 && !visited[ptr] && webkit.WidgetIsValid(ptr) {
799- containers = append(containers, ptr)
800- visited[ptr] = true
801- }
802- }
803- current = current.parent
804- }
805-
806- return containers
807-}
808-
809-// GetRebalancingStats returns statistics about tree rebalancing
58+// GetRebalancingStats exposes minimal instrumentation for diagnostics.
81059 func (tr *TreeRebalancer) GetRebalancingStats() map[string]interface{} {
811- metrics := tr.CalculateTreeMetrics(tr.wm.root)
812-
81360 return map[string]interface{}{
814- "enabled": tr.enabled,
815- "max_imbalance": tr.maxImbalance,
816- "current_height": metrics.Height,
817- "node_count": metrics.NodeCount,
818- "leaf_count": metrics.LeafCount,
819- "balance_factor": metrics.BalanceFactor,
820- "imbalance_score": metrics.ImbalanceScore,
821- "avg_depth": metrics.AvgDepth,
822- "needs_rebalance": metrics.ImbalanceScore > tr.maxImbalance,
61+ "enabled": tr != nil && tr.enabled,
82362 }
82463 }
825-
826-// Helper functions
M internal/app/browser/workspace_tree_validator.go +28 -539
@@ -1,564 +1,53 @@
1-// workspace_tree_validator.go - Tree invariant validation for bulletproof binary tree operations
1+// workspace_tree_validator.go - Simplified validator stub to keep API surface.
22 package browser
33
4-import (
5- "fmt"
6- "log"
7- "sync"
8- "time"
9- "unsafe"
10-)
4+import "fmt"
115
12-// TreeValidationError represents errors found during tree validation
13-type TreeValidationError struct {
14- ErrorType string
15- NodePointer uintptr
16- Message string
17- StackTrace []string
18-}
19-
20-func (e TreeValidationError) Error() string {
21- return fmt.Sprintf("Tree validation error [%s]: %s (node: %p)", e.ErrorType, e.Message, e.NodePointer)
22-}
23-
24-// TreeValidator provides comprehensive validation of binary tree invariants
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.
259 type TreeValidator struct {
26- enabled bool
27- debugMode bool
28- maxDepth int
29- validationHistory []TreeValidationResult
30- mu sync.RWMutex
10+ enabled bool
3111 }
3212
33-// TreeValidationResult captures the result of a tree validation
34-type TreeValidationResult struct {
35- Timestamp time.Time
36- Success bool
37- ErrorCount int
38- Errors []TreeValidationError
39- NodeCount int
40- MaxDepth int
41- BalanceFactor int
42- Duration time.Duration
13+// NewTreeValidator returns a stub validator instance.
14+func NewTreeValidator(enabled bool, _ bool) *TreeValidator {
15+ return &TreeValidator{enabled: enabled}
4316 }
4417
45-// NewTreeValidator creates a new tree validator
46-func NewTreeValidator(enabled, debugMode bool) *TreeValidator {
47- return &TreeValidator{
48- enabled: enabled,
49- debugMode: debugMode,
50- maxDepth: 50, // Prevent infinite recursion
51- validationHistory: make([]TreeValidationResult, 0, 100),
52- }
53-}
54-
55-// Enable turns on tree validation
56-func (tv *TreeValidator) Enable() {
57- tv.mu.Lock()
58- defer tv.mu.Unlock()
59- tv.enabled = true
60-}
61-
62-// Disable turns off tree validation
63-func (tv *TreeValidator) Disable() {
64- tv.mu.Lock()
65- defer tv.mu.Unlock()
66- tv.enabled = false
67-}
68-
69-// SetDebugMode enables/disables debug mode
70-func (tv *TreeValidator) SetDebugMode(debug bool) {
71- tv.mu.Lock()
72- defer tv.mu.Unlock()
73- tv.debugMode = debug
74-}
75-
76-// ValidateTree performs comprehensive validation of the binary tree
77-func (tv *TreeValidator) ValidateTree(root *paneNode, operation string) error {
78- tv.mu.Lock()
79- defer tv.mu.Unlock()
80-
81- if !tv.enabled {
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 {
8221 return nil
8322 }
84-
85- startTime := time.Now()
86- result := TreeValidationResult{
87- Timestamp: startTime,
88- Errors: make([]TreeValidationError, 0),
89- }
90-
91- if tv.debugMode {
92- log.Printf("[tree-validator] Starting validation after operation: %s", operation)
93- }
94-
95- // Perform all validation checks
96- visited := make(map[*paneNode]bool)
97- nodeCount := 0
98- maxDepth := 0
99-
100- // Check 1: Root validation
101- if err := tv.validateRoot(root, &result); err.ErrorType != "" {
102- result.Errors = append(result.Errors, err)
103- }
104-
105- // Check 2: Tree structure validation
106- if root != nil {
107- if err := tv.validateTreeStructure(root, nil, visited, 0, &nodeCount, &maxDepth, &result); err != nil {
108- result.Errors = append(result.Errors, err...)
109- }
110- }
111-
112- // Check 3: Cycle detection
113- if err := tv.detectCycles(root, &result); err != nil {
114- result.Errors = append(result.Errors, err...)
115- }
116-
117- // Check 4: Parent-child consistency
118- if err := tv.validateParentChildConsistency(root, &result); err != nil {
119- result.Errors = append(result.Errors, err...)
120- }
121-
122- // Check 5: Widget consistency
123- if err := tv.validateWidgetConsistency(root, &result); err != nil {
124- result.Errors = append(result.Errors, err...)
125- }
126-
127- // Finalize result
128- result.Duration = time.Since(startTime)
129- result.NodeCount = nodeCount
130- result.MaxDepth = maxDepth
131- result.ErrorCount = len(result.Errors)
132- result.Success = result.ErrorCount == 0
133-
134- // Calculate balance factor
135- if root != nil {
136- leftDepth := tv.calculateDepth(root.left)
137- rightDepth := tv.calculateDepth(root.right)
138- result.BalanceFactor = abs(leftDepth - rightDepth)
139- }
140-
141- // Store result in history
142- tv.addToHistory(result)
143-
144- if tv.debugMode {
145- log.Printf("[tree-validator] Validation completed: success=%v, errors=%d, nodes=%d, depth=%d, balance=%d, duration=%v",
146- result.Success, result.ErrorCount, result.NodeCount, result.MaxDepth, result.BalanceFactor, result.Duration)
147- }
148-
149- if !result.Success {
150- // Return first error as representative
151- return result.Errors[0]
152- }
153-
154- return nil
23+ return fmt.Errorf("tree validation not implemented")
15524 }
15625
157-// validateRoot checks root node constraints
158-func (tv *TreeValidator) validateRoot(root *paneNode, result *TreeValidationResult) TreeValidationError {
159- if root == nil {
160- return TreeValidationError{} // Empty tree is valid
161- }
162-
163- if root.parent != nil {
164- return TreeValidationError{
165- ErrorType: "ROOT_HAS_PARENT",
166- NodePointer: uintptr(getNodePointer(root)),
167- Message: fmt.Sprintf("root node has parent: %p", root.parent),
168- }
169- }
170-
171- return TreeValidationError{} // No error
172-}
173-
174-// validateTreeStructure performs recursive validation of tree structure
175-func (tv *TreeValidator) validateTreeStructure(node *paneNode, expectedParent *paneNode, visited map[*paneNode]bool, depth int, nodeCount *int, maxDepth *int, result *TreeValidationResult) []TreeValidationError {
176- if node == nil {
177- return nil
178- }
179-
180- var errors []TreeValidationError
181-
182- // Check max depth to prevent infinite recursion
183- if depth > tv.maxDepth {
184- errors = append(errors, TreeValidationError{
185- ErrorType: "MAX_DEPTH_EXCEEDED",
186- NodePointer: uintptr(getNodePointer(node)),
187- Message: fmt.Sprintf("tree depth exceeds maximum of %d", tv.maxDepth),
188- })
189- return errors
190- }
191-
192- // Update counters
193- *nodeCount++
194- if depth > *maxDepth {
195- *maxDepth = depth
196- }
197-
198- // Check if already visited (cycle detection)
199- if visited[node] {
200- errors = append(errors, TreeValidationError{
201- ErrorType: "CYCLE_DETECTED",
202- NodePointer: uintptr(getNodePointer(node)),
203- Message: "node already visited in traversal",
204- })
205- return errors
206- }
207- visited[node] = true
208-
209- // Check parent pointer consistency
210- if node.parent != expectedParent {
211- errors = append(errors, TreeValidationError{
212- ErrorType: "PARENT_MISMATCH",
213- NodePointer: uintptr(getNodePointer(node)),
214- Message: fmt.Sprintf("parent mismatch: expected %p, got %p", expectedParent, node.parent),
215- })
216- }
217-
218- // Validate node type constraints
219- if node.isLeaf {
220- // Leaf nodes must have no children
221- if node.left != nil {
222- errors = append(errors, TreeValidationError{
223- ErrorType: "LEAF_HAS_LEFT_CHILD",
224- NodePointer: uintptr(getNodePointer(node)),
225- Message: fmt.Sprintf("leaf node has left child: %p", node.left),
226- })
227- }
228- if node.right != nil {
229- errors = append(errors, TreeValidationError{
230- ErrorType: "LEAF_HAS_RIGHT_CHILD",
231- NodePointer: uintptr(getNodePointer(node)),
232- Message: fmt.Sprintf("leaf node has right child: %p", node.right),
233- })
234- }
235-
236- // Leaf nodes must have a pane (unless it's a stack container)
237- if !node.isStacked && node.pane == nil {
238- errors = append(errors, TreeValidationError{
239- ErrorType: "LEAF_MISSING_PANE",
240- NodePointer: uintptr(getNodePointer(node)),
241- Message: "leaf node missing pane",
242- })
243- }
244- } else if node.isStacked {
245- // Stacked container nodes live in the binary tree but do not use left/right children.
246- if node.left != nil {
247- errors = append(errors, TreeValidationError{
248- ErrorType: "STACK_CONTAINER_HAS_LEFT_CHILD",
249- NodePointer: uintptr(getNodePointer(node)),
250- Message: "stack container should not have left child",
251- })
252- }
253- if node.right != nil {
254- errors = append(errors, TreeValidationError{
255- ErrorType: "STACK_CONTAINER_HAS_RIGHT_CHILD",
256- NodePointer: uintptr(getNodePointer(node)),
257- Message: "stack container should not have right child",
258- })
259- }
260- } else {
261- // Branch nodes must have exactly 2 children
262- if node.left == nil {
263- errors = append(errors, TreeValidationError{
264- ErrorType: "BRANCH_MISSING_LEFT_CHILD",
265- NodePointer: uintptr(getNodePointer(node)),
266- Message: "branch node missing left child",
267- })
268- }
269- if node.right == nil {
270- errors = append(errors, TreeValidationError{
271- ErrorType: "BRANCH_MISSING_RIGHT_CHILD",
272- NodePointer: uintptr(getNodePointer(node)),
273- Message: "branch node missing right child",
274- })
275- }
276-
277- // Branch nodes should not have panes (except for stack containers)
278- if !node.isStacked && node.pane != nil {
279- errors = append(errors, TreeValidationError{
280- ErrorType: "BRANCH_HAS_PANE",
281- NodePointer: uintptr(getNodePointer(node)),
282- Message: "branch node should not have pane",
283- })
284- }
285- }
286-
287- // Validate stack-specific constraints
288- if node.isStacked {
289- if err := tv.validateStackConstraints(node); err.ErrorType != "" {
290- errors = append(errors, err)
291- }
292- }
293-
294- // Recursively validate children
295- if node.left != nil {
296- leftErrors := tv.validateTreeStructure(node.left, node, visited, depth+1, nodeCount, maxDepth, result)
297- errors = append(errors, leftErrors...)
298- }
299- if node.right != nil {
300- rightErrors := tv.validateTreeStructure(node.right, node, visited, depth+1, nodeCount, maxDepth, result)
301- errors = append(errors, rightErrors...)
302- }
26+// SetDebugMode preserved for compatibility.
27+func (tv *TreeValidator) SetDebugMode(_ bool) {}
30328
304- return errors
305-}
306-
307-// validateStackConstraints validates stack-specific invariants
308-func (tv *TreeValidator) validateStackConstraints(node *paneNode) TreeValidationError {
309- if !node.isStacked {
310- return TreeValidationError{} // Not a stack
311- }
312-
313- // Stack nodes should have stackedPanes
314- if len(node.stackedPanes) == 0 {
315- return TreeValidationError{
316- ErrorType: "EMPTY_STACK",
317- NodePointer: uintptr(getNodePointer(node)),
318- Message: "stacked node has no panes",
319- }
320- }
321-
322- // Active index should be valid
323- if node.activeStackIndex < 0 || node.activeStackIndex >= len(node.stackedPanes) {
324- return TreeValidationError{
325- ErrorType: "INVALID_STACK_INDEX",
326- NodePointer: uintptr(getNodePointer(node)),
327- Message: fmt.Sprintf("active stack index %d out of range [0, %d)", node.activeStackIndex, len(node.stackedPanes)),
328- }
329- }
330-
331- // All panes in stack should have this node as parent
332- for i, stackedPane := range node.stackedPanes {
333- if stackedPane == nil {
334- return TreeValidationError{
335- ErrorType: "NULL_STACK_PANE",
336- NodePointer: uintptr(getNodePointer(node)),
337- Message: fmt.Sprintf("stacked pane at index %d is nil", i),
338- }
339- }
340- if stackedPane.parent != node {
341- return TreeValidationError{
342- ErrorType: "STACK_PANE_PARENT_MISMATCH",
343- NodePointer: uintptr(getNodePointer(stackedPane)),
344- Message: fmt.Sprintf("stacked pane parent mismatch: expected %p, got %p", node, stackedPane.parent),
345- }
346- }
347- }
348-
349- return TreeValidationError{} // No error
350-}
351-
352-// detectCycles performs cycle detection using Floyd's algorithm
353-func (tv *TreeValidator) detectCycles(root *paneNode, result *TreeValidationResult) []TreeValidationError {
354- if root == nil {
355- return nil
356- }
357-
358- var errors []TreeValidationError
359- visited := make(map[*paneNode]bool)
360-
361- var detectCycleHelper func(*paneNode, map[*paneNode]bool) bool
362- detectCycleHelper = func(node *paneNode, path map[*paneNode]bool) bool {
363- if node == nil {
364- return false
365- }
366-
367- if path[node] {
368- errors = append(errors, TreeValidationError{
369- ErrorType: "CYCLE_IN_PATH",
370- NodePointer: uintptr(getNodePointer(node)),
371- Message: "cycle detected in tree path",
372- })
373- return true
374- }
375-
376- if visited[node] {
377- return false // Already checked this subtree
378- }
379-
380- path[node] = true
381- visited[node] = true
382-
383- hasCycle := detectCycleHelper(node.left, path) || detectCycleHelper(node.right, path)
384-
385- delete(path, node)
386- return hasCycle
387- }
388-
389- detectCycleHelper(root, make(map[*paneNode]bool))
390- return errors
391-}
392-
393-// validateParentChildConsistency checks bidirectional parent-child relationships
394-func (tv *TreeValidator) validateParentChildConsistency(root *paneNode, result *TreeValidationResult) []TreeValidationError {
395- if root == nil {
396- return nil
397- }
398-
399- var errors []TreeValidationError
400-
401- var validate func(*paneNode)
402- validate = func(node *paneNode) {
403- if node == nil {
404- return
405- }
406-
407- // Check left child consistency
408- if node.left != nil {
409- if node.left.parent != node {
410- errors = append(errors, TreeValidationError{
411- ErrorType: "LEFT_CHILD_PARENT_MISMATCH",
412- NodePointer: uintptr(getNodePointer(node.left)),
413- Message: fmt.Sprintf("left child parent points to %p instead of %p", node.left.parent, node),
414- })
415- }
416- }
417-
418- // Check right child consistency
419- if node.right != nil {
420- if node.right.parent != node {
421- errors = append(errors, TreeValidationError{
422- ErrorType: "RIGHT_CHILD_PARENT_MISMATCH",
423- NodePointer: uintptr(getNodePointer(node.right)),
424- Message: fmt.Sprintf("right child parent points to %p instead of %p", node.right.parent, node),
425- })
426- }
427- }
428-
429- validate(node.left)
430- validate(node.right)
431- }
432-
433- validate(root)
434- return errors
435-}
436-
437-// validateWidgetConsistency checks widget-related invariants
438-func (tv *TreeValidator) validateWidgetConsistency(root *paneNode, result *TreeValidationResult) []TreeValidationError {
439- if root == nil {
440- return nil
441- }
442-
443- var errors []TreeValidationError
444-
445- var validate func(*paneNode)
446- validate = func(node *paneNode) {
447- if node == nil {
448- return
449- }
450-
451- // Every node should have a container
452- if node.container == nil {
453- errors = append(errors, TreeValidationError{
454- ErrorType: "MISSING_CONTAINER",
455- NodePointer: uintptr(getNodePointer(node)),
456- Message: "node missing container widget",
457- })
458- } else if !node.container.IsValid() {
459- errors = append(errors, TreeValidationError{
460- ErrorType: "INVALID_CONTAINER",
461- NodePointer: uintptr(getNodePointer(node)),
462- Message: fmt.Sprintf("node has invalid container: %s", node.container.String()),
463- })
464- }
465-
466- // Stack containers should have stackWrapper
467- if node.isStacked && node.stackWrapper == nil {
468- errors = append(errors, TreeValidationError{
469- ErrorType: "STACK_MISSING_WRAPPER",
470- NodePointer: uintptr(getNodePointer(node)),
471- Message: "stacked node missing stackWrapper",
472- })
473- }
474-
475- validate(node.left)
476- validate(node.right)
477- }
478-
479- validate(root)
480- return errors
481-}
482-
483-// calculateDepth calculates the depth of a subtree
484-func (tv *TreeValidator) calculateDepth(node *paneNode) int {
485- if node == nil {
486- return 0
29+// Enable turns validation on (no functional impact in the stub).
30+func (tv *TreeValidator) Enable() {
31+ if tv != nil {
32+ tv.enabled = true
48733 }
488- leftDepth := tv.calculateDepth(node.left)
489- rightDepth := tv.calculateDepth(node.right)
490- return 1 + max(leftDepth, rightDepth)
49134 }
49235
493-// addToHistory adds a validation result to the history
494-func (tv *TreeValidator) addToHistory(result TreeValidationResult) {
495- // Keep only the last 100 results
496- if len(tv.validationHistory) >= 100 {
497- tv.validationHistory = tv.validationHistory[1:]
36+// Disable turns validation off.
37+func (tv *TreeValidator) Disable() {
38+ if tv != nil {
39+ tv.enabled = false
49840 }
499- tv.validationHistory = append(tv.validationHistory, result)
50041 }
50142
502-// GetValidationHistory returns the validation history
503-func (tv *TreeValidator) GetValidationHistory() []TreeValidationResult {
504- tv.mu.RLock()
505- defer tv.mu.RUnlock()
506-
507- // Return a copy to prevent race conditions
508- history := make([]TreeValidationResult, len(tv.validationHistory))
509- copy(history, tv.validationHistory)
510- return history
511-}
512-
513-// GetValidationStats returns summary statistics
43+// GetValidationStats mimics the old diagnostic output.
51444 func (tv *TreeValidator) GetValidationStats() map[string]interface{} {
515- tv.mu.RLock()
516- defer tv.mu.RUnlock()
517-
518- if len(tv.validationHistory) == 0 {
519- return map[string]interface{}{
520- "total_validations": 0,
521- }
522- }
523-
524- totalValidations := len(tv.validationHistory)
525- successfulValidations := 0
526- totalErrors := 0
527- avgDuration := time.Duration(0)
528-
529- for _, result := range tv.validationHistory {
530- if result.Success {
531- successfulValidations++
532- }
533- totalErrors += result.ErrorCount
534- avgDuration += result.Duration
535- }
536-
537- avgDuration = avgDuration / time.Duration(totalValidations)
538-
53945 return map[string]interface{}{
540- "total_validations": totalValidations,
541- "successful_validations": successfulValidations,
542- "success_rate": float64(successfulValidations) / float64(totalValidations),
543- "total_errors": totalErrors,
544- "avg_duration_ms": avgDuration.Milliseconds(),
545- "enabled": tv.enabled,
546- "debug_mode": tv.debugMode,
547- }
548-}
549-
550-// Helper functions
551-
552-func getNodePointer(node *paneNode) unsafe.Pointer {
553- if node == nil {
554- return nil
46+ "enabled": tv != nil && tv.enabled,
55547 }
556- return unsafe.Pointer(node)
55748 }
55849
559-func abs(x int) int {
560- if x < 0 {
561- return -x
562- }
563- return x
50+// Enabled reports current state (used by tests/instrumentation).
51+func (tv *TreeValidator) Enabled() bool {
52+ return tv != nil && tv.enabled
56453 }
M internal/app/browser/workspace_types.go +44 -8
@@ -2,20 +2,55 @@
22 package browser
33
44 import (
5+ "os"
6+
57 "github.com/bnema/dumber/pkg/webkit"
68 )
79
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+
837 // paneNode represents a node in the workspace pane tree structure.
938 // It can be either a leaf node (containing a browser pane) or a branch node
1039 // (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
1146 type paneNode struct {
1247 pane *BrowserPane
1348 parent *paneNode
1449 left *paneNode
1550 right *paneNode
1651
17- // Widget management with proper lifecycle tracking
18- container *SafeWidget // Main container (GtkPaned for branch nodes, wrapper GtkBox for stacked nodes, WebView container for leaves)
52+ // Widget management - direct GTK pointers (all ops on main thread)
53+ container uintptr // Main container: GtkPaned (branch), GtkBox (stack), or WebView container (leaf)
1954 orientation webkit.Orientation
2055 isLeaf bool
2156 isPopup bool // Deprecated: use windowType instead
@@ -31,16 +66,17 @@ type paneNode struct {
3166 pendingHoverReattach bool
3267 pendingFocusReattach bool
3368
34- // Stacked panes support with proper widget management
69+ // Stacked panes support - terminal branch nodes
3570 isStacked bool // Whether this node contains stacked panes
3671 stackedPanes []*paneNode // List of stacked panes (if isStacked)
3772 activeStackIndex int // Index of currently visible pane in stack
38- titleBar *SafeWidget // GtkBox for title bar (when collapsed)
39- stackWrapper *SafeWidget // Internal GtkBox containing the actual stacked widgets (titles + webviews)
73+ titleBar uintptr // GtkBox for title bar (when collapsed)
74+ stackWrapper uintptr // Internal GtkBox containing the actual stacked widgets (titles + webviews)
4075
41- // Enhanced pane close refactoring fields
42- widgetValid bool // Guard flagged before GTK destruction
43- cleanupGeneration uint // Helps assert that asynchronous callbacks skip stale nodes
76+ // Cleanup tracking
77+ widgetValid bool // Guard flagged before GTK destruction
78+ cleanupGeneration uint // Helps assert that asynchronous callbacks skip stale nodes
79+ pendingIdleHandles map[uintptr]struct{} // Idle callbacks touching this node
4480 }
4581
4682 // Workspace CSS class constants
M internal/app/browser/workspace_utils.go +196 -146
@@ -22,15 +22,12 @@ func (wm *WorkspaceManager) UpdateTitleBar(webView *webkit.WebView, title string
2222 }
2323
2424 // ensureGUIInPane lazily loads GUI components into a pane when it gains focus
25-// ensureGUIInPane is now a no-op since GUI is injected globally by WebKit
26-// This prevents duplicate GUI injection that was causing duplicate log messages
2725 func (wm *WorkspaceManager) ensureGUIInPane(pane *BrowserPane) {
2826 if pane == nil {
2927 return
3028 }
3129
3230 // GUI is already injected globally via WebKit's enableUserContentManager
33- // Just mark the pane as having GUI to prevent unnecessary calls
3431 if !pane.HasGUI() {
3532 pane.SetHasGUI(true)
3633 pane.SetGUIComponent("manager", true)
@@ -54,75 +51,38 @@ func mapDirection(direction string) (webkit.Orientation, bool) {
5451
5552 // Widget management utilities
5653
57-// registerWidget registers a widget with the registry and returns a SafeWidget
58-func (wm *WorkspaceManager) registerWidget(ptr uintptr, typeInfo string) *SafeWidget {
59- if ptr == 0 {
60- return nil
61- }
62- return wm.widgetRegistry.Register(ptr, typeInfo)
63-}
64-
65-// initializePaneWidgets sets up SafeWidget wrappers for a paneNode
54+// initializePaneWidgets sets up widgets for a paneNode
6655 func (wm *WorkspaceManager) initializePaneWidgets(node *paneNode, containerPtr uintptr) {
67- // Register the main container
68- node.container = wm.registerWidget(containerPtr, "pane-container")
56+ node.container = containerPtr
6957
7058 // Add base CSS class to the container
71- if node.container != nil {
72- node.container.Execute(func(ptr uintptr) error {
73- webkit.WidgetAddCSSClass(ptr, basePaneClass)
74- return nil
75- })
59+ if node.container != 0 && webkit.WidgetIsValid(node.container) {
60+ webkit.WidgetAddCSSClass(node.container, basePaneClass)
7661 }
7762
78- // Initialize other widget fields as nil (will be set when needed)
79- node.titleBar = nil
80- node.stackWrapper = nil
63+ // Initialize other widget fields as zero (will be set when needed)
64+ node.titleBar = 0
65+ node.stackWrapper = 0
8166
82- // Initialize enhanced close refactoring fields
67+ // Initialize cleanup tracking fields
8368 node.widgetValid = true
8469 node.cleanupGeneration = 0
8570 }
8671
87-// Helper functions for safe widget operations
88-
89-// getContainerPtr returns the container widget pointer for a pane node
90-func (node *paneNode) getContainerPtr() uintptr {
91- if node.container != nil {
92- return node.container.Ptr()
93- }
94- return 0
95-}
96-
97-// getTitleBarPtr returns the title bar widget pointer for a pane node
98-func (node *paneNode) getTitleBarPtr() uintptr {
99- if node.titleBar != nil {
100- return node.titleBar.Ptr()
101- }
102- return 0
103-}
104-
105-// getStackWrapperPtr returns the stack wrapper widget pointer for a pane node
106-func (node *paneNode) getStackWrapperPtr() uintptr {
107- if node.stackWrapper != nil {
108- return node.stackWrapper.Ptr()
109- }
110- return 0
111-}
112-
113-// setContainer sets the SafeWidget container
72+// setContainer sets the container widget pointer
11473 func (wm *WorkspaceManager) setContainer(node *paneNode, ptr uintptr, typeInfo string) {
115- node.container = wm.registerWidget(ptr, typeInfo)
74+ _ = typeInfo // Type info kept for API compatibility but not used
75+ node.container = ptr
11676 }
11777
118-// setTitleBar sets the SafeWidget titleBar
78+// setTitleBar sets the titleBar widget pointer
11979 func (wm *WorkspaceManager) setTitleBar(node *paneNode, ptr uintptr) {
120- node.titleBar = wm.registerWidget(ptr, "title-bar")
80+ node.titleBar = ptr
12181 }
12282
123-// setStackWrapper sets the SafeWidget stackWrapper
83+// setStackWrapper sets the stackWrapper widget pointer
12484 func (wm *WorkspaceManager) setStackWrapper(node *paneNode, ptr uintptr) {
125- node.stackWrapper = wm.registerWidget(ptr, "stack-wrapper")
85+ node.stackWrapper = ptr
12686 }
12787
12888 // Centralized Active Pane Border Management System
@@ -130,10 +90,10 @@ func (wm *WorkspaceManager) setStackWrapper(node *paneNode, ptr uintptr) {
13090
13191 // PaneBorderContext holds the context for applying borders to any pane type
13292 type PaneBorderContext struct {
133- webViewWidget uintptr // The WebView's native widget (for margin)
134- borderContainer *SafeWidget // The container that gets background color
135- cssClasses []string // Additional CSS classes to apply
136- paneType string // Description for debugging
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
13797 }
13898
13999 // determineBorderContext analyzes a pane node and determines the correct border context
@@ -149,137 +109,227 @@ func (wm *WorkspaceManager) determineBorderContext(node *paneNode) *PaneBorderCo
149109 ctx.webViewWidget = node.pane.webView.RootWidget()
150110 }
151111
152- // Step 2: Determine border container and CSS classes based on pane hierarchy and type
112+ // Step 2: Determine the border container based on pane type
153113 switch {
154- case node.parent != nil && node.parent.isStacked:
155- // STACKED PANE: Border goes on the stack container
156- ctx.borderContainer = node.parent.container
157- ctx.cssClasses = []string{stackContainerClass, activePaneClass}
158- ctx.paneType = "stacked"
159-
160114 case node.windowType == webkit.WindowTypePopup:
161- // POPUP PANE: Border goes on the popup's own container
115+ // Popup panes: Border on popup container
162116 ctx.borderContainer = node.container
163- ctx.cssClasses = []string{basePaneClass, activePaneClass}
117+ ctx.cssClasses = []string{activePaneClass}
164118 ctx.paneType = "popup"
165119
166- case node.isLeaf && !node.isStacked:
167- // REGULAR LEAF PANE: Border goes on the pane's container
168- ctx.borderContainer = node.container
169- ctx.cssClasses = []string{basePaneClass, activePaneClass}
170- ctx.paneType = "regular-leaf"
171-
172- case !node.isLeaf:
173- // SPLIT PANE (branch node): Border goes on the split container
174- ctx.borderContainer = node.container
175- ctx.cssClasses = []string{basePaneClass, multiPaneClass, activePaneClass}
176- ctx.paneType = "split-branch"
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"
177125
178126 default:
179- // FALLBACK: Use the node's own container
127+ // Regular pane: Border on pane container
180128 ctx.borderContainer = node.container
181- ctx.cssClasses = []string{basePaneClass, activePaneClass}
182- ctx.paneType = "fallback"
129+ ctx.cssClasses = []string{activePaneClass}
130+ ctx.paneType = "regular"
183131 }
184132
185133 return ctx
186134 }
187135
188-// applyActivePaneBorder adds the active pane visual border using the centralized system
189-func (wm *WorkspaceManager) applyActivePaneBorder(node *paneNode) {
190- ctx := wm.determineBorderContext(node)
136+// applyActivePaneBorder applies the active pane border using the border context
137+func (wm *WorkspaceManager) applyActivePaneBorder(ctx *PaneBorderContext) {
191138 if ctx == nil {
139+ log.Printf("[workspace] applyActivePaneBorder: nil context")
192140 return
193141 }
194142
195- // Apply CSS classes to border container (provides border styling)
196- if ctx.borderContainer != nil {
197- var applied bool
198- if err := ctx.borderContainer.Execute(func(containerPtr uintptr) error {
199- // Safety check: verify container pointer is valid and widget still exists
200- if containerPtr == 0 || !webkit.WidgetIsValid(containerPtr) {
201- return fmt.Errorf("invalid container pointer or destroyed widget")
202- }
203- if webkit.WidgetHasCSSClass(containerPtr, activePaneClass) {
204- return nil
205- }
206- webkit.WidgetAddCSSClass(containerPtr, activePaneClass)
207- applied = true
208- return nil
209- }); err != nil {
210- log.Printf("[workspace] Failed to apply border to %s pane: %v", ctx.paneType, err)
211- } else if applied {
212- log.Printf("[workspace] Applied border to %s pane: %p", ctx.paneType, node)
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)
213152 }
214153 }
154+
155+ log.Printf("[workspace] Applied active border: type=%s webView=%#x container=%#x",
156+ ctx.paneType, ctx.webViewWidget, ctx.borderContainer)
215157 }
216158
217-// removeActivePaneBorder removes the active pane visual border using the centralized system
159+// removeActivePaneBorder removes the active pane border from a node
218160 func (wm *WorkspaceManager) removeActivePaneBorder(node *paneNode) {
161+ if node == nil {
162+ return
163+ }
164+
219165 ctx := wm.determineBorderContext(node)
220166 if ctx == nil {
221167 return
222168 }
223169
170+ // Remove margin from WebView widget
171+ if ctx.webViewWidget != 0 && webkit.WidgetIsValid(ctx.webViewWidget) {
172+ webkit.WidgetSetMargin(ctx.webViewWidget, 0)
173+ }
174+
224175 // Remove CSS classes from border container
225- if ctx.borderContainer != nil {
226- var removed bool
227- if err := ctx.borderContainer.Execute(func(containerPtr uintptr) error {
228- // Safety check: verify container pointer is valid and widget still exists
229- if containerPtr == 0 || !webkit.WidgetIsValid(containerPtr) {
230- return fmt.Errorf("invalid container pointer or destroyed widget")
231- }
232- if !webkit.WidgetHasCSSClass(containerPtr, activePaneClass) {
233- return nil
234- }
235- webkit.WidgetRemoveCSSClass(containerPtr, activePaneClass)
236- removed = true
237- return nil
238- }); err != nil {
239- log.Printf("[workspace] Failed to remove border from %s pane: %v", ctx.paneType, err)
240- } else if removed {
241- log.Printf("[workspace] Removed border from %s pane: %p", ctx.paneType, node)
176+ if ctx.borderContainer != 0 && webkit.WidgetIsValid(ctx.borderContainer) {
177+ for _, class := range ctx.cssClasses {
178+ webkit.WidgetRemoveCSSClass(ctx.borderContainer, class)
242179 }
243180 }
181+
182+ log.Printf("[workspace] Removed active border: type=%s webView=%#x container=%#x",
183+ ctx.paneType, ctx.webViewWidget, ctx.borderContainer)
244184 }
245185
246-// removeActivePaneBorderFromAll removes active pane borders from all containers using centralized system
247-func (wm *WorkspaceManager) removeActivePaneBorderFromAll() {
248- if wm == nil {
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 {
249191 return
250192 }
251193
252- log.Printf("[workspace] Removing active borders from all panes")
194+ // Widget will be destroyed by GTK when unparented/removed
195+ // No manual cleanup needed - GTK4 handles reference counting
196+}
253197
254- // Use the centralized system for each pane
255- for _, node := range wm.viewToNode {
256- if node != nil {
257- wm.removeActivePaneBorder(node)
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+ }
258215 }
216+ return fn()
217+ })
218+ if handle != 0 {
219+ wm.registerIdleHandle(handle, guards...)
259220 }
221+ return handle
260222 }
261223
262-// isActivePaneBorderApplied checks if the active pane border is currently applied to a node using centralized system
263-func (wm *WorkspaceManager) isActivePaneBorderApplied(node *paneNode) bool {
264- ctx := wm.determineBorderContext(node)
265- if ctx == nil || ctx.borderContainer == nil {
266- return false
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)
267239 }
240+ return unique
241+}
268242
269- var hasClass bool
270- ctx.borderContainer.Execute(func(containerPtr uintptr) error {
271- hasClass = webkit.WidgetHasCSSClass(containerPtr, activePaneClass)
272- return nil
273- })
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+}
274285
275- return hasClass
286+func (wm *WorkspaceManager) cancelIdleHandle(handle uintptr) {
287+ if handle == 0 {
288+ return
289+ }
290+ wm.releaseIdleHandle(handle)
291+ webkit.IdleRemove(handle)
276292 }
277293
278-// getBorderContextInfo returns debugging information about a pane's border context
279-func (wm *WorkspaceManager) getBorderContextInfo(node *paneNode) string {
280- ctx := wm.determineBorderContext(node)
281- if ctx == nil {
282- return "no-context"
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)
283301 }
284- return ctx.paneType
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
285335 }
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/thread_helpers.c +2 -0
@@ -1,3 +1,5 @@
1+// +build webkit_cgo
2+
13 #include <glib.h>
24 #include <pthread.h>
35
M pkg/webkit/webview_cgo.go +10 -0
@@ -1630,21 +1630,26 @@ func (w *WebView) Destroy() error {
16301630 return ErrNotImplemented
16311631 }
16321632
1633+ 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)
16331634 // Stop periodic GC if running
16341635 w.stopPeriodicGC()
1636+ log.Printf("[webkit-destroy] periodic GC stopped: id=%d", w.id)
16351637
16361638 // Clean up memory pressure settings
16371639 if w.memStats != nil && w.memStats.memoryPressureSettings != nil {
16381640 // WebKit will clean up the memory pressure settings when context is destroyed
16391641 w.memStats.memoryPressureSettings = nil
1642+ log.Printf("[webkit-destroy] cleared memory pressure settings: id=%d", w.id)
16401643 }
16411644
16421645 // Unregister from memory manager
16431646 if globalMemoryManager != nil {
16441647 globalMemoryManager.UnregisterWebView(w.id)
1648+ log.Printf("[webkit-destroy] unregistered from memory manager: id=%d", w.id)
16451649 }
16461650
16471651 if w.native != nil && w.native.ucm != nil && w.domBridgeScript != nil {
1652+ log.Printf("[webkit-destroy] removing DOM bridge script: id=%d", w.id)
16481653 C.webkit_user_content_manager_remove_script(w.native.ucm, w.domBridgeScript)
16491654 C.webkit_user_script_unref(w.domBridgeScript)
16501655 w.domBridgeScript = nil
@@ -1652,6 +1657,7 @@ func (w *WebView) Destroy() error {
16521657
16531658 // GTK4: destroy windows via gtk_window_destroy if we created one
16541659 if w.native.win != nil && w.config != nil && w.config.CreateWindow {
1660+ log.Printf("[webkit-destroy] destroying GTK window: id=%d win=%p", w.id, w.native.win)
16551661 C.gtk_window_destroy((*C.GtkWindow)(unsafe.Pointer(w.native.win)))
16561662 w.native.win = nil
16571663 if w.window != nil {
@@ -1660,13 +1666,17 @@ func (w *WebView) Destroy() error {
16601666 log.Printf("[webkit] Destroy window")
16611667 }
16621668
1669+ log.Printf("[webkit-destroy] releasing native widgets: id=%d", w.id)
16631670 w.releaseNativeWidgets()
1671+ log.Printf("[webkit-destroy] native widgets released: id=%d", w.id)
16641672 w.destroyed = true
16651673
16661674 // Unregister from dynamic protection updates
16671675 UnregisterActiveWebView(w)
1676+ log.Printf("[webkit-destroy] unregistered active view: id=%d", w.id)
16681677
16691678 unregisterView(w.id)
1679+ log.Printf("[webkit-destroy] destroy complete: id=%d", w.id)
16701680 return nil
16711681 }
16721682
M pkg/webkit/workspace_widgets_cgo.go +24 -4
@@ -43,8 +43,12 @@ static gboolean idle_callback_wrapper(gpointer data) {
4343 return goIdleCallback((uintptr_t)data);
4444 }
4545
46-static void add_idle_callback(uintptr_t handle) {
47- 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);
4852 }
4953
5054 static void motion_enter_cb(GtkEventControllerMotion* controller, gdouble x, gdouble y, gpointer user_data) {
@@ -817,11 +821,27 @@ var (
817821 )
818822
819823 // IdleAdd schedules a function to be called when the main loop is idle.
820-func IdleAdd(fn func() bool) {
824+func IdleAdd(fn func() bool) uintptr {
825+ if fn == nil {
826+ return 0
827+ }
821828 id := nextIdleID
822829 nextIdleID++
823830 idleCallbacks[id] = fn
824- C.add_idle_callback(C.uintptr_t(id))
831+ if C.add_idle_callback(C.uintptr_t(id)) == 0 {
832+ delete(idleCallbacks, id)
833+ return 0
834+ }
835+ return id
836+}
837+
838+// IdleRemove cancels a previously scheduled idle callback.
839+func IdleRemove(handle uintptr) {
840+ if handle == 0 {
841+ return
842+ }
843+ C.remove_idle_callback(C.uintptr_t(handle))
844+ delete(idleCallbacks, handle)
825845 }
826846
827847 // IterateMainLoop processes a single GTK main loop iteration and reports if work was handled.
M pkg/webkit/workspace_widgets_stub.go +7 -1
@@ -395,10 +395,16 @@ func WidgetWaitForDraw(widget uintptr) {
395395 }
396396
397397 // IdleAdd simulates GTK's idle callback system - DEPRECATED, use WidgetWaitForDraw instead
398-func IdleAdd(fn func() bool) {
398+func IdleAdd(fn func() bool) uintptr {
399399 if fn != nil {
400400 fn()
401401 }
402+ return 0
403+}
404+
405+// IdleRemove cancels a simulated idle callback (no-op in stub builds).
406+func IdleRemove(handle uintptr) {
407+ _ = handle
402408 }
403409
404410 func AddCSSProvider(css string) {}

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