detached e55dc9a

feat(workspace): implement bulletproof pane management with enhanced widget safety

commit
e55dc9a233aebef27bee27288a5b71569b7569d3
author
Brice · · 2025-09-28 17:07 UTC
parent
043bf2c
signature
unsigned
25 files · +6438 -3161
A docs/GO_CLEANUP_PLAN.md +216 -0
@@ -0,0 +1,216 @@
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)⏎̸
A docs/PANE_ARCHITECTURE.md +694 -0
@@ -0,0 +1,694 @@
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
95+ +[]*paneNode stackedPanes
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+### Split Operation Flow
197+
198+```mermaid
199+sequenceDiagram
200+ participant User
201+ participant WM as WorkspaceManager
202+ participant BP as BulletproofOps
203+ participant TV as TreeValidator
204+ participant GV as GeometryValidator
205+ participant GTK as GTK Layer
206+
207+ User->>WM: Request split (target, direction)
208+ WM->>BP: BulletproofSplitNode()
209+ BP->>TV: ValidateTree(before_split)
210+ TV-->>BP: Validation result
211+ BP->>GV: ValidateSplit(target, direction)
212+ GV-->>BP: Geometry validation
213+
214+ alt Validation passes
215+ BP->>WM: splitNode(target, direction)
216+ WM->>GTK: Create GtkPaned
217+ GTK-->>WM: Paned widget
218+ WM->>GTK: Reparent existing widget
219+ WM->>GTK: Add new WebView
220+ WM->>WM: Update tree structure
221+ WM-->>BP: New pane node
222+ BP->>TV: ValidateTree(after_split)
223+ BP-->>User: Success + new pane
224+ else Validation fails
225+ BP-->>User: Error message
226+ end
227+```
228+
229+### Close Operation Flow
230+
231+```mermaid
232+sequenceDiagram
233+ participant User
234+ participant WM as WorkspaceManager
235+ participant BP as BulletproofOps
236+ participant STM as StateTombstoneManager
237+ participant TV as TreeValidator
238+ participant TR as TreeRebalancer
239+ participant GTK as GTK Layer
240+
241+ User->>WM: Request close (target)
242+ WM->>BP: BulletproofClosePane()
243+ BP->>STM: CaptureState("close")
244+ STM-->>BP: State tombstone
245+ BP->>TV: ValidateTree(before_close)
246+ TV-->>BP: Validation result
247+
248+ alt Has sibling
249+ BP->>WM: closePane(target)
250+ WM->>WM: Find sibling/replacement node
251+ WM->>GTK: Unparent target widget
252+ WM->>GTK: Promote sibling to new position
253+ WM->>WM: Update tree structure
254+ WM-->>BP: Return promoted node
255+ BP->>TV: ValidateTree(after_close)
256+ BP->>TR: RebalanceAfterClose(closed, promoted)
257+ TR->>TR: Execute promotion transaction
258+ TR->>GTK: WidgetResetSizeRequest(promoted)
259+ TR->>GTK: Reattach with proper expansion
260+ TR->>GTK: Queue allocation on ancestors
261+ TR->>TR: Validate geometry
262+ TR-->>BP: Promotion completed
263+ BP-->>User: Success
264+ else Last pane
265+ WM->>GTK: QuitMainLoop()
266+ WM-->>BP: Return nil (app exiting)
267+ else Error occurs
268+ BP->>STM: RestoreState(tombstone)
269+ BP-->>User: Error + rollback
270+ end
271+```
272+
273+### Stack Operation
274+
275+```mermaid
276+sequenceDiagram
277+ participant User
278+ participant WM as WorkspaceManager
279+ participant SPM as StackedPaneManager
280+ participant GTK as GTK Layer
281+
282+ User->>WM: Request stack (target)
283+ WM->>SPM: StackPane(target)
284+ SPM->>GTK: Create GtkBox container
285+ GTK-->>SPM: Box widget
286+ SPM->>GTK: Create title bar
287+ SPM->>GTK: Add target to stack
288+ SPM->>GTK: Create new WebView
289+ SPM->>GTK: Add new pane to stack
290+ SPM->>SPM: Update stack navigation
291+ SPM-->>WM: New stacked node
292+ WM-->>User: Success
293+```
294+
295+## Widget Layer Integration
296+
297+### SafeWidget Architecture
298+
299+```mermaid
300+graph TB
301+ subgraph "SafeWidget System"
302+ SW[SafeWidget]
303+ WR[WidgetRegistry]
304+ WT[WidgetTransaction]
305+ end
306+
307+ subgraph "GTK Widgets"
308+ GP[GtkPaned]
309+ GB[GtkBox]
310+ WV[WebKitWebView]
311+ GL[GtkLabel]
312+ end
313+
314+ SW --> WR
315+ SW --> WT
316+ SW --> GP
317+ SW --> GB
318+ SW --> WV
319+ SW --> GL
320+
321+ WR -.-> |"Track lifetimes"| GP
322+ WR -.-> |"Track lifetimes"| GB
323+ WR -.-> |"Track lifetimes"| WV
324+
325+ WT -.-> |"Atomic operations"| GP
326+ WT -.-> |"Atomic operations"| GB
327+ WT -.-> |"Atomic operations"| WV
328+```
329+
330+### Widget Lifecycle Management
331+
332+```mermaid
333+stateDiagram-v2
334+ [*] --> Created
335+ Created --> Registered: Register with WidgetRegistry
336+ Registered --> Realized: WidgetRealizeInContainer()
337+ Realized --> Parented: Add to parent widget
338+ Parented --> Visible: WidgetShow()
339+ Visible --> Processing: Normal operation
340+ Processing --> Reparenting: During splits/closes
341+ Reparenting --> Unparented: WidgetUnparent()
342+ Unparented --> Parented: New parent assigned
343+ Processing --> Invalidated: Widget destroyed
344+ Invalidated --> [*]
345+
346+ Reparenting --> ValidationFailed: Widget becomes invalid
347+ ValidationFailed --> Invalidated
348+```
349+
350+## Focus Management System
351+
352+### Focus State Machine
353+
354+```mermaid
355+stateDiagram-v2
356+ [*] --> Initializing
357+ Initializing --> Idle: System ready
358+ Idle --> Transitioning: Focus request received
359+ Transitioning --> Focused: Focus applied successfully
360+ Focused --> Idle: Focus settled
361+ Transitioning --> Reconciling: Focus conflict detected
362+ Reconciling --> Focused: Conflict resolved
363+ Reconciling --> Idle: Fallback to previous state
364+
365+ note right of Transitioning: Priority queue processes requests
366+ note right of Reconciling: CSS class synchronization
367+ note right of Focused: GTK focus + CSS classes applied
368+```
369+
370+### Focus Request Processing
371+
372+```mermaid
373+graph TB
374+ subgraph "Focus Request Flow"
375+ FR[Focus Request]
376+ DD[Request Deduplicator]
377+ PQ[Priority Queue]
378+ FSM[Focus State Machine]
379+ GC[GTK Controllers]
380+ CSS[CSS Classes]
381+ end
382+
383+ FR --> DD
384+ DD --> |"Not duplicate"| PQ
385+ DD --> |"Duplicate"| Discard[Discard]
386+ PQ --> FSM
387+ FSM --> GC
388+ FSM --> CSS
389+
390+ subgraph "Priority Levels"
391+ PU[Urgent: 100]
392+ PH[High: 90]
393+ PN[Normal: 50]
394+ PL[Low: 10]
395+ end
396+
397+ PQ --> PU
398+ PQ --> PH
399+ PQ --> PN
400+ PQ --> PL
401+```
402+
403+## Safety and Validation Systems
404+
405+### Tree Validation
406+
407+```mermaid
408+graph TB
409+ subgraph "Tree Validation System"
410+ TV[TreeValidator]
411+ VS[Validation Suite]
412+
413+ subgraph "Validation Checks"
414+ PC[Parent-Child Consistency]
415+ RC[Root Node Validation]
416+ LC[Leaf Node Validation]
417+ SC[Stack Consistency]
418+ WC[Widget Consistency]
419+ end
420+ end
421+
422+ TV --> VS
423+ VS --> PC
424+ VS --> RC
425+ VS --> LC
426+ VS --> SC
427+ VS --> WC
428+
429+ PC -.-> |"Bidirectional links"| TreeStructure[Tree Structure]
430+ RC -.-> |"Single root"| TreeStructure
431+ LC -.-> |"Valid panes"| TreeStructure
432+ SC -.-> |"Stack integrity"| TreeStructure
433+ WC -.-> |"Widget pointers"| TreeStructure
434+```
435+
436+### Bulletproof Operation Wrapper
437+
438+```mermaid
439+sequenceDiagram
440+ participant Client
441+ participant BP as BulletproofOps
442+ participant STM as StateTombstone
443+ participant GV as GeometryValidator
444+ participant TV as TreeValidator
445+ participant CC as ConcurrencyController
446+ participant TR as TreeRebalancer
447+ participant Core as CoreOperation
448+
449+ Client->>BP: Operation request
450+ BP->>STM: Capture state tombstone
451+ BP->>GV: Validate geometry constraints
452+ BP->>TV: Validate tree before operation
453+
454+ alt On GTK main thread
455+ BP->>Core: Execute directly
456+ else Off main thread
457+ BP->>CC: Submit to concurrency controller
458+ CC->>Core: Execute on main thread
459+ end
460+
461+ Core-->>BP: Operation result
462+
463+ alt Success
464+ BP->>TV: Validate tree after operation
465+ BP->>TR: Rebalance if needed
466+ BP-->>Client: Success
467+ else Failure
468+ BP->>STM: Restore state from tombstone
469+ BP-->>Client: Error + rollback
470+ end
471+```
472+
473+## Operation Flow Diagrams
474+
475+### Complete Split Operation
476+
477+```mermaid
478+flowchart TD
479+ Start([Split Request]) --> V1{Tree Valid?}
480+ V1 -->|No| Error1[Return Error]
481+ V1 -->|Yes| V2{Geometry Valid?}
482+ V2 -->|No| Error2[Return Error]
483+ V2 -->|Yes| Capture[Capture State Tombstone]
484+
485+ Capture --> Thread{On GTK Thread?}
486+ Thread -->|Yes| Direct[Execute Directly]
487+ Thread -->|No| Queue[Submit to Concurrency Controller]
488+ Queue --> Execute[Execute on Main Thread]
489+ Direct --> Execute
490+
491+ Execute --> CreatePaned[Create GtkPaned Widget]
492+ CreatePaned --> CreateWebView[Create New WebView]
493+ CreateWebView --> DetachControllers[Safely Detach GTK Controllers]
494+ DetachControllers --> UnparentTarget[Unparent Target Widget]
495+ UnparentTarget --> SetupPaned[Configure Paned Properties]
496+ SetupPaned --> AddChildren[Add Both Widgets to Paned]
497+ AddChildren --> UpdateTree[Update Tree Structure]
498+ UpdateTree --> AttachPaned[Attach Paned to Parent/Window]
499+ AttachPaned --> ReattachControllers[Reattach GTK Controllers]
500+ ReattachControllers --> ShowWidgets[Show All Widgets]
501+ ShowWidgets --> UpdateCSS[Update CSS Classes]
502+ UpdateCSS --> SetFocus[Set Focus to New Pane]
503+ SetFocus --> Validate[Validate Tree Structure]
504+ Validate --> Rebalance[Tree Rebalancing Check]
505+ Rebalance --> Success([Return New Pane])
506+
507+ Execute -->|Error| Rollback[Restore from Tombstone]
508+ Rollback --> Error3[Return Error]
509+
510+ style Start fill:#e1f5fe
511+ style Success fill:#e8f5e8
512+ style Error1 fill:#ffebee
513+ style Error2 fill:#ffebee
514+ style Error3 fill:#ffebee
515+```
516+
517+### Complete Close Operation
518+
519+```mermaid
520+flowchart TD
521+ Start([Close Request]) --> Stack{In Stack?}
522+ Stack -->|Yes| StackClose[Use Stack Lifecycle Manager]
523+ Stack -->|No| ValidateTree[Validate Tree Structure]
524+
525+ ValidateTree --> Capture[Capture State Tombstone]
526+ Capture --> CheckLast{Last Pane?}
527+
528+ CheckLast -->|Yes| QuitApp[Quit Application - Return nil]
529+ CheckLast -->|No| CheckRoot{Is Root?}
530+
531+ CheckRoot -->|Yes| FindReplacement[Find Replacement Root]
532+ CheckRoot -->|No| FindSibling[Find Sibling Node]
533+
534+ FindReplacement --> HasReplacement{Has Replacement?}
535+ HasReplacement -->|No| QuitApp
536+ HasReplacement -->|Yes| PromoteRoot[Promote Replacement to Root]
537+
538+ FindSibling --> ValidateWidgets[Validate Widget State]
539+ ValidateWidgets --> CheckGrand{Has Grandparent?}
540+
541+ CheckGrand -->|Yes| PromoteToParent[Promote Sibling to Parent Position]
542+ CheckGrand -->|No| PromoteToRoot
543+
544+ PromoteRoot --> TrackPromoted[Track Promoted Node]
545+ PromoteToParent --> TrackPromoted
546+
547+ TrackPromoted --> UnparentFromCurrent[Unparent from Current Parent]
548+ UnparentFromCurrent --> AttachToNew[Attach to New Parent/Window]
549+ AttachToNew --> UpdateTreeRefs[Update Tree References]
550+ UpdateTreeRefs --> CleanupOld[Cleanup Old Parent Node]
551+ CleanupOld --> FindFocus[Find Focus Target]
552+ FindFocus --> SetFocus[Set Focus]
553+ SetFocus --> CleanupPane[Cleanup Closed Pane]
554+ CleanupPane --> DestroyWebView[Destroy WebView]
555+ DestroyWebView --> UpdateMain[Update Main Pane Reference]
556+ UpdateMain --> ReturnPromoted[Return Promoted Node]
557+ ReturnPromoted --> PromotionTx[Execute Promotion Transaction]
558+ PromotionTx --> ResetSize[WidgetResetSizeRequest]
559+ ResetSize --> ReattachWidget[Reattach with Expansion]
560+ ReattachWidget --> QueueAllocation[Queue Allocation on Ancestors]
561+ QueueAllocation --> ValidateGeometry[Validate Final Geometry]
562+ ValidateGeometry --> Success([Success with Promoted Node])
563+
564+ StackClose --> Success
565+ QuitApp --> SuccessNil([Success - App Exiting])
566+
567+ style Start fill:#e1f5fe
568+ style Success fill:#e8f5e8
569+ style SuccessNil fill:#fff3e0
570+ style TrackPromoted fill:#e3f2fd
571+ style PromotionTx fill:#f3e5f5
572+```
573+
574+## CSS Class Management
575+
576+The system uses CSS classes for visual feedback and state indication:
577+
578+```mermaid
579+graph TB
580+ subgraph "CSS Class System"
581+ Base[.workspace-pane]
582+ Multi[.workspace-multi-pane]
583+ Active[.workspace-pane-active]
584+ Outline[.workspace-pane-active-outline]
585+ Stack[.stacked-pane-container]
586+ end
587+
588+ subgraph "Application Logic"
589+ Single[Single Pane] --> Base
590+ Multiple[Multiple Panes] --> Base
591+ Multiple --> Multi
592+ Focused[Focused Pane] --> Active
593+ Focused --> Outline
594+ StackContainer[Stack Container] --> Stack
595+ end
596+
597+ subgraph "Visual Effects"
598+ Base --> BasicStyling[Basic pane styling]
599+ Multi --> BorderStyling[Pane borders in multi-pane mode]
600+ Active --> FocusHighlight[Focus highlight]
601+ Outline --> FocusOutline[Focus outline border]
602+ Stack --> StackStyling[Stack container styling]
603+ end
604+```
605+
606+## Enhanced Promotion System
607+
608+The system now includes a sophisticated promotion transaction system that ensures proper widget layout after pane closures:
609+
610+### Promotion Transaction Flow
611+
612+```mermaid
613+sequenceDiagram
614+ participant BP as BulletproofOps
615+ participant WM as WorkspaceManager
616+ participant TR as TreeRebalancer
617+ participant WT as WidgetTransaction
618+ participant GTK as GTK Layer
619+
620+ BP->>WM: closePane(node)
621+ WM->>WM: Identify promoted node
622+ WM->>GTK: Update tree structure
623+ WM-->>BP: Return promoted node
624+
625+ BP->>TR: RebalanceAfterClose(closed, promoted)
626+ TR->>WT: Begin promotion transaction
627+
628+ Note over TR,WT: Widget size constraint reset
629+ TR->>WT: Add WidgetResetSizeRequest operation
630+
631+ Note over TR,WT: Widget reattachment with expansion
632+ TR->>WT: Add reattachment operation
633+
634+ Note over TR,WT: Ancestor allocation propagation
635+ TR->>WT: Add allocation queue operations
636+
637+ Note over TR,WT: Geometry validation
638+ TR->>WT: Add validation operation
639+
640+ WT->>GTK: Execute all operations atomically
641+ GTK-->>WT: Operations completed
642+ WT->>WT: Commit transaction
643+ WT-->>TR: Transaction success
644+ TR-->>BP: Promotion completed
645+```
646+
647+### Key Promotion Features
648+
649+- **Size Constraint Reset**: `WidgetResetSizeRequest()` clears stale size constraints
650+- **Proper Expansion**: Ensures promoted widgets can expand to fill available space
651+- **Ancestor Propagation**: Queues allocation updates up the widget hierarchy
652+- **Geometry Validation**: Validates final widget bounds after promotion
653+- **Transaction Safety**: All operations are atomic with rollback capability
654+
655+## Key Files and Their Responsibilities
656+
657+| File | Purpose | Key Components |
658+|------|---------|----------------|
659+| `workspace_types.go:8` | Core data structures | `paneNode`, CSS constants |
660+| `workspace_manager.go:18` | Main coordinator | `WorkspaceManager`, initialization |
661+| `workspace_pane_ops.go:707` | Tree operations | Split, close, stack operations with promoted node tracking |
662+| `workspace_tree_rebalancer.go:92` | Tree balancing | Rebalancing algorithms, promotion transactions |
663+| `workspace_bulletproof_operations.go:158` | Safety wrapper | Bulletproof operation wrappers with promotion handling |
664+| `workspace_concurrency.go:454` | Async operations | Concurrency controller with promoted node results |
665+| `focus_state_machine.go:186` | Focus management | State machine, priority queue |
666+| `workspace_widgets_cgo.go:633` | GTK integration | Widget operations, WidgetResetSizeRequest |
667+
668+## Performance Characteristics
669+
670+- **Tree Height**: O(log n) for balanced trees, O(n) worst case
671+- **Split Operation**: O(log n) average case with rebalancing
672+- **Close Operation**: O(1) tree updates + O(k) widget operations where k = ancestor depth
673+- **Focus Changes**: O(1) with priority queue and deduplication
674+- **Widget Operations**: Atomic transactions prevent partial state
675+- **Promotion Transactions**: Batched GTK operations minimize UI thread overhead
676+- **Memory Usage**: Each pane node ~200 bytes + GTK widget overhead
677+
678+### Recent Performance Improvements
679+
680+- **Promoted Node Tracking**: Eliminates redundant tree traversals during rebalancing
681+- **Batched Widget Operations**: Ancestor allocation updates processed in single transaction
682+- **Size Constraint Management**: Explicit constraint reset prevents GTK layout conflicts
683+- **Async Consistency**: Identical behavior between sync and async operation paths
684+
685+## Thread Safety
686+
687+The system implements comprehensive thread safety:
688+
689+- **Widget Operations**: All GTK operations marshaled to main thread
690+- **Focus Management**: Queue-based serialization with priority
691+- **State Changes**: Mutex protection for critical sections
692+- **Tree Modifications**: Atomic operations with rollback capability
693+
694+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/focus-refactoring.md +0 -429
@@ -1,429 +0,0 @@
1-Detailed Implementation Plan: State Machine with Focus Queue
2-
3- Architecture Overview
4-
5- The focus management system will be refactored into a centralized state machine that serializes all
6- focus operations through a queue, ensuring deterministic behavior and eliminating race conditions.
7-
8- Core Components
9-
10- 1. Focus State Machine (focus_state_machine.go)
11-
12- States:
13- - StateInitializing: System startup, determining initial focus
14- - StateIdle: No focus transition in progress, system stable
15- - StateTransitioning: Processing a focus change request
16- - StateFocused: Focus successfully applied, awaiting next request
17- - StateReconciling: Fixing inconsistencies detected in CSS/focus state
18-
19- State Transitions:
20- Initializing → Idle (on workspace ready)
21- Idle → Transitioning (on focus request)
22- Transitioning → Focused (on success)
23- Transitioning → StateReconciling (on conflict)
24- Focused → Idle (after settling time)
25- StateReconciling → Focused (after fix)
26-
27- Data Structures:
28- type FocusState string
29-
30- type FocusRequest struct {
31- ID string
32- TargetNode *paneNode
33- Source FocusSource // Keyboard, Mouse, Programmatic, System
34- Priority int // Higher priority preempts queue
35- Timestamp time.Time
36- Context map[string]interface{} // Additional metadata
37- }
38-
39- type FocusStateMachine struct {
40- mu sync.RWMutex
41- currentState FocusState
42- activePane *paneNode
43- requestQueue chan FocusRequest
44- historyRing *RingBuffer[FocusTransition]
45- cssReconciler *CSSReconciler
46- gtkController *GTKFocusController
47- validators []FocusValidator
48- settlingTimer *time.Timer
49- }
50-
51- 2. Focus Request Queue System
52-
53- Queue Management:
54- - Buffered channel with capacity 100 for focus requests
55- - Priority queue for urgent focus changes (e.g., user keyboard navigation)
56- - Request deduplication to prevent redundant operations
57- - Request coalescing for rapid focus changes
58-
59- Request Processing:
60- func (fsm *FocusStateMachine) processQueue() {
61- for request := range fsm.requestQueue {
62- // Validate request
63- if !fsm.validateRequest(request) {
64- continue
65- }
66-
67- // Check for higher priority requests
68- if fsm.hasHigherPriorityRequest(request) {
69- fsm.requeue(request)
70- continue
71- }
72-
73- // Execute state transition
74- fsm.transitionTo(StateTransitioning)
75- fsm.executeFocusChange(request)
76- fsm.transitionTo(StateFocused)
77-
78- // Start settling timer
79- fsm.startSettlingTimer()
80- }
81- }
82-
83- 3. CSS Reconciliation Engine
84-
85- Reconciler Responsibilities:
86- - Track expected CSS state for each pane
87- - Detect CSS class inconsistencies
88- - Apply corrective CSS changes atomically
89- - Verify CSS state after GTK main loop iteration
90-
91- Implementation:
92- type CSSReconciler struct {
93- expectedState map[*paneNode]CSSClasses
94- mu sync.RWMutex
95- }
96-
97- type CSSClasses struct {
98- Base []string // workspace-pane, workspace-multi-pane
99- Active bool // workspace-pane-active
100- Stacked []string // stacked-pane-active, stacked-pane-collapsed
101- Custom []string // Any additional classes
102- Timestamp time.Time
103- }
104-
105- func (cr *CSSReconciler) reconcile(node *paneNode, expected CSSClasses) error {
106- actual := cr.getCurrentClasses(node)
107- if !cr.matches(actual, expected) {
108- return cr.applyClasses(node, expected)
109- }
110- return nil
111- }
112-
113- 4. GTK4 Focus Integration
114-
115- Native Focus Tracking:
116- // In workspace_widgets_cgo.go
117- static void focus_enter_cb(GtkEventControllerFocus* controller, gpointer user_data) {
118- uintptr_t nodePtr = (uintptr_t)user_data;
119- goFocusEnterCallback(nodePtr);
120- }
121-
122- static void focus_leave_cb(GtkEventControllerFocus* controller, gpointer user_data) {
123- uintptr_t nodePtr = (uintptr_t)user_data;
124- goFocusLeaveCallback(nodePtr);
125- }
126-
127- static GtkEventController* widget_add_focus_controller(GtkWidget* widget, uintptr_t nodePtr) {
128- GtkEventController* focus = gtk_event_controller_focus_new();
129- g_signal_connect(focus, "enter", G_CALLBACK(focus_enter_cb), (gpointer)nodePtr);
130- g_signal_connect(focus, "leave", G_CALLBACK(focus_leave_cb), (gpointer)nodePtr);
131- gtk_widget_add_controller(widget, focus);
132- return focus;
133- }
134-
135- 5. Focus History & Debugging
136-
137- Ring Buffer for History:
138- type FocusTransition struct {
139- From *paneNode
140- To *paneNode
141- State FocusState
142- Source FocusSource
143- Timestamp time.Time
144- Success bool
145- Error error
146- Stack []byte // Stack trace for debugging
147- }
148-
149- type RingBuffer[T any] struct {
150- buffer []T
151- head int
152- tail int
153- size int
154- mu sync.RWMutex
155- }
156-
157- Initialization Sequence
158-
159- The initialization sequence ensures proper focus state from startup:
160-
161- func (fsm *FocusStateMachine) Initialize(wm *WorkspaceManager) error {
162- fsm.transitionTo(StateInitializing)
163-
164- // Phase 1: Setup GTK controllers for all panes
165- leaves := wm.collectLeaves()
166- for _, leaf := range leaves {
167- fsm.attachGTKController(leaf)
168- }
169-
170- // Phase 2: Determine initial focus
171- initialPane := fsm.determineInitialFocus(leaves)
172-
173- // Phase 3: Apply initial focus and CSS
174- fsm.applyInitialFocus(initialPane)
175-
176- // Phase 4: Start queue processor
177- go fsm.processQueue()
178-
179- // Phase 5: Transition to idle
180- fsm.transitionTo(StateIdle)
181-
182- return nil
183- }
184-
185- func (fsm *FocusStateMachine) determineInitialFocus(leaves []*paneNode) *paneNode {
186- // Priority order:
187- // 1. First pane with user content
188- // 2. Top-left pane (geometrically)
189- // 3. First pane in tree order
190-
191- for _, leaf := range leaves {
192- if leaf.pane != nil && leaf.pane.webView != nil {
193- if leaf.pane.webView.GetURL() != "about:blank" {
194- return leaf
195- }
196- }
197- }
198-
199- // Fall back to geometric selection
200- return fsm.findTopLeftPane(leaves)
201- }
202-
203- Focus Request Flow
204-
205- graph TD
206- A[Focus Request] --> B{Validate Request}
207- B -->|Valid| C[Add to Queue]
208- B -->|Invalid| D[Log & Reject]
209- C --> E{Check State}
210- E -->|Idle| F[Process Immediately]
211- E -->|Busy| G[Wait in Queue]
212- F --> H[Transition to Processing]
213- H --> I[Remove Old Focus]
214- I --> J[Apply New Focus]
215- J --> K[Update CSS Classes]
216- K --> L[Notify Observers]
217- L --> M[Start Settling Timer]
218- M --> N[Transition to Focused]
219- N --> O[After Settling: Idle]
220-
221- Key Methods
222-
223- Request Focus Change
224-
225- func (fsm *FocusStateMachine) RequestFocus(node *paneNode, source FocusSource) error {
226- request := FocusRequest{
227- ID: generateRequestID(),
228- TargetNode: node,
229- Source: source,
230- Priority: fsm.calculatePriority(source),
231- Timestamp: time.Now(),
232- }
233-
234- select {
235- case fsm.requestQueue <- request:
236- return nil
237- case <-time.After(100 * time.Millisecond):
238- return ErrFocusQueueFull
239- }
240- }
241-
242- Execute Focus Change
243-
244- func (fsm *FocusStateMachine) executeFocusChange(request FocusRequest) error {
245- fsm.mu.Lock()
246- defer fsm.mu.Unlock()
247-
248- oldPane := fsm.activePane
249- newPane := request.TargetNode
250-
251- // Record transition start
252- transition := FocusTransition{
253- From: oldPane,
254- To: newPane,
255- Source: request.Source,
256- Timestamp: request.Timestamp,
257- Stack: debug.Stack(),
258- }
259-
260- // Phase 1: Validate target is still valid
261- if !fsm.isValidTarget(newPane) {
262- transition.Success = false
263- transition.Error = ErrInvalidTarget
264- fsm.recordTransition(transition)
265- return ErrInvalidTarget
266- }
267-
268- // Phase 2: Remove focus from old pane
269- if oldPane != nil {
270- fsm.removeFocusFrom(oldPane)
271- }
272-
273- // Phase 3: Apply focus to new pane
274- if err := fsm.applyFocusTo(newPane); err != nil {
275- // Rollback on failure
276- if oldPane != nil {
277- fsm.applyFocusTo(oldPane)
278- }
279- transition.Success = false
280- transition.Error = err
281- fsm.recordTransition(transition)
282- return err
283- }
284-
285- // Phase 4: Update CSS classes atomically
286- fsm.cssReconciler.beginTransaction()
287- if oldPane != nil {
288- fsm.cssReconciler.removeClass(oldPane, "workspace-pane-active")
289- }
290- fsm.cssReconciler.addClass(newPane, "workspace-pane-active")
291- fsm.cssReconciler.commit()
292-
293- // Phase 5: Update state
294- fsm.activePane = newPane
295- transition.Success = true
296- fsm.recordTransition(transition)
297-
298- // Phase 6: Notify observers
299- fsm.notifyFocusChange(oldPane, newPane)
300-
301- return nil
302- }
303-
304- CSS Verification Loop
305-
306- func (fsm *FocusStateMachine) startCSSVerificationLoop() {
307- ticker := time.NewTicker(100 * time.Millisecond)
308- go func() {
309- for range ticker.C {
310- fsm.verifyCSSState()
311- }
312- }()
313- }
314-
315- func (fsm *FocusStateMachine) verifyCSSState() {
316- fsm.mu.RLock()
317- activePane := fsm.activePane
318- fsm.mu.RUnlock()
319-
320- if activePane == nil {
321- return
322- }
323-
324- leaves := fsm.wm.collectLeaves()
325- activeCount := 0
326-
327- for _, leaf := range leaves {
328- hasActive := fsm.hasActiveClass(leaf)
329- shouldHaveActive := (leaf == activePane)
330-
331- if hasActive && shouldHaveActive {
332- activeCount++
333- } else if hasActive && !shouldHaveActive {
334- // ERROR: Inactive pane has active class
335- log.Printf("[FSM] CSS ERROR: Inactive pane has active class, fixing...")
336- fsm.cssReconciler.removeClass(leaf, "workspace-pane-active")
337- } else if !hasActive && shouldHaveActive {
338- // ERROR: Active pane missing active class
339- log.Printf("[FSM] CSS ERROR: Active pane missing active class, fixing...")
340- fsm.cssReconciler.addClass(leaf, "workspace-pane-active")
341- }
342- }
343-
344- if activeCount > 1 {
345- log.Fatalf("[FSM] CRITICAL: Multiple active panes detected: %d", activeCount)
346- }
347- }
348-
349- Stacked Pane Integration
350-
351- For stacked panes, the state machine handles special transitions:
352-
353- func (fsm *FocusStateMachine) handleStackedFocus(stack *paneNode, index int) error {
354- if !stack.isStacked || index >= len(stack.stackedPanes) {
355- return ErrInvalidStackOperation
356- }
357-
358- request := FocusRequest{
359- TargetNode: stack.stackedPanes[index],
360- Source: SourceStackNavigation,
361- Priority: PriorityHigh,
362- Context: map[string]interface{}{
363- "stack": stack,
364- "oldIndex": stack.activeStackIndex,
365- "newIndex": index,
366- },
367- }
368-
369- return fsm.RequestFocus(stack.stackedPanes[index], SourceStackNavigation)
370- }
371-
372- Debug Commands
373-
374- Add debug commands for testing:
375-
376- func (fsm *FocusStateMachine) DumpState() FocusDebugInfo {
377- fsm.mu.RLock()
378- defer fsm.mu.RUnlock()
379-
380- return FocusDebugInfo{
381- CurrentState: fsm.currentState,
382- ActivePane: fsm.activePane,
383- QueueLength: len(fsm.requestQueue),
384- History: fsm.historyRing.GetAll(),
385- CSSState: fsm.cssReconciler.GetState(),
386- ValidationErrors: fsm.getValidationErrors(),
387- }
388- }
389-
390- func (fsm *FocusStateMachine) ForceReconcile() {
391- fsm.transitionTo(StateReconciling)
392- fsm.cssReconciler.forceFullReconciliation()
393- fsm.transitionTo(StateIdle)
394- }
395-
396- Implementation Timeline
397-
398- Phase 1: Core State Machine (3-4 hours)
399- - Create focus_state_machine.go with basic state management
400- - Implement queue system and request processing
401- - Add state transition logic and validation
402-
403- Phase 2: CSS Reconciler (2-3 hours)
404- - Implement CSS tracking and reconciliation
405- - Add transaction support for atomic updates
406- - Create verification loop
407-
408- Phase 3: GTK Integration (2-3 hours)
409- - Add GtkEventControllerFocus bindings
410- - Implement callbacks and signal handling
411- - Sync with state machine
412-
413- Phase 4: Testing & Debugging (2-3 hours)
414- - Add comprehensive logging
415- - Implement debug commands
416- - Test all navigation scenarios
417- - Verify stacked pane behavior
418-
419- Benefits of This Approach
420-
421- 1. Guaranteed Single Active Pane: Queue serialization ensures atomic focus changes
422- 2. Self-Healing: CSS verification loop detects and fixes inconsistencies
423- 3. Debuggable: Complete history and state inspection available
424- 4. Predictable: State machine ensures valid transitions only
425- 5. Performant: Async queue processing doesn't block UI
426- 6. Extensible: Easy to add new focus sources or validation rules
427-
428- This architecture will completely eliminate the focus initialization issues and provide a robust
429- foundation for complex pane management scenarios.
M internal/app/browser/focus_state_machine.go +15 -3
@@ -889,8 +889,13 @@ func (fsm *FocusStateMachine) attachGTKController(node *paneNode) {
889889 return
890890 }
891891
892+ if node.focusControllerToken != 0 {
893+ log.Printf("[FSM] Controller already attached to pane %p", node)
894+ return
895+ }
896+
892897 widget := node.pane.webView.Widget()
893- if widget == 0 {
898+ if widget == 0 || !webkit.WidgetIsValid(widget) {
894899 return
895900 }
896901
@@ -909,7 +914,7 @@ func (fsm *FocusStateMachine) attachGTKController(node *paneNode) {
909914 // Add the focus controller and store the token for cleanup
910915 token := webkit.WidgetAddFocusController(widget, onEnter, onLeave)
911916 if token != 0 {
912- // Store the controller token for later cleanup (we could add this to paneNode if needed)
917+ node.focusControllerToken = token
913918 log.Printf("[FSM] Attached GTK focus controller to pane %p with token %d", node, token)
914919 }
915920 }
@@ -920,8 +925,15 @@ func (fsm *FocusStateMachine) detachGTKController(node *paneNode, token uintptr)
920925 return
921926 }
922927
928+ if node.focusControllerToken != token {
929+ log.Printf("[FSM] Token mismatch, skipping detach for pane %p", node)
930+ return
931+ }
932+
933+ node.focusControllerToken = 0
934+
923935 widget := node.pane.webView.Widget()
924- if widget == 0 {
936+ if widget == 0 || !webkit.WidgetIsValid(widget) {
925937 return
926938 }
927939
M internal/app/browser/widget.go +133 -9
@@ -1,33 +1,102 @@
11 package browser
22
33 import (
4+ "context"
45 "fmt"
56 "sync"
7+ "time"
68
79 "github.com/bnema/dumber/pkg/webkit"
810 )
911
1012 // SafeWidget provides thread-safe access to GTK widgets with validation
1113 type SafeWidget struct {
12- ptr uintptr
13- typeInfo string
14- registry *WidgetRegistry
14+ ptr uintptr
15+ typeInfo string
16+ registry *WidgetRegistry
17+ isInvalidated bool
18+ mu sync.RWMutex
1519 }
1620
1721 // 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
1823 func (w *SafeWidget) Execute(fn func(uintptr) error) error {
1924 if w == nil || w.registry == nil {
2025 return fmt.Errorf("SafeWidget is nil or has no registry")
2126 }
2227
23- w.registry.mu.Lock()
24- defer w.registry.mu.Unlock()
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()
2573
2674 if !w.IsValid() {
2775 return fmt.Errorf("widget %s is no longer valid", w.String())
2876 }
2977
30- return fn(w.ptr)
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+ }
31100 }
32101
33102 // IsValid checks if the underlying GTK widget is still valid
@@ -35,7 +104,49 @@ func (w *SafeWidget) IsValid() bool {
35104 if w == nil || w.ptr == 0 {
36105 return false
37106 }
38- return webkit.WidgetIsValid(w.ptr)
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+ }
39150 }
40151
41152 // String returns a string representation of the SafeWidget for debugging
@@ -43,7 +154,13 @@ func (w *SafeWidget) String() string {
43154 if w == nil {
44155 return "SafeWidget(nil)"
45156 }
46- return fmt.Sprintf("SafeWidget(%s@%#x)", w.typeInfo, w.ptr)
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)
47164 }
48165
49166 // Ptr returns the raw uintptr to the GTK widget
@@ -51,6 +168,10 @@ func (w *SafeWidget) Ptr() uintptr {
51168 if w == nil {
52169 return 0
53170 }
171+
172+ w.mu.RLock()
173+ defer w.mu.RUnlock()
174+
54175 return w.ptr
55176 }
56177
@@ -101,7 +222,10 @@ func (r *WidgetRegistry) Unregister(ptr uintptr) {
101222 r.mu.Lock()
102223 defer r.mu.Unlock()
103224
104- delete(r.widgets, ptr)
225+ if widget, exists := r.widgets[ptr]; exists {
226+ widget.Invalidate()
227+ delete(r.widgets, ptr)
228+ }
105229 }
106230
107231 // Cleanup removes all invalid widgets from the registry
A internal/app/browser/workspace_bulletproof_operations.go +430 -0
@@ -0,0 +1,430 @@
1+// workspace_bulletproof_operations.go - Bulletproof wrapper methods for all tree operations
2+package browser
3+
4+import (
5+ "context"
6+ "fmt"
7+ "log"
8+ "time"
9+
10+ "github.com/bnema/dumber/pkg/webkit"
11+)
12+
13+// BulletproofSplitNode performs a bulletproof split operation with all safety checks
14+func (wm *WorkspaceManager) BulletproofSplitNode(target *paneNode, direction string) (*paneNode, error) {
15+ if wm == nil {
16+ return nil, fmt.Errorf("workspace manager is nil")
17+ }
18+
19+ log.Printf("[bulletproof] Starting bulletproof split operation: target=%p direction=%s", target, direction)
20+
21+ // Step 1: Capture state tombstone for rollback
22+ tombstone, err := wm.stateTombstoneManager.CaptureState("split")
23+ if err != nil {
24+ log.Printf("[bulletproof] Failed to capture state tombstone: %v", err)
25+ // Continue anyway - tombstone is for rollback safety
26+ }
27+
28+ // Step 2: Validate geometry constraints
29+ validation := wm.geometryValidator.ValidateSplit(target, direction)
30+ if !validation.IsValid {
31+ return nil, fmt.Errorf("split validation failed: %s", validation.Reason)
32+ }
33+
34+ // Log if re-validation will be needed due to pending widget allocation
35+ if validation.RequiresRevalidation {
36+ log.Printf("[bulletproof] Split validation passed with pending allocation - operation will proceed")
37+ }
38+
39+ // Step 3: Validate tree invariants before operation
40+ if err := wm.treeValidator.ValidateTree(wm.root, "before_split"); err != nil {
41+ return nil, fmt.Errorf("tree validation failed before split: %w", err)
42+ }
43+
44+ // Step 4: Execute directly if we're already on the GTK main thread
45+ if webkit.IsMainThread() {
46+ log.Printf("[bulletproof] Already on main thread, executing split directly")
47+ newNode, err := wm.splitNode(target, direction)
48+ if err != nil {
49+ if tombstone != nil {
50+ if rollbackErr := wm.stateTombstoneManager.RestoreState(tombstone.ID); rollbackErr != nil {
51+ log.Printf("[bulletproof] Rollback failed after split failure: %v", rollbackErr)
52+ }
53+ }
54+ return nil, err
55+ }
56+
57+ if err := wm.treeValidator.ValidateTree(wm.root, "after_split"); err != nil {
58+ log.Printf("[bulletproof] Tree validation failed after split: %v", err)
59+ }
60+
61+ // Tree rebalancing is only needed after close promotions. Splits have correct allocation from GTK.
62+
63+ log.Printf("[bulletproof] Split operation completed successfully (direct execution): newNode=%p", newNode)
64+ return newNode, nil
65+ }
66+
67+ // Step 5: Not on main thread, marshal through concurrency controller
68+ opReq := &OperationRequest{
69+ ID: fmt.Sprintf("split_%p_%d", target, time.Now().UnixNano()),
70+ Type: OpTypeSplit,
71+ TargetNode: target,
72+ Direction: direction,
73+ Parameters: map[string]interface{}{
74+ "direction": direction,
75+ },
76+ Context: context.Background(),
77+ MaxRetries: 3,
78+ }
79+
80+ resultChan := wm.concurrencyController.SubmitOperation(opReq)
81+
82+ log.Printf("[bulletproof] Waiting for operation result while pumping GTK events")
83+ ticker := time.NewTicker(10 * time.Millisecond)
84+ defer ticker.Stop()
85+
86+ timeout := time.NewTimer(30 * time.Second)
87+ defer timeout.Stop()
88+
89+ for {
90+ select {
91+ case result := <-resultChan:
92+ if !result.Success {
93+ if tombstone != nil {
94+ if rollbackErr := wm.stateTombstoneManager.RestoreState(tombstone.ID); rollbackErr != nil {
95+ log.Printf("[bulletproof] Rollback failed after split failure: %v", rollbackErr)
96+ }
97+ }
98+ return nil, result.Error
99+ }
100+
101+ if err := wm.treeValidator.ValidateTree(wm.root, "after_split"); err != nil {
102+ log.Printf("[bulletproof] Tree validation failed after split: %v", err)
103+ }
104+
105+ // Tree rebalancing is only needed after close promotions. Splits have correct allocation from GTK.
106+
107+ log.Printf("[bulletproof] Split operation completed successfully: newNode=%p", result.NewNode)
108+ return result.NewNode, nil
109+
110+ case <-ticker.C:
111+ if webkit.IsMainThread() {
112+ webkit.IterateMainLoop()
113+ }
114+
115+ case <-timeout.C:
116+ return nil, fmt.Errorf("split operation timed out")
117+ }
118+ }
119+}
120+
121+// BulletproofClosePane performs a bulletproof close operation with all safety checks
122+func (wm *WorkspaceManager) BulletproofClosePane(node *paneNode) error {
123+ if wm == nil {
124+ return fmt.Errorf("workspace manager is nil")
125+ }
126+
127+ log.Printf("[bulletproof] Starting bulletproof close operation: node=%p", node)
128+
129+ // Step 1: Capture state tombstone for rollback
130+ tombstone, err := wm.stateTombstoneManager.CaptureState("close")
131+ if err != nil {
132+ log.Printf("[bulletproof] Failed to capture state tombstone: %v", err)
133+ }
134+
135+ // Step 2: Validate tree invariants before operation
136+ if err := wm.treeValidator.ValidateTree(wm.root, "before_close"); err != nil {
137+ return fmt.Errorf("tree validation failed before close: %w", err)
138+ }
139+
140+ // Step 3: Check if this is a stacked pane and use enhanced lifecycle management
141+ if node.parent != nil && node.parent.isStacked {
142+ log.Printf("[bulletproof] Using enhanced stack lifecycle management for close")
143+ return wm.stackLifecycleManager.CloseStackedPaneWithLifecycle(node)
144+ }
145+
146+ // Step 4: Execute directly when already on the GTK main thread
147+ if webkit.IsMainThread() {
148+ log.Printf("[bulletproof] Already on main thread, executing close directly")
149+
150+ promoted, err := wm.closePane(node)
151+ if err != nil {
152+ if tombstone != nil {
153+ if rollbackErr := wm.stateTombstoneManager.RestoreState(tombstone.ID); rollbackErr != nil {
154+ log.Printf("[bulletproof] Rollback failed after close failure: %v", rollbackErr)
155+ }
156+ }
157+ return err
158+ }
159+
160+ if err := wm.treeValidator.ValidateTree(wm.root, "after_close"); err != nil {
161+ log.Printf("[bulletproof] Tree validation failed after close: %v", err)
162+ }
163+
164+ if wm.treeRebalancer != nil {
165+ if err := wm.treeRebalancer.RebalanceAfterClose(node, promoted); err != nil {
166+ log.Printf("[bulletproof] Tree rebalancing failed after close: %v", err)
167+ }
168+ }
169+
170+ log.Printf("[bulletproof] Close operation completed successfully (direct execution)")
171+ return nil
172+ }
173+
174+ // Step 5: Not on main thread, marshal through concurrency controller
175+ log.Printf("[bulletproof] Not on main thread, using concurrency controller")
176+ opReq := &OperationRequest{
177+ ID: fmt.Sprintf("close_%p_%d", node, time.Now().UnixNano()),
178+ Type: OpTypeClose,
179+ TargetNode: node,
180+ Parameters: map[string]interface{}{},
181+ Context: context.Background(),
182+ MaxRetries: 3,
183+ }
184+
185+ resultChan := wm.concurrencyController.SubmitOperation(opReq)
186+
187+ log.Printf("[bulletproof] Waiting for operation result while pumping GTK events")
188+ ticker := time.NewTicker(10 * time.Millisecond)
189+ defer ticker.Stop()
190+
191+ timeout := time.NewTimer(30 * time.Second)
192+ defer timeout.Stop()
193+
194+ for {
195+ select {
196+ case result := <-resultChan:
197+ if !result.Success {
198+ if tombstone != nil {
199+ if rollbackErr := wm.stateTombstoneManager.RestoreState(tombstone.ID); rollbackErr != nil {
200+ log.Printf("[bulletproof] Rollback failed after close failure: %v", rollbackErr)
201+ }
202+ }
203+ return result.Error
204+ }
205+
206+ if err := wm.treeValidator.ValidateTree(wm.root, "after_close"); err != nil {
207+ log.Printf("[bulletproof] Tree validation failed after close: %v", err)
208+ }
209+
210+ if wm.treeRebalancer != nil {
211+ if err := wm.treeRebalancer.RebalanceAfterClose(node, result.NewNode); err != nil {
212+ log.Printf("[bulletproof] Tree rebalancing failed after close: %v", err)
213+ }
214+ }
215+
216+ log.Printf("[bulletproof] Close operation completed successfully")
217+ return nil
218+
219+ case <-ticker.C:
220+ if webkit.IsMainThread() {
221+ webkit.IterateMainLoop()
222+ }
223+
224+ case <-timeout.C:
225+ return fmt.Errorf("close operation timed out")
226+ }
227+ }
228+}
229+
230+// BulletproofStackPane performs a bulletproof stack operation
231+func (wm *WorkspaceManager) BulletproofStackPane(target *paneNode) (*paneNode, error) {
232+ if wm == nil {
233+ return nil, fmt.Errorf("workspace manager is nil")
234+ }
235+
236+ log.Printf("[bulletproof] Starting bulletproof stack operation: target=%p", target)
237+
238+ // Step 1: Validate stack operation constraints
239+ validation := wm.geometryValidator.ValidateStackOperation(target)
240+ if !validation.IsValid {
241+ return nil, fmt.Errorf("stack validation failed: %s", validation.Reason)
242+ }
243+
244+ // Log if re-validation will be needed due to pending widget allocation
245+ if validation.RequiresRevalidation {
246+ log.Printf("[bulletproof] Stack validation passed with pending allocation - operation will proceed")
247+ }
248+
249+ // Step 2: Capture state tombstone for rollback
250+ tombstone, err := wm.stateTombstoneManager.CaptureState("stack")
251+ if err != nil {
252+ log.Printf("[bulletproof] Failed to capture state tombstone: %v", err)
253+ }
254+
255+ // Step 3: Validate tree invariants before operation
256+ if err := wm.treeValidator.ValidateTree(wm.root, "before_stack"); err != nil {
257+ return nil, fmt.Errorf("tree validation failed before stack: %w", err)
258+ }
259+
260+ // Step 4: Execute directly if already on the GTK main thread
261+ if webkit.IsMainThread() {
262+ log.Printf("[bulletproof] Already on main thread, executing stack directly")
263+ newNode, err := wm.stackedPaneManager.StackPane(target)
264+ if err != nil {
265+ if tombstone != nil {
266+ if rollbackErr := wm.stateTombstoneManager.RestoreState(tombstone.ID); rollbackErr != nil {
267+ log.Printf("[bulletproof] Rollback failed after stack failure: %v", rollbackErr)
268+ }
269+ }
270+ return nil, err
271+ }
272+
273+ if err := wm.treeValidator.ValidateTree(wm.root, "after_stack"); err != nil {
274+ log.Printf("[bulletproof] Tree validation failed after stack: %v", err)
275+ }
276+
277+ log.Printf("[bulletproof] Stack operation completed successfully (direct execution): newNode=%p", newNode)
278+ return newNode, nil
279+ }
280+
281+ // Step 5: Not on main thread, marshal through concurrency controller
282+ opReq := &OperationRequest{
283+ ID: fmt.Sprintf("stack_%p_%d", target, time.Now().UnixNano()),
284+ Type: OpTypeStack,
285+ TargetNode: target,
286+ Parameters: map[string]interface{}{},
287+ Context: context.Background(),
288+ MaxRetries: 3,
289+ }
290+
291+ resultChan := wm.concurrencyController.SubmitOperation(opReq)
292+
293+ log.Printf("[bulletproof] Waiting for operation result while pumping GTK events")
294+ ticker := time.NewTicker(10 * time.Millisecond)
295+ defer ticker.Stop()
296+
297+ timeout := time.NewTimer(30 * time.Second)
298+ defer timeout.Stop()
299+
300+ for {
301+ select {
302+ case result := <-resultChan:
303+ if !result.Success {
304+ if tombstone != nil {
305+ if rollbackErr := wm.stateTombstoneManager.RestoreState(tombstone.ID); rollbackErr != nil {
306+ log.Printf("[bulletproof] Rollback failed after stack failure: %v", rollbackErr)
307+ }
308+ }
309+ return nil, result.Error
310+ }
311+
312+ if err := wm.treeValidator.ValidateTree(wm.root, "after_stack"); err != nil {
313+ log.Printf("[bulletproof] Tree validation failed after stack: %v", err)
314+ }
315+
316+ log.Printf("[bulletproof] Stack operation completed successfully: newNode=%p", result.NewNode)
317+ return result.NewNode, nil
318+
319+ case <-ticker.C:
320+ if webkit.IsMainThread() {
321+ webkit.IterateMainLoop()
322+ }
323+
324+ case <-timeout.C:
325+ return nil, fmt.Errorf("stack operation timed out")
326+ }
327+ }
328+}
329+
330+// EnableBulletproofMode enables all bulletproof features
331+func (wm *WorkspaceManager) EnableBulletproofMode() {
332+ if wm.treeValidator != nil {
333+ wm.treeValidator.Enable()
334+ }
335+ if wm.treeRebalancer != nil {
336+ wm.treeRebalancer.Enable()
337+ }
338+ log.Printf("[bulletproof] Bulletproof mode enabled")
339+}
340+
341+// DisableBulletproofMode disables bulletproof features for performance
342+func (wm *WorkspaceManager) DisableBulletproofMode() {
343+ if wm.treeValidator != nil {
344+ wm.treeValidator.Disable()
345+ }
346+ if wm.treeRebalancer != nil {
347+ wm.treeRebalancer.Disable()
348+ }
349+ log.Printf("[bulletproof] Bulletproof mode disabled")
350+}
351+
352+// SetBulletproofDebugMode enables/disables debug mode for all bulletproof components
353+func (wm *WorkspaceManager) SetBulletproofDebugMode(debug bool) {
354+ if wm.treeValidator != nil {
355+ wm.treeValidator.SetDebugMode(debug)
356+ }
357+ if wm.geometryValidator != nil {
358+ wm.geometryValidator.SetDebugMode(debug)
359+ }
360+ log.Printf("[bulletproof] Debug mode set to: %v", debug)
361+}
362+
363+// GetBulletproofStats returns comprehensive statistics about all bulletproof components
364+func (wm *WorkspaceManager) GetBulletproofStats() map[string]interface{} {
365+ stats := make(map[string]interface{})
366+
367+ if wm.treeValidator != nil {
368+ stats["tree_validation"] = wm.treeValidator.GetValidationStats()
369+ }
370+
371+ if wm.widgetTxManager != nil {
372+ stats["widget_transactions"] = wm.widgetTxManager.GetTransactionStats()
373+ }
374+
375+ if wm.concurrencyController != nil {
376+ stats["concurrency"] = wm.concurrencyController.GetConcurrencyStats()
377+ }
378+
379+ if wm.treeRebalancer != nil {
380+ stats["tree_rebalancing"] = wm.treeRebalancer.GetRebalancingStats()
381+ }
382+
383+ if wm.geometryValidator != nil {
384+ stats["geometry"] = wm.geometryValidator.GetGeometryStats(wm.root)
385+ }
386+
387+ if wm.stateTombstoneManager != nil {
388+ stats["tombstones"] = wm.stateTombstoneManager.GetTombstoneStats()
389+ }
390+
391+ return stats
392+}
393+
394+// ValidateWorkspaceIntegrity performs a comprehensive validation of the workspace
395+func (wm *WorkspaceManager) ValidateWorkspaceIntegrity() error {
396+ log.Printf("[bulletproof] Performing comprehensive workspace integrity check")
397+
398+ // Tree structure validation
399+ if wm.treeValidator != nil {
400+ if err := wm.treeValidator.ValidateTree(wm.root, "integrity_check"); err != nil {
401+ return fmt.Errorf("tree validation failed: %w", err)
402+ }
403+ }
404+
405+ // Geometry validation
406+ if wm.geometryValidator != nil {
407+ results := wm.geometryValidator.ValidateWorkspaceLayout(wm.root)
408+ for i, result := range results {
409+ if !result.IsValid {
410+ log.Printf("[bulletproof] Geometry validation failed for pane %d: %s", i, result.Reason)
411+ // Don't fail the entire check for geometry issues
412+ }
413+ }
414+ }
415+
416+ log.Printf("[bulletproof] Workspace integrity check completed successfully")
417+ return nil
418+}
419+
420+// ShutdownBulletproofComponents gracefully shuts down all bulletproof components
421+func (wm *WorkspaceManager) ShutdownBulletproofComponents() {
422+ log.Printf("[bulletproof] Shutting down bulletproof components")
423+
424+ if wm.concurrencyController != nil {
425+ wm.concurrencyController.Shutdown()
426+ }
427+
428+ // Other components don't require explicit shutdown currently
429+ log.Printf("[bulletproof] Bulletproof components shutdown complete")
430+}
A internal/app/browser/workspace_concurrency.go +701 -0
@@ -0,0 +1,701 @@
1+// workspace_concurrency.go - Sequence-based concurrency control for bulletproof tree operations
2+package browser
3+
4+import (
5+ "context"
6+ "fmt"
7+ "log"
8+ "sync"
9+ "sync/atomic"
10+ "time"
11+
12+ "github.com/bnema/dumber/pkg/webkit"
13+)
14+
15+// OperationType represents different types of tree operations
16+type OperationType int
17+
18+const (
19+ OpTypeSplit OperationType = iota
20+ OpTypeClose
21+ OpTypeStack
22+ OpTypeFocus
23+ OpTypeResize
24+)
25+
26+func (ot OperationType) String() string {
27+ switch ot {
28+ case OpTypeSplit:
29+ return "split"
30+ case OpTypeClose:
31+ return "close"
32+ case OpTypeStack:
33+ return "stack"
34+ case OpTypeFocus:
35+ return "focus"
36+ case OpTypeResize:
37+ return "resize"
38+ default:
39+ return "unknown"
40+ }
41+}
42+
43+// OperationRequest represents a pending tree operation
44+type OperationRequest struct {
45+ ID string
46+ Type OperationType
47+ TargetNode *paneNode
48+ Direction string // For split operations
49+ Parameters map[string]interface{}
50+ SequenceNum uint64
51+ SubmittedAt time.Time
52+ Context context.Context
53+ ResultChan chan OperationResult
54+ RetryCount int
55+ MaxRetries int
56+}
57+
58+// OperationResult represents the result of an operation
59+type OperationResult struct {
60+ Success bool
61+ Error error
62+ NewNode *paneNode
63+ Duration time.Duration
64+ SequenceNum uint64
65+}
66+
67+// ConcurrencyController manages concurrent access to the tree
68+type ConcurrencyController struct {
69+ // Sequence numbers for operation ordering
70+ globalSequence uint64
71+ nodeSequences map[*paneNode]uint64
72+ sequenceMutex sync.RWMutex
73+
74+ // Operation queuing and processing
75+ operationQueue chan *OperationRequest
76+ workerCount int
77+ workers []*OperationWorker
78+ shutdown chan struct{}
79+ shutdownOnce sync.Once
80+
81+ // Tree-level synchronization
82+ treeMutex sync.RWMutex
83+ readOperations map[string]context.CancelFunc // Read operations that can be cancelled
84+ writeOperations map[string]*OperationRequest // Active write operations
85+
86+ // Performance tracking
87+ operationHistory []OperationStats
88+ historyMutex sync.RWMutex
89+ maxHistory int
90+
91+ // Configuration
92+ maxQueueSize int
93+ operationTimeout time.Duration
94+ retryBackoff time.Duration
95+ deadlockTimeout time.Duration
96+
97+ // Dependencies
98+ widgetTxManager *WidgetTransactionManager
99+ treeValidator *TreeValidator
100+ workspaceManager *WorkspaceManager
101+}
102+
103+// OperationStats tracks performance metrics for operations
104+type OperationStats struct {
105+ OperationType OperationType
106+ Duration time.Duration
107+ Success bool
108+ RetryCount int
109+ QueueTime time.Duration
110+ SequenceNum uint64
111+ Timestamp time.Time
112+ ConflictCount int
113+}
114+
115+// OperationWorker processes operations from the queue
116+type OperationWorker struct {
117+ id int
118+ controller *ConcurrencyController
119+ shutdown chan struct{}
120+ wg *sync.WaitGroup
121+}
122+
123+// NewConcurrencyController creates a new concurrency controller
124+func NewConcurrencyController(workerCount int, widgetTxManager *WidgetTransactionManager, treeValidator *TreeValidator) *ConcurrencyController {
125+ if workerCount <= 0 {
126+ workerCount = 2 // Default to 2 workers
127+ }
128+
129+ cc := &ConcurrencyController{
130+ globalSequence: 0,
131+ nodeSequences: make(map[*paneNode]uint64),
132+ operationQueue: make(chan *OperationRequest, 1000), // Buffered channel
133+ workerCount: workerCount,
134+ workers: make([]*OperationWorker, workerCount),
135+ shutdown: make(chan struct{}),
136+ readOperations: make(map[string]context.CancelFunc),
137+ writeOperations: make(map[string]*OperationRequest),
138+ operationHistory: make([]OperationStats, 0, 1000),
139+ maxHistory: 1000,
140+ maxQueueSize: 1000,
141+ operationTimeout: 10 * time.Second,
142+ retryBackoff: 100 * time.Millisecond,
143+ deadlockTimeout: 30 * time.Second,
144+ widgetTxManager: widgetTxManager,
145+ treeValidator: treeValidator,
146+ }
147+
148+ // Start workers
149+ var wg sync.WaitGroup
150+ for i := 0; i < workerCount; i++ {
151+ worker := &OperationWorker{
152+ id: i,
153+ controller: cc,
154+ shutdown: make(chan struct{}),
155+ wg: &wg,
156+ }
157+ cc.workers[i] = worker
158+ wg.Add(1)
159+ go worker.run()
160+ }
161+
162+ log.Printf("[concurrency] Started concurrency controller with %d workers", workerCount)
163+ return cc
164+}
165+
166+// SetWorkspaceManager sets the workspace manager reference (called after construction to avoid circular dependency)
167+func (cc *ConcurrencyController) SetWorkspaceManager(wm *WorkspaceManager) {
168+ cc.workspaceManager = wm
169+}
170+
171+// SubmitOperation submits an operation for execution
172+func (cc *ConcurrencyController) SubmitOperation(req *OperationRequest) <-chan OperationResult {
173+ // Assign sequence number
174+ req.SequenceNum = atomic.AddUint64(&cc.globalSequence, 1)
175+ req.SubmittedAt = time.Now()
176+ req.ResultChan = make(chan OperationResult, 1)
177+
178+ // Set default retry count if not specified
179+ if req.MaxRetries == 0 {
180+ req.MaxRetries = 3
181+ }
182+
183+ // Set default context if not provided
184+ if req.Context == nil {
185+ ctx, cancel := context.WithTimeout(context.Background(), cc.operationTimeout)
186+ req.Context = ctx
187+ // Schedule cleanup
188+ go func() {
189+ <-ctx.Done()
190+ cancel()
191+ }()
192+ }
193+
194+ // Try to queue the operation
195+ select {
196+ case cc.operationQueue <- req:
197+ log.Printf("[concurrency] Queued operation %s (seq=%d, type=%s)", req.ID, req.SequenceNum, req.Type)
198+ case <-req.Context.Done():
199+ // Context cancelled before queuing
200+ req.ResultChan <- OperationResult{
201+ Success: false,
202+ Error: fmt.Errorf("operation cancelled before queuing: %w", req.Context.Err()),
203+ }
204+ default:
205+ // Queue is full
206+ req.ResultChan <- OperationResult{
207+ Success: false,
208+ Error: fmt.Errorf("operation queue is full"),
209+ }
210+ }
211+
212+ return req.ResultChan
213+}
214+
215+// worker run loop
216+func (worker *OperationWorker) run() {
217+ defer worker.wg.Done()
218+ log.Printf("[concurrency] Worker %d started", worker.id)
219+
220+ for {
221+ select {
222+ case req := <-worker.controller.operationQueue:
223+ worker.processOperation(req)
224+ case <-worker.shutdown:
225+ log.Printf("[concurrency] Worker %d shutting down", worker.id)
226+ return
227+ case <-worker.controller.shutdown:
228+ log.Printf("[concurrency] Worker %d shutting down (global)", worker.id)
229+ return
230+ }
231+ }
232+}
233+
234+// processOperation processes a single operation request
235+func (worker *OperationWorker) processOperation(req *OperationRequest) {
236+ startTime := time.Now()
237+ queueTime := startTime.Sub(req.SubmittedAt)
238+
239+ log.Printf("[concurrency] Worker %d processing operation %s (seq=%d, queue_time=%v)",
240+ worker.id, req.ID, req.SequenceNum, queueTime)
241+
242+ result := OperationResult{
243+ SequenceNum: req.SequenceNum,
244+ }
245+
246+ // Check if context is already cancelled
247+ select {
248+ case <-req.Context.Done():
249+ result.Success = false
250+ result.Error = fmt.Errorf("operation context cancelled: %w", req.Context.Err())
251+ worker.sendResult(req, result, startTime, queueTime)
252+ return
253+ default:
254+ }
255+
256+ // Check for conflicts and acquire locks
257+ conflicts := worker.checkConflicts(req)
258+ if conflicts > 0 && req.RetryCount < req.MaxRetries {
259+ // Retry with backoff
260+ req.RetryCount++
261+ backoff := time.Duration(req.RetryCount) * worker.controller.retryBackoff
262+
263+ log.Printf("[concurrency] Operation %s has %d conflicts, retrying in %v (attempt %d/%d)",
264+ req.ID, conflicts, backoff, req.RetryCount, req.MaxRetries)
265+
266+ go func() {
267+ time.Sleep(backoff)
268+ select {
269+ case worker.controller.operationQueue <- req:
270+ case <-req.Context.Done():
271+ result.Success = false
272+ result.Error = fmt.Errorf("operation cancelled during retry")
273+ req.ResultChan <- result
274+ }
275+ }()
276+ return
277+ }
278+
279+ if conflicts > 0 {
280+ result.Success = false
281+ result.Error = fmt.Errorf("operation failed after %d retries due to conflicts", req.MaxRetries)
282+ worker.sendResult(req, result, startTime, queueTime)
283+ return
284+ }
285+
286+ // Execute the operation
287+ err := worker.executeOperation(req, &result)
288+ if err != nil {
289+ result.Success = false
290+ result.Error = err
291+ } else {
292+ result.Success = true
293+ }
294+
295+ // Record stats and send result
296+ worker.sendResult(req, result, startTime, queueTime)
297+}
298+
299+// checkConflicts checks for operation conflicts
300+func (worker *OperationWorker) checkConflicts(req *OperationRequest) int {
301+ cc := worker.controller
302+ conflicts := 0
303+
304+ cc.treeMutex.RLock()
305+ defer cc.treeMutex.RUnlock()
306+
307+ // Check for conflicting write operations on the same node
308+ for _, activeOp := range cc.writeOperations {
309+ if activeOp.TargetNode == req.TargetNode {
310+ conflicts++
311+ }
312+ // Check for parent-child conflicts
313+ if req.TargetNode != nil && activeOp.TargetNode != nil {
314+ if isAncestor(activeOp.TargetNode, req.TargetNode) || isAncestor(req.TargetNode, activeOp.TargetNode) {
315+ conflicts++
316+ }
317+ }
318+ }
319+
320+ return conflicts
321+}
322+
323+// executeOperation executes the actual operation
324+func (worker *OperationWorker) executeOperation(req *OperationRequest, result *OperationResult) error {
325+ cc := worker.controller
326+
327+ // Acquire tree write lock
328+ cc.treeMutex.Lock()
329+ defer cc.treeMutex.Unlock()
330+
331+ // Register this operation as active
332+ cc.writeOperations[req.ID] = req
333+ defer delete(cc.writeOperations, req.ID)
334+
335+ // Update node sequence
336+ if req.TargetNode != nil {
337+ cc.sequenceMutex.Lock()
338+ cc.nodeSequences[req.TargetNode] = req.SequenceNum
339+ cc.sequenceMutex.Unlock()
340+ }
341+
342+ // Create widget transaction for this operation
343+ txID := fmt.Sprintf("op_%s_%d", req.ID, req.SequenceNum)
344+ tx := cc.widgetTxManager.BeginTransaction(txID)
345+
346+ // Execute based on operation type
347+ var err error
348+ switch req.Type {
349+ case OpTypeSplit:
350+ result.NewNode, err = worker.executeSplitOperation(req, tx)
351+ case OpTypeClose:
352+ result.NewNode, err = worker.executeCloseOperation(req, tx)
353+ case OpTypeStack:
354+ result.NewNode, err = worker.executeStackOperation(req, tx)
355+ case OpTypeFocus:
356+ err = worker.executeFocusOperation(req, tx)
357+ case OpTypeResize:
358+ err = worker.executeResizeOperation(req, tx)
359+ default:
360+ err = fmt.Errorf("unknown operation type: %s", req.Type)
361+ }
362+
363+ if err != nil {
364+ // Rollback transaction on error
365+ if rollbackErr := tx.Rollback(); rollbackErr != nil {
366+ log.Printf("[concurrency] Failed to rollback transaction %s: %v", txID, rollbackErr)
367+ }
368+ cc.widgetTxManager.FinishTransaction(txID, false, err.Error())
369+ return err
370+ }
371+
372+ // Execute widget transaction
373+ if err = tx.Execute(); err != nil {
374+ if rollbackErr := tx.Rollback(); rollbackErr != nil {
375+ log.Printf("[concurrency] Failed to rollback transaction %s: %v", txID, rollbackErr)
376+ }
377+ cc.widgetTxManager.FinishTransaction(txID, false, err.Error())
378+ return fmt.Errorf("widget transaction failed: %w", err)
379+ }
380+
381+ // Commit transaction
382+ if err = tx.Commit(); err != nil {
383+ cc.widgetTxManager.FinishTransaction(txID, false, err.Error())
384+ return fmt.Errorf("failed to commit transaction: %w", err)
385+ }
386+
387+ cc.widgetTxManager.FinishTransaction(txID, true, "")
388+
389+ // Validate tree if validator is available
390+ if cc.treeValidator != nil {
391+ if validateErr := cc.treeValidator.ValidateTree(req.TargetNode, req.Type.String()); validateErr != nil {
392+ log.Printf("[concurrency] Tree validation failed after operation %s: %v", req.ID, validateErr)
393+ // Don't return error for validation failures in production, just log
394+ }
395+ }
396+
397+ return nil
398+}
399+
400+// Placeholder implementations for operation execution
401+func (worker *OperationWorker) executeSplitOperation(req *OperationRequest, tx *WidgetTransaction) (*paneNode, error) {
402+ log.Printf("[concurrency] Executing split operation on node %p direction=%s", req.TargetNode, req.Direction)
403+
404+ // Get the workspace manager from the controller
405+ wm := worker.controller.workspaceManager
406+ if wm == nil {
407+ return nil, fmt.Errorf("workspace manager not available")
408+ }
409+
410+ // Fast-path when we are already on the GTK main thread.
411+ if webkit.IsMainThread() {
412+ log.Printf("[concurrency] Already on main thread, executing split directly")
413+ newNode, err := wm.splitNode(req.TargetNode, req.Direction)
414+ if err != nil {
415+ log.Printf("[concurrency] Split operation failed: %v", err)
416+ return nil, err
417+ }
418+ log.Printf("[concurrency] Split operation completed successfully (direct): newNode=%p", newNode)
419+ return newNode, nil
420+ }
421+
422+ // GTK operations must be executed on the main thread
423+ // Use IdleAdd to marshal the GTK work to the main thread
424+ gtkComplete := make(chan struct {
425+ newNode *paneNode
426+ err error
427+ }, 1)
428+
429+ log.Printf("[concurrency] Marshaling split operation to main thread")
430+
431+ // Schedule GTK work on main thread using IdleAdd
432+ webkit.IdleAdd(func() bool {
433+ // Perform actual GTK split operations on main thread
434+ newNode, err := wm.splitNode(req.TargetNode, req.Direction)
435+ gtkComplete <- struct {
436+ newNode *paneNode
437+ err error
438+ }{newNode: newNode, err: err}
439+ return false // Remove idle callback (G_SOURCE_REMOVE)
440+ })
441+
442+ // Wait for GTK completion
443+ result := <-gtkComplete
444+
445+ if result.err != nil {
446+ log.Printf("[concurrency] Split operation failed on main thread: %v", result.err)
447+ return nil, result.err
448+ }
449+
450+ log.Printf("[concurrency] Split operation completed successfully on main thread: newNode=%p", result.newNode)
451+ return result.newNode, nil
452+}
453+
454+func (worker *OperationWorker) executeCloseOperation(req *OperationRequest, tx *WidgetTransaction) (*paneNode, error) {
455+ log.Printf("[concurrency] Executing close operation on node %p", req.TargetNode)
456+
457+ // Get the workspace manager from the controller
458+ wm := worker.controller.workspaceManager
459+ if wm == nil {
460+ return nil, fmt.Errorf("workspace manager not available")
461+ }
462+
463+ // Fast-path when we are already on the GTK main thread.
464+ if webkit.IsMainThread() {
465+ log.Printf("[concurrency] Already on main thread, executing close directly")
466+ promoted, err := wm.closePane(req.TargetNode)
467+ if err != nil {
468+ log.Printf("[concurrency] Close operation failed: %v", err)
469+ return nil, err
470+ }
471+ log.Printf("[concurrency] Close operation completed successfully (direct)")
472+ return promoted, nil
473+ }
474+
475+ // GTK operations must be executed on the main thread
476+ // Use IdleAdd to marshal the GTK work to the main thread
477+ gtkComplete := make(chan struct {
478+ promoted *paneNode
479+ err error
480+ }, 1)
481+
482+ log.Printf("[concurrency] Marshaling close operation to main thread")
483+
484+ // Schedule GTK work on main thread using IdleAdd
485+ webkit.IdleAdd(func() bool {
486+ // Perform actual GTK close operations on main thread
487+ promoted, err := wm.closePane(req.TargetNode)
488+ gtkComplete <- struct {
489+ promoted *paneNode
490+ err error
491+ }{promoted: promoted, err: err}
492+ return false // Remove idle callback (G_SOURCE_REMOVE)
493+ })
494+
495+ // Wait for GTK completion
496+ result := <-gtkComplete
497+
498+ if result.err != nil {
499+ log.Printf("[concurrency] Close operation failed on main thread: %v", result.err)
500+ return nil, result.err
501+ }
502+
503+ log.Printf("[concurrency] Close operation completed successfully on main thread")
504+ return result.promoted, nil
505+}
506+
507+func (worker *OperationWorker) executeStackOperation(req *OperationRequest, tx *WidgetTransaction) (*paneNode, error) {
508+ log.Printf("[concurrency] Executing stack operation on node %p", req.TargetNode)
509+
510+ // Get the workspace manager from the controller
511+ wm := worker.controller.workspaceManager
512+ if wm == nil {
513+ return nil, fmt.Errorf("workspace manager not available")
514+ }
515+
516+ // Fast-path when we are already on the GTK main thread.
517+ if webkit.IsMainThread() {
518+ log.Printf("[concurrency] Already on main thread, executing stack directly")
519+ newNode, err := wm.stackedPaneManager.StackPane(req.TargetNode)
520+ if err != nil {
521+ log.Printf("[concurrency] Stack operation failed: %v", err)
522+ return nil, err
523+ }
524+ log.Printf("[concurrency] Stack operation completed successfully (direct): newNode=%p", newNode)
525+ return newNode, nil
526+ }
527+
528+ // GTK operations must be executed on the main thread
529+ // Use IdleAdd to marshal the GTK work to the main thread
530+ gtkComplete := make(chan struct {
531+ newNode *paneNode
532+ err error
533+ }, 1)
534+
535+ log.Printf("[concurrency] Marshaling stack operation to main thread")
536+
537+ // Schedule GTK work on main thread using IdleAdd
538+ webkit.IdleAdd(func() bool {
539+ // Perform actual GTK stack operations on main thread
540+ newNode, err := wm.stackedPaneManager.StackPane(req.TargetNode)
541+ gtkComplete <- struct {
542+ newNode *paneNode
543+ err error
544+ }{newNode: newNode, err: err}
545+ return false // Remove idle callback (G_SOURCE_REMOVE)
546+ })
547+
548+ // Wait for GTK completion
549+ result := <-gtkComplete
550+
551+ if result.err != nil {
552+ log.Printf("[concurrency] Stack operation failed on main thread: %v", result.err)
553+ return nil, result.err
554+ }
555+
556+ log.Printf("[concurrency] Stack operation completed successfully on main thread: newNode=%p", result.newNode)
557+ return result.newNode, nil
558+}
559+
560+func (worker *OperationWorker) executeFocusOperation(req *OperationRequest, tx *WidgetTransaction) error {
561+ // This would integrate with your existing focus logic
562+ log.Printf("[concurrency] Executing focus operation on node %p", req.TargetNode)
563+ // TODO: Implement actual focus logic with transaction support
564+ return fmt.Errorf("focus operation not yet implemented")
565+}
566+
567+func (worker *OperationWorker) executeResizeOperation(req *OperationRequest, tx *WidgetTransaction) error {
568+ // This would integrate with your existing resize logic
569+ log.Printf("[concurrency] Executing resize operation on node %p", req.TargetNode)
570+ // TODO: Implement actual resize logic with transaction support
571+ return fmt.Errorf("resize operation not yet implemented")
572+}
573+
574+// sendResult sends the operation result and records stats
575+func (worker *OperationWorker) sendResult(req *OperationRequest, result OperationResult, startTime time.Time, queueTime time.Duration) {
576+ result.Duration = time.Since(startTime)
577+
578+ // Record stats
579+ stats := OperationStats{
580+ OperationType: req.Type,
581+ Duration: result.Duration,
582+ Success: result.Success,
583+ RetryCount: req.RetryCount,
584+ QueueTime: queueTime,
585+ SequenceNum: req.SequenceNum,
586+ Timestamp: time.Now(),
587+ }
588+
589+ worker.controller.recordStats(stats)
590+
591+ // Send result
592+ select {
593+ case req.ResultChan <- result:
594+ case <-req.Context.Done():
595+ // Context cancelled, result channel may be closed
596+ }
597+
598+ log.Printf("[concurrency] Worker %d completed operation %s: success=%v, duration=%v",
599+ worker.id, req.ID, result.Success, result.Duration)
600+}
601+
602+// recordStats records operation statistics
603+func (cc *ConcurrencyController) recordStats(stats OperationStats) {
604+ cc.historyMutex.Lock()
605+ defer cc.historyMutex.Unlock()
606+
607+ if len(cc.operationHistory) >= cc.maxHistory {
608+ cc.operationHistory = cc.operationHistory[1:]
609+ }
610+ cc.operationHistory = append(cc.operationHistory, stats)
611+}
612+
613+// GetConcurrencyStats returns performance statistics
614+func (cc *ConcurrencyController) GetConcurrencyStats() map[string]interface{} {
615+ cc.historyMutex.RLock()
616+ defer cc.historyMutex.RUnlock()
617+
618+ totalOps := len(cc.operationHistory)
619+ if totalOps == 0 {
620+ return map[string]interface{}{
621+ "total_operations": 0,
622+ }
623+ }
624+
625+ successCount := 0
626+ totalDuration := time.Duration(0)
627+ totalQueueTime := time.Duration(0)
628+ totalRetries := 0
629+
630+ opTypeCounts := make(map[OperationType]int)
631+
632+ for _, stats := range cc.operationHistory {
633+ if stats.Success {
634+ successCount++
635+ }
636+ totalDuration += stats.Duration
637+ totalQueueTime += stats.QueueTime
638+ totalRetries += stats.RetryCount
639+ opTypeCounts[stats.OperationType]++
640+ }
641+
642+ return map[string]interface{}{
643+ "total_operations": totalOps,
644+ "successful_operations": successCount,
645+ "success_rate": float64(successCount) / float64(totalOps),
646+ "avg_duration_ms": (totalDuration / time.Duration(totalOps)).Milliseconds(),
647+ "avg_queue_time_ms": (totalQueueTime / time.Duration(totalOps)).Milliseconds(),
648+ "total_retries": totalRetries,
649+ "operation_counts": opTypeCounts,
650+ "queue_size": len(cc.operationQueue),
651+ "worker_count": cc.workerCount,
652+ "global_sequence": atomic.LoadUint64(&cc.globalSequence),
653+ }
654+}
655+
656+// Shutdown stops the concurrency controller
657+func (cc *ConcurrencyController) Shutdown() {
658+ cc.shutdownOnce.Do(func() {
659+ log.Printf("[concurrency] Shutting down concurrency controller")
660+ close(cc.shutdown)
661+
662+ // Stop all workers
663+ for _, worker := range cc.workers {
664+ close(worker.shutdown)
665+ }
666+
667+ // Wait for workers to finish with timeout
668+ done := make(chan struct{})
669+ go func() {
670+ for _, worker := range cc.workers {
671+ worker.wg.Wait()
672+ }
673+ close(done)
674+ }()
675+
676+ select {
677+ case <-done:
678+ log.Printf("[concurrency] All workers stopped gracefully")
679+ case <-time.After(5 * time.Second):
680+ log.Printf("[concurrency] Shutdown timeout, some workers may not have stopped gracefully")
681+ }
682+ })
683+}
684+
685+// Helper functions
686+
687+// isAncestor checks if node1 is an ancestor of node2
688+func isAncestor(node1, node2 *paneNode) bool {
689+ if node1 == nil || node2 == nil {
690+ return false
691+ }
692+
693+ current := node2.parent
694+ for current != nil {
695+ if current == node1 {
696+ return true
697+ }
698+ current = current.parent
699+ }
700+ return false
701+}
A internal/app/browser/workspace_geometry_validator.go +596 -0
@@ -0,0 +1,596 @@
1+// workspace_geometry_validator.go - Geometry validation for split operations inspired by Zellij
2+package browser
3+
4+import (
5+ "fmt"
6+ "log"
7+ "time"
8+
9+ "github.com/bnema/dumber/pkg/webkit"
10+)
11+
12+// GeometryValidator validates pane geometry constraints for splits and operations
13+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
17+}
18+
19+// PaneGeometry represents the geometry of a pane
20+type PaneGeometry struct {
21+ X int
22+ Y int
23+ Width int
24+ Height int
25+ IsValid bool
26+}
27+
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+
36+// ValidationResult represents the result of a geometry validation
37+type ValidationResult struct {
38+ IsValid bool
39+ Reason string
40+ Constraints SplitConstraints
41+ Geometry PaneGeometry
42+ RequiresRevalidation bool // True if validation should be retried after widget allocation
43+}
44+
45+// NewGeometryValidator creates a new geometry validator with Zellij-inspired constraints
46+func NewGeometryValidator() *GeometryValidator {
47+ 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
50+ debugMode: false,
51+ }
52+}
53+
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+
61+// SetDebugMode enables or disables debug logging
62+func (gv *GeometryValidator) SetDebugMode(debug bool) {
63+ gv.debugMode = debug
64+}
65+
66+// GetPaneGeometry extracts geometry information from a pane node
67+// CRITICAL FIX: Use timeout to prevent indefinite blocking on GTK calls
68+func (gv *GeometryValidator) GetPaneGeometry(node *paneNode) PaneGeometry {
69+ geometry := PaneGeometry{IsValid: false}
70+
71+ if node == nil || node.container == nil {
72+ if gv.debugMode {
73+ log.Printf("[geometry] Invalid node or missing container")
74+ }
75+ return geometry
76+ }
77+
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
149+
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)
157+
158+ if err != nil {
159+ log.Printf("[geometry] Failed to get pane geometry: %v", err)
160+ geometry.IsValid = false
161+ }
162+
163+ return geometry
164+}
165+
166+// ValidateHorizontalSplit validates if a pane can be split horizontally
167+func (gv *GeometryValidator) ValidateHorizontalSplit(node *paneNode) ValidationResult {
168+ 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+ }
179+
180+ if !geometry.IsValid {
181+ // Check if this is a zero allocation during startup
182+ if geometry.Width == 0 && geometry.Height == 0 {
183+ // Allow operation but flag for re-validation
184+ result.IsValid = true
185+ result.Reason = "geometry pending allocation, allowing horizontal split"
186+ result.RequiresRevalidation = true
187+ log.Printf("[geometry] Allowing horizontal split with zero allocation - will revalidate after first allocation")
188+ return result
189+ } else {
190+ result.IsValid = false
191+ result.Reason = "could not determine pane geometry"
192+ return result
193+ }
194+ }
195+
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
199+
200+ if geometry.Width < requiredWidth {
201+ result.IsValid = false
202+ result.Reason = fmt.Sprintf("pane width %d is less than required %d for horizontal split",
203+ geometry.Width, requiredWidth)
204+ return result
205+ }
206+
207+ // Check height constraints
208+ if geometry.Height < gv.minPaneHeight {
209+ result.IsValid = false
210+ result.Reason = fmt.Sprintf("pane height %d is less than minimum %d",
211+ geometry.Height, gv.minPaneHeight)
212+ return result
213+ }
214+
215+ result.IsValid = true
216+ 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+
223+ return result
224+}
225+
226+// ValidateVerticalSplit validates if a pane can be split vertically
227+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+ }
254+ }
255+
256+ // Check if the pane is tall enough to split vertically
257+ // Each resulting pane must be at least minPaneHeight tall
258+ requiredHeight := gv.minPaneHeight * 2
259+
260+ if geometry.Height < requiredHeight {
261+ result.IsValid = false
262+ result.Reason = fmt.Sprintf("pane height %d is less than required %d for vertical split",
263+ geometry.Height, requiredHeight)
264+ return result
265+ }
266+
267+ // Check width constraints
268+ if geometry.Width < gv.minPaneWidth {
269+ result.IsValid = false
270+ result.Reason = fmt.Sprintf("pane width %d is less than minimum %d",
271+ geometry.Width, gv.minPaneWidth)
272+ return result
273+ }
274+
275+ result.IsValid = true
276+ 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+
283+ return result
284+}
285+
286+// ValidateSplit validates a split operation based on direction (inspired by Zellij's split logic)
287+func (gv *GeometryValidator) ValidateSplit(node *paneNode, direction string) ValidationResult {
288+ if node == nil {
289+ return ValidationResult{
290+ IsValid: false,
291+ Reason: "node is nil",
292+ }
293+ }
294+
295+ if node.isStacked {
296+ return ValidationResult{
297+ IsValid: false,
298+ Reason: "cannot split stacked pane container directly",
299+ }
300+ }
301+
302+ if !node.isLeaf {
303+ return ValidationResult{
304+ IsValid: false,
305+ Reason: "can only split leaf panes",
306+ }
307+ }
308+
309+ switch direction {
310+ case "left", "right":
311+ return gv.ValidateHorizontalSplit(node)
312+ case "up", "down":
313+ return gv.ValidateVerticalSplit(node)
314+ 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+ }
381+ }
382+}
383+
384+// ValidateStackOperation validates if a pane can be converted to a stack
385+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+ }
410+ }
411+
412+ // Check if pane is already stacked
413+ if node.isStacked {
414+ result.IsValid = false
415+ result.Reason = "pane is already stacked"
416+ return result
417+ }
418+
419+ // Check if pane is large enough for stacking (needs space for title bars)
420+ minStackHeight := gv.minPaneHeight + 30 // Extra space for title bar
421+
422+ if geometry.Height < minStackHeight {
423+ result.IsValid = false
424+ result.Reason = fmt.Sprintf("pane height %d is less than required %d for stacking",
425+ geometry.Height, minStackHeight)
426+ return result
427+ }
428+
429+ if geometry.Width < gv.minPaneWidth {
430+ result.IsValid = false
431+ result.Reason = fmt.Sprintf("pane width %d is less than minimum %d",
432+ geometry.Width, gv.minPaneWidth)
433+ return result
434+ }
435+
436+ result.IsValid = true
437+ result.Reason = "stack operation is valid"
438+
439+ if gv.debugMode {
440+ log.Printf("[geometry] Stack validation: %s", result.Reason)
441+ }
442+
443+ return result
444+}
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 +66 -12
@@ -7,6 +7,7 @@ import (
77 "log"
88 "strings"
99 "sync"
10+ "sync/atomic"
1011 "time"
1112
1213 "github.com/bnema/dumber/internal/app/messaging"
@@ -23,7 +24,7 @@ type WorkspaceManager struct {
2324 lastSplitMsg map[*webkit.WebView]time.Time
2425 lastExitMsg map[*webkit.WebView]time.Time
2526 paneModeActive bool
26- splitting bool
27+ splitting int32 // atomic: 0=false, 1=true
2728 cssInitialized bool
2829 createWebViewFn func() (*webkit.WebView, error)
2930 createPaneFn func(*webkit.WebView) (*BrowserPane, error)
@@ -55,6 +56,15 @@ type WorkspaceManager struct {
5556 lastFocusTime time.Time
5657 lastFocusTarget *paneNode
5758 focusDebounce time.Duration
59+
60+ // BULLETPROOF COMPONENTS: Enhanced validation and safety systems
61+ treeValidator *TreeValidator
62+ widgetTxManager *WidgetTransactionManager
63+ concurrencyController *ConcurrencyController
64+ treeRebalancer *TreeRebalancer
65+ geometryValidator *GeometryValidator
66+ stackLifecycleManager *StackLifecycleManager
67+ stateTombstoneManager *StateTombstoneManager
5868 }
5969
6070 // Workspace navigation shortcuts are now handled globally by WindowShortcutHandler
@@ -72,7 +82,23 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag
7282 focusDebounce: 150 * time.Millisecond, // 150ms focus debouncing for all sources
7383 }
7484
75- // Initialize specialized managers
85+ // Initialize bulletproof components first
86+ manager.treeValidator = NewTreeValidator(true, false) // enabled, debug off
87+ manager.widgetTxManager = NewWidgetTransactionManager()
88+ manager.geometryValidator = NewGeometryValidator()
89+ manager.stateTombstoneManager = NewStateTombstoneManager(manager)
90+
91+ // Initialize concurrency controller with bulletproof components
92+ manager.concurrencyController = NewConcurrencyController(2, manager.widgetTxManager, manager.treeValidator)
93+ manager.concurrencyController.SetWorkspaceManager(manager)
94+
95+ // Initialize tree rebalancer
96+ manager.treeRebalancer = NewTreeRebalancer(manager, manager.treeValidator, manager.widgetTxManager)
97+
98+ // Initialize stack lifecycle manager with bulletproof components
99+ manager.stackLifecycleManager = NewStackLifecycleManager(manager, manager.treeValidator, manager.widgetTxManager)
100+
101+ // Initialize existing specialized managers (now enhanced with bulletproof components)
76102 manager.stackedPaneManager = NewStackedPaneManager(manager)
77103 manager.focusStateMachine = NewFocusStateMachine(manager)
78104 manager.createWebViewFn = func() (*webkit.WebView, error) {
@@ -208,20 +234,34 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa
208234 return
209235 }
210236 }
211- if wm.splitting {
237+
238+ // CRITICAL FIX: Use atomic operations for splitting flag to prevent race conditions
239+ if !atomic.CompareAndSwapInt32(&wm.splitting, 0, 1) {
212240 log.Printf("[workspace] split ignored: operation already in progress")
213241 return
214242 }
215- wm.splitting = true
243+
244+ // CRITICAL FIX: Always clear splitting flag on exit (panic recovery)
245+ defer func() {
246+ atomic.StoreInt32(&wm.splitting, 0)
247+ if r := recover(); r != nil {
248+ log.Printf("[workspace] split operation panicked, flag cleared: %v", r)
249+ panic(r) // Re-panic to maintain error handling
250+ }
251+ }()
252+
216253 wm.lastSplitMsg[source] = time.Now()
217- newNode, err := wm.splitNode(node, direction)
254+
255+ newNode, err := wm.BulletproofSplitNode(node, direction)
218256 if err != nil {
219257 log.Printf("[workspace] split failed: %v", err)
220- wm.splitting = false
258+ // CRITICAL FIX: Pump GTK events after validation failure to clear pending operations
259+ if webkit.IsMainThread() {
260+ webkit.IterateMainLoop()
261+ }
221262 return
222263 }
223264 wm.clonePaneState(node, newNode)
224- wm.splitting = false
225265 case "pane-stack":
226266 wm.SetActivePane(node, SourceProgrammatic)
227267 if last, ok := wm.lastSplitMsg[source]; ok {
@@ -230,20 +270,34 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa
230270 return
231271 }
232272 }
233- if wm.splitting {
273+
274+ // CRITICAL FIX: Use atomic operations for splitting flag
275+ if !atomic.CompareAndSwapInt32(&wm.splitting, 0, 1) {
234276 log.Printf("[workspace] stack ignored: operation already in progress")
235277 return
236278 }
237- wm.splitting = true
279+
280+ // CRITICAL FIX: Always clear splitting flag on exit (panic recovery)
281+ defer func() {
282+ atomic.StoreInt32(&wm.splitting, 0)
283+ if r := recover(); r != nil {
284+ log.Printf("[workspace] stack operation panicked, flag cleared: %v", r)
285+ panic(r) // Re-panic to maintain error handling
286+ }
287+ }()
288+
238289 wm.lastSplitMsg[source] = time.Now()
290+
239291 newNode, err := wm.stackedPaneManager.StackPane(node)
240292 if err != nil {
241293 log.Printf("[workspace] stack failed: %v", err)
242- wm.splitting = false
294+ // CRITICAL FIX: Pump GTK events after stack failure
295+ if webkit.IsMainThread() {
296+ webkit.IterateMainLoop()
297+ }
243298 return
244299 }
245300 wm.clonePaneState(node, newNode)
246- wm.splitting = false
247301 case "create-pane":
248302 log.Printf("[workspace] create-pane requested: url=%s action=%s requestId=%s", msg.URL, msg.Action, msg.RequestID)
249303
@@ -329,7 +383,7 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa
329383 log.Printf("[workspace] Closing popup pane due to %s", msg.Reason)
330384
331385 // Close the popup pane
332- if err := wm.closePane(targetNode); err != nil {
386+ if err := wm.BulletproofClosePane(targetNode); err != nil {
333387 log.Printf("[workspace] Failed to close popup pane: %v", err)
334388 } else {
335389 log.Printf("[workspace] Successfully closed popup pane: %s", msg.WebViewID)
D internal/app/browser/workspace_manager.go.deprecated +0 -2627 large diff (2627 lines changed): collapsed
M internal/app/browser/workspace_navigation.go +12 -2
@@ -114,11 +114,21 @@ func (wm *WorkspaceManager) detachHover(node *paneNode) {
114114 if wm == nil || node == nil || node.hoverToken == 0 {
115115 return
116116 }
117+
118+ if node.container == nil || !node.container.IsValid() {
119+ node.hoverToken = 0
120+ return
121+ }
122+
123+ token := node.hoverToken
124+ node.hoverToken = 0
125+
117126 node.container.Execute(func(containerPtr uintptr) error {
118- webkit.WidgetRemoveHoverHandler(containerPtr, node.hoverToken)
127+ if containerPtr != 0 && webkit.WidgetIsValid(containerPtr) {
128+ webkit.WidgetRemoveHoverHandler(containerPtr, token)
129+ }
119130 return nil
120131 })
121- node.hoverToken = 0
122132 }
123133
124134 // FocusNeighbor moves focus to the nearest pane in the requested direction using the
M internal/app/browser/workspace_pane_ops.go +228 -19
@@ -3,6 +3,7 @@ package browser
33
44 import (
55 "errors"
6+ "fmt"
67 "log"
78
89 "github.com/bnema/dumber/pkg/webkit"
@@ -101,12 +102,48 @@ func (wm *WorkspaceManager) clonePaneState(_ *paneNode, target *paneNode) {
101102 })
102103 }
103104
105+// safelyDetachControllersBeforeReparent removes GTK controllers that GTK will auto-clean up during reparenting.
106+// It marks nodes for reattachment once the widget hierarchy settles after the split operation.
107+func (wm *WorkspaceManager) safelyDetachControllersBeforeReparent(node *paneNode) {
108+ if wm == nil || node == nil {
109+ return
110+ }
111+
112+ markForDetachment := func(target *paneNode) {
113+ if target == nil {
114+ return
115+ }
116+
117+ if target.hoverToken != 0 {
118+ target.pendingHoverReattach = true
119+ wm.detachHover(target)
120+ }
121+
122+ if wm.focusStateMachine != nil && target.focusControllerToken != 0 {
123+ token := target.focusControllerToken
124+ target.pendingFocusReattach = true
125+ wm.focusStateMachine.detachGTKController(target, token)
126+ }
127+ }
128+
129+ if node.isStacked {
130+ for _, child := range node.stackedPanes {
131+ markForDetachment(child)
132+ }
133+ return
134+ }
135+
136+ if node.isLeaf {
137+ markForDetachment(node)
138+ }
139+}
140+
104141 // closeCurrentPane closes the currently focused pane
105142 func (wm *WorkspaceManager) closeCurrentPane() {
106143 if wm == nil || wm.GetActiveNode() == nil {
107144 return
108145 }
109- if err := wm.closePane(wm.GetActiveNode()); err != nil {
146+ if err := wm.BulletproofClosePane(wm.GetActiveNode()); err != nil {
110147 log.Printf("[workspace] close current pane failed: %v", err)
111148 }
112149 }
@@ -283,6 +320,9 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa
283320
284321 parent := split.parent
285322
323+ // Prevent GTK from auto-removing controllers while the widget is temporarily unparented.
324+ wm.safelyDetachControllersBeforeReparent(target)
325+
286326 // Detach existing container from its current GTK parent before inserting into new paned.
287327 existingContainer := target.getContainerPtr()
288328 if parent == nil {
@@ -366,6 +406,11 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa
366406
367407 // Update CSS classes for all panes now that we have multiple panes
368408
409+ reattachTargets := []*paneNode{target}
410+ if target != nil && target.isStacked {
411+ reattachTargets = append(reattachTargets, target.stackedPanes...)
412+ }
413+
369414 webkit.IdleAdd(func() bool {
370415 if newContainer != 0 {
371416 webkit.WidgetShow(newContainer)
@@ -374,6 +419,25 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa
374419 if wm.focusStateMachine != nil {
375420 wm.focusStateMachine.attachGTKController(newLeaf)
376421 }
422+
423+ for _, candidate := range reattachTargets {
424+ if candidate == nil {
425+ continue
426+ }
427+ if candidate.pendingHoverReattach {
428+ wm.ensureHover(candidate)
429+ if candidate.hoverToken != 0 {
430+ candidate.pendingHoverReattach = false
431+ }
432+ }
433+ if candidate.pendingFocusReattach && wm.focusStateMachine != nil {
434+ wm.focusStateMachine.attachGTKController(candidate)
435+ if candidate.focusControllerToken != 0 {
436+ candidate.pendingFocusReattach = false
437+ }
438+ }
439+ }
440+
377441 wm.SetActivePane(newLeaf, SourceSplit)
378442 return false
379443 })
@@ -589,7 +653,9 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
589653 // The originalTarget is still in the stack and should not interfere with the new split pane
590654 if originalTarget == target {
591655 // Only ensure hover on target if it's a regular (non-stack) split
592- wm.ensureHover(target)
656+ if !target.pendingHoverReattach {
657+ wm.ensureHover(target)
658+ }
593659 }
594660
595661 wm.app.panes = append(wm.app.panes, newPane)
@@ -599,6 +665,11 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
599665
600666 // Update CSS classes for all panes now that we have multiple panes
601667
668+ reattachTargets := []*paneNode{splitTarget}
669+ if splitTarget != nil && splitTarget.isStacked {
670+ reattachTargets = append(reattachTargets, splitTarget.stackedPanes...)
671+ }
672+
602673 webkit.IdleAdd(func() bool {
603674 if newContainer != 0 {
604675 webkit.WidgetShow(newContainer)
@@ -607,6 +678,24 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
607678 if wm.focusStateMachine != nil {
608679 wm.focusStateMachine.attachGTKController(newLeaf)
609680 }
681+
682+ for _, candidate := range reattachTargets {
683+ if candidate == nil {
684+ continue
685+ }
686+ if candidate.pendingHoverReattach {
687+ wm.ensureHover(candidate)
688+ if candidate.hoverToken != 0 {
689+ candidate.pendingHoverReattach = false
690+ }
691+ }
692+ if candidate.pendingFocusReattach && wm.focusStateMachine != nil {
693+ wm.focusStateMachine.attachGTKController(candidate)
694+ if candidate.focusControllerToken != 0 {
695+ candidate.pendingFocusReattach = false
696+ }
697+ }
698+ }
610699 wm.SetActivePane(newLeaf, SourceSplit)
611700 return false
612701 })
@@ -615,17 +704,19 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
615704 }
616705
617706 // closePane closes a specific pane and handles tree restructuring
618-func (wm *WorkspaceManager) closePane(node *paneNode) error {
707+func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) {
619708 if wm == nil || node == nil || !node.isLeaf {
620- return errors.New("close target must be a leaf pane")
709+ return nil, errors.New("close target must be a leaf pane")
621710 }
622711 if node.pane == nil || node.pane.webView == nil {
623- return errors.New("close target missing webview")
712+ return nil, errors.New("close target missing webview")
624713 }
625714
715+ var promoted *paneNode
716+
626717 // Handle closing panes in a stack
627718 if node.parent != nil && node.parent.isStacked {
628- return wm.stackedPaneManager.CloseStackedPane(node)
719+ return nil, wm.stackedPaneManager.CloseStackedPane(node)
629720 }
630721
631722 if node.parent != nil && node.parent.container != nil {
@@ -724,8 +815,8 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error {
724815 wm.root = nil
725816 wm.mainPane = nil
726817
727- webkit.QuitMainLoop()
728- return nil
818+ webkit.QuitMainLoop()
819+ return nil, nil
729820 }
730821 }
731822 }
@@ -737,14 +828,14 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error {
737828 log.Printf("[workspace] failed to destroy webview: %v", err)
738829 }
739830 webkit.QuitMainLoop()
740- return nil
831+ return nil, nil
741832 }
742833
743834 parent := node.parent
744835 if parent == nil {
745836 // This is the root pane (no parent in tree structure)
746837 if node != wm.root {
747- return errors.New("inconsistent state: node has no parent but is not root")
838+ return nil, errors.New("inconsistent state: node has no parent but is not root")
748839 }
749840
750841 // Check if we can find a replacement root
@@ -758,7 +849,7 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error {
758849 log.Printf("[workspace] failed to destroy webview: %v", err)
759850 }
760851 webkit.QuitMainLoop()
761- return nil
852+ return nil, nil
762853 }
763854
764855 // We have a replacement - delegate root status and close this pane
@@ -777,11 +868,16 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error {
777868
778869 // Set replacement as new root
779870 wm.root = replacement
780- replacement.parent = nil
871+ replacement.parent = nil // Critical: clear parent pointer for new root
872+ promoted = replacement
781873
782- // Replace window child directly (GTK handles reparenting automatically)
874+ // Replace window child - must unparent first to satisfy GTK4 assertion
783875 if wm.window != nil && replacement.container != nil {
784876 replacement.container.Execute(func(containerPtr uintptr) error {
877+ // Unparent from current parent before setting as window child
878+ if webkit.WidgetGetParent(containerPtr) != 0 {
879+ webkit.WidgetUnparent(containerPtr)
880+ }
785881 wm.window.SetChild(containerPtr)
786882 webkit.WidgetQueueAllocate(containerPtr)
787883 webkit.WidgetShow(containerPtr)
@@ -818,7 +914,7 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error {
818914 wm.updateMainPane()
819915 // Update CSS classes after pane count changes
820916 log.Printf("[workspace] root pane closed and delegated; panes remaining=%d", len(wm.app.panes))
821- return nil
917+ return promoted, nil
822918 }
823919
824920 var sibling *paneNode
@@ -828,7 +924,68 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error {
828924 sibling = parent.left
829925 }
830926 if sibling == nil {
831- return errors.New("pane close failed: missing sibling")
927+ return nil, errors.New("pane close failed: missing sibling")
928+ }
929+
930+ grand := parent.parent
931+
932+ ensureSafeWidget := func(owner string, widget **SafeWidget) error {
933+ if widget == nil {
934+ return nil
935+ }
936+ current := *widget
937+ if current == nil {
938+ return fmt.Errorf("%s widget missing", owner)
939+ }
940+ if current.IsValid() {
941+ return nil
942+ }
943+
944+ ptr := current.Ptr()
945+ typeInfo := current.typeInfo
946+ current.Invalidate()
947+ if wm.widgetRegistry != nil && ptr != 0 {
948+ wm.widgetRegistry.Unregister(ptr)
949+ }
950+
951+ if ptr == 0 || !webkit.WidgetIsValid(ptr) {
952+ *widget = nil
953+ return fmt.Errorf("%s widget %#x invalidated", owner, ptr)
954+ }
955+
956+ if wm.widgetRegistry == nil {
957+ return fmt.Errorf("%s widget registry unavailable", owner)
958+ }
959+
960+ reRegistered := wm.widgetRegistry.Register(ptr, typeInfo)
961+ if reRegistered == nil {
962+ return fmt.Errorf("%s widget %#x re-registration failed", owner, ptr)
963+ }
964+ *widget = reRegistered
965+ log.Printf("[workspace] re-registered %s widget: ptr=%#x type=%s", owner, ptr, typeInfo)
966+ return nil
967+ }
968+
969+ if err := ensureSafeWidget("sibling", &sibling.container); err != nil {
970+ log.Printf("[workspace] close aborted: %v", err)
971+ return nil, err
972+ }
973+ if parent.container != nil {
974+ if err := ensureSafeWidget("parent", &parent.container); err != nil {
975+ log.Printf("[workspace] close aborted: %v", err)
976+ return nil, err
977+ }
978+ }
979+ if grand != nil && grand.container != nil {
980+ if err := ensureSafeWidget("grandparent", &grand.container); err != nil {
981+ log.Printf("[workspace] close aborted: %v", err)
982+ return nil, err
983+ }
984+ }
985+
986+ if err := wm.validateWidgetsForReparenting(sibling, parent, grand); err != nil {
987+ log.Printf("[workspace] invalid widget state prior to close: %v", err)
988+ return nil, err
832989 }
833990
834991 log.Printf("[workspace] closing pane: target=%#x parent=%#x sibling=%#x remaining=%d", node.container, parent.container, sibling.container, remaining)
@@ -844,21 +1001,40 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error {
8441001 if wm.GetActiveNode() == node {
8451002 }
8461003
847- grand := parent.parent
1004+ promoted = sibling
8481005 if grand == nil {
8491006 // Parent is the root node. Promote sibling to become the new root.
8501007 log.Printf("[workspace] promoting sibling to root: container=%#x, isLeaf=%v", sibling.container, sibling.isLeaf)
8511008
8521009 // Update tree structure first
8531010 wm.root = sibling
854- sibling.parent = nil
1011+ sibling.parent = nil // Critical: clear parent pointer for new root
8551012
856- // For root promotion, GTK4 handles unparenting automatically when we set the new window child
1013+ // For root promotion, must unparent first to satisfy GTK4 assertion
8571014 if wm.window != nil && sibling.container != nil {
8581015 sibling.container.Execute(func(containerPtr uintptr) error {
1016+ parentBefore := webkit.WidgetGetParent(containerPtr)
1017+ log.Printf("[workspace] root promotion: sibling parent before=%#x", parentBefore)
1018+
1019+ if parentBefore == 0 {
1020+ log.Printf("[workspace] root promotion: sibling already detached")
1021+ } else {
1022+ oldParentStart := webkit.PanedGetStartChild(parentBefore)
1023+ oldParentEnd := webkit.PanedGetEndChild(parentBefore)
1024+ log.Printf("[workspace] root promotion: old parent start=%#x end=%#x", oldParentStart, oldParentEnd)
1025+ }
1026+
1027+ // Unparent from current parent before setting as window child
1028+ if webkit.WidgetGetParent(containerPtr) != 0 {
1029+ webkit.WidgetUnparent(containerPtr)
1030+ }
1031+
8591032 wm.window.SetChild(containerPtr)
8601033 webkit.WidgetQueueAllocate(containerPtr)
8611034 webkit.WidgetShow(containerPtr)
1035+
1036+ parentAfter := webkit.WidgetGetParent(containerPtr)
1037+ log.Printf("[workspace] root promotion: sibling parent after=%#x", parentAfter)
8621038 log.Printf("[workspace] successfully promoted sibling to root: %#x", containerPtr)
8631039 return nil
8641040 })
@@ -879,20 +1055,53 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error {
8791055 // GTK4 handles unparenting automatically when we set the new paned child
8801056 grand.container.Execute(func(grandPtr uintptr) error {
8811057 sibling.container.Execute(func(siblingPtr uintptr) error {
1058+ parentBefore := webkit.WidgetGetParent(siblingPtr)
1059+ log.Printf("[workspace] promotion: sibling current parent=%#x grand=%#x", parentBefore, grandPtr)
1060+
1061+ startBefore := webkit.PanedGetStartChild(grandPtr)
1062+ endBefore := webkit.PanedGetEndChild(grandPtr)
1063+ log.Printf("[workspace] promotion: grand start before=%#x end before=%#x", startBefore, endBefore)
1064+
8821065 if grand.left == sibling {
8831066 webkit.PanedSetStartChild(grandPtr, siblingPtr)
8841067 } else {
8851068 webkit.PanedSetEndChild(grandPtr, siblingPtr)
8861069 }
1070+
1071+ webkit.WidgetQueueAllocate(grandPtr)
8871072 webkit.WidgetQueueAllocate(siblingPtr)
8881073 webkit.WidgetShow(siblingPtr)
1074+
1075+ parentAfter := webkit.WidgetGetParent(siblingPtr)
1076+ startAfter := webkit.PanedGetStartChild(grandPtr)
1077+ endAfter := webkit.PanedGetEndChild(grandPtr)
1078+ log.Printf("[workspace] promotion: sibling parent after=%#x", parentAfter)
1079+ log.Printf("[workspace] promotion: grand start after=%#x end after=%#x", startAfter, endAfter)
1080+ if parentAfter == 0 {
1081+ log.Printf("[workspace] WARNING: sibling widget %#x has no parent after promotion", siblingPtr)
1082+ }
8891083 log.Printf("[workspace] successfully promoted sibling to parent position: %#x", siblingPtr)
8901084 return nil
8911085 })
1086+
1087+ // Also queue allocate the grandparent to ensure layout updates
8921088 return nil
8931089 })
8941090 }
8951091
1092+ // Parent container is now detached; ensure it is cleaned up to avoid lingering invalid widgets
1093+ if parent.container != nil {
1094+ ptr := parent.container.Ptr()
1095+ parent.container.Invalidate()
1096+ if wm.widgetRegistry != nil && ptr != 0 {
1097+ wm.widgetRegistry.Unregister(ptr)
1098+ }
1099+ parent.container = nil
1100+ }
1101+ parent.left = nil
1102+ parent.right = nil
1103+ parent.parent = nil
1104+
8961105 // Find a suitable focus target
8971106 focusTarget := wm.leftmostLeaf(sibling)
8981107 if focusTarget != nil {
@@ -922,5 +1131,5 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error {
9221131 wm.updateMainPane()
9231132 // Update CSS classes after pane count changes
9241133 log.Printf("[workspace] pane closed; panes remaining=%d", len(wm.app.panes))
925- return nil
1134+ return promoted, nil
9261135 }
M internal/app/browser/workspace_popup.go +3 -3
@@ -155,7 +155,7 @@ func (wm *WorkspaceManager) HandlePopup(source *webkit.WebView, url string) *web
155155 // Brief delay to allow any final redirects to complete
156156 time.AfterFunc(200*time.Millisecond, func() {
157157 webkit.IdleAdd(func() bool {
158- if err := wm.closePane(node); err != nil {
158+ if err := wm.BulletproofClosePane(node); err != nil {
159159 log.Printf("[workspace] Failed to close popup pane: %v", err)
160160 }
161161 return false
@@ -409,7 +409,7 @@ func (wm *WorkspaceManager) handleIntentAsPopup(sourceNode *paneNode, url string
409409 if n := wm.viewToNode[newView]; n != nil && n.isPopup {
410410 time.AfterFunc(200*time.Millisecond, func() {
411411 webkit.IdleAdd(func() bool {
412- if err := wm.closePane(n); err != nil {
412+ if err := wm.BulletproofClosePane(n); err != nil {
413413 log.Printf("[workspace] Failed to close popup pane: %v", err)
414414 }
415415 return false
@@ -495,7 +495,7 @@ func (wm *WorkspaceManager) configureRelatedPopup(sourceNode *paneNode, webView
495495 if n := wm.viewToNode[related]; n != nil && n.isPopup {
496496 time.AfterFunc(200*time.Millisecond, func() {
497497 webkit.IdleAdd(func() bool {
498- if err := wm.closePane(n); err != nil {
498+ if err := wm.BulletproofClosePane(n); err != nil {
499499 log.Printf("[workspace] Failed to close popup pane: %v", err)
500500 }
501501 return false
A internal/app/browser/workspace_stack_lifecycle.go +575 -0
@@ -0,0 +1,575 @@
1+// workspace_stack_lifecycle.go - Enhanced stack container lifecycle management for bulletproof operations
2+package browser
3+
4+import (
5+ "errors"
6+ "fmt"
7+ "log"
8+
9+ "github.com/bnema/dumber/pkg/webkit"
10+)
11+
12+// StackLifecycleManager manages the complete lifecycle of stack containers
13+type StackLifecycleManager struct {
14+ wm *WorkspaceManager
15+ treeValidator *TreeValidator
16+ widgetTxManager *WidgetTransactionManager
17+}
18+
19+// NewStackLifecycleManager creates a new stack lifecycle manager
20+func NewStackLifecycleManager(wm *WorkspaceManager, treeValidator *TreeValidator, widgetTxManager *WidgetTransactionManager) *StackLifecycleManager {
21+ return &StackLifecycleManager{
22+ wm: wm,
23+ treeValidator: treeValidator,
24+ widgetTxManager: widgetTxManager,
25+ }
26+}
27+
28+// CloseStackedPaneWithLifecycle handles closing a stacked pane with proper lifecycle management
29+func (slm *StackLifecycleManager) CloseStackedPaneWithLifecycle(node *paneNode) error {
30+ if node.parent == nil || !node.parent.isStacked {
31+ return errors.New("node is not part of a stacked pane")
32+ }
33+
34+ stackNode := node.parent
35+
36+ log.Printf("[stack-lifecycle] Closing stacked pane: node=%p stack=%p stackSize=%d",
37+ node, stackNode, len(stackNode.stackedPanes))
38+
39+ // Validate initial state
40+ if err := slm.validateStackState(stackNode, "before close"); err != nil {
41+ return fmt.Errorf("invalid stack state before close: %w", err)
42+ }
43+
44+ // Find the index of the node to be closed
45+ nodeIndex := -1
46+ for i, stackedPane := range stackNode.stackedPanes {
47+ if stackedPane == node {
48+ nodeIndex = i
49+ break
50+ }
51+ }
52+
53+ if nodeIndex == -1 {
54+ return errors.New("node not found in stack")
55+ }
56+
57+ // Determine what to do based on remaining stack size
58+ remainingSize := len(stackNode.stackedPanes) - 1
59+
60+ switch remainingSize {
61+ case 0:
62+ // Last pane in stack - remove entire stack container
63+ return slm.removeEmptyStackContainer(stackNode, node)
64+ case 1:
65+ // Two panes, closing one - convert remaining pane back to regular pane
66+ return slm.unstackLastPane(stackNode, node, nodeIndex)
67+ default:
68+ // Multiple panes remain - remove pane from stack
69+ return slm.removePaneFromStack(stackNode, node, nodeIndex)
70+ }
71+}
72+
73+// removeEmptyStackContainer removes a stack container when the last pane is closed
74+func (slm *StackLifecycleManager) removeEmptyStackContainer(stackNode *paneNode, closingNode *paneNode) error {
75+ log.Printf("[stack-lifecycle] Removing empty stack container: stack=%p", stackNode)
76+
77+ // Create transaction for this operation
78+ txID := fmt.Sprintf("remove_empty_stack_%p", stackNode)
79+ tx := slm.widgetTxManager.BeginTransaction(txID)
80+
81+ // Clean up the closing pane first
82+ slm.cleanupPaneResources(closingNode)
83+
84+ // Remove stack from tree structure
85+ if err := slm.removeStackFromTree(stackNode, tx); err != nil {
86+ tx.Rollback()
87+ slm.widgetTxManager.FinishTransaction(txID, false, err.Error())
88+ return fmt.Errorf("failed to remove stack from tree: %w", err)
89+ }
90+
91+ // Execute widget operations
92+ if err := tx.Execute(); err != nil {
93+ tx.Rollback()
94+ slm.widgetTxManager.FinishTransaction(txID, false, err.Error())
95+ return fmt.Errorf("failed to execute widget operations: %w", err)
96+ }
97+
98+ // Commit transaction
99+ if err := tx.Commit(); err != nil {
100+ slm.widgetTxManager.FinishTransaction(txID, false, err.Error())
101+ return fmt.Errorf("failed to commit transaction: %w", err)
102+ }
103+
104+ slm.widgetTxManager.FinishTransaction(txID, true, "")
105+
106+ // Validate final state
107+ if slm.treeValidator != nil {
108+ if err := slm.treeValidator.ValidateTree(slm.wm.root, "remove_empty_stack"); err != nil {
109+ log.Printf("[stack-lifecycle] Tree validation failed after removing empty stack: %v", err)
110+ }
111+ }
112+
113+ log.Printf("[stack-lifecycle] Successfully removed empty stack container")
114+ return nil
115+}
116+
117+// unstackLastPane converts a two-pane stack back to a regular pane when one is closed
118+func (slm *StackLifecycleManager) unstackLastPane(stackNode *paneNode, closingNode *paneNode, closingIndex int) error {
119+ log.Printf("[stack-lifecycle] Unstacking last pane: stack=%p closingIndex=%d", stackNode, closingIndex)
120+
121+ // Find the remaining pane
122+ var remainingPane *paneNode
123+ for i, pane := range stackNode.stackedPanes {
124+ if i != closingIndex && pane != closingNode {
125+ remainingPane = pane
126+ break
127+ }
128+ }
129+
130+ if remainingPane == nil {
131+ return errors.New("could not find remaining pane in stack")
132+ }
133+
134+ // Create transaction for this operation
135+ txID := fmt.Sprintf("unstack_last_%p", stackNode)
136+ tx := slm.widgetTxManager.BeginTransaction(txID)
137+
138+ // Clean up the closing pane
139+ slm.cleanupPaneResources(closingNode)
140+
141+ // Convert remaining pane back to regular pane
142+ if err := slm.convertStackToRegularPane(stackNode, remainingPane, tx); err != nil {
143+ tx.Rollback()
144+ slm.widgetTxManager.FinishTransaction(txID, false, err.Error())
145+ return fmt.Errorf("failed to convert stack to regular pane: %w", err)
146+ }
147+
148+ // Execute widget operations
149+ if err := tx.Execute(); err != nil {
150+ tx.Rollback()
151+ slm.widgetTxManager.FinishTransaction(txID, false, err.Error())
152+ return fmt.Errorf("failed to execute widget operations: %w", err)
153+ }
154+
155+ // Commit transaction
156+ if err := tx.Commit(); err != nil {
157+ slm.widgetTxManager.FinishTransaction(txID, false, err.Error())
158+ return fmt.Errorf("failed to commit transaction: %w", err)
159+ }
160+
161+ slm.widgetTxManager.FinishTransaction(txID, true, "")
162+
163+ // Validate final state
164+ if slm.treeValidator != nil {
165+ if err := slm.treeValidator.ValidateTree(slm.wm.root, "unstack_last"); err != nil {
166+ log.Printf("[stack-lifecycle] Tree validation failed after unstacking: %v", err)
167+ }
168+ }
169+
170+ log.Printf("[stack-lifecycle] Successfully unstacked last pane")
171+ return nil
172+}
173+
174+// removePaneFromStack removes a pane from a multi-pane stack
175+func (slm *StackLifecycleManager) removePaneFromStack(stackNode *paneNode, closingNode *paneNode, closingIndex int) error {
176+ log.Printf("[stack-lifecycle] Removing pane from stack: pane=%p index=%d", closingNode, closingIndex)
177+
178+ // Create transaction for this operation
179+ txID := fmt.Sprintf("remove_from_stack_%p_%d", stackNode, closingIndex)
180+ tx := slm.widgetTxManager.BeginTransaction(txID)
181+
182+ // Clean up the closing pane
183+ slm.cleanupPaneResources(closingNode)
184+
185+ // Remove pane widgets from stack container
186+ if err := slm.removePaneWidgetsFromStack(stackNode, closingNode, tx); err != nil {
187+ tx.Rollback()
188+ slm.widgetTxManager.FinishTransaction(txID, false, err.Error())
189+ return fmt.Errorf("failed to remove pane widgets: %w", err)
190+ }
191+
192+ // Update stack data structures
193+ slm.updateStackAfterRemoval(stackNode, closingIndex)
194+
195+ // Execute widget operations
196+ if err := tx.Execute(); err != nil {
197+ tx.Rollback()
198+ slm.widgetTxManager.FinishTransaction(txID, false, err.Error())
199+ return fmt.Errorf("failed to execute widget operations: %w", err)
200+ }
201+
202+ // Commit transaction
203+ if err := tx.Commit(); err != nil {
204+ slm.widgetTxManager.FinishTransaction(txID, false, err.Error())
205+ return fmt.Errorf("failed to commit transaction: %w", err)
206+ }
207+
208+ slm.widgetTxManager.FinishTransaction(txID, true, "")
209+
210+ // Update stack visibility after removal
211+ if slm.wm.stackedPaneManager != nil {
212+ slm.wm.stackedPaneManager.UpdateStackVisibility(stackNode)
213+ }
214+
215+ // Validate final state
216+ if slm.treeValidator != nil {
217+ if err := slm.treeValidator.ValidateTree(slm.wm.root, "remove_from_stack"); err != nil {
218+ log.Printf("[stack-lifecycle] Tree validation failed after removing from stack: %v", err)
219+ }
220+ }
221+
222+ log.Printf("[stack-lifecycle] Successfully removed pane from stack")
223+ return nil
224+}
225+
226+// removeStackFromTree removes the stack container from the tree structure
227+func (slm *StackLifecycleManager) removeStackFromTree(stackNode *paneNode, tx *WidgetTransaction) error {
228+ if stackNode == nil {
229+ return errors.New("stack node is nil")
230+ }
231+
232+ parent := stackNode.parent
233+
234+ // Add operation to unparent stack container when still valid
235+ if stackNode.container != nil {
236+ if !stackNode.container.IsValid() {
237+ log.Printf("[stack-lifecycle] stack container already invalid, skipping unparent: node=%p", stackNode)
238+ slm.invalidateSafeWidget(&stackNode.container)
239+ slm.invalidateSafeWidget(&stackNode.stackWrapper)
240+ } else {
241+ op := CreateWidgetUnparentOperation(
242+ fmt.Sprintf("unparent_stack_%p", stackNode),
243+ stackNode.container,
244+ )
245+ if err := tx.AddOperation(op); err != nil {
246+ return fmt.Errorf("failed to add unparent operation: %w", err)
247+ }
248+
249+ // Schedule cleanup of stack container and wrapper after unparent
250+ cleanupOp := &WidgetOperation{
251+ ID: fmt.Sprintf("cleanup_stack_widgets_%p", stackNode),
252+ Description: "Invalidate stack container and wrapper",
253+ Priority: 10,
254+ Execute: func() error {
255+ slm.invalidateSafeWidget(&stackNode.container)
256+ slm.invalidateSafeWidget(&stackNode.stackWrapper)
257+ return nil
258+ },
259+ }
260+ if err := tx.AddOperation(cleanupOp); err != nil {
261+ return fmt.Errorf("failed to add stack cleanup operation: %w", err)
262+ }
263+ }
264+ }
265+
266+ // Update tree structure
267+ if parent == nil {
268+ // Stack was root - clear root
269+ slm.wm.root = nil
270+ slm.wm.mainPane = nil
271+ } else {
272+ // Remove stack from parent
273+ if parent.left == stackNode {
274+ parent.left = nil
275+ } else if parent.right == stackNode {
276+ parent.right = nil
277+ }
278+
279+ // If parent now has only one child, it needs restructuring
280+ if (parent.left == nil) != (parent.right == nil) {
281+ // Parent has exactly one child - promote the child
282+ var remainingChild *paneNode
283+ if parent.left != nil {
284+ remainingChild = parent.left
285+ } else {
286+ remainingChild = parent.right
287+ }
288+
289+ if err := slm.promoteChildNode(parent, remainingChild, tx); err != nil {
290+ return fmt.Errorf("failed to promote child node: %w", err)
291+ }
292+ }
293+ }
294+
295+ return nil
296+}
297+
298+// convertStackToRegularPane converts a stack container to a regular pane
299+func (slm *StackLifecycleManager) convertStackToRegularPane(stackNode *paneNode, remainingPane *paneNode, tx *WidgetTransaction) error {
300+ parent := stackNode.parent
301+
302+ // Remove remaining pane from stack container
303+ if remainingPane.container != nil {
304+ op := CreateWidgetUnparentOperation(
305+ fmt.Sprintf("unparent_remaining_%p", remainingPane),
306+ remainingPane.container,
307+ )
308+ if err := tx.AddOperation(op); err != nil {
309+ return fmt.Errorf("failed to add unparent operation: %w", err)
310+ }
311+ }
312+
313+ // Update remaining pane properties
314+ remainingPane.parent = parent
315+ remainingPane.isStacked = false
316+
317+ // Clear stack-related fields
318+ remainingPane.titleBar = nil
319+
320+ // Update tree structure
321+ if parent == nil {
322+ // Stack was root - make remaining pane the new root
323+ slm.wm.root = remainingPane
324+ slm.wm.mainPane = remainingPane
325+
326+ // Reparent to window
327+ if remainingPane.container != nil && slm.wm.window != nil {
328+ op := &WidgetOperation{
329+ ID: fmt.Sprintf("reparent_to_window_%p", remainingPane),
330+ Description: "Reparent remaining pane to window",
331+ Priority: 200,
332+ Execute: func() error {
333+ return remainingPane.container.Execute(func(ptr uintptr) error {
334+ slm.wm.window.SetChild(ptr)
335+ webkit.WidgetQueueAllocate(ptr)
336+ webkit.WidgetShow(ptr)
337+ return nil
338+ })
339+ },
340+ }
341+ if err := tx.AddOperation(op); err != nil {
342+ return fmt.Errorf("failed to add reparent operation: %w", err)
343+ }
344+ }
345+ } else {
346+ // Replace stack with remaining pane in parent
347+ if parent.left == stackNode {
348+ parent.left = remainingPane
349+ } else if parent.right == stackNode {
350+ parent.right = remainingPane
351+ }
352+
353+ // Reparent to parent container
354+ if remainingPane.container != nil && parent.container != nil {
355+ isStart := parent.left == remainingPane
356+ op := CreateWidgetReparentOperation(
357+ fmt.Sprintf("reparent_remaining_%p", remainingPane),
358+ remainingPane.container,
359+ parent.container.Ptr(),
360+ isStart,
361+ )
362+ if err := tx.AddOperation(op); err != nil {
363+ return fmt.Errorf("failed to add reparent operation: %w", err)
364+ }
365+ }
366+ }
367+
368+ return nil
369+}
370+
371+// removePaneWidgetsFromStack removes a pane's widgets from the stack container
372+func (slm *StackLifecycleManager) removePaneWidgetsFromStack(stackNode *paneNode, closingNode *paneNode, tx *WidgetTransaction) error {
373+ // Remove title bar if it exists
374+ if closingNode.titleBar != nil {
375+ if !closingNode.titleBar.IsValid() {
376+ log.Printf("[stack-lifecycle] title bar already invalid, skipping removal: pane=%p", closingNode)
377+ slm.invalidateSafeWidget(&closingNode.titleBar)
378+ } else {
379+ op := CreateWidgetUnparentOperation(
380+ fmt.Sprintf("remove_titlebar_%p", closingNode),
381+ closingNode.titleBar,
382+ )
383+ if err := tx.AddOperation(op); err != nil {
384+ return fmt.Errorf("failed to add titlebar removal operation: %w", err)
385+ }
386+ }
387+ }
388+
389+ // Remove container
390+ if closingNode.container != nil {
391+ if !closingNode.container.IsValid() {
392+ log.Printf("[stack-lifecycle] pane container already invalid, skipping removal: pane=%p", closingNode)
393+ slm.invalidateSafeWidget(&closingNode.container)
394+ } else {
395+ op := CreateWidgetUnparentOperation(
396+ fmt.Sprintf("remove_container_%p", closingNode),
397+ closingNode.container,
398+ )
399+ if err := tx.AddOperation(op); err != nil {
400+ return fmt.Errorf("failed to add container removal operation: %w", err)
401+ }
402+ }
403+ }
404+
405+ cleanupOp := &WidgetOperation{
406+ ID: fmt.Sprintf("cleanup_removed_pane_%p", closingNode),
407+ Description: "Invalidate removed pane widgets",
408+ Priority: 10,
409+ Execute: func() error {
410+ slm.invalidateSafeWidget(&closingNode.titleBar)
411+ slm.invalidateSafeWidget(&closingNode.container)
412+ return nil
413+ },
414+ }
415+ if err := tx.AddOperation(cleanupOp); err != nil {
416+ return fmt.Errorf("failed to add pane widget cleanup operation: %w", err)
417+ }
418+
419+ return nil
420+}
421+
422+// updateStackAfterRemoval updates stack data structures after removing a pane
423+func (slm *StackLifecycleManager) updateStackAfterRemoval(stackNode *paneNode, removedIndex int) {
424+ // Remove from stackedPanes slice
425+ stackNode.stackedPanes = append(
426+ stackNode.stackedPanes[:removedIndex],
427+ stackNode.stackedPanes[removedIndex+1:]...,
428+ )
429+
430+ // Update active index if necessary
431+ if stackNode.activeStackIndex >= removedIndex {
432+ if stackNode.activeStackIndex > 0 {
433+ stackNode.activeStackIndex--
434+ }
435+ }
436+
437+ // Ensure active index is valid
438+ if stackNode.activeStackIndex >= len(stackNode.stackedPanes) {
439+ stackNode.activeStackIndex = len(stackNode.stackedPanes) - 1
440+ }
441+ if stackNode.activeStackIndex < 0 && len(stackNode.stackedPanes) > 0 {
442+ stackNode.activeStackIndex = 0
443+ }
444+
445+ log.Printf("[stack-lifecycle] Updated stack: remaining=%d activeIndex=%d",
446+ len(stackNode.stackedPanes), stackNode.activeStackIndex)
447+}
448+
449+// promoteChildNode promotes a child node to replace its parent
450+func (slm *StackLifecycleManager) promoteChildNode(parent *paneNode, child *paneNode, tx *WidgetTransaction) error {
451+ grandparent := parent.parent
452+
453+ // Update child's parent pointer
454+ child.parent = grandparent
455+
456+ // Update grandparent or root reference
457+ if grandparent == nil {
458+ // Child becomes new root
459+ slm.wm.root = child
460+ if child.isLeaf {
461+ slm.wm.mainPane = child
462+ }
463+
464+ // Reparent to window
465+ if child.container != nil && slm.wm.window != nil {
466+ op := &WidgetOperation{
467+ ID: fmt.Sprintf("promote_to_root_%p", child),
468+ Description: "Promote child to root",
469+ Priority: 200,
470+ Execute: func() error {
471+ return child.container.Execute(func(ptr uintptr) error {
472+ slm.wm.window.SetChild(ptr)
473+ webkit.WidgetQueueAllocate(ptr)
474+ webkit.WidgetShow(ptr)
475+ return nil
476+ })
477+ },
478+ }
479+ if err := tx.AddOperation(op); err != nil {
480+ return fmt.Errorf("failed to add promote operation: %w", err)
481+ }
482+ }
483+ } else {
484+ // Update grandparent's child pointer
485+ if grandparent.left == parent {
486+ grandparent.left = child
487+ } else if grandparent.right == parent {
488+ grandparent.right = child
489+ }
490+
491+ // Reparent widget to grandparent
492+ if child.container != nil && grandparent.container != nil {
493+ isStart := grandparent.left == child
494+ op := CreateWidgetReparentOperation(
495+ fmt.Sprintf("promote_child_%p", child),
496+ child.container,
497+ grandparent.container.Ptr(),
498+ isStart,
499+ )
500+ if err := tx.AddOperation(op); err != nil {
501+ return fmt.Errorf("failed to add promote reparent operation: %w", err)
502+ }
503+ }
504+ }
505+
506+ return nil
507+}
508+
509+// cleanupPaneResources cleans up resources associated with a pane
510+func (slm *StackLifecycleManager) cleanupPaneResources(node *paneNode) {
511+ // Detach hover
512+ if slm.wm != nil {
513+ slm.wm.detachHover(node)
514+ }
515+
516+ // Clean up from workspace
517+ if node.pane != nil {
518+ node.pane.CleanupFromWorkspace(slm.wm)
519+ }
520+
521+ // Clear references
522+ node.parent = nil
523+ node.titleBar = nil
524+ node.stackWrapper = nil
525+}
526+
527+// validateStackState validates the current state of a stack
528+func (slm *StackLifecycleManager) validateStackState(stackNode *paneNode, operation string) error {
529+ if stackNode == nil {
530+ return errors.New("stack node is nil")
531+ }
532+
533+ if !stackNode.isStacked {
534+ return errors.New("node is not marked as stacked")
535+ }
536+
537+ if len(stackNode.stackedPanes) == 0 {
538+ return errors.New("stack has no panes")
539+ }
540+
541+ if stackNode.activeStackIndex < 0 || stackNode.activeStackIndex >= len(stackNode.stackedPanes) {
542+ return fmt.Errorf("invalid active stack index: %d (size: %d)",
543+ stackNode.activeStackIndex, len(stackNode.stackedPanes))
544+ }
545+
546+ // Check that all stacked panes have this node as parent
547+ for i, pane := range stackNode.stackedPanes {
548+ if pane == nil {
549+ return fmt.Errorf("stacked pane at index %d is nil", i)
550+ }
551+ if pane.parent != stackNode {
552+ return fmt.Errorf("stacked pane at index %d has wrong parent", i)
553+ }
554+ }
555+
556+ log.Printf("[stack-lifecycle] Stack state valid for operation '%s': size=%d activeIndex=%d",
557+ operation, len(stackNode.stackedPanes), stackNode.activeStackIndex)
558+ return nil
559+}
560+
561+// invalidateSafeWidget invalidates and unregisters a widget safely
562+func (slm *StackLifecycleManager) invalidateSafeWidget(widget **SafeWidget) {
563+ if widget == nil || *widget == nil {
564+ return
565+ }
566+
567+ ptr := (*widget).Ptr()
568+ (*widget).Invalidate()
569+
570+ if slm.wm != nil && slm.wm.widgetRegistry != nil && ptr != 0 {
571+ slm.wm.widgetRegistry.Unregister(ptr)
572+ }
573+
574+ *widget = nil
575+}
A internal/app/browser/workspace_state_tombstone.go +705 -0
@@ -0,0 +1,705 @@
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 *TreeSnapshot
22+ WidgetState *WidgetSnapshot
23+ FocusState *FocusSnapshot
24+ StackState *StackSnapshot
25+ Compressed bool
26+ Size int
27+}
28+
29+// TreeSnapshot captures the tree structure
30+type TreeSnapshot 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(&TreeSnapshot{})
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() (*TreeSnapshot, error) {
211+ snapshot := &TreeSnapshot{
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 *TreeSnapshot) {
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+}
A internal/app/browser/workspace_tree_rebalancer.go +772 -0
@@ -0,0 +1,772 @@
1+// workspace_tree_rebalancer.go - Tree rebalancing for optimal split pane layout
2+package browser
3+
4+import (
5+ "fmt"
6+ "log"
7+ "math"
8+
9+ "github.com/bnema/dumber/pkg/webkit"
10+)
11+
12+// TreeRebalancer provides algorithms for rebalancing the binary tree after operations
13+type TreeRebalancer struct {
14+ wm *WorkspaceManager
15+ treeValidator *TreeValidator
16+ widgetTxManager *WidgetTransactionManager
17+ maxImbalance int // Maximum allowed imbalance before rebalancing
18+ enabled bool // Whether rebalancing is enabled
19+}
20+
21+// RebalanceOperation represents a tree rebalancing operation
22+type RebalanceOperation struct {
23+ Type RebalanceType
24+ Node *paneNode
25+ Description string
26+}
27+
28+// RebalanceType represents different types of rebalancing operations
29+type RebalanceType int
30+
31+const (
32+ RebalanceRotateLeft RebalanceType = iota
33+ RebalanceRotateRight
34+ RebalancePromote
35+ RebalanceRestructure
36+)
37+
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, widgetTxManager *WidgetTransactionManager) *TreeRebalancer {
66+ return &TreeRebalancer{
67+ wm: wm,
68+ treeValidator: treeValidator,
69+ widgetTxManager: widgetTxManager,
70+ maxImbalance: 2, // Allow up to 2 levels of imbalance
71+ enabled: true,
72+ }
73+}
74+
75+// SetMaxImbalance sets the maximum allowed imbalance
76+func (tr *TreeRebalancer) SetMaxImbalance(maxImbalance int) {
77+ tr.maxImbalance = maxImbalance
78+}
79+
80+// Enable enables tree rebalancing
81+func (tr *TreeRebalancer) Enable() {
82+ tr.enabled = true
83+ log.Printf("[tree-rebalancer] Tree rebalancing enabled")
84+}
85+
86+// Disable disables tree rebalancing
87+func (tr *TreeRebalancer) Disable() {
88+ tr.enabled = false
89+ log.Printf("[tree-rebalancer] Tree rebalancing disabled")
90+}
91+
92+// RebalanceAfterClose performs tree rebalancing after a pane close operation
93+func (tr *TreeRebalancer) RebalanceAfterClose(closedNode *paneNode, promotedNode *paneNode) error {
94+ if !tr.enabled {
95+ return nil
96+ }
97+
98+ log.Printf("[tree-rebalancer] Analyzing tree for rebalancing after close operation")
99+
100+ if promotedNode != nil {
101+ txID := fmt.Sprintf("promotion_tx_%p", promotedNode)
102+ tx := tr.widgetTxManager.BeginTransaction(txID)
103+
104+ if err := tr.executePromotion(promotedNode, tx); err != nil {
105+ if rollbackErr := tx.Rollback(); rollbackErr != nil {
106+ log.Printf("[tree-rebalancer] Failed to rollback promotion transaction %s: %v", txID, rollbackErr)
107+ }
108+ tr.widgetTxManager.FinishTransaction(txID, false, err.Error())
109+ return fmt.Errorf("promotion execution failed: %w", err)
110+ }
111+
112+ if err := tx.Execute(); err != nil {
113+ if rollbackErr := tx.Rollback(); rollbackErr != nil {
114+ log.Printf("[tree-rebalancer] Failed to rollback promotion transaction %s: %v", txID, rollbackErr)
115+ }
116+ tr.widgetTxManager.FinishTransaction(txID, false, err.Error())
117+ return fmt.Errorf("promotion widget execution failed: %w", err)
118+ }
119+
120+ if err := tx.Commit(); err != nil {
121+ tr.widgetTxManager.FinishTransaction(txID, false, err.Error())
122+ return fmt.Errorf("failed to commit promotion transaction: %w", err)
123+ }
124+
125+ tr.widgetTxManager.FinishTransaction(txID, true, "")
126+
127+ if tr.wm != nil && tr.wm.geometryValidator != nil {
128+ geom := tr.wm.geometryValidator.GetPaneGeometry(promotedNode)
129+ if !geom.IsValid || geom.Width <= 0 || geom.Height <= 0 {
130+ log.Printf("[tree-rebalancer] WARNING: promoted pane geometry invalid after promotion (%dx%d)", geom.Width, geom.Height)
131+ } else {
132+ log.Printf("[tree-rebalancer] Promotion geometry validated: %dx%d", geom.Width, geom.Height)
133+ }
134+ }
135+ }
136+
137+ // Calculate tree metrics
138+ metrics := tr.CalculateTreeMetrics(tr.wm.root)
139+ log.Printf("[tree-rebalancer] Tree metrics: height=%d, nodes=%d, balance=%d, imbalance=%d",
140+ metrics.Height, metrics.NodeCount, metrics.BalanceFactor, metrics.ImbalanceScore)
141+
142+ // Check if rebalancing is needed
143+ if metrics.ImbalanceScore <= tr.maxImbalance {
144+ log.Printf("[tree-rebalancer] Tree is balanced, no rebalancing needed")
145+ return nil
146+ }
147+
148+ // Find rebalancing operations
149+ operations := tr.findRebalancingOperations(tr.wm.root, metrics)
150+ if len(operations) == 0 {
151+ log.Printf("[tree-rebalancer] No rebalancing operations found")
152+ return nil
153+ }
154+
155+ log.Printf("[tree-rebalancer] Found %d rebalancing operations", len(operations))
156+
157+ // Execute rebalancing operations
158+ return tr.executeRebalancingOperations(operations)
159+}
160+
161+// CalculateTreeMetrics calculates comprehensive metrics about the tree
162+func (tr *TreeRebalancer) CalculateTreeMetrics(root *paneNode) TreeMetrics {
163+ if root == nil {
164+ return TreeMetrics{}
165+ }
166+
167+ metrics := TreeMetrics{}
168+
169+ // Calculate basic metrics
170+ metrics.Height = tr.calculateHeight(root)
171+ metrics.NodeCount = tr.countNodes(root)
172+ metrics.LeafCount = tr.countLeaves(root)
173+ metrics.MaxDepth = tr.calculateMaxDepth(root)
174+ metrics.AvgDepth = tr.calculateAverageDepth(root)
175+
176+ // Calculate balance factor
177+ leftHeight := tr.calculateHeight(root.left)
178+ rightHeight := tr.calculateHeight(root.right)
179+ metrics.BalanceFactor = rightHeight - leftHeight
180+
181+ // Calculate imbalance score (how far from balanced the tree is)
182+ metrics.ImbalanceScore = tr.calculateImbalanceScore(root)
183+
184+ return metrics
185+}
186+
187+// calculateHeight calculates the height of a subtree
188+func (tr *TreeRebalancer) calculateHeight(node *paneNode) int {
189+ if node == nil {
190+ return 0
191+ }
192+
193+ // Skip stacked nodes for height calculation (they don't affect layout balance)
194+ if node.isStacked {
195+ // For stacked nodes, height is determined by the active pane
196+ if len(node.stackedPanes) > 0 && node.activeStackIndex >= 0 && node.activeStackIndex < len(node.stackedPanes) {
197+ return tr.calculateHeight(node.stackedPanes[node.activeStackIndex])
198+ }
199+ return 1
200+ }
201+
202+ if node.isLeaf {
203+ return 1
204+ }
205+
206+ leftHeight := tr.calculateHeight(node.left)
207+ rightHeight := tr.calculateHeight(node.right)
208+ return 1 + max(leftHeight, rightHeight)
209+}
210+
211+// countNodes counts the total number of nodes in the tree
212+func (tr *TreeRebalancer) countNodes(node *paneNode) int {
213+ if node == nil {
214+ return 0
215+ }
216+
217+ count := 1
218+ if node.isStacked {
219+ // Count stacked panes
220+ count += len(node.stackedPanes)
221+ } else {
222+ count += tr.countNodes(node.left)
223+ count += tr.countNodes(node.right)
224+ }
225+
226+ return count
227+}
228+
229+// countLeaves counts the number of leaf nodes
230+func (tr *TreeRebalancer) countLeaves(node *paneNode) int {
231+ if node == nil {
232+ return 0
233+ }
234+
235+ if node.isLeaf {
236+ return 1
237+ }
238+
239+ if node.isStacked {
240+ return len(node.stackedPanes)
241+ }
242+
243+ return tr.countLeaves(node.left) + tr.countLeaves(node.right)
244+}
245+
246+// calculateMaxDepth calculates the maximum depth of any leaf
247+func (tr *TreeRebalancer) calculateMaxDepth(node *paneNode) int {
248+ return tr.calculateMaxDepthHelper(node, 0)
249+}
250+
251+func (tr *TreeRebalancer) calculateMaxDepthHelper(node *paneNode, currentDepth int) int {
252+ if node == nil {
253+ return currentDepth
254+ }
255+
256+ if node.isLeaf || node.isStacked {
257+ return currentDepth + 1
258+ }
259+
260+ leftDepth := tr.calculateMaxDepthHelper(node.left, currentDepth+1)
261+ rightDepth := tr.calculateMaxDepthHelper(node.right, currentDepth+1)
262+ return max(leftDepth, rightDepth)
263+}
264+
265+// calculateAverageDepth calculates the average depth of all leaves
266+func (tr *TreeRebalancer) calculateAverageDepth(node *paneNode) float64 {
267+ if node == nil {
268+ return 0
269+ }
270+
271+ totalDepth, leafCount := tr.calculateTotalDepthAndCount(node, 0)
272+ if leafCount == 0 {
273+ return 0
274+ }
275+
276+ return float64(totalDepth) / float64(leafCount)
277+}
278+
279+func (tr *TreeRebalancer) calculateTotalDepthAndCount(node *paneNode, currentDepth int) (int, int) {
280+ if node == nil {
281+ return 0, 0
282+ }
283+
284+ if node.isLeaf {
285+ return currentDepth + 1, 1
286+ }
287+
288+ if node.isStacked {
289+ // All stacked panes have the same depth
290+ return (currentDepth + 1) * len(node.stackedPanes), len(node.stackedPanes)
291+ }
292+
293+ leftDepth, leftCount := tr.calculateTotalDepthAndCount(node.left, currentDepth+1)
294+ rightDepth, rightCount := tr.calculateTotalDepthAndCount(node.right, currentDepth+1)
295+
296+ return leftDepth + rightDepth, leftCount + rightCount
297+}
298+
299+// calculateImbalanceScore calculates how imbalanced the tree is
300+func (tr *TreeRebalancer) calculateImbalanceScore(node *paneNode) int {
301+ if node == nil {
302+ return 0
303+ }
304+
305+ leftHeight := tr.calculateHeight(node.left)
306+ rightHeight := tr.calculateHeight(node.right)
307+ currentImbalance := int(math.Abs(float64(leftHeight - rightHeight)))
308+
309+ // Recursively calculate imbalance for subtrees
310+ leftImbalance := tr.calculateImbalanceScore(node.left)
311+ rightImbalance := tr.calculateImbalanceScore(node.right)
312+
313+ return max(currentImbalance, max(leftImbalance, rightImbalance))
314+}
315+
316+// findRebalancingOperations identifies operations needed to rebalance the tree
317+func (tr *TreeRebalancer) findRebalancingOperations(root *paneNode, metrics TreeMetrics) []RebalanceOperation {
318+ var operations []RebalanceOperation
319+
320+ // Find all imbalanced nodes
321+ imbalancedNodes := tr.findImbalancedNodes(root)
322+
323+ for _, node := range imbalancedNodes {
324+ // Determine the best rebalancing operation for this node
325+ operation := tr.determineRebalancingOperation(node)
326+ if operation != nil {
327+ operations = append(operations, *operation)
328+ }
329+ }
330+
331+ return operations
332+}
333+
334+// findImbalancedNodes finds all nodes that are significantly imbalanced
335+func (tr *TreeRebalancer) findImbalancedNodes(node *paneNode) []*paneNode {
336+ if node == nil {
337+ return nil
338+ }
339+
340+ var imbalanced []*paneNode
341+
342+ leftHeight := tr.calculateHeight(node.left)
343+ rightHeight := tr.calculateHeight(node.right)
344+ balance := int(math.Abs(float64(leftHeight - rightHeight)))
345+
346+ if balance > tr.maxImbalance {
347+ imbalanced = append(imbalanced, node)
348+ }
349+
350+ // Recursively check children
351+ imbalanced = append(imbalanced, tr.findImbalancedNodes(node.left)...)
352+ imbalanced = append(imbalanced, tr.findImbalancedNodes(node.right)...)
353+
354+ return imbalanced
355+}
356+
357+// determineRebalancingOperation determines the best rebalancing operation for a node
358+func (tr *TreeRebalancer) determineRebalancingOperation(node *paneNode) *RebalanceOperation {
359+ if node == nil || node.isLeaf {
360+ return nil
361+ }
362+
363+ leftHeight := tr.calculateHeight(node.left)
364+ rightHeight := tr.calculateHeight(node.right)
365+
366+ if leftHeight > rightHeight+tr.maxImbalance {
367+ // Left-heavy, need right rotation
368+ return &RebalanceOperation{
369+ Type: RebalanceRotateRight,
370+ Node: node,
371+ Description: fmt.Sprintf("Right rotation on node %p (left=%d, right=%d)", node, leftHeight, rightHeight),
372+ }
373+ } else if rightHeight > leftHeight+tr.maxImbalance {
374+ // Right-heavy, need left rotation
375+ return &RebalanceOperation{
376+ Type: RebalanceRotateLeft,
377+ Node: node,
378+ Description: fmt.Sprintf("Left rotation on node %p (left=%d, right=%d)", node, leftHeight, rightHeight),
379+ }
380+ }
381+
382+ return nil
383+}
384+
385+// executeRebalancingOperations executes a series of rebalancing operations
386+func (tr *TreeRebalancer) executeRebalancingOperations(operations []RebalanceOperation) error {
387+ if len(operations) == 0 {
388+ return nil
389+ }
390+
391+ // Create transaction for all rebalancing operations
392+ txID := fmt.Sprintf("rebalance_%d_ops", len(operations))
393+ tx := tr.widgetTxManager.BeginTransaction(txID)
394+
395+ log.Printf("[tree-rebalancer] Executing %d rebalancing operations", len(operations))
396+
397+ // Execute each operation
398+ for i, operation := range operations {
399+ log.Printf("[tree-rebalancer] Executing operation %d/%d: %s", i+1, len(operations), operation.Description)
400+
401+ var err error
402+ switch operation.Type {
403+ case RebalanceRotateLeft:
404+ err = tr.executeLeftRotation(operation.Node, tx)
405+ case RebalanceRotateRight:
406+ err = tr.executeRightRotation(operation.Node, tx)
407+ case RebalancePromote:
408+ err = tr.executePromotion(operation.Node, tx)
409+ case RebalanceRestructure:
410+ err = tr.executeRestructure(operation.Node, tx)
411+ default:
412+ err = fmt.Errorf("unknown rebalance operation type: %s", operation.Type)
413+ }
414+
415+ if err != nil {
416+ log.Printf("[tree-rebalancer] Operation failed: %v", err)
417+ tx.Rollback()
418+ tr.widgetTxManager.FinishTransaction(txID, false, err.Error())
419+ return fmt.Errorf("rebalancing operation failed: %w", err)
420+ }
421+ }
422+
423+ // Execute widget transaction
424+ if err := tx.Execute(); err != nil {
425+ tx.Rollback()
426+ tr.widgetTxManager.FinishTransaction(txID, false, err.Error())
427+ return fmt.Errorf("failed to execute rebalancing widget operations: %w", err)
428+ }
429+
430+ // Commit transaction
431+ if err := tx.Commit(); err != nil {
432+ tr.widgetTxManager.FinishTransaction(txID, false, err.Error())
433+ return fmt.Errorf("failed to commit rebalancing transaction: %w", err)
434+ }
435+
436+ tr.widgetTxManager.FinishTransaction(txID, true, "")
437+
438+ // Validate tree after rebalancing
439+ if tr.treeValidator != nil {
440+ if err := tr.treeValidator.ValidateTree(tr.wm.root, "rebalance"); err != nil {
441+ log.Printf("[tree-rebalancer] Tree validation failed after rebalancing: %v", err)
442+ }
443+ }
444+
445+ // Calculate new metrics
446+ newMetrics := tr.CalculateTreeMetrics(tr.wm.root)
447+ log.Printf("[tree-rebalancer] Rebalancing completed: new imbalance=%d (was %d)",
448+ newMetrics.ImbalanceScore, newMetrics.ImbalanceScore)
449+
450+ return nil
451+}
452+
453+// executeLeftRotation performs a left rotation on the given node
454+func (tr *TreeRebalancer) executeLeftRotation(node *paneNode, tx *WidgetTransaction) error {
455+ if node == nil || node.right == nil {
456+ return fmt.Errorf("cannot perform left rotation: invalid node structure")
457+ }
458+
459+ log.Printf("[tree-rebalancer] Performing left rotation on node %p", node)
460+
461+ // Store original structure
462+ rightChild := node.right
463+ rightLeftChild := rightChild.left
464+ parent := node.parent
465+
466+ // Update tree structure
467+ rightChild.left = node
468+ node.right = rightLeftChild
469+ rightChild.parent = parent
470+ node.parent = rightChild
471+
472+ if rightLeftChild != nil {
473+ rightLeftChild.parent = node
474+ }
475+
476+ // Update parent's child pointer or root
477+ if parent == nil {
478+ tr.wm.root = rightChild
479+ } else if parent.left == node {
480+ parent.left = rightChild
481+ } else {
482+ parent.right = rightChild
483+ }
484+
485+ // Add widget operations for the rotation
486+ return tr.addRotationWidgetOperations(node, rightChild, tx, "left")
487+}
488+
489+// executeRightRotation performs a right rotation on the given node
490+func (tr *TreeRebalancer) executeRightRotation(node *paneNode, tx *WidgetTransaction) error {
491+ if node == nil || node.left == nil {
492+ return fmt.Errorf("cannot perform right rotation: invalid node structure")
493+ }
494+
495+ log.Printf("[tree-rebalancer] Performing right rotation on node %p", node)
496+
497+ // Store original structure
498+ leftChild := node.left
499+ leftRightChild := leftChild.right
500+ parent := node.parent
501+
502+ // Update tree structure
503+ leftChild.right = node
504+ node.left = leftRightChild
505+ leftChild.parent = parent
506+ node.parent = leftChild
507+
508+ if leftRightChild != nil {
509+ leftRightChild.parent = node
510+ }
511+
512+ // Update parent's child pointer or root
513+ if parent == nil {
514+ tr.wm.root = leftChild
515+ } else if parent.left == node {
516+ parent.left = leftChild
517+ } else {
518+ parent.right = leftChild
519+ }
520+
521+ // Add widget operations for the rotation
522+ return tr.addRotationWidgetOperations(node, leftChild, tx, "right")
523+}
524+
525+// addRotationWidgetOperations adds widget operations needed for tree rotation
526+func (tr *TreeRebalancer) addRotationWidgetOperations(oldRoot, newRoot *paneNode, tx *WidgetTransaction, rotationType string) error {
527+ // The widget hierarchy needs to be updated to match the new tree structure
528+ // This is complex because GTK widgets need to be reparented correctly
529+
530+ if oldRoot.container == nil || newRoot.container == nil {
531+ return fmt.Errorf("rotation nodes missing containers")
532+ }
533+
534+ // Add operation to update widget hierarchy
535+ op := &WidgetOperation{
536+ ID: fmt.Sprintf("rotation_%s_%p_%p", rotationType, oldRoot, newRoot),
537+ Description: fmt.Sprintf("%s rotation widget update", rotationType),
538+ Priority: 300, // High priority for structural changes
539+ Execute: func() error {
540+ // This is a simplified version - in practice, you'd need to:
541+ // 1. Unparent both widgets
542+ // 2. Re-establish the parent-child relationships
543+ // 3. Update any size allocations
544+
545+ log.Printf("[tree-rebalancer] Updating widget hierarchy for %s rotation", rotationType)
546+
547+ // Force a layout update
548+ if newRoot.container != nil {
549+ return newRoot.container.Execute(func(ptr uintptr) error {
550+ webkit.WidgetQueueAllocate(ptr)
551+ return nil
552+ })
553+ }
554+ return nil
555+ },
556+ Rollback: func() error {
557+ // Rotation rollback would require reversing the rotation
558+ log.Printf("[tree-rebalancer] Rolling back %s rotation", rotationType)
559+ return nil
560+ },
561+ }
562+
563+ return tx.AddOperation(op)
564+}
565+
566+// executePromotion promotes a child node to replace its parent
567+func (tr *TreeRebalancer) executePromotion(node *paneNode, tx *WidgetTransaction) error {
568+ if node == nil {
569+ return fmt.Errorf("promotion target is nil")
570+ }
571+ if node.container == nil {
572+ return fmt.Errorf("promotion target missing container")
573+ }
574+ if !node.container.IsValid() {
575+ return fmt.Errorf("promotion target container %s invalid", node.container.String())
576+ }
577+
578+ log.Printf("[tree-rebalancer] Promoting node %p (parent=%p)", node, node.parent)
579+
580+ // Ensure the promoted widget can expand to occupy available space
581+ expandOp := &WidgetOperation{
582+ ID: fmt.Sprintf("promotion_expand_%p", node),
583+ Description: "Ensure promoted pane expands to fill space",
584+ Priority: 200,
585+ Execute: func() error {
586+ return node.container.Execute(func(ptr uintptr) error {
587+ if ptr == 0 || !webkit.WidgetIsValid(ptr) {
588+ return fmt.Errorf("promotion expand: invalid widget pointer")
589+ }
590+ webkit.WidgetResetSizeRequest(ptr)
591+ webkit.WidgetSetHExpand(ptr, true)
592+ webkit.WidgetSetVExpand(ptr, true)
593+ webkit.WidgetQueueAllocate(ptr)
594+ return nil
595+ })
596+ },
597+ }
598+ if err := tx.AddOperation(expandOp); err != nil {
599+ return fmt.Errorf("failed to add promotion expand operation: %w", err)
600+ }
601+
602+ parent := node.parent
603+ if parent == nil {
604+ if tr.wm != nil && tr.wm.window != nil {
605+ op := &WidgetOperation{
606+ ID: fmt.Sprintf("promotion_attach_window_%p", node),
607+ Description: "Attach promoted pane to window root",
608+ Priority: 190,
609+ Execute: func() error {
610+ return node.container.Execute(func(ptr uintptr) error {
611+ if ptr == 0 || !webkit.WidgetIsValid(ptr) {
612+ return fmt.Errorf("promotion attach window: invalid widget pointer")
613+ }
614+ if webkit.WidgetGetParent(ptr) != 0 {
615+ webkit.WidgetUnparent(ptr)
616+ }
617+ tr.wm.window.SetChild(ptr)
618+ webkit.WidgetQueueAllocate(ptr)
619+ return nil
620+ })
621+ },
622+ }
623+ if err := tx.AddOperation(op); err != nil {
624+ return fmt.Errorf("failed to add window attachment operation: %w", err)
625+ }
626+ }
627+ } else {
628+ if parent.container == nil {
629+ return fmt.Errorf("promotion parent missing container")
630+ }
631+ if !parent.container.IsValid() {
632+ return fmt.Errorf("promotion parent container %s invalid", parent.container.String())
633+ }
634+
635+ attachOp := &WidgetOperation{
636+ ID: fmt.Sprintf("promotion_reparent_%p", node),
637+ Description: "Attach promoted pane to parent container",
638+ Priority: 190,
639+ Execute: func() error {
640+ return parent.container.Execute(func(parentPtr uintptr) error {
641+ if parentPtr == 0 || !webkit.WidgetIsValid(parentPtr) {
642+ return fmt.Errorf("promotion reparent: invalid parent widget")
643+ }
644+ return node.container.Execute(func(childPtr uintptr) error {
645+ if childPtr == 0 || !webkit.WidgetIsValid(childPtr) {
646+ return fmt.Errorf("promotion reparent: invalid child widget")
647+ }
648+ if webkit.WidgetGetParent(childPtr) != 0 {
649+ webkit.WidgetUnparent(childPtr)
650+ }
651+
652+ // Reattach promoted widget to the GtkPaned and request full allocation
653+ if parent.left == node {
654+ webkit.PanedSetStartChild(parentPtr, childPtr)
655+ } else {
656+ webkit.PanedSetEndChild(parentPtr, childPtr)
657+ }
658+ webkit.WidgetResetSizeRequest(childPtr)
659+ webkit.WidgetSetHExpand(childPtr, true)
660+ webkit.WidgetSetVExpand(childPtr, true)
661+ webkit.WidgetQueueAllocate(childPtr)
662+ webkit.WidgetQueueAllocate(parentPtr)
663+ return nil
664+ })
665+ })
666+ },
667+ }
668+ if err := tx.AddOperation(attachOp); err != nil {
669+ return fmt.Errorf("failed to add promotion reparent operation: %w", err)
670+ }
671+ }
672+
673+ // Propagate allocation updates up the ancestor chain so GTK recalculates sizes
674+ ancestorPtrs := tr.collectAncestorContainers(node)
675+ if len(ancestorPtrs) > 0 {
676+ allocationOp := &WidgetOperation{
677+ ID: fmt.Sprintf("promotion_allocate_%p", node),
678+ Description: "Queue allocation for promoted pane ancestors",
679+ Priority: 150,
680+ Execute: func() error {
681+ for _, ancestorPtr := range ancestorPtrs {
682+ if ancestorPtr == 0 || !webkit.WidgetIsValid(ancestorPtr) {
683+ continue
684+ }
685+ webkit.WidgetQueueAllocate(ancestorPtr)
686+ }
687+ return nil
688+ },
689+ }
690+ if err := tx.AddOperation(allocationOp); err != nil {
691+ return fmt.Errorf("failed to add promotion allocation operation: %w", err)
692+ }
693+ }
694+
695+ // Validate allocation after promotion for debugging purposes
696+ validateOp := &WidgetOperation{
697+ ID: fmt.Sprintf("promotion_validate_%p", node),
698+ Description: "Validate promoted pane allocation",
699+ Priority: 100,
700+ Execute: func() error {
701+ return node.container.Execute(func(ptr uintptr) error {
702+ if ptr == 0 || !webkit.WidgetIsValid(ptr) {
703+ return fmt.Errorf("promotion validate: invalid widget pointer")
704+ }
705+ bounds, ok := webkit.WidgetGetBounds(ptr)
706+ if !ok {
707+ log.Printf("[tree-rebalancer] promotion validation: failed to read bounds for %#x", ptr)
708+ return nil
709+ }
710+ if bounds.Width <= 1 || bounds.Height <= 1 {
711+ log.Printf("[tree-rebalancer] WARNING: promoted pane %#x has tiny allocation %.1fx%.1f", ptr, bounds.Width, bounds.Height)
712+ } else {
713+ log.Printf("[tree-rebalancer] Promotion allocation verified for %#x: %.1fx%.1f", ptr, bounds.Width, bounds.Height)
714+ }
715+ return nil
716+ })
717+ },
718+ }
719+ if err := tx.AddOperation(validateOp); err != nil {
720+ return fmt.Errorf("failed to add promotion validation operation: %w", err)
721+ }
722+
723+ return nil
724+}
725+
726+// executeRestructure performs a complete restructure of a subtree
727+func (tr *TreeRebalancer) executeRestructure(node *paneNode, tx *WidgetTransaction) error {
728+ // This would be used for more complex rebalancing scenarios
729+ log.Printf("[tree-rebalancer] Restructuring subtree at node %p", node)
730+
731+ // TODO: Implement restructure logic
732+ return fmt.Errorf("restructure operation not yet implemented")
733+}
734+
735+// collectAncestorContainers returns the widget pointers for the promoted node and all ancestors up to the root.
736+func (tr *TreeRebalancer) collectAncestorContainers(node *paneNode) []uintptr {
737+ var containers []uintptr
738+ visited := make(map[uintptr]bool)
739+
740+ current := node
741+ for current != nil {
742+ if current.container != nil && current.container.IsValid() {
743+ ptr := current.container.Ptr()
744+ if ptr != 0 && !visited[ptr] && webkit.WidgetIsValid(ptr) {
745+ containers = append(containers, ptr)
746+ visited[ptr] = true
747+ }
748+ }
749+ current = current.parent
750+ }
751+
752+ return containers
753+}
754+
755+// GetRebalancingStats returns statistics about tree rebalancing
756+func (tr *TreeRebalancer) GetRebalancingStats() map[string]interface{} {
757+ metrics := tr.CalculateTreeMetrics(tr.wm.root)
758+
759+ return map[string]interface{}{
760+ "enabled": tr.enabled,
761+ "max_imbalance": tr.maxImbalance,
762+ "current_height": metrics.Height,
763+ "node_count": metrics.NodeCount,
764+ "leaf_count": metrics.LeafCount,
765+ "balance_factor": metrics.BalanceFactor,
766+ "imbalance_score": metrics.ImbalanceScore,
767+ "avg_depth": metrics.AvgDepth,
768+ "needs_rebalance": metrics.ImbalanceScore > tr.maxImbalance,
769+ }
770+}
771+
772+// Helper functions
A internal/app/browser/workspace_tree_validator.go +564 -0
@@ -0,0 +1,564 @@
1+// workspace_tree_validator.go - Tree invariant validation for bulletproof binary tree operations
2+package browser
3+
4+import (
5+ "fmt"
6+ "log"
7+ "sync"
8+ "time"
9+ "unsafe"
10+)
11+
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
25+type TreeValidator struct {
26+ enabled bool
27+ debugMode bool
28+ maxDepth int
29+ validationHistory []TreeValidationResult
30+ mu sync.RWMutex
31+}
32+
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
43+}
44+
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 {
82+ return nil
83+ }
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
155+}
156+
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+ }
303+
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
487+ }
488+ leftDepth := tv.calculateDepth(node.left)
489+ rightDepth := tv.calculateDepth(node.right)
490+ return 1 + max(leftDepth, rightDepth)
491+}
492+
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:]
498+ }
499+ tv.validationHistory = append(tv.validationHistory, result)
500+}
501+
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
514+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+
539+ 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
555+ }
556+ return unsafe.Pointer(node)
557+}
558+
559+func abs(x int) int {
560+ if x < 0 {
561+ return -x
562+ }
563+ return x
564+}
M internal/app/browser/workspace_types.go +9 -6
@@ -21,12 +21,15 @@ type paneNode struct {
2121 isPopup bool // Deprecated: use windowType instead
2222
2323 // Window type tracking
24- windowType webkit.WindowType // Tab or Popup
25- windowFeatures *webkit.WindowFeatures // Features if popup
26- isRelated bool // Shares context
27- parentPane *paneNode // Parent for related views
28- autoClose bool // Auto-close on OAuth success
29- hoverToken uintptr
24+ windowType webkit.WindowType // Tab or Popup
25+ windowFeatures *webkit.WindowFeatures // Features if popup
26+ isRelated bool // Shares context
27+ parentPane *paneNode // Parent for related views
28+ autoClose bool // Auto-close on OAuth success
29+ hoverToken uintptr
30+ focusControllerToken uintptr
31+ pendingHoverReattach bool
32+ pendingFocusReattach bool
3033
3134 // Stacked panes support with proper widget management
3235 isStacked bool // Whether this node contains stacked panes
M internal/app/browser/workspace_utils.go +20 -24
@@ -221,23 +221,21 @@ func (wm *WorkspaceManager) applyActivePaneBorder(node *paneNode) {
221221
222222 // Apply CSS classes to border container (provides border styling)
223223 if ctx.borderContainer != nil {
224- // Check if this container already has the active class to avoid redundant operations
225- var alreadyActive bool
226- ctx.borderContainer.Execute(func(containerPtr uintptr) error {
224+ var applied bool
225+ if err := ctx.borderContainer.Execute(func(containerPtr uintptr) error {
227226 // Safety check: verify container pointer is valid
228227 if containerPtr == 0 {
229228 return fmt.Errorf("invalid container pointer")
230229 }
231- alreadyActive = webkit.WidgetHasCSSClass(containerPtr, activePaneClass)
232- return nil
233- })
234-
235- // Only apply if not already active (prevents redundant GTK operations)
236- if !alreadyActive {
237- ctx.borderContainer.Execute(func(containerPtr uintptr) error {
238- webkit.WidgetAddCSSClass(containerPtr, activePaneClass)
230+ if webkit.WidgetHasCSSClass(containerPtr, activePaneClass) {
239231 return nil
240- })
232+ }
233+ webkit.WidgetAddCSSClass(containerPtr, activePaneClass)
234+ applied = true
235+ return nil
236+ }); err != nil {
237+ log.Printf("[workspace] Failed to apply border to %s pane: %v", ctx.paneType, err)
238+ } else if applied {
241239 log.Printf("[workspace] Applied border to %s pane: %p", ctx.paneType, node)
242240 }
243241 }
@@ -252,23 +250,21 @@ func (wm *WorkspaceManager) removeActivePaneBorder(node *paneNode) {
252250
253251 // Remove CSS classes from border container
254252 if ctx.borderContainer != nil {
255- // Check if this container has the active class before attempting removal
256- var hasActiveClass bool
257- ctx.borderContainer.Execute(func(containerPtr uintptr) error {
253+ var removed bool
254+ if err := ctx.borderContainer.Execute(func(containerPtr uintptr) error {
258255 // Safety check: verify container pointer is valid
259256 if containerPtr == 0 {
260257 return fmt.Errorf("invalid container pointer")
261258 }
262- hasActiveClass = webkit.WidgetHasCSSClass(containerPtr, activePaneClass)
263- return nil
264- })
265-
266- // Only remove if it actually has the class (prevents redundant GTK operations)
267- if hasActiveClass {
268- ctx.borderContainer.Execute(func(containerPtr uintptr) error {
269- webkit.WidgetRemoveCSSClass(containerPtr, activePaneClass)
259+ if !webkit.WidgetHasCSSClass(containerPtr, activePaneClass) {
270260 return nil
271- })
261+ }
262+ webkit.WidgetRemoveCSSClass(containerPtr, activePaneClass)
263+ removed = true
264+ return nil
265+ }); err != nil {
266+ log.Printf("[workspace] Failed to remove border from %s pane: %v", ctx.paneType, err)
267+ } else if removed {
272268 log.Printf("[workspace] Removed border from %s pane: %p", ctx.paneType, node)
273269 }
274270 }
A internal/app/browser/workspace_widget_transaction.go +446 -0
@@ -0,0 +1,446 @@
1+// workspace_widget_transaction.go - Atomic widget operations for bulletproof GTK widget management
2+package browser
3+
4+import (
5+ "fmt"
6+ "log"
7+ "sync"
8+ "time"
9+
10+ "github.com/bnema/dumber/pkg/webkit"
11+)
12+
13+// WidgetOperation represents a single widget operation that can be executed or rolled back
14+type WidgetOperation struct {
15+ ID string
16+ Description string
17+ Execute func() error
18+ Rollback func() error
19+ Executed bool
20+ Priority int // Higher priority operations execute first
21+}
22+
23+// WidgetTransaction manages a set of widget operations atomically
24+type WidgetTransaction struct {
25+ ID string
26+ operations []*WidgetOperation
27+ rollbacks []func() error
28+ executed []bool
29+ committed bool
30+ rolledBack bool
31+ startTime time.Time
32+ timeout time.Duration
33+ mu sync.Mutex
34+}
35+
36+// WidgetTransactionManager manages widget transactions for thread safety
37+type WidgetTransactionManager struct {
38+ activeTransactions map[string]*WidgetTransaction
39+ transactionHistory []TransactionResult
40+ mu sync.RWMutex
41+ globalTimeout time.Duration
42+ maxHistory int
43+}
44+
45+// TransactionResult captures the result of a transaction
46+type TransactionResult struct {
47+ TransactionID string
48+ Success bool
49+ OperationCount int
50+ Duration time.Duration
51+ ErrorMessage string
52+ Timestamp time.Time
53+}
54+
55+// NewWidgetTransactionManager creates a new transaction manager
56+func NewWidgetTransactionManager() *WidgetTransactionManager {
57+ return &WidgetTransactionManager{
58+ activeTransactions: make(map[string]*WidgetTransaction),
59+ transactionHistory: make([]TransactionResult, 0, 100),
60+ globalTimeout: 30 * time.Second,
61+ maxHistory: 100,
62+ }
63+}
64+
65+// BeginTransaction starts a new widget transaction
66+func (wtm *WidgetTransactionManager) BeginTransaction(id string) *WidgetTransaction {
67+ wtm.mu.Lock()
68+ defer wtm.mu.Unlock()
69+
70+ // Check if transaction already exists
71+ if _, exists := wtm.activeTransactions[id]; exists {
72+ log.Printf("[widget-tx] Transaction %s already exists, returning existing", id)
73+ return wtm.activeTransactions[id]
74+ }
75+
76+ tx := &WidgetTransaction{
77+ ID: id,
78+ operations: make([]*WidgetOperation, 0),
79+ rollbacks: make([]func() error, 0),
80+ executed: make([]bool, 0),
81+ startTime: time.Now(),
82+ timeout: wtm.globalTimeout,
83+ }
84+
85+ wtm.activeTransactions[id] = tx
86+ log.Printf("[widget-tx] Started transaction: %s", id)
87+ return tx
88+}
89+
90+// AddOperation adds an operation to the transaction
91+func (tx *WidgetTransaction) AddOperation(op *WidgetOperation) error {
92+ tx.mu.Lock()
93+ defer tx.mu.Unlock()
94+
95+ if tx.committed || tx.rolledBack {
96+ return fmt.Errorf("cannot add operation to finalized transaction")
97+ }
98+
99+ // Check for timeout
100+ if time.Since(tx.startTime) > tx.timeout {
101+ return fmt.Errorf("transaction timed out")
102+ }
103+
104+ tx.operations = append(tx.operations, op)
105+ tx.executed = append(tx.executed, false)
106+
107+ log.Printf("[widget-tx] Added operation %s to transaction %s", op.ID, tx.ID)
108+ return nil
109+}
110+
111+// Execute runs all operations in the transaction
112+func (tx *WidgetTransaction) Execute() error {
113+ tx.mu.Lock()
114+ defer tx.mu.Unlock()
115+
116+ if tx.committed || tx.rolledBack {
117+ return fmt.Errorf("transaction already finalized")
118+ }
119+
120+ // Sort operations by priority (higher priority first)
121+ for i := 0; i < len(tx.operations)-1; i++ {
122+ for j := i + 1; j < len(tx.operations); j++ {
123+ if tx.operations[i].Priority < tx.operations[j].Priority {
124+ tx.operations[i], tx.operations[j] = tx.operations[j], tx.operations[i]
125+ tx.executed[i], tx.executed[j] = tx.executed[j], tx.executed[i]
126+ }
127+ }
128+ }
129+
130+ log.Printf("[widget-tx] Executing transaction %s with %d operations", tx.ID, len(tx.operations))
131+
132+ // Execute operations one by one
133+ for i, op := range tx.operations {
134+ if time.Since(tx.startTime) > tx.timeout {
135+ // Timeout - rollback executed operations
136+ tx.rollbackExecuted()
137+ return fmt.Errorf("transaction %s timed out during execution", tx.ID)
138+ }
139+
140+ log.Printf("[widget-tx] Executing operation %s (%s)", op.ID, op.Description)
141+
142+ if err := op.Execute(); err != nil {
143+ log.Printf("[widget-tx] Operation %s failed: %v", op.ID, err)
144+
145+ // Rollback all executed operations
146+ tx.rollbackExecuted()
147+ return fmt.Errorf("operation %s failed: %w", op.ID, err)
148+ }
149+
150+ tx.executed[i] = true
151+ op.Executed = true
152+
153+ // Store rollback function if provided
154+ if op.Rollback != nil {
155+ tx.rollbacks = append([]func() error{op.Rollback}, tx.rollbacks...) // Prepend for reverse order
156+ }
157+ }
158+
159+ log.Printf("[widget-tx] Transaction %s executed successfully", tx.ID)
160+ return nil
161+}
162+
163+// Commit finalizes the transaction (prevents rollback)
164+func (tx *WidgetTransaction) Commit() error {
165+ tx.mu.Lock()
166+ defer tx.mu.Unlock()
167+
168+ if tx.committed {
169+ return fmt.Errorf("transaction already committed")
170+ }
171+ if tx.rolledBack {
172+ return fmt.Errorf("cannot commit rolled back transaction")
173+ }
174+
175+ tx.committed = true
176+ tx.rollbacks = nil // Clear rollback functions to prevent accidental use
177+
178+ log.Printf("[widget-tx] Transaction %s committed", tx.ID)
179+ return nil
180+}
181+
182+// Rollback reverses all executed operations
183+func (tx *WidgetTransaction) Rollback() error {
184+ tx.mu.Lock()
185+ defer tx.mu.Unlock()
186+
187+ if tx.committed {
188+ return fmt.Errorf("cannot rollback committed transaction")
189+ }
190+ if tx.rolledBack {
191+ return fmt.Errorf("transaction already rolled back")
192+ }
193+
194+ return tx.rollbackExecuted()
195+}
196+
197+// rollbackExecuted performs the actual rollback (internal, assumes lock held)
198+func (tx *WidgetTransaction) rollbackExecuted() error {
199+ log.Printf("[widget-tx] Rolling back transaction %s", tx.ID)
200+
201+ var rollbackErrors []error
202+
203+ // Execute rollbacks in reverse order
204+ for _, rollback := range tx.rollbacks {
205+ if err := rollback(); err != nil {
206+ rollbackErrors = append(rollbackErrors, err)
207+ log.Printf("[widget-tx] Rollback operation failed: %v", err)
208+ }
209+ }
210+
211+ tx.rolledBack = true
212+
213+ if len(rollbackErrors) > 0 {
214+ return fmt.Errorf("rollback completed with %d errors: %v", len(rollbackErrors), rollbackErrors[0])
215+ }
216+
217+ log.Printf("[widget-tx] Transaction %s rolled back successfully", tx.ID)
218+ return nil
219+}
220+
221+// FinishTransaction completes and removes the transaction from active list
222+func (wtm *WidgetTransactionManager) FinishTransaction(id string, success bool, errorMsg string) {
223+ wtm.mu.Lock()
224+ defer wtm.mu.Unlock()
225+
226+ tx, exists := wtm.activeTransactions[id]
227+ if !exists {
228+ log.Printf("[widget-tx] Transaction %s not found for finishing", id)
229+ return
230+ }
231+
232+ // Record result
233+ result := TransactionResult{
234+ TransactionID: id,
235+ Success: success,
236+ OperationCount: len(tx.operations),
237+ Duration: time.Since(tx.startTime),
238+ ErrorMessage: errorMsg,
239+ Timestamp: time.Now(),
240+ }
241+
242+ // Add to history
243+ if len(wtm.transactionHistory) >= wtm.maxHistory {
244+ wtm.transactionHistory = wtm.transactionHistory[1:] // Remove oldest
245+ }
246+ wtm.transactionHistory = append(wtm.transactionHistory, result)
247+
248+ // Remove from active transactions
249+ delete(wtm.activeTransactions, id)
250+
251+ log.Printf("[widget-tx] Finished transaction %s: success=%v, duration=%v", id, success, result.Duration)
252+}
253+
254+// Common widget operations for convenience
255+
256+// CreateWidgetUnparentOperation creates an operation to unparent a widget
257+func CreateWidgetUnparentOperation(id string, widget *SafeWidget) *WidgetOperation {
258+ return &WidgetOperation{
259+ ID: id,
260+ Description: fmt.Sprintf("Unparent widget %s", widget.String()),
261+ Priority: 100, // High priority for cleanup operations
262+ Execute: func() error {
263+ if widget == nil || !widget.IsValid() {
264+ return fmt.Errorf("invalid widget for unparent operation")
265+ }
266+
267+ return widget.Execute(func(ptr uintptr) error {
268+ if webkit.WidgetGetParent(ptr) != 0 {
269+ webkit.WidgetUnparent(ptr)
270+ }
271+ return nil
272+ })
273+ },
274+ Rollback: func() error {
275+ // Unparent operations are typically not reversible
276+ log.Printf("[widget-tx] Unparent rollback for %s (no-op)", id)
277+ return nil
278+ },
279+ }
280+}
281+
282+// CreateWidgetReparentOperation creates an operation to reparent a widget
283+func CreateWidgetReparentOperation(id string, widget *SafeWidget, newParent uintptr, isStart bool) *WidgetOperation {
284+ var oldParent uintptr
285+
286+ return &WidgetOperation{
287+ ID: id,
288+ Description: fmt.Sprintf("Reparent widget %s", widget.String()),
289+ Priority: 200, // Medium priority
290+ Execute: func() error {
291+ if widget == nil || !widget.IsValid() {
292+ return fmt.Errorf("invalid widget for reparent operation")
293+ }
294+ if newParent == 0 {
295+ return fmt.Errorf("invalid new parent for reparent operation")
296+ }
297+
298+ return widget.Execute(func(ptr uintptr) error {
299+ // Store old parent for rollback
300+ oldParent = webkit.WidgetGetParent(ptr)
301+
302+ // Unparent first if needed
303+ if oldParent != 0 {
304+ webkit.WidgetUnparent(ptr)
305+ }
306+
307+ // Reparent to new parent
308+ if isStart {
309+ webkit.PanedSetStartChild(newParent, ptr)
310+ } else {
311+ webkit.PanedSetEndChild(newParent, ptr)
312+ }
313+
314+ return nil
315+ })
316+ },
317+ Rollback: func() error {
318+ if widget == nil || !widget.IsValid() {
319+ return nil // Widget is gone, nothing to rollback
320+ }
321+
322+ return widget.Execute(func(ptr uintptr) error {
323+ // Remove from current parent
324+ current := webkit.WidgetGetParent(ptr)
325+ if current != 0 {
326+ webkit.WidgetUnparent(ptr)
327+ }
328+
329+ // Restore to old parent if it existed
330+ if oldParent != 0 {
331+ // Note: This is simplified - in practice we'd need to know which child position to restore
332+ log.Printf("[widget-tx] Widget reparent rollback: restoring to parent %#x", oldParent)
333+ }
334+
335+ return nil
336+ })
337+ },
338+ }
339+}
340+
341+// CreateWidgetShowOperation creates an operation to show/hide a widget
342+func CreateWidgetShowOperation(id string, widget *SafeWidget, visible bool) *WidgetOperation {
343+ var wasVisible bool
344+
345+ return &WidgetOperation{
346+ ID: id,
347+ Description: fmt.Sprintf("Set widget %s visibility to %v", widget.String(), visible),
348+ Priority: 50, // Low priority for visibility operations
349+ Execute: func() error {
350+ if widget == nil || !widget.IsValid() {
351+ return fmt.Errorf("invalid widget for show operation")
352+ }
353+
354+ return widget.Execute(func(ptr uintptr) error {
355+ // Store current visibility for rollback
356+ wasVisible = webkit.WidgetGetVisible(ptr)
357+
358+ webkit.WidgetSetVisible(ptr, visible)
359+ return nil
360+ })
361+ },
362+ Rollback: func() error {
363+ if widget == nil || !widget.IsValid() {
364+ return nil // Widget is gone, nothing to rollback
365+ }
366+
367+ return widget.Execute(func(ptr uintptr) error {
368+ webkit.WidgetSetVisible(ptr, wasVisible)
369+ return nil
370+ })
371+ },
372+ }
373+}
374+
375+// CreateCSSClassOperation creates an operation to add/remove CSS classes
376+func CreateCSSClassOperation(id string, widget *SafeWidget, className string, add bool) *WidgetOperation {
377+ var hadClass bool
378+
379+ return &WidgetOperation{
380+ ID: id,
381+ Description: fmt.Sprintf("CSS class %s: %s on widget %s", map[bool]string{true: "add", false: "remove"}[add], className, widget.String()),
382+ Priority: 10, // Very low priority for styling operations
383+ Execute: func() error {
384+ if widget == nil || !widget.IsValid() {
385+ return fmt.Errorf("invalid widget for CSS operation")
386+ }
387+
388+ return widget.Execute(func(ptr uintptr) error {
389+ // Store current state for rollback
390+ hadClass = webkit.WidgetHasCSSClass(ptr, className)
391+
392+ if add {
393+ webkit.WidgetAddCSSClass(ptr, className)
394+ } else {
395+ webkit.WidgetRemoveCSSClass(ptr, className)
396+ }
397+ return nil
398+ })
399+ },
400+ Rollback: func() error {
401+ if widget == nil || !widget.IsValid() {
402+ return nil // Widget is gone, nothing to rollback
403+ }
404+
405+ return widget.Execute(func(ptr uintptr) error {
406+ if hadClass {
407+ webkit.WidgetAddCSSClass(ptr, className)
408+ } else {
409+ webkit.WidgetRemoveCSSClass(ptr, className)
410+ }
411+ return nil
412+ })
413+ },
414+ }
415+}
416+
417+// GetTransactionStats returns statistics about transactions
418+func (wtm *WidgetTransactionManager) GetTransactionStats() map[string]interface{} {
419+ wtm.mu.RLock()
420+ defer wtm.mu.RUnlock()
421+
422+ totalTransactions := len(wtm.transactionHistory)
423+ successfulTransactions := 0
424+ totalDuration := time.Duration(0)
425+
426+ for _, result := range wtm.transactionHistory {
427+ if result.Success {
428+ successfulTransactions++
429+ }
430+ totalDuration += result.Duration
431+ }
432+
433+ var avgDuration time.Duration
434+ if totalTransactions > 0 {
435+ avgDuration = totalDuration / time.Duration(totalTransactions)
436+ }
437+
438+ return map[string]interface{}{
439+ "total_transactions": totalTransactions,
440+ "successful_transactions": successfulTransactions,
441+ "success_rate": float64(successfulTransactions) / float64(max(totalTransactions, 1)),
442+ "active_transactions": len(wtm.activeTransactions),
443+ "avg_duration_ms": avgDuration.Milliseconds(),
444+ "global_timeout_sec": wtm.globalTimeout.Seconds(),
445+ }
446+}
A pkg/webkit/thread_helpers.c +17 -0
@@ -0,0 +1,17 @@
1+#include <glib.h>
2+#include <pthread.h>
3+
4+static pthread_t main_thread_id;
5+
6+void store_main_thread_id() {
7+ main_thread_id = pthread_self();
8+}
9+
10+int is_main_thread() {
11+ return pthread_equal(pthread_self(), main_thread_id);
12+}
13+
14+int iterate_main_loop() {
15+ GMainContext *ctx = g_main_context_default();
16+ return g_main_context_iteration(ctx, FALSE);
17+}
M pkg/webkit/webview_cgo.go +4 -0
@@ -1002,6 +1002,8 @@ func NewWebView(cfg *Config) (*WebView, error) {
10021002 return nil, errors.New("failed to initialize GTK")
10031003 }
10041004
1005+ InitMainThread()
1006+
10051007 if cfg == nil {
10061008 cfg = &Config{}
10071009 }
@@ -1458,6 +1460,8 @@ func NewWebViewWithRelated(cfg *Config, relatedView *WebView) (*WebView, error)
14581460 return nil, errors.New("failed to initialize GTK")
14591461 }
14601462
1463+ InitMainThread()
1464+
14611465 if cfg == nil {
14621466 cfg = &Config{}
14631467 }
M pkg/webkit/window_cgo.go +2 -0
@@ -27,6 +27,8 @@ func NewWindow(title string) (*Window, error) {
2727 if C.gtk_init_check() == 0 {
2828 return nil, ErrNotImplemented
2929 }
30+
31+ InitMainThread()
3032 // GTK4: gtk_window_new() returns GtkWindow*, no toplevel enum
3133 w := (*C.GtkWidget)(unsafe.Pointer(C.gtk_window_new()))
3234 if w == nil {
M pkg/webkit/workspace_widgets_cgo.go +196 -27
@@ -10,6 +10,7 @@ package webkit
1010 #include <glib.h>
1111 #include <glib-object.h>
1212 #include <graphene.h>
13+#include <pthread.h>
1314 #include <stdint.h>
1415 #include <stdlib.h>
1516
@@ -22,6 +23,11 @@ extern void goHoverCallback(uintptr_t handle);
2223 extern void goFocusEnterCallback(uintptr_t handle);
2324 extern void goFocusLeaveCallback(uintptr_t handle);
2425
26+// Main thread helpers implemented in thread_helpers.c
27+void store_main_thread_id();
28+int is_main_thread();
29+int iterate_main_loop();
30+
2531 static gboolean idle_callback_wrapper(gpointer data) {
2632 return goIdleCallback((uintptr_t)data);
2733 }
@@ -81,6 +87,89 @@ static gboolean is_valid_widget(gpointer widget) {
8187 return widget != NULL && GTK_IS_WIDGET(widget);
8288 }
8389
90+// Forward declarations for focus controller callbacks defined later in this file
91+static void focus_enter_cb(GtkEventControllerFocus* controller, gpointer user_data);
92+static void focus_leave_cb(GtkEventControllerFocus* controller, gpointer user_data);
93+
94+typedef struct {
95+ GtkEventController* controller;
96+ GtkWidget* widget;
97+ gboolean is_attached;
98+} ControllerTracker;
99+
100+static GHashTable* controller_registry = NULL;
101+
102+static void init_controller_registry(void) {
103+ if (controller_registry == NULL) {
104+ controller_registry = g_hash_table_new_full(g_direct_hash, g_direct_equal, NULL, g_free);
105+ }
106+}
107+
108+static void controller_on_widget_destroy(GtkWidget* widget, gpointer user_data) {
109+ (void)widget;
110+ ControllerTracker* tracker = (ControllerTracker*)user_data;
111+ if (!tracker) {
112+ return;
113+ }
114+ tracker->is_attached = FALSE;
115+}
116+
117+static GtkEventController* widget_add_focus_controller(GtkWidget* widget, uintptr_t nodePtr) {
118+ if (!widget) {
119+ return NULL;
120+ }
121+
122+ init_controller_registry();
123+
124+ GtkEventController* focus = gtk_event_controller_focus_new();
125+ if (!focus) {
126+ return NULL;
127+ }
128+
129+ gtk_event_controller_set_propagation_phase(focus, GTK_PHASE_CAPTURE);
130+ g_signal_connect(focus, "enter", G_CALLBACK(focus_enter_cb), (gpointer)nodePtr);
131+ g_signal_connect(focus, "leave", G_CALLBACK(focus_leave_cb), (gpointer)nodePtr);
132+
133+ ControllerTracker* tracker = g_new0(ControllerTracker, 1);
134+ tracker->controller = focus;
135+ tracker->widget = widget;
136+ tracker->is_attached = TRUE;
137+
138+ g_hash_table_insert(controller_registry, focus, tracker);
139+
140+ g_signal_connect(widget, "destroy", G_CALLBACK(controller_on_widget_destroy), tracker);
141+
142+ gtk_widget_add_controller(widget, focus);
143+ return focus;
144+}
145+
146+static void widget_remove_focus_controller(GtkWidget* widget, GtkEventController* controller) {
147+ if (!controller_registry || !controller) {
148+ return;
149+ }
150+
151+ ControllerTracker* tracker = g_hash_table_lookup(controller_registry, controller);
152+ if (!tracker) {
153+ return;
154+ }
155+
156+ if (!tracker->is_attached) {
157+ return;
158+ }
159+
160+ if (!GTK_IS_WIDGET(widget)) {
161+ tracker->is_attached = FALSE;
162+ return;
163+ }
164+
165+ GtkWidget* owner = gtk_event_controller_get_widget(controller);
166+ if (GTK_IS_WIDGET(owner) && owner == widget) {
167+ gtk_widget_remove_controller(widget, controller);
168+ }
169+
170+ tracker->is_attached = FALSE;
171+}
172+
84173 static GtkWidget* paned_get_start_child(GtkWidget* paned) {
85174 if (!paned) return NULL;
86175 return gtk_paned_get_start_child(GTK_PANED(paned));
@@ -238,26 +327,27 @@ static void focus_leave_cb(GtkEventControllerFocus* controller, gpointer user_da
238327 goFocusLeaveCallback(nodePtr);
239328 }
240329
241-static GtkEventController* widget_add_focus_controller(GtkWidget* widget, uintptr_t nodePtr) {
242- if (!widget) {
243- return NULL;
244- }
245- GtkEventController* focus = gtk_event_controller_focus_new();
246- if (!focus) {
247- return NULL;
330+// Widget allocation helper using modern GTK4 API
331+static void widget_get_allocation_modern(GtkWidget* widget, int* x, int* y, int* width, int* height) {
332+ if (!widget || !x || !y || !width || !height) {
333+ *x = *y = *width = *height = 0;
334+ return;
248335 }
249- gtk_event_controller_set_propagation_phase(focus, GTK_PHASE_CAPTURE);
250- g_signal_connect(focus, "enter", G_CALLBACK(focus_enter_cb), (gpointer)nodePtr);
251- g_signal_connect(focus, "leave", G_CALLBACK(focus_leave_cb), (gpointer)nodePtr);
252- gtk_widget_add_controller(widget, focus);
253- return focus;
254-}
255336
256-static void widget_remove_focus_controller(GtkWidget* widget, GtkEventController* controller) {
257- if (!widget || !controller) {
258- return;
337+ graphene_rect_t bounds;
338+ // Compute bounds relative to the widget itself (pass widget as target)
339+ if (gtk_widget_compute_bounds(widget, widget, &bounds)) {
340+ // Extract integer coordinates from the graphene rect
341+ *x = (int)bounds.origin.x;
342+ *y = (int)bounds.origin.y;
343+ *width = (int)bounds.size.width;
344+ *height = (int)bounds.size.height;
345+ } else {
346+ // Fallback if compute_bounds fails
347+ *x = *y = 0;
348+ *width = gtk_widget_get_width(widget);
349+ *height = gtk_widget_get_height(widget);
259350 }
260- gtk_widget_remove_controller(widget, controller);
261351 }
262352 */
263353 import "C"
@@ -282,6 +372,14 @@ type FocusCallbacks struct {
282372 OnLeave func()
283373 }
284374
375+// WidgetAllocation represents a widget's allocation (position and size)
376+type WidgetAllocation struct {
377+ X int
378+ Y int
379+ Width int
380+ Height int
381+}
382+
285383 var (
286384 hoverMu sync.Mutex
287385 hoverCallbacks = make(map[uintptr]func())
@@ -294,8 +392,26 @@ var (
294392 focusCallbacks = make(map[uintptr]FocusCallbacks)
295393 focusControllers = make(map[uintptr]uintptr)
296394 nextFocusID uintptr = 1
395+
396+ mainThreadInitialized bool
297397 )
298398
399+// InitMainThread should be called once during gtk_init to store the main thread ID.
400+func InitMainThread() {
401+ if !mainThreadInitialized {
402+ C.store_main_thread_id()
403+ mainThreadInitialized = true
404+ }
405+}
406+
407+// IsMainThread returns true if the current goroutine is running on the GTK main thread.
408+func IsMainThread() bool {
409+ if !mainThreadInitialized {
410+ return false
411+ }
412+ return C.is_main_thread() == 1
413+}
414+
299415 func widgetIsValid(widget uintptr) bool {
300416 if widget == 0 {
301417 return false
@@ -441,22 +557,45 @@ func PanedSetPosition(paned uintptr, pos int) {
441557
442558 // WidgetUnparent detaches a widget from its current parent.
443559 func WidgetUnparent(widget uintptr) {
560+ if ok := WidgetUnparentChecked(widget); !ok {
561+ log.Printf("[workspace] WidgetUnparent skipped for widget=%#x", widget)
562+ }
563+}
564+
565+// WidgetUnparentChecked safely detaches a widget from its parent if still valid.
566+// Returns true when the widget was unparented or already detached.
567+func WidgetUnparentChecked(widget uintptr) bool {
444568 if widget == 0 {
445- return
569+ log.Printf("[workspace] WidgetUnparentChecked: widget=0")
570+ return false
446571 }
572+
447573 if !widgetIsValid(widget) {
448- log.Printf("[workspace] WidgetUnparent skipped invalid widget=%#x", widget)
449- return
574+ log.Printf("[workspace] WidgetUnparentChecked: widget=%#x invalid before unparent", widget)
575+ return false
450576 }
451- before := widgetRefCount(widget)
452- log.Printf("[workspace] WidgetUnparent widget=%#x ref_before=%d", widget, before)
577+
578+ parentBefore := WidgetGetParent(widget)
579+ if parentBefore == 0 {
580+ log.Printf("[workspace] WidgetUnparentChecked: widget=%#x has no parent", widget)
581+ return true
582+ }
583+
584+ refBefore := widgetRefCount(widget)
585+ log.Printf("[workspace] WidgetUnparentChecked: widget=%#x parent=%#x ref_before=%d", widget, parentBefore, refBefore)
586+
453587 C.gtk_widget_unparent((*C.GtkWidget)(unsafe.Pointer(widget)))
454- if widgetIsValid(widget) {
455- after := widgetRefCount(widget)
456- log.Printf("[workspace] WidgetUnparent widget=%#x ref_after=%d", widget, after)
457- } else {
458- log.Printf("[workspace] WidgetUnparent widget=%#x now invalid", widget)
588+
589+ if !widgetIsValid(widget) {
590+ log.Printf("[workspace] WidgetUnparentChecked: widget=%#x invalid after unparent", widget)
591+ return true
459592 }
593+
594+ parentAfter := WidgetGetParent(widget)
595+ refAfter := widgetRefCount(widget)
596+ log.Printf("[workspace] WidgetUnparentChecked: widget=%#x parent_after=%#x ref_after=%d", widget, parentAfter, refAfter)
597+
598+ return parentAfter == 0 || parentAfter != parentBefore
460599 }
461600
462601 // WidgetSetHExpand configures horizontal expand for a widget.
@@ -491,6 +630,14 @@ func WidgetSetVExpand(widget uintptr, expand bool) {
491630 C.gtk_widget_set_vexpand((*C.GtkWidget)(unsafe.Pointer(widget)), goBool(expand))
492631 }
493632
633+// WidgetResetSizeRequest clears explicit size constraints so GTK can recalculate allocation.
634+func WidgetResetSizeRequest(widget uintptr) {
635+ if widget == 0 || !widgetIsValid(widget) {
636+ return
637+ }
638+ C.gtk_widget_set_size_request((*C.GtkWidget)(unsafe.Pointer(widget)), C.int(-1), C.int(-1))
639+}
640+
494641 // WidgetShow makes the widget visible.
495642 func WidgetShow(widget uintptr) {
496643 if widget == 0 {
@@ -650,6 +797,11 @@ func IdleAdd(fn func() bool) {
650797 C.add_idle_callback(C.uintptr_t(id))
651798 }
652799
800+// IterateMainLoop processes a single GTK main loop iteration and reports if work was handled.
801+func IterateMainLoop() bool {
802+ return C.iterate_main_loop() == 1
803+}
804+
653805 //export goIdleCallback
654806 func goIdleCallback(handle uintptr) C.gboolean {
655807 if fn, ok := idleCallbacks[handle]; ok {
@@ -955,3 +1107,20 @@ func goFocusLeaveCallback(handle C.uintptr_t) {
9551107 callbacks.OnLeave()
9561108 }
9571109 }
1110+
1111+// WidgetGetAllocation gets the bounds (position and size) of a widget using modern GTK4 API
1112+func WidgetGetAllocation(widget uintptr) WidgetAllocation {
1113+ var x, y, width, height C.int
1114+
1115+ C.widget_get_allocation_modern(
1116+ (*C.GtkWidget)(unsafe.Pointer(widget)),
1117+ &x, &y, &width, &height,
1118+ )
1119+
1120+ return WidgetAllocation{
1121+ X: int(x),
1122+ Y: int(y),
1123+ Width: int(width),
1124+ Height: int(height),
1125+ }
1126+}
M pkg/webkit/workspace_widgets_stub.go +34 -0
@@ -20,6 +20,14 @@ type WidgetBounds struct {
2020 Height float64
2121 }
2222
23+// WidgetAllocation represents a widget's allocation (position and size) in stub builds
24+type WidgetAllocation struct {
25+ X int
26+ Y int
27+ Width int
28+ Height int
29+}
30+
2331 type widgetStub struct {
2432 startChild uintptr
2533 endChild uintptr
@@ -271,6 +279,8 @@ func WidgetQueueAllocate(widget uintptr) {}
271279
272280 func WidgetRealizeInContainer(widget uintptr) {}
273281
282+func WidgetResetSizeRequest(widget uintptr) {}
283+
274284 func WidgetHookDestroy(widget uintptr) {}
275285
276286 func WidgetIsValid(widget uintptr) bool {
@@ -491,3 +501,27 @@ func LabelSetMaxWidthChars(label uintptr, nChars int) {}
491501 func (w *WebView) SetTestContainer(container uintptr) {
492502 w.container = container
493503 }
504+
505+// WidgetGetAllocation returns a stub allocation for the widget
506+func WidgetGetAllocation(widget uintptr) WidgetAllocation {
507+ widgetMu.Lock()
508+ defer widgetMu.Unlock()
509+
510+ // Return stub allocation based on widget bounds if available
511+ if stub, ok := widgetState[widget]; ok && stub.hasBounds {
512+ return WidgetAllocation{
513+ X: int(stub.bounds.X),
514+ Y: int(stub.bounds.Y),
515+ Width: int(stub.bounds.Width),
516+ Height: int(stub.bounds.Height),
517+ }
518+ }
519+
520+ // Default stub allocation for testing
521+ return WidgetAllocation{
522+ X: 0,
523+ Y: 0,
524+ Width: 400,
525+ Height: 300,
526+ }
527+}

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