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) { | |||||
| 889 | return | 889 | return | ||
| 890 | } | 890 | } | ||
| 891 | 891 | ||||
| 892 | + | if node.focusControllerToken != 0 { | |||
| 893 | + | log.Printf("[FSM] Controller already attached to pane %p", node) | |||
| 894 | + | return | |||
| 895 | + | } | |||
| 896 | + | ||||
| 892 | widget := node.pane.webView.Widget() | 897 | widget := node.pane.webView.Widget() | ||
| 893 | - | if widget == 0 { | 898 | + | if widget == 0 || !webkit.WidgetIsValid(widget) { |
| 894 | return | 899 | return | ||
| 895 | } | 900 | } | ||
| 896 | 901 | ||||
| @@ -909,7 +914,7 @@ func (fsm *FocusStateMachine) attachGTKController(node *paneNode) { | |||||
| 909 | // Add the focus controller and store the token for cleanup | 914 | // Add the focus controller and store the token for cleanup | ||
| 910 | token := webkit.WidgetAddFocusController(widget, onEnter, onLeave) | 915 | token := webkit.WidgetAddFocusController(widget, onEnter, onLeave) | ||
| 911 | if token != 0 { | 916 | if token != 0 { | ||
| 912 | - | // Store the controller token for later cleanup (we could add this to paneNode if needed) | 917 | + | node.focusControllerToken = token |
| 913 | log.Printf("[FSM] Attached GTK focus controller to pane %p with token %d", node, token) | 918 | log.Printf("[FSM] Attached GTK focus controller to pane %p with token %d", node, token) | ||
| 914 | } | 919 | } | ||
| 915 | } | 920 | } | ||
| @@ -920,8 +925,15 @@ func (fsm *FocusStateMachine) detachGTKController(node *paneNode, token uintptr) | |||||
| 920 | return | 925 | return | ||
| 921 | } | 926 | } | ||
| 922 | 927 | ||||
| 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 | + | ||||
| 923 | widget := node.pane.webView.Widget() | 935 | widget := node.pane.webView.Widget() | ||
| 924 | - | if widget == 0 { | 936 | + | if widget == 0 || !webkit.WidgetIsValid(widget) { |
| 925 | return | 937 | return | ||
| 926 | } | 938 | } | ||
| 927 | 939 | ||||
M internal/app/browser/widget.go +133 -9
| @@ -1,33 +1,102 @@ | |||||
| 1 | package browser | 1 | package browser | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "fmt" | 5 | "fmt" | ||
| 5 | "sync" | 6 | "sync" | ||
| 7 | + | "time" | |||
| 6 | 8 | ||||
| 7 | "github.com/bnema/dumber/pkg/webkit" | 9 | "github.com/bnema/dumber/pkg/webkit" | ||
| 8 | ) | 10 | ) | ||
| 9 | 11 | ||||
| 10 | // SafeWidget provides thread-safe access to GTK widgets with validation | 12 | // SafeWidget provides thread-safe access to GTK widgets with validation | ||
| 11 | type SafeWidget struct { | 13 | type SafeWidget struct { | ||
| 12 | - | ptr uintptr | 14 | + | ptr uintptr |
| 13 | - | typeInfo string | 15 | + | typeInfo string |
| 14 | - | registry *WidgetRegistry | 16 | + | registry *WidgetRegistry |
| 17 | + | isInvalidated bool | |||
| 18 | + | mu sync.RWMutex | |||
| 15 | } | 19 | } | ||
| 16 | 20 | ||||
| 17 | // Execute safely executes a function with the widget pointer, ensuring thread safety and validation | 21 | // Execute safely executes a function with the widget pointer, ensuring thread safety and validation | ||
| 22 | + | // CRITICAL FIX: Don't hold mutex during GTK operations to prevent deadlock | |||
| 18 | func (w *SafeWidget) Execute(fn func(uintptr) error) error { | 23 | func (w *SafeWidget) Execute(fn func(uintptr) error) error { | ||
| 19 | if w == nil || w.registry == nil { | 24 | if w == nil || w.registry == nil { | ||
| 20 | return fmt.Errorf("SafeWidget is nil or has no registry") | 25 | return fmt.Errorf("SafeWidget is nil or has no registry") | ||
| 21 | } | 26 | } | ||
| 22 | 27 | ||||
| 23 | - | w.registry.mu.Lock() | 28 | + | w.mu.RLock() |
| 24 | - | defer w.registry.mu.Unlock() | 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() | |||
| 25 | 73 | ||||
| 26 | if !w.IsValid() { | 74 | if !w.IsValid() { | ||
| 27 | return fmt.Errorf("widget %s is no longer valid", w.String()) | 75 | return fmt.Errorf("widget %s is no longer valid", w.String()) | ||
| 28 | } | 76 | } | ||
| 29 | 77 | ||||
| 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 | + | } | |||
| 31 | } | 100 | } | ||
| 32 | 101 | ||||
| 33 | // IsValid checks if the underlying GTK widget is still valid | 102 | // IsValid checks if the underlying GTK widget is still valid | ||
| @@ -35,7 +104,49 @@ func (w *SafeWidget) IsValid() bool { | |||||
| 35 | if w == nil || w.ptr == 0 { | 104 | if w == nil || w.ptr == 0 { | ||
| 36 | return false | 105 | return false | ||
| 37 | } | 106 | } | ||
| 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 | + | } | |||
| 39 | } | 150 | } | ||
| 40 | 151 | ||||
| 41 | // String returns a string representation of the SafeWidget for debugging | 152 | // String returns a string representation of the SafeWidget for debugging | ||
| @@ -43,7 +154,13 @@ func (w *SafeWidget) String() string { | |||||
| 43 | if w == nil { | 154 | if w == nil { | ||
| 44 | return "SafeWidget(nil)" | 155 | return "SafeWidget(nil)" | ||
| 45 | } | 156 | } | ||
| 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) | |||
| 47 | } | 164 | } | ||
| 48 | 165 | ||||
| 49 | // Ptr returns the raw uintptr to the GTK widget | 166 | // Ptr returns the raw uintptr to the GTK widget | ||
| @@ -51,6 +168,10 @@ func (w *SafeWidget) Ptr() uintptr { | |||||
| 51 | if w == nil { | 168 | if w == nil { | ||
| 52 | return 0 | 169 | return 0 | ||
| 53 | } | 170 | } | ||
| 171 | + | ||||
| 172 | + | w.mu.RLock() | |||
| 173 | + | defer w.mu.RUnlock() | |||
| 174 | + | ||||
| 54 | return w.ptr | 175 | return w.ptr | ||
| 55 | } | 176 | } | ||
| 56 | 177 | ||||
| @@ -101,7 +222,10 @@ func (r *WidgetRegistry) Unregister(ptr uintptr) { | |||||
| 101 | r.mu.Lock() | 222 | r.mu.Lock() | ||
| 102 | defer r.mu.Unlock() | 223 | defer r.mu.Unlock() | ||
| 103 | 224 | ||||
| 104 | - | delete(r.widgets, ptr) | 225 | + | if widget, exists := r.widgets[ptr]; exists { |
| 226 | + | widget.Invalidate() | |||
| 227 | + | delete(r.widgets, ptr) | |||
| 228 | + | } | |||
| 105 | } | 229 | } | ||
| 106 | 230 | ||||
| 107 | // Cleanup removes all invalid widgets from the registry | 231 | // 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 ( | |||||
| 7 | "log" | 7 | "log" | ||
| 8 | "strings" | 8 | "strings" | ||
| 9 | "sync" | 9 | "sync" | ||
| 10 | + | "sync/atomic" | |||
| 10 | "time" | 11 | "time" | ||
| 11 | 12 | ||||
| 12 | "github.com/bnema/dumber/internal/app/messaging" | 13 | "github.com/bnema/dumber/internal/app/messaging" | ||
| @@ -23,7 +24,7 @@ type WorkspaceManager struct { | |||||
| 23 | lastSplitMsg map[*webkit.WebView]time.Time | 24 | lastSplitMsg map[*webkit.WebView]time.Time | ||
| 24 | lastExitMsg map[*webkit.WebView]time.Time | 25 | lastExitMsg map[*webkit.WebView]time.Time | ||
| 25 | paneModeActive bool | 26 | paneModeActive bool | ||
| 26 | - | splitting bool | 27 | + | splitting int32 // atomic: 0=false, 1=true |
| 27 | cssInitialized bool | 28 | cssInitialized bool | ||
| 28 | createWebViewFn func() (*webkit.WebView, error) | 29 | createWebViewFn func() (*webkit.WebView, error) | ||
| 29 | createPaneFn func(*webkit.WebView) (*BrowserPane, error) | 30 | createPaneFn func(*webkit.WebView) (*BrowserPane, error) | ||
| @@ -55,6 +56,15 @@ type WorkspaceManager struct { | |||||
| 55 | lastFocusTime time.Time | 56 | lastFocusTime time.Time | ||
| 56 | lastFocusTarget *paneNode | 57 | lastFocusTarget *paneNode | ||
| 57 | focusDebounce time.Duration | 58 | 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 | |||
| 58 | } | 68 | } | ||
| 59 | 69 | ||||
| 60 | // Workspace navigation shortcuts are now handled globally by WindowShortcutHandler | 70 | // Workspace navigation shortcuts are now handled globally by WindowShortcutHandler | ||
| @@ -72,7 +82,23 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag | |||||
| 72 | focusDebounce: 150 * time.Millisecond, // 150ms focus debouncing for all sources | 82 | focusDebounce: 150 * time.Millisecond, // 150ms focus debouncing for all sources | ||
| 73 | } | 83 | } | ||
| 74 | 84 | ||||
| 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) | |||
| 76 | manager.stackedPaneManager = NewStackedPaneManager(manager) | 102 | manager.stackedPaneManager = NewStackedPaneManager(manager) | ||
| 77 | manager.focusStateMachine = NewFocusStateMachine(manager) | 103 | manager.focusStateMachine = NewFocusStateMachine(manager) | ||
| 78 | manager.createWebViewFn = func() (*webkit.WebView, error) { | 104 | manager.createWebViewFn = func() (*webkit.WebView, error) { | ||
| @@ -208,20 +234,34 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa | |||||
| 208 | return | 234 | return | ||
| 209 | } | 235 | } | ||
| 210 | } | 236 | } | ||
| 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) { | |||
| 212 | log.Printf("[workspace] split ignored: operation already in progress") | 240 | log.Printf("[workspace] split ignored: operation already in progress") | ||
| 213 | return | 241 | return | ||
| 214 | } | 242 | } | ||
| 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 | + | ||||
| 216 | wm.lastSplitMsg[source] = time.Now() | 253 | wm.lastSplitMsg[source] = time.Now() | ||
| 217 | - | newNode, err := wm.splitNode(node, direction) | 254 | + | |
| 255 | + | newNode, err := wm.BulletproofSplitNode(node, direction) | |||
| 218 | if err != nil { | 256 | if err != nil { | ||
| 219 | log.Printf("[workspace] split failed: %v", err) | 257 | 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 | + | } | |||
| 221 | return | 262 | return | ||
| 222 | } | 263 | } | ||
| 223 | wm.clonePaneState(node, newNode) | 264 | wm.clonePaneState(node, newNode) | ||
| 224 | - | wm.splitting = false | |||
| 225 | case "pane-stack": | 265 | case "pane-stack": | ||
| 226 | wm.SetActivePane(node, SourceProgrammatic) | 266 | wm.SetActivePane(node, SourceProgrammatic) | ||
| 227 | if last, ok := wm.lastSplitMsg[source]; ok { | 267 | if last, ok := wm.lastSplitMsg[source]; ok { | ||
| @@ -230,20 +270,34 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa | |||||
| 230 | return | 270 | return | ||
| 231 | } | 271 | } | ||
| 232 | } | 272 | } | ||
| 233 | - | if wm.splitting { | 273 | + | |
| 274 | + | // CRITICAL FIX: Use atomic operations for splitting flag | |||
| 275 | + | if !atomic.CompareAndSwapInt32(&wm.splitting, 0, 1) { | |||
| 234 | log.Printf("[workspace] stack ignored: operation already in progress") | 276 | log.Printf("[workspace] stack ignored: operation already in progress") | ||
| 235 | return | 277 | return | ||
| 236 | } | 278 | } | ||
| 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 | + | ||||
| 238 | wm.lastSplitMsg[source] = time.Now() | 289 | wm.lastSplitMsg[source] = time.Now() | ||
| 290 | + | ||||
| 239 | newNode, err := wm.stackedPaneManager.StackPane(node) | 291 | newNode, err := wm.stackedPaneManager.StackPane(node) | ||
| 240 | if err != nil { | 292 | if err != nil { | ||
| 241 | log.Printf("[workspace] stack failed: %v", err) | 293 | 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 | + | } | |||
| 243 | return | 298 | return | ||
| 244 | } | 299 | } | ||
| 245 | wm.clonePaneState(node, newNode) | 300 | wm.clonePaneState(node, newNode) | ||
| 246 | - | wm.splitting = false | |||
| 247 | case "create-pane": | 301 | case "create-pane": | ||
| 248 | log.Printf("[workspace] create-pane requested: url=%s action=%s requestId=%s", msg.URL, msg.Action, msg.RequestID) | 302 | log.Printf("[workspace] create-pane requested: url=%s action=%s requestId=%s", msg.URL, msg.Action, msg.RequestID) | ||
| 249 | 303 | ||||
| @@ -329,7 +383,7 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa | |||||
| 329 | log.Printf("[workspace] Closing popup pane due to %s", msg.Reason) | 383 | log.Printf("[workspace] Closing popup pane due to %s", msg.Reason) | ||
| 330 | 384 | ||||
| 331 | // Close the popup pane | 385 | // Close the popup pane | ||
| 332 | - | if err := wm.closePane(targetNode); err != nil { | 386 | + | if err := wm.BulletproofClosePane(targetNode); err != nil { |
| 333 | log.Printf("[workspace] Failed to close popup pane: %v", err) | 387 | log.Printf("[workspace] Failed to close popup pane: %v", err) | ||
| 334 | } else { | 388 | } else { | ||
| 335 | log.Printf("[workspace] Successfully closed popup pane: %s", msg.WebViewID) | 389 | 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) { | |||||
| 114 | if wm == nil || node == nil || node.hoverToken == 0 { | 114 | if wm == nil || node == nil || node.hoverToken == 0 { | ||
| 115 | return | 115 | return | ||
| 116 | } | 116 | } | ||
| 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 | + | ||||
| 117 | node.container.Execute(func(containerPtr uintptr) error { | 126 | 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 | + | } | |||
| 119 | return nil | 130 | return nil | ||
| 120 | }) | 131 | }) | ||
| 121 | - | node.hoverToken = 0 | |||
| 122 | } | 132 | } | ||
| 123 | 133 | ||||
| 124 | // FocusNeighbor moves focus to the nearest pane in the requested direction using the | 134 | // 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 | |||||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | + | "fmt" | |||
| 6 | "log" | 7 | "log" | ||
| 7 | 8 | ||||
| 8 | "github.com/bnema/dumber/pkg/webkit" | 9 | "github.com/bnema/dumber/pkg/webkit" | ||
| @@ -101,12 +102,48 @@ func (wm *WorkspaceManager) clonePaneState(_ *paneNode, target *paneNode) { | |||||
| 101 | }) | 102 | }) | ||
| 102 | } | 103 | } | ||
| 103 | 104 | ||||
| 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 | + | ||||
| 104 | // closeCurrentPane closes the currently focused pane | 141 | // closeCurrentPane closes the currently focused pane | ||
| 105 | func (wm *WorkspaceManager) closeCurrentPane() { | 142 | func (wm *WorkspaceManager) closeCurrentPane() { | ||
| 106 | if wm == nil || wm.GetActiveNode() == nil { | 143 | if wm == nil || wm.GetActiveNode() == nil { | ||
| 107 | return | 144 | return | ||
| 108 | } | 145 | } | ||
| 109 | - | if err := wm.closePane(wm.GetActiveNode()); err != nil { | 146 | + | if err := wm.BulletproofClosePane(wm.GetActiveNode()); err != nil { |
| 110 | log.Printf("[workspace] close current pane failed: %v", err) | 147 | log.Printf("[workspace] close current pane failed: %v", err) | ||
| 111 | } | 148 | } | ||
| 112 | } | 149 | } | ||
| @@ -283,6 +320,9 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa | |||||
| 283 | 320 | ||||
| 284 | parent := split.parent | 321 | parent := split.parent | ||
| 285 | 322 | ||||
| 323 | + | // Prevent GTK from auto-removing controllers while the widget is temporarily unparented. | |||
| 324 | + | wm.safelyDetachControllersBeforeReparent(target) | |||
| 325 | + | ||||
| 286 | // Detach existing container from its current GTK parent before inserting into new paned. | 326 | // Detach existing container from its current GTK parent before inserting into new paned. | ||
| 287 | existingContainer := target.getContainerPtr() | 327 | existingContainer := target.getContainerPtr() | ||
| 288 | if parent == nil { | 328 | if parent == nil { | ||
| @@ -366,6 +406,11 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa | |||||
| 366 | 406 | ||||
| 367 | // Update CSS classes for all panes now that we have multiple panes | 407 | // Update CSS classes for all panes now that we have multiple panes | ||
| 368 | 408 | ||||
| 409 | + | reattachTargets := []*paneNode{target} | |||
| 410 | + | if target != nil && target.isStacked { | |||
| 411 | + | reattachTargets = append(reattachTargets, target.stackedPanes...) | |||
| 412 | + | } | |||
| 413 | + | ||||
| 369 | webkit.IdleAdd(func() bool { | 414 | webkit.IdleAdd(func() bool { | ||
| 370 | if newContainer != 0 { | 415 | if newContainer != 0 { | ||
| 371 | webkit.WidgetShow(newContainer) | 416 | webkit.WidgetShow(newContainer) | ||
| @@ -374,6 +419,25 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa | |||||
| 374 | if wm.focusStateMachine != nil { | 419 | if wm.focusStateMachine != nil { | ||
| 375 | wm.focusStateMachine.attachGTKController(newLeaf) | 420 | wm.focusStateMachine.attachGTKController(newLeaf) | ||
| 376 | } | 421 | } | ||
| 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 | + | ||||
| 377 | wm.SetActivePane(newLeaf, SourceSplit) | 441 | wm.SetActivePane(newLeaf, SourceSplit) | ||
| 378 | return false | 442 | return false | ||
| 379 | }) | 443 | }) | ||
| @@ -589,7 +653,9 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane | |||||
| 589 | // The originalTarget is still in the stack and should not interfere with the new split pane | 653 | // The originalTarget is still in the stack and should not interfere with the new split pane | ||
| 590 | if originalTarget == target { | 654 | if originalTarget == target { | ||
| 591 | // Only ensure hover on target if it's a regular (non-stack) split | 655 | // 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 | + | } | |||
| 593 | } | 659 | } | ||
| 594 | 660 | ||||
| 595 | wm.app.panes = append(wm.app.panes, newPane) | 661 | wm.app.panes = append(wm.app.panes, newPane) | ||
| @@ -599,6 +665,11 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane | |||||
| 599 | 665 | ||||
| 600 | // Update CSS classes for all panes now that we have multiple panes | 666 | // Update CSS classes for all panes now that we have multiple panes | ||
| 601 | 667 | ||||
| 668 | + | reattachTargets := []*paneNode{splitTarget} | |||
| 669 | + | if splitTarget != nil && splitTarget.isStacked { | |||
| 670 | + | reattachTargets = append(reattachTargets, splitTarget.stackedPanes...) | |||
| 671 | + | } | |||
| 672 | + | ||||
| 602 | webkit.IdleAdd(func() bool { | 673 | webkit.IdleAdd(func() bool { | ||
| 603 | if newContainer != 0 { | 674 | if newContainer != 0 { | ||
| 604 | webkit.WidgetShow(newContainer) | 675 | webkit.WidgetShow(newContainer) | ||
| @@ -607,6 +678,24 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane | |||||
| 607 | if wm.focusStateMachine != nil { | 678 | if wm.focusStateMachine != nil { | ||
| 608 | wm.focusStateMachine.attachGTKController(newLeaf) | 679 | wm.focusStateMachine.attachGTKController(newLeaf) | ||
| 609 | } | 680 | } | ||
| 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 | + | } | |||
| 610 | wm.SetActivePane(newLeaf, SourceSplit) | 699 | wm.SetActivePane(newLeaf, SourceSplit) | ||
| 611 | return false | 700 | return false | ||
| 612 | }) | 701 | }) | ||
| @@ -615,17 +704,19 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane | |||||
| 615 | } | 704 | } | ||
| 616 | 705 | ||||
| 617 | // closePane closes a specific pane and handles tree restructuring | 706 | // 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) { |
| 619 | if wm == nil || node == nil || !node.isLeaf { | 708 | 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") |
| 621 | } | 710 | } | ||
| 622 | if node.pane == nil || node.pane.webView == nil { | 711 | 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") |
| 624 | } | 713 | } | ||
| 625 | 714 | ||||
| 715 | + | var promoted *paneNode | |||
| 716 | + | ||||
| 626 | // Handle closing panes in a stack | 717 | // Handle closing panes in a stack | ||
| 627 | if node.parent != nil && node.parent.isStacked { | 718 | if node.parent != nil && node.parent.isStacked { | ||
| 628 | - | return wm.stackedPaneManager.CloseStackedPane(node) | 719 | + | return nil, wm.stackedPaneManager.CloseStackedPane(node) |
| 629 | } | 720 | } | ||
| 630 | 721 | ||||
| 631 | if node.parent != nil && node.parent.container != nil { | 722 | if node.parent != nil && node.parent.container != nil { | ||
| @@ -724,8 +815,8 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error { | |||||
| 724 | wm.root = nil | 815 | wm.root = nil | ||
| 725 | wm.mainPane = nil | 816 | wm.mainPane = nil | ||
| 726 | 817 | ||||
| 727 | - | webkit.QuitMainLoop() | 818 | + | webkit.QuitMainLoop() |
| 728 | - | return nil | 819 | + | return nil, nil |
| 729 | } | 820 | } | ||
| 730 | } | 821 | } | ||
| 731 | } | 822 | } | ||
| @@ -737,14 +828,14 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error { | |||||
| 737 | log.Printf("[workspace] failed to destroy webview: %v", err) | 828 | log.Printf("[workspace] failed to destroy webview: %v", err) | ||
| 738 | } | 829 | } | ||
| 739 | webkit.QuitMainLoop() | 830 | webkit.QuitMainLoop() | ||
| 740 | - | return nil | 831 | + | return nil, nil |
| 741 | } | 832 | } | ||
| 742 | 833 | ||||
| 743 | parent := node.parent | 834 | parent := node.parent | ||
| 744 | if parent == nil { | 835 | if parent == nil { | ||
| 745 | // This is the root pane (no parent in tree structure) | 836 | // This is the root pane (no parent in tree structure) | ||
| 746 | if node != wm.root { | 837 | 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") |
| 748 | } | 839 | } | ||
| 749 | 840 | ||||
| 750 | // Check if we can find a replacement root | 841 | // Check if we can find a replacement root | ||
| @@ -758,7 +849,7 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error { | |||||
| 758 | log.Printf("[workspace] failed to destroy webview: %v", err) | 849 | log.Printf("[workspace] failed to destroy webview: %v", err) | ||
| 759 | } | 850 | } | ||
| 760 | webkit.QuitMainLoop() | 851 | webkit.QuitMainLoop() | ||
| 761 | - | return nil | 852 | + | return nil, nil |
| 762 | } | 853 | } | ||
| 763 | 854 | ||||
| 764 | // We have a replacement - delegate root status and close this pane | 855 | // We have a replacement - delegate root status and close this pane | ||
| @@ -777,11 +868,16 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error { | |||||
| 777 | 868 | ||||
| 778 | // Set replacement as new root | 869 | // Set replacement as new root | ||
| 779 | wm.root = replacement | 870 | wm.root = replacement | ||
| 780 | - | replacement.parent = nil | 871 | + | replacement.parent = nil // Critical: clear parent pointer for new root |
| 872 | + | promoted = replacement | |||
| 781 | 873 | ||||
| 782 | - | // Replace window child directly (GTK handles reparenting automatically) | 874 | + | // Replace window child - must unparent first to satisfy GTK4 assertion |
| 783 | if wm.window != nil && replacement.container != nil { | 875 | if wm.window != nil && replacement.container != nil { | ||
| 784 | replacement.container.Execute(func(containerPtr uintptr) error { | 876 | 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 | + | } | |||
| 785 | wm.window.SetChild(containerPtr) | 881 | wm.window.SetChild(containerPtr) | ||
| 786 | webkit.WidgetQueueAllocate(containerPtr) | 882 | webkit.WidgetQueueAllocate(containerPtr) | ||
| 787 | webkit.WidgetShow(containerPtr) | 883 | webkit.WidgetShow(containerPtr) | ||
| @@ -818,7 +914,7 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error { | |||||
| 818 | wm.updateMainPane() | 914 | wm.updateMainPane() | ||
| 819 | // Update CSS classes after pane count changes | 915 | // Update CSS classes after pane count changes | ||
| 820 | log.Printf("[workspace] root pane closed and delegated; panes remaining=%d", len(wm.app.panes)) | 916 | log.Printf("[workspace] root pane closed and delegated; panes remaining=%d", len(wm.app.panes)) | ||
| 821 | - | return nil | 917 | + | return promoted, nil |
| 822 | } | 918 | } | ||
| 823 | 919 | ||||
| 824 | var sibling *paneNode | 920 | var sibling *paneNode | ||
| @@ -828,7 +924,68 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error { | |||||
| 828 | sibling = parent.left | 924 | sibling = parent.left | ||
| 829 | } | 925 | } | ||
| 830 | if sibling == nil { | 926 | 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 | |||
| 832 | } | 989 | } | ||
| 833 | 990 | ||||
| 834 | log.Printf("[workspace] closing pane: target=%#x parent=%#x sibling=%#x remaining=%d", node.container, parent.container, sibling.container, remaining) | 991 | 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 { | |||||
| 844 | if wm.GetActiveNode() == node { | 1001 | if wm.GetActiveNode() == node { | ||
| 845 | } | 1002 | } | ||
| 846 | 1003 | ||||
| 847 | - | grand := parent.parent | 1004 | + | promoted = sibling |
| 848 | if grand == nil { | 1005 | if grand == nil { | ||
| 849 | // Parent is the root node. Promote sibling to become the new root. | 1006 | // Parent is the root node. Promote sibling to become the new root. | ||
| 850 | log.Printf("[workspace] promoting sibling to root: container=%#x, isLeaf=%v", sibling.container, sibling.isLeaf) | 1007 | log.Printf("[workspace] promoting sibling to root: container=%#x, isLeaf=%v", sibling.container, sibling.isLeaf) | ||
| 851 | 1008 | ||||
| 852 | // Update tree structure first | 1009 | // Update tree structure first | ||
| 853 | wm.root = sibling | 1010 | wm.root = sibling | ||
| 854 | - | sibling.parent = nil | 1011 | + | sibling.parent = nil // Critical: clear parent pointer for new root |
| 855 | 1012 | ||||
| 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 |
| 857 | if wm.window != nil && sibling.container != nil { | 1014 | if wm.window != nil && sibling.container != nil { | ||
| 858 | sibling.container.Execute(func(containerPtr uintptr) error { | 1015 | 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 | + | ||||
| 859 | wm.window.SetChild(containerPtr) | 1032 | wm.window.SetChild(containerPtr) | ||
| 860 | webkit.WidgetQueueAllocate(containerPtr) | 1033 | webkit.WidgetQueueAllocate(containerPtr) | ||
| 861 | webkit.WidgetShow(containerPtr) | 1034 | webkit.WidgetShow(containerPtr) | ||
| 1035 | + | ||||
| 1036 | + | parentAfter := webkit.WidgetGetParent(containerPtr) | |||
| 1037 | + | log.Printf("[workspace] root promotion: sibling parent after=%#x", parentAfter) | |||
| 862 | log.Printf("[workspace] successfully promoted sibling to root: %#x", containerPtr) | 1038 | log.Printf("[workspace] successfully promoted sibling to root: %#x", containerPtr) | ||
| 863 | return nil | 1039 | return nil | ||
| 864 | }) | 1040 | }) | ||
| @@ -879,20 +1055,53 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error { | |||||
| 879 | // GTK4 handles unparenting automatically when we set the new paned child | 1055 | // GTK4 handles unparenting automatically when we set the new paned child | ||
| 880 | grand.container.Execute(func(grandPtr uintptr) error { | 1056 | grand.container.Execute(func(grandPtr uintptr) error { | ||
| 881 | sibling.container.Execute(func(siblingPtr uintptr) error { | 1057 | 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 | + | ||||
| 882 | if grand.left == sibling { | 1065 | if grand.left == sibling { | ||
| 883 | webkit.PanedSetStartChild(grandPtr, siblingPtr) | 1066 | webkit.PanedSetStartChild(grandPtr, siblingPtr) | ||
| 884 | } else { | 1067 | } else { | ||
| 885 | webkit.PanedSetEndChild(grandPtr, siblingPtr) | 1068 | webkit.PanedSetEndChild(grandPtr, siblingPtr) | ||
| 886 | } | 1069 | } | ||
| 1070 | + | ||||
| 1071 | + | webkit.WidgetQueueAllocate(grandPtr) | |||
| 887 | webkit.WidgetQueueAllocate(siblingPtr) | 1072 | webkit.WidgetQueueAllocate(siblingPtr) | ||
| 888 | webkit.WidgetShow(siblingPtr) | 1073 | 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 | + | } | |||
| 889 | log.Printf("[workspace] successfully promoted sibling to parent position: %#x", siblingPtr) | 1083 | log.Printf("[workspace] successfully promoted sibling to parent position: %#x", siblingPtr) | ||
| 890 | return nil | 1084 | return nil | ||
| 891 | }) | 1085 | }) | ||
| 1086 | + | ||||
| 1087 | + | // Also queue allocate the grandparent to ensure layout updates | |||
| 892 | return nil | 1088 | return nil | ||
| 893 | }) | 1089 | }) | ||
| 894 | } | 1090 | } | ||
| 895 | 1091 | ||||
| 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 | + | ||||
| 896 | // Find a suitable focus target | 1105 | // Find a suitable focus target | ||
| 897 | focusTarget := wm.leftmostLeaf(sibling) | 1106 | focusTarget := wm.leftmostLeaf(sibling) | ||
| 898 | if focusTarget != nil { | 1107 | if focusTarget != nil { | ||
| @@ -922,5 +1131,5 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error { | |||||
| 922 | wm.updateMainPane() | 1131 | wm.updateMainPane() | ||
| 923 | // Update CSS classes after pane count changes | 1132 | // Update CSS classes after pane count changes | ||
| 924 | log.Printf("[workspace] pane closed; panes remaining=%d", len(wm.app.panes)) | 1133 | log.Printf("[workspace] pane closed; panes remaining=%d", len(wm.app.panes)) | ||
| 925 | - | return nil | 1134 | + | return promoted, nil |
| 926 | } | 1135 | } | ||
M internal/app/browser/workspace_popup.go +3 -3
| @@ -155,7 +155,7 @@ func (wm *WorkspaceManager) HandlePopup(source *webkit.WebView, url string) *web | |||||
| 155 | // Brief delay to allow any final redirects to complete | 155 | // Brief delay to allow any final redirects to complete | ||
| 156 | time.AfterFunc(200*time.Millisecond, func() { | 156 | time.AfterFunc(200*time.Millisecond, func() { | ||
| 157 | webkit.IdleAdd(func() bool { | 157 | webkit.IdleAdd(func() bool { | ||
| 158 | - | if err := wm.closePane(node); err != nil { | 158 | + | if err := wm.BulletproofClosePane(node); err != nil { |
| 159 | log.Printf("[workspace] Failed to close popup pane: %v", err) | 159 | log.Printf("[workspace] Failed to close popup pane: %v", err) | ||
| 160 | } | 160 | } | ||
| 161 | return false | 161 | return false | ||
| @@ -409,7 +409,7 @@ func (wm *WorkspaceManager) handleIntentAsPopup(sourceNode *paneNode, url string | |||||
| 409 | if n := wm.viewToNode[newView]; n != nil && n.isPopup { | 409 | if n := wm.viewToNode[newView]; n != nil && n.isPopup { | ||
| 410 | time.AfterFunc(200*time.Millisecond, func() { | 410 | time.AfterFunc(200*time.Millisecond, func() { | ||
| 411 | webkit.IdleAdd(func() bool { | 411 | webkit.IdleAdd(func() bool { | ||
| 412 | - | if err := wm.closePane(n); err != nil { | 412 | + | if err := wm.BulletproofClosePane(n); err != nil { |
| 413 | log.Printf("[workspace] Failed to close popup pane: %v", err) | 413 | log.Printf("[workspace] Failed to close popup pane: %v", err) | ||
| 414 | } | 414 | } | ||
| 415 | return false | 415 | return false | ||
| @@ -495,7 +495,7 @@ func (wm *WorkspaceManager) configureRelatedPopup(sourceNode *paneNode, webView | |||||
| 495 | if n := wm.viewToNode[related]; n != nil && n.isPopup { | 495 | if n := wm.viewToNode[related]; n != nil && n.isPopup { | ||
| 496 | time.AfterFunc(200*time.Millisecond, func() { | 496 | time.AfterFunc(200*time.Millisecond, func() { | ||
| 497 | webkit.IdleAdd(func() bool { | 497 | webkit.IdleAdd(func() bool { | ||
| 498 | - | if err := wm.closePane(n); err != nil { | 498 | + | if err := wm.BulletproofClosePane(n); err != nil { |
| 499 | log.Printf("[workspace] Failed to close popup pane: %v", err) | 499 | log.Printf("[workspace] Failed to close popup pane: %v", err) | ||
| 500 | } | 500 | } | ||
| 501 | return false | 501 | 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 { | |||||
| 21 | isPopup bool // Deprecated: use windowType instead | 21 | isPopup bool // Deprecated: use windowType instead | ||
| 22 | 22 | ||||
| 23 | // Window type tracking | 23 | // Window type tracking | ||
| 24 | - | windowType webkit.WindowType // Tab or Popup | 24 | + | windowType webkit.WindowType // Tab or Popup |
| 25 | - | windowFeatures *webkit.WindowFeatures // Features if popup | 25 | + | windowFeatures *webkit.WindowFeatures // Features if popup |
| 26 | - | isRelated bool // Shares context | 26 | + | isRelated bool // Shares context |
| 27 | - | parentPane *paneNode // Parent for related views | 27 | + | parentPane *paneNode // Parent for related views |
| 28 | - | autoClose bool // Auto-close on OAuth success | 28 | + | autoClose bool // Auto-close on OAuth success |
| 29 | - | hoverToken uintptr | 29 | + | hoverToken uintptr |
| 30 | + | focusControllerToken uintptr | |||
| 31 | + | pendingHoverReattach bool | |||
| 32 | + | pendingFocusReattach bool | |||
| 30 | 33 | ||||
| 31 | // Stacked panes support with proper widget management | 34 | // Stacked panes support with proper widget management | ||
| 32 | isStacked bool // Whether this node contains stacked panes | 35 | 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) { | |||||
| 221 | 221 | ||||
| 222 | // Apply CSS classes to border container (provides border styling) | 222 | // Apply CSS classes to border container (provides border styling) | ||
| 223 | if ctx.borderContainer != nil { | 223 | if ctx.borderContainer != nil { | ||
| 224 | - | // Check if this container already has the active class to avoid redundant operations | 224 | + | var applied bool |
| 225 | - | var alreadyActive bool | 225 | + | if err := ctx.borderContainer.Execute(func(containerPtr uintptr) error { |
| 226 | - | ctx.borderContainer.Execute(func(containerPtr uintptr) error { | |||
| 227 | // Safety check: verify container pointer is valid | 226 | // Safety check: verify container pointer is valid | ||
| 228 | if containerPtr == 0 { | 227 | if containerPtr == 0 { | ||
| 229 | return fmt.Errorf("invalid container pointer") | 228 | return fmt.Errorf("invalid container pointer") | ||
| 230 | } | 229 | } | ||
| 231 | - | alreadyActive = webkit.WidgetHasCSSClass(containerPtr, activePaneClass) | 230 | + | if 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) | |||
| 239 | return nil | 231 | 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 { | |||
| 241 | log.Printf("[workspace] Applied border to %s pane: %p", ctx.paneType, node) | 239 | log.Printf("[workspace] Applied border to %s pane: %p", ctx.paneType, node) | ||
| 242 | } | 240 | } | ||
| 243 | } | 241 | } | ||
| @@ -252,23 +250,21 @@ func (wm *WorkspaceManager) removeActivePaneBorder(node *paneNode) { | |||||
| 252 | 250 | ||||
| 253 | // Remove CSS classes from border container | 251 | // Remove CSS classes from border container | ||
| 254 | if ctx.borderContainer != nil { | 252 | if ctx.borderContainer != nil { | ||
| 255 | - | // Check if this container has the active class before attempting removal | 253 | + | var removed bool |
| 256 | - | var hasActiveClass bool | 254 | + | if err := ctx.borderContainer.Execute(func(containerPtr uintptr) error { |
| 257 | - | ctx.borderContainer.Execute(func(containerPtr uintptr) error { | |||
| 258 | // Safety check: verify container pointer is valid | 255 | // Safety check: verify container pointer is valid | ||
| 259 | if containerPtr == 0 { | 256 | if containerPtr == 0 { | ||
| 260 | return fmt.Errorf("invalid container pointer") | 257 | return fmt.Errorf("invalid container pointer") | ||
| 261 | } | 258 | } | ||
| 262 | - | hasActiveClass = webkit.WidgetHasCSSClass(containerPtr, activePaneClass) | 259 | + | if !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) | |||
| 270 | return nil | 260 | 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 { | |||
| 272 | log.Printf("[workspace] Removed border from %s pane: %p", ctx.paneType, node) | 268 | log.Printf("[workspace] Removed border from %s pane: %p", ctx.paneType, node) | ||
| 273 | } | 269 | } | ||
| 274 | } | 270 | } | ||
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) { | |||||
| 1002 | return nil, errors.New("failed to initialize GTK") | 1002 | return nil, errors.New("failed to initialize GTK") | ||
| 1003 | } | 1003 | } | ||
| 1004 | 1004 | ||||
| 1005 | + | InitMainThread() | |||
| 1006 | + | ||||
| 1005 | if cfg == nil { | 1007 | if cfg == nil { | ||
| 1006 | cfg = &Config{} | 1008 | cfg = &Config{} | ||
| 1007 | } | 1009 | } | ||
| @@ -1458,6 +1460,8 @@ func NewWebViewWithRelated(cfg *Config, relatedView *WebView) (*WebView, error) | |||||
| 1458 | return nil, errors.New("failed to initialize GTK") | 1460 | return nil, errors.New("failed to initialize GTK") | ||
| 1459 | } | 1461 | } | ||
| 1460 | 1462 | ||||
| 1463 | + | InitMainThread() | |||
| 1464 | + | ||||
| 1461 | if cfg == nil { | 1465 | if cfg == nil { | ||
| 1462 | cfg = &Config{} | 1466 | cfg = &Config{} | ||
| 1463 | } | 1467 | } | ||
M pkg/webkit/window_cgo.go +2 -0
| @@ -27,6 +27,8 @@ func NewWindow(title string) (*Window, error) { | |||||
| 27 | if C.gtk_init_check() == 0 { | 27 | if C.gtk_init_check() == 0 { | ||
| 28 | return nil, ErrNotImplemented | 28 | return nil, ErrNotImplemented | ||
| 29 | } | 29 | } | ||
| 30 | + | ||||
| 31 | + | InitMainThread() | |||
| 30 | // GTK4: gtk_window_new() returns GtkWindow*, no toplevel enum | 32 | // GTK4: gtk_window_new() returns GtkWindow*, no toplevel enum | ||
| 31 | w := (*C.GtkWidget)(unsafe.Pointer(C.gtk_window_new())) | 33 | w := (*C.GtkWidget)(unsafe.Pointer(C.gtk_window_new())) | ||
| 32 | if w == nil { | 34 | if w == nil { | ||
M pkg/webkit/workspace_widgets_cgo.go +196 -27
| @@ -10,6 +10,7 @@ package webkit | |||||
| 10 | #include <glib.h> | 10 | #include <glib.h> | ||
| 11 | #include <glib-object.h> | 11 | #include <glib-object.h> | ||
| 12 | #include <graphene.h> | 12 | #include <graphene.h> | ||
| 13 | + | #include <pthread.h> | |||
| 13 | #include <stdint.h> | 14 | #include <stdint.h> | ||
| 14 | #include <stdlib.h> | 15 | #include <stdlib.h> | ||
| 15 | 16 | ||||
| @@ -22,6 +23,11 @@ extern void goHoverCallback(uintptr_t handle); | |||||
| 22 | extern void goFocusEnterCallback(uintptr_t handle); | 23 | extern void goFocusEnterCallback(uintptr_t handle); | ||
| 23 | extern void goFocusLeaveCallback(uintptr_t handle); | 24 | extern void goFocusLeaveCallback(uintptr_t handle); | ||
| 24 | 25 | ||||
| 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 | + | ||||
| 25 | static gboolean idle_callback_wrapper(gpointer data) { | 31 | static gboolean idle_callback_wrapper(gpointer data) { | ||
| 26 | return goIdleCallback((uintptr_t)data); | 32 | return goIdleCallback((uintptr_t)data); | ||
| 27 | } | 33 | } | ||
| @@ -81,6 +87,89 @@ static gboolean is_valid_widget(gpointer widget) { | |||||
| 81 | return widget != NULL && GTK_IS_WIDGET(widget); | 87 | return widget != NULL && GTK_IS_WIDGET(widget); | ||
| 82 | } | 88 | } | ||
| 83 | 89 | ||||
| 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 | + | ||||
| 84 | static GtkWidget* paned_get_start_child(GtkWidget* paned) { | 173 | static GtkWidget* paned_get_start_child(GtkWidget* paned) { | ||
| 85 | if (!paned) return NULL; | 174 | if (!paned) return NULL; | ||
| 86 | return gtk_paned_get_start_child(GTK_PANED(paned)); | 175 | return gtk_paned_get_start_child(GTK_PANED(paned)); | ||
| @@ -238,26 +327,27 @@ static void focus_leave_cb(GtkEventControllerFocus* controller, gpointer user_da | |||||
| 238 | goFocusLeaveCallback(nodePtr); | 327 | goFocusLeaveCallback(nodePtr); | ||
| 239 | } | 328 | } | ||
| 240 | 329 | ||||
| 241 | - | static GtkEventController* widget_add_focus_controller(GtkWidget* widget, uintptr_t nodePtr) { | 330 | + | // Widget allocation helper using modern GTK4 API |
| 242 | - | if (!widget) { | 331 | + | static void widget_get_allocation_modern(GtkWidget* widget, int* x, int* y, int* width, int* height) { |
| 243 | - | return NULL; | 332 | + | if (!widget || !x || !y || !width || !height) { |
| 244 | - | } | 333 | + | *x = *y = *width = *height = 0; |
| 245 | - | GtkEventController* focus = gtk_event_controller_focus_new(); | 334 | + | return; |
| 246 | - | if (!focus) { | |||
| 247 | - | return NULL; | |||
| 248 | } | 335 | } | ||
| 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 | - | } | |||
| 255 | 336 | ||||
| 256 | - | static void widget_remove_focus_controller(GtkWidget* widget, GtkEventController* controller) { | 337 | + | graphene_rect_t bounds; |
| 257 | - | if (!widget || !controller) { | 338 | + | // Compute bounds relative to the widget itself (pass widget as target) |
| 258 | - | return; | 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); | |||
| 259 | } | 350 | } | ||
| 260 | - | gtk_widget_remove_controller(widget, controller); | |||
| 261 | } | 351 | } | ||
| 262 | */ | 352 | */ | ||
| 263 | import "C" | 353 | import "C" | ||
| @@ -282,6 +372,14 @@ type FocusCallbacks struct { | |||||
| 282 | OnLeave func() | 372 | OnLeave func() | ||
| 283 | } | 373 | } | ||
| 284 | 374 | ||||
| 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 | + | ||||
| 285 | var ( | 383 | var ( | ||
| 286 | hoverMu sync.Mutex | 384 | hoverMu sync.Mutex | ||
| 287 | hoverCallbacks = make(map[uintptr]func()) | 385 | hoverCallbacks = make(map[uintptr]func()) | ||
| @@ -294,8 +392,26 @@ var ( | |||||
| 294 | focusCallbacks = make(map[uintptr]FocusCallbacks) | 392 | focusCallbacks = make(map[uintptr]FocusCallbacks) | ||
| 295 | focusControllers = make(map[uintptr]uintptr) | 393 | focusControllers = make(map[uintptr]uintptr) | ||
| 296 | nextFocusID uintptr = 1 | 394 | nextFocusID uintptr = 1 | ||
| 395 | + | ||||
| 396 | + | mainThreadInitialized bool | |||
| 297 | ) | 397 | ) | ||
| 298 | 398 | ||||
| 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 | + | ||||
| 299 | func widgetIsValid(widget uintptr) bool { | 415 | func widgetIsValid(widget uintptr) bool { | ||
| 300 | if widget == 0 { | 416 | if widget == 0 { | ||
| 301 | return false | 417 | return false | ||
| @@ -441,22 +557,45 @@ func PanedSetPosition(paned uintptr, pos int) { | |||||
| 441 | 557 | ||||
| 442 | // WidgetUnparent detaches a widget from its current parent. | 558 | // WidgetUnparent detaches a widget from its current parent. | ||
| 443 | func WidgetUnparent(widget uintptr) { | 559 | 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 { | |||
| 444 | if widget == 0 { | 568 | if widget == 0 { | ||
| 445 | - | return | 569 | + | log.Printf("[workspace] WidgetUnparentChecked: widget=0") |
| 570 | + | return false | |||
| 446 | } | 571 | } | ||
| 572 | + | ||||
| 447 | if !widgetIsValid(widget) { | 573 | if !widgetIsValid(widget) { | ||
| 448 | - | log.Printf("[workspace] WidgetUnparent skipped invalid widget=%#x", widget) | 574 | + | log.Printf("[workspace] WidgetUnparentChecked: widget=%#x invalid before unparent", widget) |
| 449 | - | return | 575 | + | return false |
| 450 | } | 576 | } | ||
| 451 | - | before := widgetRefCount(widget) | 577 | + | |
| 452 | - | log.Printf("[workspace] WidgetUnparent widget=%#x ref_before=%d", widget, before) | 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 | + | ||||
| 453 | C.gtk_widget_unparent((*C.GtkWidget)(unsafe.Pointer(widget))) | 587 | C.gtk_widget_unparent((*C.GtkWidget)(unsafe.Pointer(widget))) | ||
| 454 | - | if widgetIsValid(widget) { | 588 | + | |
| 455 | - | after := widgetRefCount(widget) | 589 | + | if !widgetIsValid(widget) { |
| 456 | - | log.Printf("[workspace] WidgetUnparent widget=%#x ref_after=%d", widget, after) | 590 | + | log.Printf("[workspace] WidgetUnparentChecked: widget=%#x invalid after unparent", widget) |
| 457 | - | } else { | 591 | + | return true |
| 458 | - | log.Printf("[workspace] WidgetUnparent widget=%#x now invalid", widget) | |||
| 459 | } | 592 | } | ||
| 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 | |||
| 460 | } | 599 | } | ||
| 461 | 600 | ||||
| 462 | // WidgetSetHExpand configures horizontal expand for a widget. | 601 | // WidgetSetHExpand configures horizontal expand for a widget. | ||
| @@ -491,6 +630,14 @@ func WidgetSetVExpand(widget uintptr, expand bool) { | |||||
| 491 | C.gtk_widget_set_vexpand((*C.GtkWidget)(unsafe.Pointer(widget)), goBool(expand)) | 630 | C.gtk_widget_set_vexpand((*C.GtkWidget)(unsafe.Pointer(widget)), goBool(expand)) | ||
| 492 | } | 631 | } | ||
| 493 | 632 | ||||
| 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 | + | ||||
| 494 | // WidgetShow makes the widget visible. | 641 | // WidgetShow makes the widget visible. | ||
| 495 | func WidgetShow(widget uintptr) { | 642 | func WidgetShow(widget uintptr) { | ||
| 496 | if widget == 0 { | 643 | if widget == 0 { | ||
| @@ -650,6 +797,11 @@ func IdleAdd(fn func() bool) { | |||||
| 650 | C.add_idle_callback(C.uintptr_t(id)) | 797 | C.add_idle_callback(C.uintptr_t(id)) | ||
| 651 | } | 798 | } | ||
| 652 | 799 | ||||
| 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 | + | ||||
| 653 | //export goIdleCallback | 805 | //export goIdleCallback | ||
| 654 | func goIdleCallback(handle uintptr) C.gboolean { | 806 | func goIdleCallback(handle uintptr) C.gboolean { | ||
| 655 | if fn, ok := idleCallbacks[handle]; ok { | 807 | if fn, ok := idleCallbacks[handle]; ok { | ||
| @@ -955,3 +1107,20 @@ func goFocusLeaveCallback(handle C.uintptr_t) { | |||||
| 955 | callbacks.OnLeave() | 1107 | callbacks.OnLeave() | ||
| 956 | } | 1108 | } | ||
| 957 | } | 1109 | } | ||
| 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 { | |||||
| 20 | Height float64 | 20 | Height float64 | ||
| 21 | } | 21 | } | ||
| 22 | 22 | ||||
| 23 | + | // WidgetAllocation represents a widget's allocation (position and size) in stub builds | |||
| 24 | + | type WidgetAllocation struct { | |||
| 25 | + | X int | |||
| 26 | + | Y int | |||
| 27 | + | Width int | |||
| 28 | + | Height int | |||
| 29 | + | } | |||
| 30 | + | ||||
| 23 | type widgetStub struct { | 31 | type widgetStub struct { | ||
| 24 | startChild uintptr | 32 | startChild uintptr | ||
| 25 | endChild uintptr | 33 | endChild uintptr | ||
| @@ -271,6 +279,8 @@ func WidgetQueueAllocate(widget uintptr) {} | |||||
| 271 | 279 | ||||
| 272 | func WidgetRealizeInContainer(widget uintptr) {} | 280 | func WidgetRealizeInContainer(widget uintptr) {} | ||
| 273 | 281 | ||||
| 282 | + | func WidgetResetSizeRequest(widget uintptr) {} | |||
| 283 | + | ||||
| 274 | func WidgetHookDestroy(widget uintptr) {} | 284 | func WidgetHookDestroy(widget uintptr) {} | ||
| 275 | 285 | ||||
| 276 | func WidgetIsValid(widget uintptr) bool { | 286 | func WidgetIsValid(widget uintptr) bool { | ||
| @@ -491,3 +501,27 @@ func LabelSetMaxWidthChars(label uintptr, nChars int) {} | |||||
| 491 | func (w *WebView) SetTestContainer(container uintptr) { | 501 | func (w *WebView) SetTestContainer(container uintptr) { | ||
| 492 | w.container = container | 502 | w.container = container | ||
| 493 | } | 503 | } | ||
| 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 | + | } | |||