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 | 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 | 897 | widget := node.pane.webView.Widget() | |
| 893 | - | if widget == 0 { | |
| 898 | + | if widget == 0 || !webkit.WidgetIsValid(widget) { | |
| 894 | 899 | return | |
| 895 | 900 | } | |
| 896 | 901 | ||
| @@ -909,7 +914,7 @@ func (fsm *FocusStateMachine) attachGTKController(node *paneNode) { | |||
| 909 | 914 | // Add the focus controller and store the token for cleanup | |
| 910 | 915 | token := webkit.WidgetAddFocusController(widget, onEnter, onLeave) | |
| 911 | 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 | 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 | 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 | 935 | widget := node.pane.webView.Widget() | |
| 924 | - | if widget == 0 { | |
| 936 | + | if widget == 0 || !webkit.WidgetIsValid(widget) { | |
| 925 | 937 | return | |
| 926 | 938 | } | |
| 927 | 939 | ||
M internal/app/browser/widget.go +133 -9
| @@ -1,33 +1,102 @@ | |||
| 1 | 1 | package browser | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "fmt" | |
| 5 | 6 | "sync" | |
| 7 | + | "time" | |
| 6 | 8 | ||
| 7 | 9 | "github.com/bnema/dumber/pkg/webkit" | |
| 8 | 10 | ) | |
| 9 | 11 | ||
| 10 | 12 | // SafeWidget provides thread-safe access to GTK widgets with validation | |
| 11 | 13 | type SafeWidget struct { | |
| 12 | - | ptr uintptr | |
| 13 | - | typeInfo string | |
| 14 | - | registry *WidgetRegistry | |
| 14 | + | ptr uintptr | |
| 15 | + | typeInfo string | |
| 16 | + | registry *WidgetRegistry | |
| 17 | + | isInvalidated bool | |
| 18 | + | mu sync.RWMutex | |
| 15 | 19 | } | |
| 16 | 20 | ||
| 17 | 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 | 23 | func (w *SafeWidget) Execute(fn func(uintptr) error) error { | |
| 19 | 24 | if w == nil || w.registry == nil { | |
| 20 | 25 | return fmt.Errorf("SafeWidget is nil or has no registry") | |
| 21 | 26 | } | |
| 22 | 27 | ||
| 23 | - | w.registry.mu.Lock() | |
| 24 | - | defer w.registry.mu.Unlock() | |
| 28 | + | w.mu.RLock() | |
| 29 | + | if w.isInvalidated { | |
| 30 | + | w.mu.RUnlock() | |
| 31 | + | return fmt.Errorf("widget %s permanently invalidated", w.String()) | |
| 32 | + | } | |
| 33 | + | ptr := w.ptr | |
| 34 | + | w.mu.RUnlock() | |
| 35 | + | ||
| 36 | + | if !w.IsValid() { | |
| 37 | + | return fmt.Errorf("widget %s is no longer valid", w.String()) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | if err := fn(ptr); err != nil { | |
| 41 | + | w.markInvalidIfNeeded(ptr) | |
| 42 | + | return err | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | if !w.IsValid() { | |
| 46 | + | w.markInvalidIfNeeded(ptr) | |
| 47 | + | return fmt.Errorf("widget %s became invalid during execution", w.String()) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | return nil | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // ExecuteWithTimeout executes a function with timeout to prevent indefinite blocking | |
| 54 | + | func (w *SafeWidget) ExecuteWithTimeout(fn func(uintptr) error, timeout time.Duration) error { | |
| 55 | + | if w == nil || w.registry == nil { | |
| 56 | + | return fmt.Errorf("SafeWidget is nil or has no registry") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // Create context with timeout | |
| 60 | + | ctx, cancel := context.WithTimeout(context.Background(), timeout) | |
| 61 | + | defer cancel() | |
| 62 | + | ||
| 63 | + | // Channel for result | |
| 64 | + | resultChan := make(chan error, 1) | |
| 65 | + | ||
| 66 | + | w.mu.RLock() | |
| 67 | + | if w.isInvalidated { | |
| 68 | + | w.mu.RUnlock() | |
| 69 | + | return fmt.Errorf("widget %s permanently invalidated", w.String()) | |
| 70 | + | } | |
| 71 | + | ptr := w.ptr | |
| 72 | + | w.mu.RUnlock() | |
| 25 | 73 | ||
| 26 | 74 | if !w.IsValid() { | |
| 27 | 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 | 102 | // IsValid checks if the underlying GTK widget is still valid | |
| @@ -35,7 +104,49 @@ func (w *SafeWidget) IsValid() bool { | |||
| 35 | 104 | if w == nil || w.ptr == 0 { | |
| 36 | 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 | 152 | // String returns a string representation of the SafeWidget for debugging | |
| @@ -43,7 +154,13 @@ func (w *SafeWidget) String() string { | |||
| 43 | 154 | if w == nil { | |
| 44 | 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 | 166 | // Ptr returns the raw uintptr to the GTK widget | |
| @@ -51,6 +168,10 @@ func (w *SafeWidget) Ptr() uintptr { | |||
| 51 | 168 | if w == nil { | |
| 52 | 169 | return 0 | |
| 53 | 170 | } | |
| 171 | + | ||
| 172 | + | w.mu.RLock() | |
| 173 | + | defer w.mu.RUnlock() | |
| 174 | + | ||
| 54 | 175 | return w.ptr | |
| 55 | 176 | } | |
| 56 | 177 | ||
| @@ -101,7 +222,10 @@ func (r *WidgetRegistry) Unregister(ptr uintptr) { | |||
| 101 | 222 | r.mu.Lock() | |
| 102 | 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 | 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 | 7 | "log" | |
| 8 | 8 | "strings" | |
| 9 | 9 | "sync" | |
| 10 | + | "sync/atomic" | |
| 10 | 11 | "time" | |
| 11 | 12 | ||
| 12 | 13 | "github.com/bnema/dumber/internal/app/messaging" | |
| @@ -23,7 +24,7 @@ type WorkspaceManager struct { | |||
| 23 | 24 | lastSplitMsg map[*webkit.WebView]time.Time | |
| 24 | 25 | lastExitMsg map[*webkit.WebView]time.Time | |
| 25 | 26 | paneModeActive bool | |
| 26 | - | splitting bool | |
| 27 | + | splitting int32 // atomic: 0=false, 1=true | |
| 27 | 28 | cssInitialized bool | |
| 28 | 29 | createWebViewFn func() (*webkit.WebView, error) | |
| 29 | 30 | createPaneFn func(*webkit.WebView) (*BrowserPane, error) | |
| @@ -55,6 +56,15 @@ type WorkspaceManager struct { | |||
| 55 | 56 | lastFocusTime time.Time | |
| 56 | 57 | lastFocusTarget *paneNode | |
| 57 | 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 | 70 | // Workspace navigation shortcuts are now handled globally by WindowShortcutHandler | |
| @@ -72,7 +82,23 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag | |||
| 72 | 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 | 102 | manager.stackedPaneManager = NewStackedPaneManager(manager) | |
| 77 | 103 | manager.focusStateMachine = NewFocusStateMachine(manager) | |
| 78 | 104 | manager.createWebViewFn = func() (*webkit.WebView, error) { | |
| @@ -208,20 +234,34 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa | |||
| 208 | 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 | 240 | log.Printf("[workspace] split ignored: operation already in progress") | |
| 213 | 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 | 253 | wm.lastSplitMsg[source] = time.Now() | |
| 217 | - | newNode, err := wm.splitNode(node, direction) | |
| 254 | + | ||
| 255 | + | newNode, err := wm.BulletproofSplitNode(node, direction) | |
| 218 | 256 | if err != nil { | |
| 219 | 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 | 262 | return | |
| 222 | 263 | } | |
| 223 | 264 | wm.clonePaneState(node, newNode) | |
| 224 | - | wm.splitting = false | |
| 225 | 265 | case "pane-stack": | |
| 226 | 266 | wm.SetActivePane(node, SourceProgrammatic) | |
| 227 | 267 | if last, ok := wm.lastSplitMsg[source]; ok { | |
| @@ -230,20 +270,34 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa | |||
| 230 | 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 | 276 | log.Printf("[workspace] stack ignored: operation already in progress") | |
| 235 | 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 | 289 | wm.lastSplitMsg[source] = time.Now() | |
| 290 | + | ||
| 239 | 291 | newNode, err := wm.stackedPaneManager.StackPane(node) | |
| 240 | 292 | if err != nil { | |
| 241 | 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 | 298 | return | |
| 244 | 299 | } | |
| 245 | 300 | wm.clonePaneState(node, newNode) | |
| 246 | - | wm.splitting = false | |
| 247 | 301 | case "create-pane": | |
| 248 | 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 | 383 | log.Printf("[workspace] Closing popup pane due to %s", msg.Reason) | |
| 330 | 384 | ||
| 331 | 385 | // Close the popup pane | |
| 332 | - | if err := wm.closePane(targetNode); err != nil { | |
| 386 | + | if err := wm.BulletproofClosePane(targetNode); err != nil { | |
| 333 | 387 | log.Printf("[workspace] Failed to close popup pane: %v", err) | |
| 334 | 388 | } else { | |
| 335 | 389 | log.Printf("[workspace] Successfully closed popup pane: %s", msg.WebViewID) | |
D internal/app/browser/workspace_manager.go.deprecated +0 -2627
| @@ -1,2627 +0,0 @@ | |||
| 1 | - | package browser | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "errors" | |
| 5 | - | "fmt" | |
| 6 | - | "log" | |
| 7 | - | "math" | |
| 8 | - | "strings" | |
| 9 | - | "sync" | |
| 10 | - | "time" | |
| 11 | - | ||
| 12 | - | "github.com/bnema/dumber/internal/app/messaging" | |
| 13 | - | "github.com/bnema/dumber/internal/config" | |
| 14 | - | "github.com/bnema/dumber/pkg/webkit" | |
| 15 | - | ) | |
| 16 | - | ||
| 17 | - | type paneNode struct { | |
| 18 | - | pane *BrowserPane | |
| 19 | - | parent *paneNode | |
| 20 | - | left *paneNode | |
| 21 | - | right *paneNode | |
| 22 | - | ||
| 23 | - | // Widget management with proper lifecycle tracking | |
| 24 | - | container *SafeWidget // Main container (GtkPaned for branch nodes, wrapper GtkBox for stacked nodes, WebView container for leaves) | |
| 25 | - | orientation webkit.Orientation | |
| 26 | - | isLeaf bool | |
| 27 | - | isPopup bool // Deprecated: use windowType instead | |
| 28 | - | ||
| 29 | - | // Window type tracking | |
| 30 | - | windowType webkit.WindowType // Tab or Popup | |
| 31 | - | windowFeatures *webkit.WindowFeatures // Features if popup | |
| 32 | - | isRelated bool // Shares context | |
| 33 | - | parentPane *paneNode // Parent for related views | |
| 34 | - | autoClose bool // Auto-close on OAuth success | |
| 35 | - | hoverToken uintptr | |
| 36 | - | ||
| 37 | - | // Stacked panes support with proper widget management | |
| 38 | - | isStacked bool // Whether this node contains stacked panes | |
| 39 | - | stackedPanes []*paneNode // List of stacked panes (if isStacked) | |
| 40 | - | activeStackIndex int // Index of currently visible pane in stack | |
| 41 | - | titleBar *SafeWidget // GtkBox for title bar (when collapsed) | |
| 42 | - | stackWrapper *SafeWidget // Internal GtkBox containing the actual stacked widgets (titles + webviews) | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | // WorkspaceManager coordinates Zellij-style pane operations. | |
| 46 | - | type WorkspaceManager struct { | |
| 47 | - | app *BrowserApp | |
| 48 | - | window *webkit.Window | |
| 49 | - | root *paneNode | |
| 50 | - | mainPane *paneNode | |
| 51 | - | viewToNode map[*webkit.WebView]*paneNode | |
| 52 | - | lastSplitMsg map[*webkit.WebView]time.Time | |
| 53 | - | lastExitMsg map[*webkit.WebView]time.Time | |
| 54 | - | paneModeActive bool | |
| 55 | - | splitting bool | |
| 56 | - | cssInitialized bool | |
| 57 | - | createWebViewFn func() (*webkit.WebView, error) | |
| 58 | - | createPaneFn func(*webkit.WebView) (*BrowserPane, error) | |
| 59 | - | ||
| 60 | - | // Coordination fields for preventing duplicate events | |
| 61 | - | paneModeSource *webkit.WebView // Which webview initiated pane mode | |
| 62 | - | lastPaneModeEntry time.Time // When pane mode was last entered | |
| 63 | - | paneMutex sync.Mutex // Protects pane mode state | |
| 64 | - | ||
| 65 | - | // Focus throttling fields to prevent infinite loops | |
| 66 | - | lastFocusChange time.Time // When focus was last changed | |
| 67 | - | focusThrottleMutex sync.Mutex // Protects focus throttling state | |
| 68 | - | ||
| 69 | - | // Stack operation timing to prevent focus conflicts | |
| 70 | - | lastStackOperation time.Time // When a stack operation was last performed | |
| 71 | - | ||
| 72 | - | // NEW: Pane creation deduplicator | |
| 73 | - | paneDeduplicator *messaging.PaneRequestDeduplicator | |
| 74 | - | ||
| 75 | - | // Specialized managers for different pane operations | |
| 76 | - | stackedPaneManager *StackedPaneManager | |
| 77 | - | focusManager *FocusManager | |
| 78 | - | ||
| 79 | - | // Current focused pane (Single Source of Truth for focus) | |
| 80 | - | currentlyFocused *paneNode | |
| 81 | - | ||
| 82 | - | // Widget operation synchronization to prevent GTK race conditions | |
| 83 | - | widgetMutex sync.Mutex | |
| 84 | - | widgetRegistry *WidgetRegistry | |
| 85 | - | } | |
| 86 | - | ||
| 87 | - | const ( | |
| 88 | - | basePaneClass = "workspace-pane" | |
| 89 | - | multiPaneClass = "workspace-multi-pane" | |
| 90 | - | ) | |
| 91 | - | ||
| 92 | - | // Workspace navigation shortcuts are now handled globally by WindowShortcutHandler | |
| 93 | - | ||
| 94 | - | // NewWorkspaceManager builds a workspace manager rooted at the provided pane. | |
| 95 | - | func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManager { | |
| 96 | - | manager := &WorkspaceManager{ | |
| 97 | - | app: app, | |
| 98 | - | window: rootPane.webView.Window(), | |
| 99 | - | viewToNode: make(map[*webkit.WebView]*paneNode), | |
| 100 | - | lastSplitMsg: make(map[*webkit.WebView]time.Time), | |
| 101 | - | lastExitMsg: make(map[*webkit.WebView]time.Time), | |
| 102 | - | paneDeduplicator: messaging.NewPaneRequestDeduplicator(), // NEW: Initialize deduplicator | |
| 103 | - | widgetRegistry: NewWidgetRegistry(), // Initialize widget registry | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | // Initialize specialized managers | |
| 107 | - | manager.stackedPaneManager = NewStackedPaneManager(manager) | |
| 108 | - | manager.focusManager = NewFocusManager(manager) | |
| 109 | - | manager.createWebViewFn = func() (*webkit.WebView, error) { | |
| 110 | - | if manager.app == nil { | |
| 111 | - | return nil, errors.New("workspace manager missing app reference") | |
| 112 | - | } | |
| 113 | - | cfg, err := manager.app.buildWebkitConfig() | |
| 114 | - | if err != nil { | |
| 115 | - | return nil, err | |
| 116 | - | } | |
| 117 | - | // Keep CreateWindow = true for popup WebViews to ensure proper window features initialization | |
| 118 | - | // This prevents WindowFeatures optional crashes when WebKit accesses popup properties | |
| 119 | - | cfg.CreateWindow = true | |
| 120 | - | return webkit.NewWebView(cfg) | |
| 121 | - | } | |
| 122 | - | manager.createPaneFn = func(view *webkit.WebView) (*BrowserPane, error) { | |
| 123 | - | if manager.app == nil { | |
| 124 | - | return nil, errors.New("workspace manager missing app reference") | |
| 125 | - | } | |
| 126 | - | return manager.app.createPaneForView(view) | |
| 127 | - | } | |
| 128 | - | manager.ensureStyles() | |
| 129 | - | ||
| 130 | - | rootContainer := rootPane.webView.RootWidget() | |
| 131 | - | root := &paneNode{ | |
| 132 | - | pane: rootPane, | |
| 133 | - | isLeaf: true, | |
| 134 | - | } | |
| 135 | - | // Initialize widgets properly | |
| 136 | - | manager.initializePaneWidgets(root, rootContainer) | |
| 137 | - | ||
| 138 | - | manager.root = root | |
| 139 | - | manager.mainPane = root | |
| 140 | - | manager.viewToNode[rootPane.webView] = root | |
| 141 | - | manager.ensureHover(root) | |
| 142 | - | ||
| 143 | - | if handler := rootPane.MessageHandler(); handler != nil { | |
| 144 | - | handler.SetWorkspaceObserver(manager) | |
| 145 | - | } | |
| 146 | - | ||
| 147 | - | app.workspace = manager | |
| 148 | - | manager.currentlyFocused = root | |
| 149 | - | manager.focusManager.SetActivePane(root) | |
| 150 | - | // Workspace navigation shortcuts are now handled globally by WindowShortcutHandler | |
| 151 | - | // Ensure initial CSS classes are applied | |
| 152 | - | manager.ensurePaneBaseClasses() | |
| 153 | - | return manager | |
| 154 | - | } | |
| 155 | - | ||
| 156 | - | // OnWorkspaceMessage implements messaging.WorkspaceObserver. | |
| 157 | - | func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messaging.Message) { | |
| 158 | - | node := wm.viewToNode[source] | |
| 159 | - | if node == nil { | |
| 160 | - | log.Printf("[workspace] message from unknown webview: event=%s", msg.Event) | |
| 161 | - | return | |
| 162 | - | } | |
| 163 | - | ||
| 164 | - | switch event := strings.ToLower(msg.Event); event { | |
| 165 | - | case "pane-mode-entered": | |
| 166 | - | // Use mutex to prevent race conditions when multiple webviews try to enter pane mode | |
| 167 | - | wm.paneMutex.Lock() | |
| 168 | - | defer wm.paneMutex.Unlock() | |
| 169 | - | ||
| 170 | - | // Check if pane mode was already entered recently (within 200ms debounce window) | |
| 171 | - | if time.Since(wm.lastPaneModeEntry) < 200*time.Millisecond { | |
| 172 | - | log.Printf("[workspace] pane-mode-entered rejected: debounce protection (%.0fms ago) source=%p", | |
| 173 | - | time.Since(wm.lastPaneModeEntry).Seconds()*1000, source) | |
| 174 | - | return | |
| 175 | - | } | |
| 176 | - | ||
| 177 | - | // Check if pane mode is already active from a different source | |
| 178 | - | if wm.paneModeActive && wm.paneModeSource != nil && wm.paneModeSource != source { | |
| 179 | - | log.Printf("[workspace] pane-mode-entered rejected: already active from different source (current=%p, new=%p)", | |
| 180 | - | wm.paneModeSource, source) | |
| 181 | - | return | |
| 182 | - | } | |
| 183 | - | ||
| 184 | - | log.Printf("[workspace] pane-mode-entered accepted: source=%p", source) | |
| 185 | - | wm.paneModeActive = true | |
| 186 | - | wm.paneModeSource = source | |
| 187 | - | wm.lastPaneModeEntry = time.Now() | |
| 188 | - | wm.currentlyFocused = node | |
| 189 | - | wm.focusManager.SetActivePane(node) | |
| 190 | - | case "pane-confirmed", "pane-cancelled", "pane-mode-exited": | |
| 191 | - | // Debounce pane-mode-exited events to prevent duplicate focus calls | |
| 192 | - | if event == "pane-mode-exited" { | |
| 193 | - | if last, ok := wm.lastExitMsg[source]; ok { | |
| 194 | - | if time.Since(last) < 100*time.Millisecond { | |
| 195 | - | log.Printf("[workspace] pane-mode-exited ignored: debounce (%.0fms)", time.Since(last).Seconds()*1000) | |
| 196 | - | return | |
| 197 | - | } | |
| 198 | - | } | |
| 199 | - | wm.lastExitMsg[source] = time.Now() | |
| 200 | - | } | |
| 201 | - | ||
| 202 | - | wm.paneMutex.Lock() | |
| 203 | - | wm.paneModeActive = false | |
| 204 | - | wm.paneModeSource = nil | |
| 205 | - | wm.paneMutex.Unlock() | |
| 206 | - | ||
| 207 | - | wm.focusAfterPaneMode(node) | |
| 208 | - | case "pane-close": | |
| 209 | - | if !wm.paneModeActive { | |
| 210 | - | log.Printf("[workspace] close requested outside pane mode; ignoring") | |
| 211 | - | break | |
| 212 | - | } | |
| 213 | - | ||
| 214 | - | wm.paneMutex.Lock() | |
| 215 | - | wm.paneModeActive = false | |
| 216 | - | wm.paneModeSource = nil | |
| 217 | - | wm.paneMutex.Unlock() | |
| 218 | - | ||
| 219 | - | // Don't focus the node that's about to be closed - closeCurrentPane() will handle focus | |
| 220 | - | wm.closeCurrentPane() | |
| 221 | - | case "pane-split": | |
| 222 | - | wm.currentlyFocused = node | |
| 223 | - | wm.focusManager.SetActivePane(node) | |
| 224 | - | direction := strings.ToLower(msg.Direction) | |
| 225 | - | if direction == "" { | |
| 226 | - | direction = "right" | |
| 227 | - | } | |
| 228 | - | if last, ok := wm.lastSplitMsg[source]; ok { | |
| 229 | - | if time.Since(last) < 200*time.Millisecond { | |
| 230 | - | log.Printf("[workspace] split ignored: debounce (%.0fms)", time.Since(last).Seconds()*1000) | |
| 231 | - | return | |
| 232 | - | } | |
| 233 | - | } | |
| 234 | - | if wm.splitting { | |
| 235 | - | log.Printf("[workspace] split ignored: operation already in progress") | |
| 236 | - | return | |
| 237 | - | } | |
| 238 | - | wm.splitting = true | |
| 239 | - | wm.lastSplitMsg[source] = time.Now() | |
| 240 | - | newNode, err := wm.splitNode(node, direction) | |
| 241 | - | if err != nil { | |
| 242 | - | log.Printf("[workspace] split failed: %v", err) | |
| 243 | - | wm.splitting = false | |
| 244 | - | return | |
| 245 | - | } | |
| 246 | - | wm.clonePaneState(node, newNode) | |
| 247 | - | wm.splitting = false | |
| 248 | - | case "pane-stack": | |
| 249 | - | wm.currentlyFocused = node | |
| 250 | - | wm.focusManager.SetActivePane(node) | |
| 251 | - | if last, ok := wm.lastSplitMsg[source]; ok { | |
| 252 | - | if time.Since(last) < 200*time.Millisecond { | |
| 253 | - | log.Printf("[workspace] stack ignored: debounce (%.0fms)", time.Since(last).Seconds()*1000) | |
| 254 | - | return | |
| 255 | - | } | |
| 256 | - | } | |
| 257 | - | if wm.splitting { | |
| 258 | - | log.Printf("[workspace] stack ignored: operation already in progress") | |
| 259 | - | return | |
| 260 | - | } | |
| 261 | - | wm.splitting = true | |
| 262 | - | wm.lastSplitMsg[source] = time.Now() | |
| 263 | - | newNode, err := wm.stackedPaneManager.StackPane(node) | |
| 264 | - | if err != nil { | |
| 265 | - | log.Printf("[workspace] stack failed: %v", err) | |
| 266 | - | wm.splitting = false | |
| 267 | - | return | |
| 268 | - | } | |
| 269 | - | wm.clonePaneState(node, newNode) | |
| 270 | - | wm.splitting = false | |
| 271 | - | case "create-pane": | |
| 272 | - | log.Printf("[workspace] create-pane requested: url=%s action=%s requestId=%s", msg.URL, msg.Action, msg.RequestID) | |
| 273 | - | ||
| 274 | - | if msg.URL == "" { | |
| 275 | - | log.Printf("[workspace] create-pane: empty URL, ignoring") | |
| 276 | - | break | |
| 277 | - | } | |
| 278 | - | ||
| 279 | - | // NEW: Get WebView ID for deduplication | |
| 280 | - | webViewID := "unknown" | |
| 281 | - | if source != nil { | |
| 282 | - | // Try to get a unique identifier for the WebView | |
| 283 | - | webViewID = fmt.Sprintf("%p", source) | |
| 284 | - | } | |
| 285 | - | ||
| 286 | - | // NEW: Create intent for deduplication check | |
| 287 | - | intent := &messaging.WindowIntent{ | |
| 288 | - | URL: msg.URL, | |
| 289 | - | WindowType: msg.Action, | |
| 290 | - | Timestamp: time.Now().UnixMilli(), | |
| 291 | - | RequestID: msg.RequestID, | |
| 292 | - | UserTriggered: true, | |
| 293 | - | } | |
| 294 | - | ||
| 295 | - | // NEW: Check for duplicates | |
| 296 | - | if isDup, reason := wm.paneDeduplicator.IsDuplicate(intent, webViewID); isDup { | |
| 297 | - | log.Printf("[workspace] create-pane BLOCKED: %s", reason) | |
| 298 | - | break | |
| 299 | - | } | |
| 300 | - | ||
| 301 | - | // Use the existing methods to handle tab vs popup creation | |
| 302 | - | switch strings.ToLower(msg.Action) { | |
| 303 | - | case "tab": | |
| 304 | - | newView := wm.handleIntentAsTab(node, msg.URL, intent) | |
| 305 | - | if newView != nil { | |
| 306 | - | log.Printf("[workspace] create-pane: tab created successfully") | |
| 307 | - | } else { | |
| 308 | - | log.Printf("[workspace] create-pane: failed to create tab") | |
| 309 | - | } | |
| 310 | - | case "popup": | |
| 311 | - | newView := wm.handleIntentAsPopup(node, msg.URL, intent) | |
| 312 | - | if newView != nil { | |
| 313 | - | log.Printf("[workspace] create-pane: popup created successfully") | |
| 314 | - | } else { | |
| 315 | - | log.Printf("[workspace] create-pane: failed to create popup") | |
| 316 | - | } | |
| 317 | - | default: | |
| 318 | - | log.Printf("[workspace] create-pane: unknown action '%s', defaulting to tab", msg.Action) | |
| 319 | - | newView := wm.handleIntentAsTab(node, msg.URL, intent) | |
| 320 | - | if newView != nil { | |
| 321 | - | log.Printf("[workspace] create-pane: default tab created successfully") | |
| 322 | - | } else { | |
| 323 | - | log.Printf("[workspace] create-pane: failed to create default tab") | |
| 324 | - | } | |
| 325 | - | } | |
| 326 | - | case "close-popup": | |
| 327 | - | log.Printf("[workspace] close-popup requested: webviewId=%s reason=%s", msg.WebViewID, msg.Reason) | |
| 328 | - | ||
| 329 | - | if msg.WebViewID == "" { | |
| 330 | - | log.Printf("[workspace] close-popup: empty webviewId, ignoring") | |
| 331 | - | break | |
| 332 | - | } | |
| 333 | - | ||
| 334 | - | // Find the popup pane by webview ID | |
| 335 | - | var targetNode *paneNode | |
| 336 | - | for webView, node := range wm.viewToNode { | |
| 337 | - | if webView != nil && webView.ID() == msg.WebViewID { | |
| 338 | - | targetNode = node | |
| 339 | - | break | |
| 340 | - | } | |
| 341 | - | } | |
| 342 | - | ||
| 343 | - | if targetNode == nil { | |
| 344 | - | log.Printf("[workspace] close-popup: webview not found: %s", msg.WebViewID) | |
| 345 | - | break | |
| 346 | - | } | |
| 347 | - | ||
| 348 | - | if !targetNode.isPopup { | |
| 349 | - | log.Printf("[workspace] close-popup: target is not a popup: %s", msg.WebViewID) | |
| 350 | - | break | |
| 351 | - | } | |
| 352 | - | ||
| 353 | - | log.Printf("[workspace] Closing popup pane due to %s", msg.Reason) | |
| 354 | - | ||
| 355 | - | // Close the popup pane | |
| 356 | - | if err := wm.closePane(targetNode); err != nil { | |
| 357 | - | log.Printf("[workspace] Failed to close popup pane: %v", err) | |
| 358 | - | } else { | |
| 359 | - | log.Printf("[workspace] Successfully closed popup pane: %s", msg.WebViewID) | |
| 360 | - | } | |
| 361 | - | default: | |
| 362 | - | log.Printf("[workspace] unhandled workspace event: %s", msg.Event) | |
| 363 | - | } | |
| 364 | - | } | |
| 365 | - | ||
| 366 | - | // GetActiveNode returns the currently active pane node | |
| 367 | - | func (wm *WorkspaceManager) GetActiveNode() *paneNode { | |
| 368 | - | return wm.currentlyFocused | |
| 369 | - | } | |
| 370 | - | ||
| 371 | - | // GetNodeForWebView returns the pane node associated with a WebView | |
| 372 | - | func (wm *WorkspaceManager) GetNodeForWebView(webView *webkit.WebView) *paneNode { | |
| 373 | - | return wm.viewToNode[webView] | |
| 374 | - | } | |
| 375 | - | ||
| 376 | - | // RegisterNavigationHandler sets up navigation handling for a webview (simplified) | |
| 377 | - | func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) { | |
| 378 | - | if webView == nil { | |
| 379 | - | return | |
| 380 | - | } | |
| 381 | - | ||
| 382 | - | log.Printf("[workspace] Registered navigation handler for webview: %s", webView.ID()) | |
| 383 | - | } | |
| 384 | - | ||
| 385 | - | // DispatchPaneFocusEvent sends a workspace focus event to a pane's webview | |
| 386 | - | func (wm *WorkspaceManager) DispatchPaneFocusEvent(node *paneNode, active bool) { | |
| 387 | - | if node == nil || node.pane == nil || node.pane.webView == nil { | |
| 388 | - | return | |
| 389 | - | } | |
| 390 | - | ||
| 391 | - | detail := map[string]any{ | |
| 392 | - | "active": active, | |
| 393 | - | "webview": fmt.Sprintf("%p", node.pane.webView), | |
| 394 | - | "webviewId": node.pane.webView.ID(), | |
| 395 | - | "hasGUI": node.pane.HasGUI(), | |
| 396 | - | "timestamp": time.Now().UnixMilli(), | |
| 397 | - | } | |
| 398 | - | ||
| 399 | - | if err := node.pane.webView.DispatchCustomEvent("dumber:workspace-focus", detail); err != nil { | |
| 400 | - | log.Printf("[workspace] failed to dispatch focus event: %v", err) | |
| 401 | - | } else if wm.app.config != nil && wm.app.config.Debug.EnableWorkspaceDebug { | |
| 402 | - | log.Printf("[workspace] dispatched focus event for webview %s (active=%v)", node.pane.webView.ID(), active) | |
| 403 | - | } | |
| 404 | - | } | |
| 405 | - | ||
| 406 | - | // DEPRECATED: Moved to FocusManager | |
| 407 | - | ||
| 408 | - | // focusAfterPaneMode restores focus when a pane mode operation completes without | |
| 409 | - | // stealing focus from the active pane inside a stack that just spawned a sibling. | |
| 410 | - | func (wm *WorkspaceManager) focusAfterPaneMode(node *paneNode) { | |
| 411 | - | wm.focusRespectingStack(node, "focus-after-pane-mode") | |
| 412 | - | } | |
| 413 | - | ||
| 414 | - | func (wm *WorkspaceManager) focusRespectingStack(node *paneNode, reason string) { | |
| 415 | - | if node == nil { | |
| 416 | - | return | |
| 417 | - | } | |
| 418 | - | ||
| 419 | - | if stack := node.parent; stack != nil && stack.isStacked { | |
| 420 | - | activeIndex := stack.activeStackIndex | |
| 421 | - | if activeIndex >= 0 && activeIndex < len(stack.stackedPanes) { | |
| 422 | - | activePane := stack.stackedPanes[activeIndex] | |
| 423 | - | if activePane != nil && activePane != node { | |
| 424 | - | if reason != "" { | |
| 425 | - | log.Printf("[workspace] %s: preserving active stacked pane index=%d", reason, activeIndex) | |
| 426 | - | } | |
| 427 | - | wm.currentlyFocused = activePane | |
| 428 | - | wm.focusManager.SetActivePane(activePane) | |
| 429 | - | return | |
| 430 | - | } | |
| 431 | - | } | |
| 432 | - | } | |
| 433 | - | ||
| 434 | - | wm.currentlyFocused = node | |
| 435 | - | wm.focusManager.SetActivePane(node) | |
| 436 | - | } | |
| 437 | - | ||
| 438 | - | // Helper functions for stacked pane CSS colors | |
| 439 | - | func getStackTitleBg(isDark bool) string { | |
| 440 | - | if isDark { | |
| 441 | - | return "#404040" | |
| 442 | - | } | |
| 443 | - | return "#f0f0f0" | |
| 444 | - | } | |
| 445 | - | ||
| 446 | - | func getStackTitleHoverBg(isDark bool) string { | |
| 447 | - | if isDark { | |
| 448 | - | return "#505050" | |
| 449 | - | } | |
| 450 | - | return "#e0e0e0" | |
| 451 | - | } | |
| 452 | - | ||
| 453 | - | func getStackTitleTextColor(isDark bool) string { | |
| 454 | - | if isDark { | |
| 455 | - | return "#ffffff" | |
| 456 | - | } | |
| 457 | - | return "#333333" | |
| 458 | - | } | |
| 459 | - | ||
| 460 | - | // generateActivePaneCSS generates the CSS for workspace panes based on config | |
| 461 | - | func (wm *WorkspaceManager) generateActivePaneCSS() string { | |
| 462 | - | cfg := config.Get() | |
| 463 | - | styling := cfg.Workspace.Styling | |
| 464 | - | ||
| 465 | - | // Get appropriate border colors based on GTK theme preference | |
| 466 | - | var inactiveBorderColor, windowBackgroundColor string | |
| 467 | - | isDark := webkit.PrefersDarkTheme() | |
| 468 | - | if isDark { | |
| 469 | - | inactiveBorderColor = "#333333" // Dark border for dark theme | |
| 470 | - | windowBackgroundColor = "#2b2b2b" // Dark window background | |
| 471 | - | } else { | |
| 472 | - | inactiveBorderColor = "#dddddd" // Light border for light theme | |
| 473 | - | windowBackgroundColor = "#ffffff" // Light window background | |
| 474 | - | } | |
| 475 | - | ||
| 476 | - | // Log the border color values for debugging | |
| 477 | - | log.Printf("[workspace] GTK prefers dark: %v, inactive border color: %s", isDark, inactiveBorderColor) | |
| 478 | - | ||
| 479 | - | css := fmt.Sprintf(`window { | |
| 480 | - | background-color: %s; | |
| 481 | - | } | |
| 482 | - | ||
| 483 | - | .workspace-pane { | |
| 484 | - | background-color: %s; | |
| 485 | - | border: %dpx solid %s; | |
| 486 | - | transition: border-color %dms ease-in-out; | |
| 487 | - | border-radius: %dpx; | |
| 488 | - | } | |
| 489 | - | ||
| 490 | - | .workspace-pane.workspace-multi-pane { | |
| 491 | - | border: %dpx solid %s; | |
| 492 | - | border-radius: %dpx; | |
| 493 | - | } | |
| 494 | - | ||
| 495 | - | .workspace-pane.workspace-multi-pane.workspace-pane-active { | |
| 496 | - | border-color: %s; | |
| 497 | - | } | |
| 498 | - | ||
| 499 | - | /* Active border for ALL workspace panes (including stacked panes) */ | |
| 500 | - | .workspace-pane.workspace-pane-active { | |
| 501 | - | border-color: %s !important; | |
| 502 | - | } | |
| 503 | - | ||
| 504 | - | /* Stacked panes styling */ | |
| 505 | - | .stacked-pane-container { | |
| 506 | - | background-color: %s; | |
| 507 | - | border-radius: %dpx; | |
| 508 | - | } | |
| 509 | - | ||
| 510 | - | .stacked-pane-title { | |
| 511 | - | background-color: %s; | |
| 512 | - | border-bottom: 1px solid %s; | |
| 513 | - | padding: 4px 8px; | |
| 514 | - | min-height: 24px; | |
| 515 | - | transition: background-color %dms ease-in-out; | |
| 516 | - | } | |
| 517 | - | ||
| 518 | - | .stacked-pane-title:hover { | |
| 519 | - | background-color: %s; | |
| 520 | - | } | |
| 521 | - | ||
| 522 | - | .stacked-pane-title-text { | |
| 523 | - | font-size: 12px; | |
| 524 | - | color: %s; | |
| 525 | - | font-weight: 500; | |
| 526 | - | } | |
| 527 | - | ||
| 528 | - | .stacked-pane-active { | |
| 529 | - | /* Active pane is fully visible */ | |
| 530 | - | } | |
| 531 | - | ||
| 532 | - | .stacked-pane-collapsed { | |
| 533 | - | /* Collapsed panes are hidden - handled in code via widget visibility */ | |
| 534 | - | }`, | |
| 535 | - | windowBackgroundColor, | |
| 536 | - | windowBackgroundColor, | |
| 537 | - | styling.BorderWidth, | |
| 538 | - | inactiveBorderColor, | |
| 539 | - | styling.TransitionDuration, | |
| 540 | - | styling.BorderRadius, | |
| 541 | - | styling.BorderWidth, | |
| 542 | - | inactiveBorderColor, | |
| 543 | - | styling.BorderRadius, | |
| 544 | - | styling.BorderColor, | |
| 545 | - | styling.BorderColor, // NEW: .workspace-pane.workspace-pane-active border-color | |
| 546 | - | // Additional parameters for stacked pane styles | |
| 547 | - | windowBackgroundColor, // stacked-pane-container background | |
| 548 | - | styling.BorderRadius, // stacked-pane-container border-radius | |
| 549 | - | getStackTitleBg(isDark), // stacked-pane-title background | |
| 550 | - | inactiveBorderColor, // stacked-pane-title border-bottom | |
| 551 | - | styling.TransitionDuration, // stacked-pane-title transition | |
| 552 | - | getStackTitleHoverBg(isDark), // stacked-pane-title:hover background | |
| 553 | - | getStackTitleTextColor(isDark), // stacked-pane-title-text color | |
| 554 | - | ) | |
| 555 | - | ||
| 556 | - | // Log the actual CSS being generated | |
| 557 | - | log.Printf("[workspace] Generated CSS: %s", css) | |
| 558 | - | ||
| 559 | - | return css | |
| 560 | - | } | |
| 561 | - | ||
| 562 | - | func (wm *WorkspaceManager) ensureStyles() { | |
| 563 | - | if wm == nil || wm.cssInitialized { | |
| 564 | - | return | |
| 565 | - | } | |
| 566 | - | activePaneCSS := wm.generateActivePaneCSS() | |
| 567 | - | webkit.AddCSSProvider(activePaneCSS) | |
| 568 | - | wm.cssInitialized = true | |
| 569 | - | } | |
| 570 | - | ||
| 571 | - | // hasMultiplePanes returns true if there are multiple panes in the workspace | |
| 572 | - | func (wm *WorkspaceManager) hasMultiplePanes() bool { | |
| 573 | - | return wm != nil && wm.app != nil && len(wm.app.panes) > 1 | |
| 574 | - | } | |
| 575 | - | ||
| 576 | - | // ensurePaneBaseClasses ensures all panes have the proper base CSS classes | |
| 577 | - | func (wm *WorkspaceManager) ensurePaneBaseClasses() { | |
| 578 | - | if wm == nil { | |
| 579 | - | return | |
| 580 | - | } | |
| 581 | - | ||
| 582 | - | leaves := wm.collectLeaves() | |
| 583 | - | for _, leaf := range leaves { | |
| 584 | - | if leaf != nil && leaf.container != nil { | |
| 585 | - | leaf.container.Execute(func(containerPtr uintptr) error { | |
| 586 | - | webkit.WidgetAddCSSClass(containerPtr, basePaneClass) | |
| 587 | - | if wm.hasMultiplePanes() { | |
| 588 | - | webkit.WidgetAddCSSClass(containerPtr, multiPaneClass) | |
| 589 | - | } else { | |
| 590 | - | webkit.WidgetRemoveCSSClass(containerPtr, multiPaneClass) | |
| 591 | - | } | |
| 592 | - | return nil | |
| 593 | - | }) | |
| 594 | - | } | |
| 595 | - | } | |
| 596 | - | } | |
| 597 | - | ||
| 598 | - | func (wm *WorkspaceManager) focusByView(view *webkit.WebView) { | |
| 599 | - | if wm == nil || view == nil { | |
| 600 | - | return | |
| 601 | - | } | |
| 602 | - | ||
| 603 | - | // Throttle focus changes to prevent infinite loops | |
| 604 | - | wm.focusThrottleMutex.Lock() | |
| 605 | - | const focusThrottleInterval = 100 * time.Millisecond | |
| 606 | - | if time.Since(wm.lastFocusChange) < focusThrottleInterval { | |
| 607 | - | wm.focusThrottleMutex.Unlock() | |
| 608 | - | return | |
| 609 | - | } | |
| 610 | - | wm.lastFocusChange = time.Now() | |
| 611 | - | wm.focusThrottleMutex.Unlock() | |
| 612 | - | ||
| 613 | - | if node, ok := wm.viewToNode[view]; ok { | |
| 614 | - | if wm.currentlyFocused != node { | |
| 615 | - | wm.focusRespectingStack(node, "focus-by-view") | |
| 616 | - | } | |
| 617 | - | } | |
| 618 | - | } | |
| 619 | - | ||
| 620 | - | func (wm *WorkspaceManager) ensureHover(node *paneNode) { | |
| 621 | - | if wm == nil || node == nil || !node.isLeaf { | |
| 622 | - | return | |
| 623 | - | } | |
| 624 | - | if node.container == nil || node.hoverToken != 0 { | |
| 625 | - | return | |
| 626 | - | } | |
| 627 | - | ||
| 628 | - | var token uintptr | |
| 629 | - | node.container.Execute(func(containerPtr uintptr) error { | |
| 630 | - | token = webkit.WidgetAddHoverHandler(containerPtr, func() { | |
| 631 | - | if wm == nil { | |
| 632 | - | return | |
| 633 | - | } | |
| 634 | - | wm.focusManager.SetActivePane(node) | |
| 635 | - | wm.currentlyFocused = node | |
| 636 | - | }) | |
| 637 | - | return nil | |
| 638 | - | }) | |
| 639 | - | node.hoverToken = token | |
| 640 | - | if token == 0 { | |
| 641 | - | log.Printf("[workspace] failed to attach hover handler") | |
| 642 | - | } | |
| 643 | - | } | |
| 644 | - | ||
| 645 | - | func (wm *WorkspaceManager) detachHover(node *paneNode) { | |
| 646 | - | if wm == nil || node == nil || node.hoverToken == 0 { | |
| 647 | - | return | |
| 648 | - | } | |
| 649 | - | node.container.Execute(func(containerPtr uintptr) error { | |
| 650 | - | webkit.WidgetRemoveHoverHandler(containerPtr, node.hoverToken) | |
| 651 | - | return nil | |
| 652 | - | }) | |
| 653 | - | node.hoverToken = 0 | |
| 654 | - | } | |
| 655 | - | ||
| 656 | - | // FocusNeighbor moves focus to the nearest pane in the requested direction using the | |
| 657 | - | // actual widget geometry to determine adjacency. For stacked panes, "up" and "down" | |
| 658 | - | // navigate within the stack. | |
| 659 | - | func (wm *WorkspaceManager) FocusNeighbor(direction string) bool { | |
| 660 | - | if wm == nil { | |
| 661 | - | return false | |
| 662 | - | } | |
| 663 | - | switch strings.ToLower(direction) { | |
| 664 | - | case "up", "down": | |
| 665 | - | // Check if current pane is part of a stack and handle stack navigation | |
| 666 | - | if wm.stackedPaneManager.NavigateStack(strings.ToLower(direction)) { | |
| 667 | - | return true | |
| 668 | - | } | |
| 669 | - | // Fall back to regular adjacency navigation | |
| 670 | - | return wm.focusAdjacent(strings.ToLower(direction)) | |
| 671 | - | case "left", "right": | |
| 672 | - | return wm.focusAdjacent(strings.ToLower(direction)) | |
| 673 | - | default: | |
| 674 | - | return false | |
| 675 | - | } | |
| 676 | - | } | |
| 677 | - | ||
| 678 | - | // navigateStack handles navigation within a stacked pane container. | |
| 679 | - | func (wm *WorkspaceManager) navigateStack(direction string) bool { | |
| 680 | - | if wm.currentlyFocused == nil { | |
| 681 | - | return false | |
| 682 | - | } | |
| 683 | - | ||
| 684 | - | // Find the stack container this pane belongs to | |
| 685 | - | var stackNode *paneNode | |
| 686 | - | current := wm.currentlyFocused | |
| 687 | - | ||
| 688 | - | // Check if current pane is directly in a stack | |
| 689 | - | if current.parent != nil && current.parent.isStacked { | |
| 690 | - | stackNode = current.parent | |
| 691 | - | } else { | |
| 692 | - | // Current pane might be the stack container itself if it was the first pane converted to stack | |
| 693 | - | if current.isStacked { | |
| 694 | - | stackNode = current | |
| 695 | - | } | |
| 696 | - | } | |
| 697 | - | ||
| 698 | - | if stackNode == nil || !stackNode.isStacked || len(stackNode.stackedPanes) <= 1 { | |
| 699 | - | return false // Not in a stack or stack has only one pane | |
| 700 | - | } | |
| 701 | - | ||
| 702 | - | // Find current pane's index in the stack | |
| 703 | - | currentIndex := -1 | |
| 704 | - | for i, pane := range stackNode.stackedPanes { | |
| 705 | - | if pane == current || (pane.pane != nil && current.pane != nil && pane.pane.webView == current.pane.webView) { | |
| 706 | - | currentIndex = i | |
| 707 | - | break | |
| 708 | - | } | |
| 709 | - | } | |
| 710 | - | ||
| 711 | - | if currentIndex == -1 { | |
| 712 | - | log.Printf("[workspace] navigateStack: current pane not found in stack") | |
| 713 | - | return false | |
| 714 | - | } | |
| 715 | - | ||
| 716 | - | // Calculate new index based on direction | |
| 717 | - | var newIndex int | |
| 718 | - | switch direction { | |
| 719 | - | case "up": | |
| 720 | - | newIndex = currentIndex - 1 | |
| 721 | - | if newIndex < 0 { | |
| 722 | - | newIndex = len(stackNode.stackedPanes) - 1 // Wrap to last | |
| 723 | - | } | |
| 724 | - | case "down": | |
| 725 | - | newIndex = currentIndex + 1 | |
| 726 | - | if newIndex >= len(stackNode.stackedPanes) { | |
| 727 | - | newIndex = 0 // Wrap to first | |
| 728 | - | } | |
| 729 | - | default: | |
| 730 | - | return false | |
| 731 | - | } | |
| 732 | - | ||
| 733 | - | if newIndex == currentIndex { | |
| 734 | - | return false // No change | |
| 735 | - | } | |
| 736 | - | ||
| 737 | - | // Update active stack index and visibility | |
| 738 | - | stackNode.activeStackIndex = newIndex | |
| 739 | - | wm.stackedPaneManager.UpdateStackVisibility(stackNode) | |
| 740 | - | ||
| 741 | - | // Focus the new active pane | |
| 742 | - | newActivePane := stackNode.stackedPanes[newIndex] | |
| 743 | - | wm.focusManager.SetActivePane(newActivePane) | |
| 744 | - | wm.currentlyFocused = newActivePane | |
| 745 | - | ||
| 746 | - | log.Printf("[workspace] navigated stack: direction=%s from=%d to=%d stackSize=%d", | |
| 747 | - | direction, currentIndex, newIndex, len(stackNode.stackedPanes)) | |
| 748 | - | return true | |
| 749 | - | } | |
| 750 | - | ||
| 751 | - | const focusEpsilon = 1e-3 | |
| 752 | - | ||
| 753 | - | func (wm *WorkspaceManager) focusAdjacent(direction string) bool { | |
| 754 | - | if wm.currentlyFocused == nil || !wm.currentlyFocused.isLeaf || wm.currentlyFocused.container == nil { | |
| 755 | - | return false | |
| 756 | - | } | |
| 757 | - | ||
| 758 | - | if neighbor := wm.structuralNeighbor(wm.currentlyFocused, direction); neighbor != nil { | |
| 759 | - | wm.focusManager.SetActivePane(neighbor) | |
| 760 | - | wm.currentlyFocused = neighbor | |
| 761 | - | return true | |
| 762 | - | } | |
| 763 | - | ||
| 764 | - | var currentBounds webkit.WidgetBounds | |
| 765 | - | var ok bool | |
| 766 | - | wm.currentlyFocused.container.Execute(func(containerPtr uintptr) error { | |
| 767 | - | currentBounds, ok = webkit.WidgetGetBounds(containerPtr) | |
| 768 | - | return nil | |
| 769 | - | }) | |
| 770 | - | if !ok { | |
| 771 | - | log.Printf("[workspace] unable to compute bounds for active pane") | |
| 772 | - | return false | |
| 773 | - | } | |
| 774 | - | ||
| 775 | - | cx := currentBounds.X + currentBounds.Width/2.0 | |
| 776 | - | cy := currentBounds.Y + currentBounds.Height/2.0 | |
| 777 | - | ||
| 778 | - | leaves := wm.collectLeaves() | |
| 779 | - | bestScore := math.MaxFloat64 | |
| 780 | - | var best *paneNode | |
| 781 | - | var debugCandidates []string | |
| 782 | - | ||
| 783 | - | for _, candidate := range leaves { | |
| 784 | - | if candidate == nil || candidate == wm.currentlyFocused || candidate.container == nil { | |
| 785 | - | continue | |
| 786 | - | } | |
| 787 | - | var bounds webkit.WidgetBounds | |
| 788 | - | var boundsOk bool | |
| 789 | - | candidate.container.Execute(func(containerPtr uintptr) error { | |
| 790 | - | bounds, boundsOk = webkit.WidgetGetBounds(containerPtr) | |
| 791 | - | return nil | |
| 792 | - | }) | |
| 793 | - | ok := boundsOk | |
| 794 | - | if !ok { | |
| 795 | - | continue | |
| 796 | - | } | |
| 797 | - | tx := bounds.X + bounds.Width/2.0 | |
| 798 | - | ty := bounds.Y + bounds.Height/2.0 | |
| 799 | - | ||
| 800 | - | dx := tx - cx | |
| 801 | - | dy := ty - cy | |
| 802 | - | ||
| 803 | - | var score float64 | |
| 804 | - | switch direction { | |
| 805 | - | case "left": | |
| 806 | - | if dx >= -focusEpsilon { | |
| 807 | - | continue | |
| 808 | - | } | |
| 809 | - | score = math.Abs(dx)*1000 + math.Abs(dy) | |
| 810 | - | case "right": | |
| 811 | - | if dx <= focusEpsilon { | |
| 812 | - | continue | |
| 813 | - | } | |
| 814 | - | score = math.Abs(dx)*1000 + math.Abs(dy) | |
| 815 | - | case "up": | |
| 816 | - | if dy >= -focusEpsilon { | |
| 817 | - | continue | |
| 818 | - | } | |
| 819 | - | score = math.Abs(dy)*1000 + math.Abs(dx) | |
| 820 | - | case "down": | |
| 821 | - | if dy <= focusEpsilon { | |
| 822 | - | continue | |
| 823 | - | } | |
| 824 | - | score = math.Abs(dy)*1000 + math.Abs(dx) | |
| 825 | - | } | |
| 826 | - | ||
| 827 | - | if direction == "up" || direction == "down" { | |
| 828 | - | debugCandidates = append(debugCandidates, fmt.Sprintf("cand=%#x dx=%.2f dy=%.2f score=%.2f", candidate.container, dx, dy, score)) | |
| 829 | - | } | |
| 830 | - | ||
| 831 | - | if score < bestScore { | |
| 832 | - | bestScore = score | |
| 833 | - | best = candidate | |
| 834 | - | } | |
| 835 | - | } | |
| 836 | - | ||
| 837 | - | if best != nil { | |
| 838 | - | wm.focusManager.SetActivePane(best) | |
| 839 | - | wm.currentlyFocused = best | |
| 840 | - | return true | |
| 841 | - | } | |
| 842 | - | ||
| 843 | - | if len(debugCandidates) > 0 { | |
| 844 | - | log.Printf("[workspace] focusAdjacent no candidate direction=%s current=%#x candidates=%s", direction, wm.currentlyFocused.container, strings.Join(debugCandidates, "; ")) | |
| 845 | - | } | |
| 846 | - | return false | |
| 847 | - | } | |
| 848 | - | ||
| 849 | - | func (wm *WorkspaceManager) structuralNeighbor(node *paneNode, direction string) *paneNode { | |
| 850 | - | if node == nil || node.container == nil { | |
| 851 | - | return nil | |
| 852 | - | } | |
| 853 | - | ||
| 854 | - | var refBounds webkit.WidgetBounds | |
| 855 | - | var ok bool | |
| 856 | - | node.container.Execute(func(containerPtr uintptr) error { | |
| 857 | - | refBounds, ok = webkit.WidgetGetBounds(containerPtr) | |
| 858 | - | return nil | |
| 859 | - | }) | |
| 860 | - | if !ok { | |
| 861 | - | return nil | |
| 862 | - | } | |
| 863 | - | cx := refBounds.X + refBounds.Width/2.0 | |
| 864 | - | cy := refBounds.Y + refBounds.Height/2.0 | |
| 865 | - | axisVertical := direction == "up" || direction == "down" | |
| 866 | - | ||
| 867 | - | for parent := node.parent; parent != nil; parent = parent.parent { | |
| 868 | - | switch direction { | |
| 869 | - | case "up": | |
| 870 | - | if axisVertical && parent.orientation == webkit.OrientationVertical && parent.right == node { | |
| 871 | - | if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil { | |
| 872 | - | return leaf | |
| 873 | - | } | |
| 874 | - | } | |
| 875 | - | case "down": | |
| 876 | - | if axisVertical && parent.orientation == webkit.OrientationVertical && parent.left == node { | |
| 877 | - | if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil { | |
| 878 | - | return leaf | |
| 879 | - | } | |
| 880 | - | } | |
| 881 | - | case "left": | |
| 882 | - | if !axisVertical && parent.orientation == webkit.OrientationHorizontal && parent.right == node { | |
| 883 | - | if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil { | |
| 884 | - | return leaf | |
| 885 | - | } | |
| 886 | - | } | |
| 887 | - | case "right": | |
| 888 | - | if !axisVertical && parent.orientation == webkit.OrientationHorizontal && parent.left == node { | |
| 889 | - | if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil { | |
| 890 | - | return leaf | |
| 891 | - | } | |
| 892 | - | } | |
| 893 | - | } | |
| 894 | - | node = parent | |
| 895 | - | } | |
| 896 | - | return nil | |
| 897 | - | } | |
| 898 | - | ||
| 899 | - | func (wm *WorkspaceManager) closestLeafFromSubtree(node *paneNode, cx, cy float64, direction string) *paneNode { | |
| 900 | - | leaves := wm.collectLeavesFrom(node) | |
| 901 | - | bestScore := math.MaxFloat64 | |
| 902 | - | var best *paneNode | |
| 903 | - | for _, leaf := range leaves { | |
| 904 | - | if leaf == nil || leaf.container == nil { | |
| 905 | - | continue | |
| 906 | - | } | |
| 907 | - | var bounds webkit.WidgetBounds | |
| 908 | - | var boundsOk bool | |
| 909 | - | leaf.container.Execute(func(containerPtr uintptr) error { | |
| 910 | - | bounds, boundsOk = webkit.WidgetGetBounds(containerPtr) | |
| 911 | - | return nil | |
| 912 | - | }) | |
| 913 | - | ok := boundsOk | |
| 914 | - | if !ok { | |
| 915 | - | continue | |
| 916 | - | } | |
| 917 | - | tx := bounds.X + bounds.Width/2.0 | |
| 918 | - | ty := bounds.Y + bounds.Height/2.0 | |
| 919 | - | dx := tx - cx | |
| 920 | - | dy := ty - cy | |
| 921 | - | var score float64 | |
| 922 | - | switch direction { | |
| 923 | - | case "left": | |
| 924 | - | if dx >= -focusEpsilon { | |
| 925 | - | continue | |
| 926 | - | } | |
| 927 | - | score = math.Abs(dx)*1000 + math.Abs(dy) | |
| 928 | - | case "right": | |
| 929 | - | if dx <= focusEpsilon { | |
| 930 | - | continue | |
| 931 | - | } | |
| 932 | - | score = math.Abs(dx)*1000 + math.Abs(dy) | |
| 933 | - | case "up": | |
| 934 | - | if dy >= -focusEpsilon { | |
| 935 | - | continue | |
| 936 | - | } | |
| 937 | - | score = math.Abs(dy)*1000 + math.Abs(dx) | |
| 938 | - | case "down": | |
| 939 | - | if dy <= focusEpsilon { | |
| 940 | - | continue | |
| 941 | - | } | |
| 942 | - | score = math.Abs(dy)*1000 + math.Abs(dx) | |
| 943 | - | default: | |
| 944 | - | continue | |
| 945 | - | } | |
| 946 | - | if score < bestScore { | |
| 947 | - | bestScore = score | |
| 948 | - | best = leaf | |
| 949 | - | } | |
| 950 | - | } | |
| 951 | - | if best == nil { | |
| 952 | - | return wm.boundaryFallback(node, direction) | |
| 953 | - | } | |
| 954 | - | return best | |
| 955 | - | } | |
| 956 | - | ||
| 957 | - | func (wm *WorkspaceManager) boundaryFallback(node *paneNode, direction string) *paneNode { | |
| 958 | - | return wm.boundaryFallbackWithDepth(node, direction, 0) | |
| 959 | - | } | |
| 960 | - | ||
| 961 | - | func (wm *WorkspaceManager) boundaryFallbackWithDepth(node *paneNode, direction string, depth int) *paneNode { | |
| 962 | - | // Prevent infinite recursion - max tree depth should be reasonable | |
| 963 | - | const maxDepth = 50 | |
| 964 | - | if depth > maxDepth { | |
| 965 | - | log.Printf("[workspace] boundaryFallback: max depth exceeded, possible tree corruption") | |
| 966 | - | return nil | |
| 967 | - | } | |
| 968 | - | ||
| 969 | - | if node == nil { | |
| 970 | - | return nil | |
| 971 | - | } | |
| 972 | - | if node.isLeaf { | |
| 973 | - | return node | |
| 974 | - | } | |
| 975 | - | switch direction { | |
| 976 | - | case "up": | |
| 977 | - | if leaf := wm.boundaryFallbackWithDepth(node.right, direction, depth+1); leaf != nil { | |
| 978 | - | return leaf | |
| 979 | - | } | |
| 980 | - | return wm.boundaryFallbackWithDepth(node.left, direction, depth+1) | |
| 981 | - | case "down": | |
| 982 | - | if leaf := wm.boundaryFallbackWithDepth(node.left, direction, depth+1); leaf != nil { | |
| 983 | - | return leaf | |
| 984 | - | } | |
| 985 | - | return wm.boundaryFallbackWithDepth(node.right, direction, depth+1) | |
| 986 | - | case "left": | |
| 987 | - | if leaf := wm.boundaryFallbackWithDepth(node.right, direction, depth+1); leaf != nil { | |
| 988 | - | return leaf | |
| 989 | - | } | |
| 990 | - | return wm.boundaryFallbackWithDepth(node.left, direction, depth+1) | |
| 991 | - | case "right": | |
| 992 | - | if leaf := wm.boundaryFallbackWithDepth(node.left, direction, depth+1); leaf != nil { | |
| 993 | - | return leaf | |
| 994 | - | } | |
| 995 | - | return wm.boundaryFallbackWithDepth(node.right, direction, depth+1) | |
| 996 | - | default: | |
| 997 | - | return nil | |
| 998 | - | } | |
| 999 | - | } | |
| 1000 | - | ||
| 1001 | - | func (wm *WorkspaceManager) collectLeaves() []*paneNode { | |
| 1002 | - | return wm.collectLeavesFrom(wm.root) | |
| 1003 | - | } | |
| 1004 | - | ||
| 1005 | - | func (wm *WorkspaceManager) collectLeavesFrom(node *paneNode) []*paneNode { | |
| 1006 | - | var leaves []*paneNode | |
| 1007 | - | visited := make(map[*paneNode]bool) | |
| 1008 | - | ||
| 1009 | - | var walk func(*paneNode, int) | |
| 1010 | - | walk = func(n *paneNode, depth int) { | |
| 1011 | - | // Prevent infinite recursion and cycles | |
| 1012 | - | const maxDepth = 50 | |
| 1013 | - | if n == nil || depth > maxDepth { | |
| 1014 | - | return | |
| 1015 | - | } | |
| 1016 | - | if visited[n] { | |
| 1017 | - | log.Printf("[workspace] collectLeavesFrom: cycle detected in tree") | |
| 1018 | - | return | |
| 1019 | - | } | |
| 1020 | - | visited[n] = true | |
| 1021 | - | ||
| 1022 | - | if n.isLeaf { | |
| 1023 | - | leaves = append(leaves, n) | |
| 1024 | - | return | |
| 1025 | - | } | |
| 1026 | - | ||
| 1027 | - | // Handle stacked panes - only include the currently ACTIVE pane as a leaf | |
| 1028 | - | // This prevents multiple CSS classes and ensures correct focus management | |
| 1029 | - | if n.isStacked && len(n.stackedPanes) > 0 { | |
| 1030 | - | activeIndex := n.activeStackIndex | |
| 1031 | - | if activeIndex >= 0 && activeIndex < len(n.stackedPanes) { | |
| 1032 | - | // Only the active pane in the stack counts as a leaf for focus/navigation | |
| 1033 | - | activePaneInStack := n.stackedPanes[activeIndex] | |
| 1034 | - | walk(activePaneInStack, depth+1) | |
| 1035 | - | } | |
| 1036 | - | return | |
| 1037 | - | } | |
| 1038 | - | ||
| 1039 | - | // Handle regular split nodes | |
| 1040 | - | walk(n.left, depth+1) | |
| 1041 | - | walk(n.right, depth+1) | |
| 1042 | - | } | |
| 1043 | - | walk(node, 0) | |
| 1044 | - | return leaves | |
| 1045 | - | } | |
| 1046 | - | ||
| 1047 | - | func (wm *WorkspaceManager) createWebView() (*webkit.WebView, error) { | |
| 1048 | - | if wm == nil || wm.createWebViewFn == nil { | |
| 1049 | - | return nil, errors.New("workspace manager missing webview factory") | |
| 1050 | - | } | |
| 1051 | - | return wm.createWebViewFn() | |
| 1052 | - | } | |
| 1053 | - | ||
| 1054 | - | func (wm *WorkspaceManager) createPane(view *webkit.WebView) (*BrowserPane, error) { | |
| 1055 | - | if wm == nil || wm.createPaneFn == nil { | |
| 1056 | - | return nil, errors.New("workspace manager missing pane factory") | |
| 1057 | - | } | |
| 1058 | - | return wm.createPaneFn(view) | |
| 1059 | - | } | |
| 1060 | - | ||
| 1061 | - | func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*paneNode, error) { | |
| 1062 | - | if target == nil || !target.isLeaf || target.pane == nil { | |
| 1063 | - | return nil, errors.New("split target must be a leaf pane") | |
| 1064 | - | } | |
| 1065 | - | ||
| 1066 | - | // IMPORTANT: If the target is inside a stacked pane, we need to split AROUND the stack, | |
| 1067 | - | // not the individual pane or the stack itself. We create a new split at the stack's parent level. | |
| 1068 | - | originalTarget := target | |
| 1069 | - | var stackContainer *paneNode | |
| 1070 | - | if target.parent != nil && target.parent.isStacked { | |
| 1071 | - | // Target is in a stack - we'll create the split around the entire stack | |
| 1072 | - | stackContainer = target.parent | |
| 1073 | - | log.Printf("[workspace] target is in stack, will split around the stack: originalTarget=%p stackContainer=%p", originalTarget, stackContainer) | |
| 1074 | - | } | |
| 1075 | - | ||
| 1076 | - | // Determine what we're actually splitting | |
| 1077 | - | var splitTarget *paneNode | |
| 1078 | - | var splitTargetContainer *SafeWidget | |
| 1079 | - | ||
| 1080 | - | if stackContainer != nil { | |
| 1081 | - | // Case: splitting from inside a stack - we split around the entire stack | |
| 1082 | - | splitTarget = stackContainer | |
| 1083 | - | splitTargetContainer = stackContainer.container | |
| 1084 | - | log.Printf("[workspace] splitting around stack: stackContainer=%p", splitTarget) | |
| 1085 | - | } else { | |
| 1086 | - | // Case: normal split from a simple pane | |
| 1087 | - | splitTarget = target | |
| 1088 | - | splitTargetContainer = target.container | |
| 1089 | - | log.Printf("[workspace] normal split from pane: target=%p", splitTarget) | |
| 1090 | - | } | |
| 1091 | - | ||
| 1092 | - | newView, err := wm.createWebView() | |
| 1093 | - | if err != nil { | |
| 1094 | - | return nil, err | |
| 1095 | - | } | |
| 1096 | - | ||
| 1097 | - | newPane, err := wm.createPane(newView) | |
| 1098 | - | if err != nil { | |
| 1099 | - | return nil, err | |
| 1100 | - | } | |
| 1101 | - | ||
| 1102 | - | if handler := newPane.MessageHandler(); handler != nil { | |
| 1103 | - | handler.SetWorkspaceObserver(wm) | |
| 1104 | - | } | |
| 1105 | - | ||
| 1106 | - | newContainer := newPane.webView.RootWidget() | |
| 1107 | - | if newContainer == 0 { | |
| 1108 | - | return nil, errors.New("new pane missing container") | |
| 1109 | - | } | |
| 1110 | - | webkit.WidgetSetHExpand(newContainer, true) | |
| 1111 | - | webkit.WidgetSetVExpand(newContainer, true) | |
| 1112 | - | webkit.WidgetRealizeInContainer(newContainer) | |
| 1113 | - | ||
| 1114 | - | // Use the determined split target container | |
| 1115 | - | if splitTargetContainer == nil { | |
| 1116 | - | return nil, errors.New("split target missing container") | |
| 1117 | - | } | |
| 1118 | - | ||
| 1119 | - | // Only apply GTK operations if this is a simple widget (leaf pane) | |
| 1120 | - | // For stacked containers, we need to be more careful | |
| 1121 | - | if splitTarget.isLeaf { | |
| 1122 | - | splitTargetContainer.Execute(func(containerPtr uintptr) error { | |
| 1123 | - | webkit.WidgetSetHExpand(containerPtr, true) | |
| 1124 | - | webkit.WidgetSetVExpand(containerPtr, true) | |
| 1125 | - | webkit.WidgetRealizeInContainer(containerPtr) | |
| 1126 | - | return nil | |
| 1127 | - | }) | |
| 1128 | - | } else if splitTarget.isStacked { | |
| 1129 | - | // Stack containers need the same setup as regular panes for proper splitting | |
| 1130 | - | splitTargetContainer.Execute(func(containerPtr uintptr) error { | |
| 1131 | - | webkit.WidgetSetHExpand(containerPtr, true) | |
| 1132 | - | webkit.WidgetSetVExpand(containerPtr, true) | |
| 1133 | - | webkit.WidgetRealizeInContainer(containerPtr) | |
| 1134 | - | log.Printf("[workspace] configured stack wrapper for split: %#x", containerPtr) | |
| 1135 | - | return nil | |
| 1136 | - | }) | |
| 1137 | - | } | |
| 1138 | - | ||
| 1139 | - | orientation, existingFirst := mapDirection(direction) | |
| 1140 | - | log.Printf("[workspace] splitting direction=%s orientation=%v existingFirst=%v splitTarget.parent=%p splitTarget.isStacked=%v", | |
| 1141 | - | direction, orientation, existingFirst, splitTarget.parent, splitTarget.isStacked) | |
| 1142 | - | ||
| 1143 | - | paned := webkit.NewPaned(orientation) | |
| 1144 | - | if paned == 0 { | |
| 1145 | - | return nil, errors.New("failed to create GtkPaned") | |
| 1146 | - | } | |
| 1147 | - | webkit.WidgetSetHExpand(paned, true) | |
| 1148 | - | webkit.WidgetSetVExpand(paned, true) | |
| 1149 | - | webkit.PanedSetResizeStart(paned, true) | |
| 1150 | - | webkit.PanedSetResizeEnd(paned, true) | |
| 1151 | - | ||
| 1152 | - | newLeaf := &paneNode{ | |
| 1153 | - | pane: newPane, | |
| 1154 | - | isLeaf: true, | |
| 1155 | - | } | |
| 1156 | - | wm.initializePaneWidgets(newLeaf, newContainer) | |
| 1157 | - | ||
| 1158 | - | split := &paneNode{ | |
| 1159 | - | parent: splitTarget.parent, | |
| 1160 | - | orientation: orientation, | |
| 1161 | - | isLeaf: false, | |
| 1162 | - | } | |
| 1163 | - | wm.initializePaneWidgets(split, paned) | |
| 1164 | - | ||
| 1165 | - | parent := split.parent | |
| 1166 | - | ||
| 1167 | - | // Detach split target container from its current GTK parent before inserting into new paned. | |
| 1168 | - | if parent == nil { | |
| 1169 | - | // Split target is the root - remove it from the window | |
| 1170 | - | log.Printf("[workspace] removing split target container=%#x from window", splitTargetContainer) | |
| 1171 | - | if wm.window != nil { | |
| 1172 | - | wm.window.SetChild(0) | |
| 1173 | - | } | |
| 1174 | - | // For root widgets, only unparent if they actually have a GTK parent | |
| 1175 | - | splitTargetContainer.Execute(func(containerPtr uintptr) error { | |
| 1176 | - | if webkit.WidgetGetParent(containerPtr) != 0 { | |
| 1177 | - | webkit.WidgetUnparent(containerPtr) | |
| 1178 | - | } | |
| 1179 | - | return nil | |
| 1180 | - | }) | |
| 1181 | - | } else if parent.container != nil { | |
| 1182 | - | // Split target has a parent paned - remove it (automatically unparents in GTK4) | |
| 1183 | - | log.Printf("[workspace] unparenting split target container=%#x from parent paned=%#x", splitTargetContainer, parent.container) | |
| 1184 | - | parent.container.Execute(func(panedPtr uintptr) error { | |
| 1185 | - | if parent.left == splitTarget { | |
| 1186 | - | webkit.PanedSetStartChild(panedPtr, 0) | |
| 1187 | - | } else if parent.right == splitTarget { | |
| 1188 | - | webkit.PanedSetEndChild(panedPtr, 0) | |
| 1189 | - | } | |
| 1190 | - | // GTK4 automatically unparents when we set paned child to 0 - no manual unparent needed | |
| 1191 | - | webkit.WidgetQueueAllocate(panedPtr) | |
| 1192 | - | return nil | |
| 1193 | - | }) | |
| 1194 | - | } | |
| 1195 | - | ||
| 1196 | - | // Set up the tree structure first | |
| 1197 | - | if existingFirst { | |
| 1198 | - | split.left = splitTarget | |
| 1199 | - | split.right = newLeaf | |
| 1200 | - | } else { | |
| 1201 | - | split.left = newLeaf | |
| 1202 | - | split.right = splitTarget | |
| 1203 | - | } | |
| 1204 | - | ||
| 1205 | - | splitTarget.parent = split | |
| 1206 | - | newLeaf.parent = split | |
| 1207 | - | ||
| 1208 | - | // Update tree root/parent references | |
| 1209 | - | if parent == nil { | |
| 1210 | - | wm.root = split | |
| 1211 | - | } else { | |
| 1212 | - | if parent.left == splitTarget { | |
| 1213 | - | parent.left = split | |
| 1214 | - | } else if parent.right == splitTarget { | |
| 1215 | - | parent.right = split | |
| 1216 | - | } | |
| 1217 | - | } | |
| 1218 | - | ||
| 1219 | - | // GTK4 handles widget operations automatically - no need to force redraw | |
| 1220 | - | ||
| 1221 | - | // Add both containers to the new paned | |
| 1222 | - | if existingFirst { | |
| 1223 | - | splitTargetContainer.Execute(func(splitPtr uintptr) error { | |
| 1224 | - | webkit.PanedSetStartChild(paned, splitPtr) | |
| 1225 | - | webkit.PanedSetEndChild(paned, newContainer) | |
| 1226 | - | log.Printf("[workspace] added splitTarget=%#x as start child, new=%#x as end child", splitPtr, newContainer) | |
| 1227 | - | return nil | |
| 1228 | - | }) | |
| 1229 | - | } else { | |
| 1230 | - | splitTargetContainer.Execute(func(splitPtr uintptr) error { | |
| 1231 | - | webkit.PanedSetStartChild(paned, newContainer) | |
| 1232 | - | webkit.PanedSetEndChild(paned, splitPtr) | |
| 1233 | - | log.Printf("[workspace] added new=%#x as start child, splitTarget=%#x as end child", newContainer, splitPtr) | |
| 1234 | - | return nil | |
| 1235 | - | }) | |
| 1236 | - | } | |
| 1237 | - | ||
| 1238 | - | // Attach the new paned to its parent | |
| 1239 | - | if parent == nil { | |
| 1240 | - | if wm.window != nil { | |
| 1241 | - | wm.window.SetChild(paned) | |
| 1242 | - | webkit.WidgetQueueAllocate(paned) | |
| 1243 | - | log.Printf("[workspace] paned set as window child: paned=%#x", paned) | |
| 1244 | - | } | |
| 1245 | - | } else { | |
| 1246 | - | parent.container.Execute(func(parentPtr uintptr) error { | |
| 1247 | - | if parent.left == split { | |
| 1248 | - | webkit.PanedSetStartChild(parentPtr, paned) | |
| 1249 | - | } else if parent.right == split { | |
| 1250 | - | webkit.PanedSetEndChild(parentPtr, paned) | |
| 1251 | - | } | |
| 1252 | - | webkit.WidgetQueueAllocate(parentPtr) | |
| 1253 | - | webkit.WidgetQueueAllocate(paned) | |
| 1254 | - | log.Printf("[workspace] paned inserted into parent=%#x", parentPtr) | |
| 1255 | - | return nil | |
| 1256 | - | }) | |
| 1257 | - | } | |
| 1258 | - | ||
| 1259 | - | webkit.WidgetShow(paned) | |
| 1260 | - | ||
| 1261 | - | wm.viewToNode[newPane.webView] = newLeaf | |
| 1262 | - | wm.ensureHover(newLeaf) | |
| 1263 | - | ||
| 1264 | - | // Don't ensure hover on originalTarget when splitting from stack - it competes with new pane focus | |
| 1265 | - | // The originalTarget is still in the stack and should not interfere with the new split pane | |
| 1266 | - | if originalTarget == target { | |
| 1267 | - | // Only ensure hover on target if it's a regular (non-stack) split | |
| 1268 | - | wm.ensureHover(target) | |
| 1269 | - | } | |
| 1270 | - | ||
| 1271 | - | wm.app.panes = append(wm.app.panes, newPane) | |
| 1272 | - | if newPane.zoomController != nil { | |
| 1273 | - | newPane.zoomController.ApplyInitialZoom() | |
| 1274 | - | } | |
| 1275 | - | ||
| 1276 | - | // Update CSS classes for all panes now that we have multiple panes | |
| 1277 | - | wm.ensurePaneBaseClasses() | |
| 1278 | - | ||
| 1279 | - | webkit.IdleAdd(func() bool { | |
| 1280 | - | if newContainer != 0 { | |
| 1281 | - | webkit.WidgetShow(newContainer) | |
| 1282 | - | } | |
| 1283 | - | wm.currentlyFocused = newLeaf | |
| 1284 | - | wm.focusManager.SetActivePane(newLeaf) | |
| 1285 | - | return false | |
| 1286 | - | }) | |
| 1287 | - | ||
| 1288 | - | return newLeaf, nil | |
| 1289 | - | } | |
| 1290 | - | ||
| 1291 | - | // UpdateTitleBar updates the title bar label for a WebView in stacked panes | |
| 1292 | - | func (wm *WorkspaceManager) UpdateTitleBar(webView *webkit.WebView, title string) { | |
| 1293 | - | // Delegate to StackedPaneManager which has the correct logic | |
| 1294 | - | if wm.stackedPaneManager != nil { | |
| 1295 | - | wm.stackedPaneManager.UpdateTitleBar(webView, title) | |
| 1296 | - | } | |
| 1297 | - | } | |
| 1298 | - | ||
| 1299 | - | // updateStackVisibility updates the visibility of panes in a stack. | |
| 1300 | - | // DEPRECATED: Moved to StackedPaneManager | |
| 1301 | - | ||
| 1302 | - | func (wm *WorkspaceManager) clonePaneState(_ *paneNode, target *paneNode) { | |
| 1303 | - | if wm == nil || target == nil { | |
| 1304 | - | return | |
| 1305 | - | } | |
| 1306 | - | if target.pane == nil || target.pane.webView == nil { | |
| 1307 | - | return | |
| 1308 | - | } | |
| 1309 | - | ||
| 1310 | - | const blankURL = "about:blank" | |
| 1311 | - | ||
| 1312 | - | if err := target.pane.webView.LoadURL(blankURL); err != nil { | |
| 1313 | - | log.Printf("[workspace] failed to prepare blank pane: %v", err) | |
| 1314 | - | } | |
| 1315 | - | ||
| 1316 | - | // Wait for about:blank to load before opening omnibox | |
| 1317 | - | target.pane.webView.RegisterURIChangedHandler(func(uri string) { | |
| 1318 | - | if uri == blankURL { | |
| 1319 | - | // Defer omnibox opening to allow page to fully initialize | |
| 1320 | - | webkit.IdleAdd(func() bool { | |
| 1321 | - | if injErr := target.pane.webView.InjectScript("window.__dumber_omnibox?.open('omnibox');"); injErr != nil { | |
| 1322 | - | log.Printf("[workspace] failed to open omnibox: %v", injErr) | |
| 1323 | - | } | |
| 1324 | - | return false // Remove idle callback | |
| 1325 | - | }) | |
| 1326 | - | } | |
| 1327 | - | }) | |
| 1328 | - | } | |
| 1329 | - | ||
| 1330 | - | func (wm *WorkspaceManager) closeCurrentPane() { | |
| 1331 | - | if wm == nil || wm.currentlyFocused == nil { | |
| 1332 | - | return | |
| 1333 | - | } | |
| 1334 | - | if err := wm.closePane(wm.currentlyFocused); err != nil { | |
| 1335 | - | log.Printf("[workspace] close current pane failed: %v", err) | |
| 1336 | - | } | |
| 1337 | - | } | |
| 1338 | - | ||
| 1339 | - | func (wm *WorkspaceManager) closePane(node *paneNode) error { | |
| 1340 | - | if wm == nil || node == nil || !node.isLeaf { | |
| 1341 | - | return errors.New("close target must be a leaf pane") | |
| 1342 | - | } | |
| 1343 | - | if node.pane == nil || node.pane.webView == nil { | |
| 1344 | - | return errors.New("close target missing webview") | |
| 1345 | - | } | |
| 1346 | - | ||
| 1347 | - | // Handle closing panes in a stack | |
| 1348 | - | if node.parent != nil && node.parent.isStacked { | |
| 1349 | - | return wm.stackedPaneManager.CloseStackedPane(node) | |
| 1350 | - | } | |
| 1351 | - | ||
| 1352 | - | if node.parent != nil && node.parent.container != nil { | |
| 1353 | - | node.parent.container.Execute(func(panedPtr uintptr) error { | |
| 1354 | - | if node.parent.left == node { | |
| 1355 | - | webkit.PanedSetStartChild(panedPtr, 0) | |
| 1356 | - | // GTK4 automatically unparents when setting child to 0 | |
| 1357 | - | } else if node.parent.right == node { | |
| 1358 | - | webkit.PanedSetEndChild(panedPtr, 0) | |
| 1359 | - | // GTK4 automatically unparents when setting child to 0 | |
| 1360 | - | } | |
| 1361 | - | return nil | |
| 1362 | - | }) | |
| 1363 | - | } else if node.container != nil { | |
| 1364 | - | // Only manually unparent if widget wasn't auto-unparented above | |
| 1365 | - | node.container.Execute(func(containerPtr uintptr) error { | |
| 1366 | - | if webkit.WidgetGetParent(containerPtr) != 0 { | |
| 1367 | - | webkit.WidgetUnparent(containerPtr) | |
| 1368 | - | } | |
| 1369 | - | return nil | |
| 1370 | - | }) | |
| 1371 | - | } | |
| 1372 | - | ||
| 1373 | - | remaining := len(wm.app.panes) | |
| 1374 | - | willBeLastPane := remaining <= 1 | |
| 1375 | - | paneCleaned := false | |
| 1376 | - | ensureCleanup := func() { | |
| 1377 | - | if paneCleaned { | |
| 1378 | - | return | |
| 1379 | - | } | |
| 1380 | - | node.pane.Cleanup() | |
| 1381 | - | paneCleaned = true | |
| 1382 | - | } | |
| 1383 | - | ||
| 1384 | - | if remaining == 2 { | |
| 1385 | - | var sibling *paneNode | |
| 1386 | - | if node.parent != nil { | |
| 1387 | - | if node.parent.left == node { | |
| 1388 | - | sibling = node.parent.right | |
| 1389 | - | } else if node.parent.right == node { | |
| 1390 | - | sibling = node.parent.left | |
| 1391 | - | } | |
| 1392 | - | } | |
| 1393 | - | if sibling != nil && sibling.isLeaf && sibling.pane != nil && sibling.pane.webView != nil { | |
| 1394 | - | if sibling.isPopup && !node.isPopup { | |
| 1395 | - | parentNode := node.parent | |
| 1396 | - | var parentContainer *SafeWidget | |
| 1397 | - | if parentNode != nil { | |
| 1398 | - | parentContainer = parentNode.container | |
| 1399 | - | } | |
| 1400 | - | ||
| 1401 | - | // Detach remaining GTK widgets before destroying the WebViews so GTK | |
| 1402 | - | // does not attempt to dispose still-parented children. | |
| 1403 | - | if parentContainer != nil { | |
| 1404 | - | parentContainer.Execute(func(panedPtr uintptr) error { | |
| 1405 | - | if parentNode.left == sibling { | |
| 1406 | - | webkit.PanedSetStartChild(panedPtr, 0) | |
| 1407 | - | } else if parentNode.right == sibling { | |
| 1408 | - | webkit.PanedSetEndChild(panedPtr, 0) | |
| 1409 | - | } | |
| 1410 | - | return nil | |
| 1411 | - | }) | |
| 1412 | - | } | |
| 1413 | - | if sibling.container != nil { | |
| 1414 | - | sibling.container.Execute(func(containerPtr uintptr) error { | |
| 1415 | - | webkit.WidgetUnparent(containerPtr) | |
| 1416 | - | return nil | |
| 1417 | - | }) | |
| 1418 | - | } | |
| 1419 | - | if parentContainer != nil && wm.window != nil { | |
| 1420 | - | wm.window.SetChild(0) | |
| 1421 | - | } | |
| 1422 | - | ||
| 1423 | - | log.Printf("[workspace] closing primary pane with related popup present; exiting") | |
| 1424 | - | ensureCleanup() | |
| 1425 | - | sibling.pane.Cleanup() | |
| 1426 | - | ||
| 1427 | - | wm.detachHover(node) | |
| 1428 | - | wm.detachHover(sibling) | |
| 1429 | - | ||
| 1430 | - | // Clean up workspace tracking for both panes | |
| 1431 | - | node.pane.CleanupFromWorkspace(wm) | |
| 1432 | - | sibling.pane.CleanupFromWorkspace(wm) | |
| 1433 | - | ||
| 1434 | - | if node.pane.webView != nil { | |
| 1435 | - | if err := node.pane.webView.Destroy(); err != nil { | |
| 1436 | - | log.Printf("[workspace] failed to destroy primary webview: %v", err) | |
| 1437 | - | } | |
| 1438 | - | } | |
| 1439 | - | if sibling.pane.webView != nil { | |
| 1440 | - | if err := sibling.pane.webView.Destroy(); err != nil { | |
| 1441 | - | log.Printf("[workspace] failed to destroy popup webview: %v", err) | |
| 1442 | - | } | |
| 1443 | - | } | |
| 1444 | - | ||
| 1445 | - | wm.root = nil | |
| 1446 | - | wm.currentlyFocused = nil | |
| 1447 | - | wm.mainPane = nil | |
| 1448 | - | ||
| 1449 | - | webkit.QuitMainLoop() | |
| 1450 | - | return nil | |
| 1451 | - | } | |
| 1452 | - | } | |
| 1453 | - | } | |
| 1454 | - | if willBeLastPane && wm.root == node { | |
| 1455 | - | log.Printf("[workspace] closing final pane; exiting browser") | |
| 1456 | - | ensureCleanup() | |
| 1457 | - | wm.detachHover(node) | |
| 1458 | - | if err := node.pane.webView.Destroy(); err != nil { | |
| 1459 | - | log.Printf("[workspace] failed to destroy webview: %v", err) | |
| 1460 | - | } | |
| 1461 | - | webkit.QuitMainLoop() | |
| 1462 | - | return nil | |
| 1463 | - | } | |
| 1464 | - | ||
| 1465 | - | parent := node.parent | |
| 1466 | - | if parent == nil { | |
| 1467 | - | // This is the root pane (no parent in tree structure) | |
| 1468 | - | if node != wm.root { | |
| 1469 | - | return errors.New("inconsistent state: node has no parent but is not root") | |
| 1470 | - | } | |
| 1471 | - | ||
| 1472 | - | // Check if we can find a replacement root | |
| 1473 | - | replacement := wm.findReplacementRoot(node) | |
| 1474 | - | if replacement == nil { | |
| 1475 | - | // No other panes exist, this is the final pane | |
| 1476 | - | log.Printf("[workspace] closing final pane; exiting browser") | |
| 1477 | - | ensureCleanup() | |
| 1478 | - | wm.detachHover(node) | |
| 1479 | - | if err := node.pane.webView.Destroy(); err != nil { | |
| 1480 | - | log.Printf("[workspace] failed to destroy webview: %v", err) | |
| 1481 | - | } | |
| 1482 | - | webkit.QuitMainLoop() | |
| 1483 | - | return nil | |
| 1484 | - | } | |
| 1485 | - | ||
| 1486 | - | // We have a replacement - delegate root status and close this pane | |
| 1487 | - | log.Printf("[workspace] delegating root status from node=%#x to replacement=%#x", node.container, replacement.container) | |
| 1488 | - | ||
| 1489 | - | // Clean up workspace tracking | |
| 1490 | - | node.pane.CleanupFromWorkspace(wm) | |
| 1491 | - | ||
| 1492 | - | if wm.mainPane == node { | |
| 1493 | - | wm.mainPane = nil | |
| 1494 | - | } | |
| 1495 | - | ||
| 1496 | - | // Clear current active if it's the node being closed | |
| 1497 | - | if wm.currentlyFocused == node { | |
| 1498 | - | wm.currentlyFocused = nil | |
| 1499 | - | } | |
| 1500 | - | ||
| 1501 | - | // Set replacement as new root | |
| 1502 | - | wm.root = replacement | |
| 1503 | - | replacement.parent = nil | |
| 1504 | - | ||
| 1505 | - | // Replace window child directly (GTK handles reparenting automatically) | |
| 1506 | - | if wm.window != nil && replacement.container != nil { | |
| 1507 | - | replacement.container.Execute(func(containerPtr uintptr) error { | |
| 1508 | - | wm.window.SetChild(containerPtr) | |
| 1509 | - | webkit.WidgetQueueAllocate(containerPtr) | |
| 1510 | - | webkit.WidgetShow(containerPtr) | |
| 1511 | - | return nil | |
| 1512 | - | }) | |
| 1513 | - | } | |
| 1514 | - | ||
| 1515 | - | // Focus a suitable pane | |
| 1516 | - | focusTarget := wm.leftmostLeaf(replacement) | |
| 1517 | - | if focusTarget != nil { | |
| 1518 | - | wm.currentlyFocused = focusTarget | |
| 1519 | - | wm.focusManager.SetActivePane(focusTarget) | |
| 1520 | - | } | |
| 1521 | - | ||
| 1522 | - | // Destroy the webview and detach hover AFTER rearranging hierarchy | |
| 1523 | - | // Only destroy the webview if this is the final pane, otherwise just clean up | |
| 1524 | - | ensureCleanup() | |
| 1525 | - | wm.detachHover(node) | |
| 1526 | - | switch { | |
| 1527 | - | case node.isPopup: | |
| 1528 | - | if err := node.pane.webView.Destroy(); err != nil { | |
| 1529 | - | log.Printf("[workspace] failed to destroy popup webview: %v", err) | |
| 1530 | - | } | |
| 1531 | - | case willBeLastPane: | |
| 1532 | - | // This is the last pane, safe to destroy completely | |
| 1533 | - | if err := node.pane.webView.Destroy(); err != nil { | |
| 1534 | - | log.Printf("[workspace] failed to destroy webview: %v", err) | |
| 1535 | - | } | |
| 1536 | - | default: | |
| 1537 | - | // Multiple panes remain, don't destroy the window - just clean up the webview | |
| 1538 | - | log.Printf("[workspace] skipping webview destruction to preserve window (panes remaining: %d)", remaining-1) | |
| 1539 | - | // TODO: Add a method to destroy just the webview without the window | |
| 1540 | - | } | |
| 1541 | - | ||
| 1542 | - | wm.updateMainPane() | |
| 1543 | - | // Update CSS classes after pane count changes | |
| 1544 | - | wm.ensurePaneBaseClasses() | |
| 1545 | - | log.Printf("[workspace] root pane closed and delegated; panes remaining=%d", len(wm.app.panes)) | |
| 1546 | - | return nil | |
| 1547 | - | } | |
| 1548 | - | ||
| 1549 | - | var sibling *paneNode | |
| 1550 | - | if parent.left == node { | |
| 1551 | - | sibling = parent.right | |
| 1552 | - | } else { | |
| 1553 | - | sibling = parent.left | |
| 1554 | - | } | |
| 1555 | - | if sibling == nil { | |
| 1556 | - | return errors.New("pane close failed: missing sibling") | |
| 1557 | - | } | |
| 1558 | - | ||
| 1559 | - | log.Printf("[workspace] closing pane: target=%#x parent=%#x sibling=%#x remaining=%d", node.container, parent.container, sibling.container, remaining) | |
| 1560 | - | ||
| 1561 | - | // Clean up workspace tracking | |
| 1562 | - | node.pane.CleanupFromWorkspace(wm) | |
| 1563 | - | ||
| 1564 | - | if wm.mainPane == node { | |
| 1565 | - | wm.mainPane = nil | |
| 1566 | - | } | |
| 1567 | - | ||
| 1568 | - | // Clear current active if it's the node being closed | |
| 1569 | - | if wm.currentlyFocused == node { | |
| 1570 | - | wm.currentlyFocused = nil | |
| 1571 | - | } | |
| 1572 | - | ||
| 1573 | - | grand := parent.parent | |
| 1574 | - | if grand == nil { | |
| 1575 | - | // Parent is the root node. Promote sibling to become the new root. | |
| 1576 | - | log.Printf("[workspace] promoting sibling to root: container=%#x, isLeaf=%v", sibling.container, sibling.isLeaf) | |
| 1577 | - | ||
| 1578 | - | // Update tree structure first | |
| 1579 | - | wm.root = sibling | |
| 1580 | - | sibling.parent = nil | |
| 1581 | - | ||
| 1582 | - | // For root promotion, GTK4 handles unparenting automatically when we set the new window child | |
| 1583 | - | if wm.window != nil && sibling.container != nil { | |
| 1584 | - | sibling.container.Execute(func(containerPtr uintptr) error { | |
| 1585 | - | wm.window.SetChild(containerPtr) | |
| 1586 | - | webkit.WidgetQueueAllocate(containerPtr) | |
| 1587 | - | webkit.WidgetShow(containerPtr) | |
| 1588 | - | log.Printf("[workspace] successfully promoted sibling to root: %#x", containerPtr) | |
| 1589 | - | return nil | |
| 1590 | - | }) | |
| 1591 | - | } | |
| 1592 | - | } else { | |
| 1593 | - | // Parent has a grandparent, so promote sibling to take parent's place | |
| 1594 | - | log.Printf("[workspace] promoting sibling to parent's position: sibling=%#x grand=%#x", sibling.container, grand.container) | |
| 1595 | - | ||
| 1596 | - | // Update tree structure | |
| 1597 | - | sibling.parent = grand | |
| 1598 | - | if grand.left == parent { | |
| 1599 | - | grand.left = sibling | |
| 1600 | - | } else { | |
| 1601 | - | grand.right = sibling | |
| 1602 | - | } | |
| 1603 | - | ||
| 1604 | - | // Move sibling to grandparent's paned widget | |
| 1605 | - | // GTK4 handles unparenting automatically when we set the new paned child | |
| 1606 | - | grand.container.Execute(func(grandPtr uintptr) error { | |
| 1607 | - | sibling.container.Execute(func(siblingPtr uintptr) error { | |
| 1608 | - | if grand.left == sibling { | |
| 1609 | - | webkit.PanedSetStartChild(grandPtr, siblingPtr) | |
| 1610 | - | } else { | |
| 1611 | - | webkit.PanedSetEndChild(grandPtr, siblingPtr) | |
| 1612 | - | } | |
| 1613 | - | webkit.WidgetQueueAllocate(siblingPtr) | |
| 1614 | - | webkit.WidgetShow(siblingPtr) | |
| 1615 | - | log.Printf("[workspace] successfully promoted sibling to parent position: %#x", siblingPtr) | |
| 1616 | - | return nil | |
| 1617 | - | }) | |
| 1618 | - | return nil | |
| 1619 | - | }) | |
| 1620 | - | } | |
| 1621 | - | ||
| 1622 | - | // Find a suitable focus target | |
| 1623 | - | focusTarget := wm.leftmostLeaf(sibling) | |
| 1624 | - | if focusTarget != nil { | |
| 1625 | - | wm.currentlyFocused = focusTarget | |
| 1626 | - | wm.focusManager.SetActivePane(focusTarget) | |
| 1627 | - | } | |
| 1628 | - | ||
| 1629 | - | // Clean up the node being closed | |
| 1630 | - | ensureCleanup() | |
| 1631 | - | wm.detachHover(node) | |
| 1632 | - | ||
| 1633 | - | switch { | |
| 1634 | - | case node.isPopup: | |
| 1635 | - | if err := node.pane.webView.Destroy(); err != nil { | |
| 1636 | - | log.Printf("[workspace] failed to destroy popup webview: %v", err) | |
| 1637 | - | } | |
| 1638 | - | case willBeLastPane: | |
| 1639 | - | // This is the last pane, safe to destroy completely | |
| 1640 | - | if err := node.pane.webView.Destroy(); err != nil { | |
| 1641 | - | log.Printf("[workspace] failed to destroy webview: %v", err) | |
| 1642 | - | } | |
| 1643 | - | default: | |
| 1644 | - | // Multiple panes remain, don't destroy the window - just clean up the webview | |
| 1645 | - | log.Printf("[workspace] skipping webview destruction to preserve window (panes remaining: %d)", remaining-1) | |
| 1646 | - | // TODO: Add a method to destroy just the webview without the window | |
| 1647 | - | } | |
| 1648 | - | ||
| 1649 | - | wm.updateMainPane() | |
| 1650 | - | // Update CSS classes after pane count changes | |
| 1651 | - | wm.ensurePaneBaseClasses() | |
| 1652 | - | log.Printf("[workspace] pane closed; panes remaining=%d", len(wm.app.panes)) | |
| 1653 | - | return nil | |
| 1654 | - | } | |
| 1655 | - | ||
| 1656 | - | func (wm *WorkspaceManager) leftmostLeaf(node *paneNode) *paneNode { | |
| 1657 | - | for node != nil && !node.isLeaf { | |
| 1658 | - | if node.left != nil { | |
| 1659 | - | node = node.left | |
| 1660 | - | continue | |
| 1661 | - | } | |
| 1662 | - | node = node.right | |
| 1663 | - | } | |
| 1664 | - | return node | |
| 1665 | - | } | |
| 1666 | - | ||
| 1667 | - | // validateWidgetsForReparenting validates that all widgets needed for reparenting are valid | |
| 1668 | - | func (wm *WorkspaceManager) validateWidgetsForReparenting(sibling, parent, grand *paneNode) error { | |
| 1669 | - | // Validate sibling container (this is the most critical one) | |
| 1670 | - | if sibling.container == nil { | |
| 1671 | - | return errors.New("sibling container is nil") | |
| 1672 | - | } | |
| 1673 | - | if !sibling.container.IsValid() { | |
| 1674 | - | return fmt.Errorf("sibling container %s is not valid", sibling.container.String()) | |
| 1675 | - | } | |
| 1676 | - | ||
| 1677 | - | // Validate parent container if present | |
| 1678 | - | if parent != nil && parent.container != nil && !parent.container.IsValid() { | |
| 1679 | - | return fmt.Errorf("parent container %s is not valid", parent.container.String()) | |
| 1680 | - | } | |
| 1681 | - | ||
| 1682 | - | // Validate grandparent container if present | |
| 1683 | - | if grand != nil && grand.container != nil && !grand.container.IsValid() { | |
| 1684 | - | return fmt.Errorf("grandparent container %s is not valid", grand.container.String()) | |
| 1685 | - | } | |
| 1686 | - | ||
| 1687 | - | return nil | |
| 1688 | - | } | |
| 1689 | - | ||
| 1690 | - | // safeWidgetOperation performs a widget operation with proper locking and validation | |
| 1691 | - | func (wm *WorkspaceManager) safeWidgetOperation(operation func() error) error { | |
| 1692 | - | wm.widgetMutex.Lock() | |
| 1693 | - | defer wm.widgetMutex.Unlock() | |
| 1694 | - | return operation() | |
| 1695 | - | } | |
| 1696 | - | ||
| 1697 | - | // registerWidget registers a widget with the registry and returns a SafeWidget | |
| 1698 | - | func (wm *WorkspaceManager) registerWidget(ptr uintptr, typeInfo string) *SafeWidget { | |
| 1699 | - | if ptr == 0 { | |
| 1700 | - | return nil | |
| 1701 | - | } | |
| 1702 | - | return wm.widgetRegistry.Register(ptr, typeInfo) | |
| 1703 | - | } | |
| 1704 | - | ||
| 1705 | - | // initializePaneWidgets sets up SafeWidget wrappers for a paneNode | |
| 1706 | - | func (wm *WorkspaceManager) initializePaneWidgets(node *paneNode, containerPtr uintptr) { | |
| 1707 | - | // Register the main container | |
| 1708 | - | node.container = wm.registerWidget(containerPtr, "pane-container") | |
| 1709 | - | ||
| 1710 | - | // Initialize other widget fields as nil (will be set when needed) | |
| 1711 | - | node.titleBar = nil | |
| 1712 | - | node.stackWrapper = nil | |
| 1713 | - | } | |
| 1714 | - | ||
| 1715 | - | // Helper functions for safe widget operations | |
| 1716 | - | func (node *paneNode) getContainerPtr() uintptr { | |
| 1717 | - | if node.container != nil { | |
| 1718 | - | return node.container.Ptr() | |
| 1719 | - | } | |
| 1720 | - | return 0 | |
| 1721 | - | } | |
| 1722 | - | ||
| 1723 | - | func (node *paneNode) getTitleBarPtr() uintptr { | |
| 1724 | - | if node.titleBar != nil { | |
| 1725 | - | return node.titleBar.Ptr() | |
| 1726 | - | } | |
| 1727 | - | return 0 | |
| 1728 | - | } | |
| 1729 | - | ||
| 1730 | - | func (node *paneNode) getStackWrapperPtr() uintptr { | |
| 1731 | - | if node.stackWrapper != nil { | |
| 1732 | - | return node.stackWrapper.Ptr() | |
| 1733 | - | } | |
| 1734 | - | return 0 | |
| 1735 | - | } | |
| 1736 | - | ||
| 1737 | - | // setContainer sets the SafeWidget container | |
| 1738 | - | func (wm *WorkspaceManager) setContainer(node *paneNode, ptr uintptr, typeInfo string) { | |
| 1739 | - | node.container = wm.registerWidget(ptr, typeInfo) | |
| 1740 | - | } | |
| 1741 | - | ||
| 1742 | - | // setTitleBar sets the SafeWidget titleBar | |
| 1743 | - | func (wm *WorkspaceManager) setTitleBar(node *paneNode, ptr uintptr) { | |
| 1744 | - | node.titleBar = wm.registerWidget(ptr, "title-bar") | |
| 1745 | - | } | |
| 1746 | - | ||
| 1747 | - | // setStackWrapper sets the SafeWidget stackWrapper | |
| 1748 | - | func (wm *WorkspaceManager) setStackWrapper(node *paneNode, ptr uintptr) { | |
| 1749 | - | node.stackWrapper = wm.registerWidget(ptr, "stack-wrapper") | |
| 1750 | - | } | |
| 1751 | - | ||
| 1752 | - | // findReplacementRoot finds a suitable replacement when closing the current root pane | |
| 1753 | - | func (wm *WorkspaceManager) findReplacementRoot(excludeNode *paneNode) *paneNode { | |
| 1754 | - | if wm == nil || wm.root == nil { | |
| 1755 | - | return nil | |
| 1756 | - | } | |
| 1757 | - | ||
| 1758 | - | // If root is being closed and there are other panes, find a replacement | |
| 1759 | - | leaves := wm.collectLeaves() | |
| 1760 | - | for _, leaf := range leaves { | |
| 1761 | - | if leaf != excludeNode && leaf != nil && leaf.isLeaf { | |
| 1762 | - | // Find the topmost ancestor that's not the current root | |
| 1763 | - | current := leaf | |
| 1764 | - | for current.parent != nil && current.parent != wm.root { | |
| 1765 | - | current = current.parent | |
| 1766 | - | } | |
| 1767 | - | ||
| 1768 | - | // If this leaf is a direct child of root, or root only has one subtree, | |
| 1769 | - | // we can promote this subtree to be the new root | |
| 1770 | - | if current.parent == wm.root { | |
| 1771 | - | // If the sibling is being excluded, promote this subtree | |
| 1772 | - | var sibling *paneNode | |
| 1773 | - | if wm.root.left == current { | |
| 1774 | - | sibling = wm.root.right | |
| 1775 | - | } else { | |
| 1776 | - | sibling = wm.root.left | |
| 1777 | - | } | |
| 1778 | - | ||
| 1779 | - | if sibling == excludeNode { | |
| 1780 | - | // The sibling is being closed, so promote this subtree | |
| 1781 | - | return current | |
| 1782 | - | } | |
| 1783 | - | } | |
| 1784 | - | ||
| 1785 | - | // Otherwise, return the first suitable leaf | |
| 1786 | - | return leaf | |
| 1787 | - | } | |
| 1788 | - | } | |
| 1789 | - | ||
| 1790 | - | return nil | |
| 1791 | - | } | |
| 1792 | - | ||
| 1793 | - | func (wm *WorkspaceManager) updateMainPane() { | |
| 1794 | - | if len(wm.app.panes) == 1 { | |
| 1795 | - | if leaf := wm.viewToNode[wm.app.panes[0].webView]; leaf != nil { | |
| 1796 | - | wm.mainPane = leaf | |
| 1797 | - | } | |
| 1798 | - | return | |
| 1799 | - | } | |
| 1800 | - | ||
| 1801 | - | if wm.mainPane == nil || !wm.mainPane.isLeaf { | |
| 1802 | - | if wm.currentlyFocused != nil && wm.currentlyFocused.isLeaf { | |
| 1803 | - | wm.mainPane = wm.currentlyFocused | |
| 1804 | - | } | |
| 1805 | - | } | |
| 1806 | - | } | |
| 1807 | - | ||
| 1808 | - | func (wm *WorkspaceManager) HandlePopup(source *webkit.WebView, url string) *webkit.WebView { | |
| 1809 | - | log.Printf("[workspace] HandlePopup called for URL: %s", url) | |
| 1810 | - | ||
| 1811 | - | // Check for frame type markers added by WebKit layer | |
| 1812 | - | isBlankTarget := strings.HasSuffix(url, "#__dumber_frame_blank") | |
| 1813 | - | isPopupTarget := strings.HasSuffix(url, "#__dumber_frame_popup") | |
| 1814 | - | ||
| 1815 | - | // Clean the URL by removing our markers | |
| 1816 | - | if isBlankTarget || isPopupTarget { | |
| 1817 | - | if isBlankTarget { | |
| 1818 | - | url = strings.TrimSuffix(url, "#__dumber_frame_blank") | |
| 1819 | - | log.Printf("[workspace] Detected _blank target - will create regular pane for: %s", url) | |
| 1820 | - | } else { | |
| 1821 | - | url = strings.TrimSuffix(url, "#__dumber_frame_popup") | |
| 1822 | - | log.Printf("[workspace] Detected popup target - will create popup pane for: %s", url) | |
| 1823 | - | } | |
| 1824 | - | } | |
| 1825 | - | ||
| 1826 | - | if wm == nil || source == nil { | |
| 1827 | - | log.Printf("[workspace] HandlePopup: nil workspace manager or source - allowing native popup") | |
| 1828 | - | return nil | |
| 1829 | - | } | |
| 1830 | - | ||
| 1831 | - | node := wm.viewToNode[source] | |
| 1832 | - | if node == nil { | |
| 1833 | - | log.Printf("[workspace] popup from unknown webview - allowing native popup") | |
| 1834 | - | return nil | |
| 1835 | - | } | |
| 1836 | - | ||
| 1837 | - | // Note: HandlePopup is now obsolete - window.open is handled directly via JavaScript bypass | |
| 1838 | - | ||
| 1839 | - | cfg := wm.app.config | |
| 1840 | - | if cfg == nil { | |
| 1841 | - | log.Printf("[workspace] HandlePopup: nil config - allowing native popup") | |
| 1842 | - | return nil | |
| 1843 | - | } | |
| 1844 | - | ||
| 1845 | - | popCfg := cfg.Workspace.Popups | |
| 1846 | - | log.Printf("[workspace] Popup config - OpenInNewPane: %v, Placement: %s", popCfg.OpenInNewPane, popCfg.Placement) | |
| 1847 | - | ||
| 1848 | - | if !popCfg.OpenInNewPane { | |
| 1849 | - | log.Printf("[workspace] Popup creation disabled in config - allowing native popup") | |
| 1850 | - | return nil | |
| 1851 | - | } | |
| 1852 | - | ||
| 1853 | - | // Smart detection path: create temporary view and decide placement once type is known | |
| 1854 | - | if popCfg.EnableSmartDetection { | |
| 1855 | - | webkitCfg, err := wm.app.buildWebkitConfig() | |
| 1856 | - | if err != nil { | |
| 1857 | - | log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err) | |
| 1858 | - | return nil | |
| 1859 | - | } | |
| 1860 | - | webkitCfg.CreateWindow = false | |
| 1861 | - | // Create as related to avoid WindowFeatures crash; we'll decide final placement later | |
| 1862 | - | newView, err := webkit.NewWebViewWithRelated(webkitCfg, source) | |
| 1863 | - | if err != nil { | |
| 1864 | - | log.Printf("[workspace] failed to create temp WebView: %v - allowing native popup", err) | |
| 1865 | - | return nil | |
| 1866 | - | } | |
| 1867 | - | ||
| 1868 | - | // Register detection callback | |
| 1869 | - | newView.OnWindowTypeDetected(func(t webkit.WindowType, feat *webkit.WindowFeatures) { | |
| 1870 | - | wm.RunOnUI(func() { | |
| 1871 | - | wm.handleDetectedWindowType(node, newView, url, t, feat) | |
| 1872 | - | }) | |
| 1873 | - | }) | |
| 1874 | - | ||
| 1875 | - | // Fallback: if detection never fires, treat as popup as before | |
| 1876 | - | go func() { | |
| 1877 | - | time.Sleep(1500 * time.Millisecond) | |
| 1878 | - | if newView != nil { | |
| 1879 | - | wm.RunOnUI(func() { | |
| 1880 | - | if wm.viewToNode[newView] == nil { // not yet placed | |
| 1881 | - | wm.handleDetectedWindowType(node, newView, url, webkit.WindowTypePopup, nil) | |
| 1882 | - | } | |
| 1883 | - | }) | |
| 1884 | - | } | |
| 1885 | - | }() | |
| 1886 | - | ||
| 1887 | - | return newView | |
| 1888 | - | } | |
| 1889 | - | ||
| 1890 | - | // Legacy path preserved | |
| 1891 | - | webkitCfg, err := wm.app.buildWebkitConfig() | |
| 1892 | - | if err != nil { | |
| 1893 | - | log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err) | |
| 1894 | - | return nil | |
| 1895 | - | } | |
| 1896 | - | webkitCfg.CreateWindow = false | |
| 1897 | - | newView, err := webkit.NewWebViewWithRelated(webkitCfg, source) | |
| 1898 | - | if err != nil { | |
| 1899 | - | log.Printf("[workspace] failed to create placeholder WebView: %v - allowing native popup", err) | |
| 1900 | - | return nil | |
| 1901 | - | } | |
| 1902 | - | ||
| 1903 | - | // Workspace navigation shortcuts are now handled globally by WindowShortcutHandler | |
| 1904 | - | ||
| 1905 | - | // Create a pane for the new WebView | |
| 1906 | - | newPane, err := wm.createPane(newView) | |
| 1907 | - | if err != nil { | |
| 1908 | - | log.Printf("[workspace] failed to create popup pane: %v - allowing native popup", err) | |
| 1909 | - | return nil | |
| 1910 | - | } | |
| 1911 | - | ||
| 1912 | - | // Determine placement direction | |
| 1913 | - | direction := strings.ToLower(popCfg.Placement) | |
| 1914 | - | if direction == "" { | |
| 1915 | - | direction = "right" | |
| 1916 | - | } | |
| 1917 | - | ||
| 1918 | - | // Determine target node for splitting | |
| 1919 | - | target := node | |
| 1920 | - | if !popCfg.FollowPaneContext && wm.currentlyFocused != nil { | |
| 1921 | - | target = wm.currentlyFocused | |
| 1922 | - | } | |
| 1923 | - | ||
| 1924 | - | // Add the popup pane to the workspace using manual pane insertion | |
| 1925 | - | if err := wm.insertPopupPane(target, newPane, direction); err != nil { | |
| 1926 | - | log.Printf("[workspace] popup pane insertion failed: %v - allowing native popup", err) | |
| 1927 | - | return nil | |
| 1928 | - | } | |
| 1929 | - | ||
| 1930 | - | // Apply different behavior based on target type | |
| 1931 | - | if isBlankTarget { | |
| 1932 | - | log.Printf("[workspace] Treating _blank target as regular pane - no auto-close behavior") | |
| 1933 | - | // For _blank targets, just ensure GUI - no popup-specific behavior | |
| 1934 | - | } else { | |
| 1935 | - | log.Printf("[workspace] Treating as popup pane - applying popup-specific behavior") | |
| 1936 | - | // Mark as popup for auto-close handling (OAuth flows, etc.) | |
| 1937 | - | newNode := wm.viewToNode[newView] | |
| 1938 | - | if newNode != nil { | |
| 1939 | - | newNode.isPopup = true | |
| 1940 | - | log.Printf("[workspace] Marked pane as popup for auto-close handling") | |
| 1941 | - | ||
| 1942 | - | // Register close handler for popup auto-close on window.close() | |
| 1943 | - | newView.RegisterCloseHandler(func() { | |
| 1944 | - | log.Printf("[workspace] Popup requesting close via window.close()") | |
| 1945 | - | // Look up the node at close time | |
| 1946 | - | if node := wm.viewToNode[newView]; node != nil && node.isPopup { | |
| 1947 | - | log.Printf("[workspace] Closing popup pane") | |
| 1948 | - | // Brief delay to allow any final redirects to complete | |
| 1949 | - | time.AfterFunc(200*time.Millisecond, func() { | |
| 1950 | - | webkit.IdleAdd(func() bool { | |
| 1951 | - | if err := wm.closePane(node); err != nil { | |
| 1952 | - | log.Printf("[workspace] Failed to close popup pane: %v", err) | |
| 1953 | - | } | |
| 1954 | - | return false | |
| 1955 | - | }) | |
| 1956 | - | }) | |
| 1957 | - | } else { | |
| 1958 | - | log.Printf("[workspace] Could not find popup node for close handler") | |
| 1959 | - | } | |
| 1960 | - | }) | |
| 1961 | - | } else { | |
| 1962 | - | log.Printf("[workspace] Warning: could not find node for popup WebView in viewToNode map") | |
| 1963 | - | } | |
| 1964 | - | } | |
| 1965 | - | ||
| 1966 | - | // Ensure GUI components are available in the new pane | |
| 1967 | - | wm.ensureGUIInPane(newPane) | |
| 1968 | - | ||
| 1969 | - | // Inject GUI components into the popup pane | |
| 1970 | - | wm.ensureGUIInPane(newPane) | |
| 1971 | - | ||
| 1972 | - | // Load the URL if provided | |
| 1973 | - | if url != "" { | |
| 1974 | - | paneType := "popup" | |
| 1975 | - | if isBlankTarget { | |
| 1976 | - | paneType = "_blank target" | |
| 1977 | - | } | |
| 1978 | - | log.Printf("[webkit] LoadURL (%s): %s", paneType, url) | |
| 1979 | - | if err := newView.LoadURL(url); err != nil { | |
| 1980 | - | log.Printf("[workspace] failed to load %s URL: %v", paneType, err) | |
| 1981 | - | } | |
| 1982 | - | // Ensure the WebView is visible after loading URL | |
| 1983 | - | if err := newView.Show(); err != nil { | |
| 1984 | - | log.Printf("[workspace] failed to show popup WebView: %v", err) | |
| 1985 | - | } | |
| 1986 | - | } | |
| 1987 | - | ||
| 1988 | - | if isBlankTarget { | |
| 1989 | - | log.Printf("[workspace] Created regular pane for _blank target URL: %s", url) | |
| 1990 | - | } else { | |
| 1991 | - | log.Printf("[workspace] Created popup pane for URL: %s", url) | |
| 1992 | - | } | |
| 1993 | - | return newView | |
| 1994 | - | } | |
| 1995 | - | ||
| 1996 | - | // registerOAuthAutoClose sets up OAuth auto-close functionality for popups | |
| 1997 | - | // Note: OAuth detection is now handled by the main-world.js injection script | |
| 1998 | - | func (wm *WorkspaceManager) registerOAuthAutoClose(view *webkit.WebView, url string) { | |
| 1999 | - | log.Printf("[workspace] OAuth auto-close enabled for popup with URL: %s", url) | |
| 2000 | - | log.Printf("[workspace] OAuth detection will be handled by main-world.js injection script") | |
| 2001 | - | } | |
| 2002 | - | ||
| 2003 | - | func (wm *WorkspaceManager) applyWindowFeatures(view *webkit.WebView, intent *messaging.WindowIntent, isPopup bool) { | |
| 2004 | - | if intent == nil { | |
| 2005 | - | return | |
| 2006 | - | } | |
| 2007 | - | ||
| 2008 | - | features := &webkit.WindowFeatures{} | |
| 2009 | - | ||
| 2010 | - | // Apply dimensions if specified | |
| 2011 | - | if intent.Width != nil { | |
| 2012 | - | features.Width = *intent.Width | |
| 2013 | - | } | |
| 2014 | - | if intent.Height != nil { | |
| 2015 | - | features.Height = *intent.Height | |
| 2016 | - | } | |
| 2017 | - | ||
| 2018 | - | // Apply visibility features based on window type | |
| 2019 | - | defaultToolbar := !isPopup | |
| 2020 | - | defaultLocation := !isPopup | |
| 2021 | - | defaultMenubar := !isPopup | |
| 2022 | - | ||
| 2023 | - | if intent.Toolbar != nil { | |
| 2024 | - | features.ToolbarVisible = *intent.Toolbar | |
| 2025 | - | } else { | |
| 2026 | - | features.ToolbarVisible = defaultToolbar | |
| 2027 | - | } | |
| 2028 | - | ||
| 2029 | - | if intent.Location != nil { | |
| 2030 | - | features.LocationbarVisible = *intent.Location | |
| 2031 | - | } else { | |
| 2032 | - | features.LocationbarVisible = defaultLocation | |
| 2033 | - | } | |
| 2034 | - | ||
| 2035 | - | if intent.Menubar != nil { | |
| 2036 | - | features.MenubarVisible = *intent.Menubar | |
| 2037 | - | } else { | |
| 2038 | - | features.MenubarVisible = defaultMenubar | |
| 2039 | - | } | |
| 2040 | - | ||
| 2041 | - | if intent.Resizable != nil { | |
| 2042 | - | features.Resizable = *intent.Resizable | |
| 2043 | - | } else { | |
| 2044 | - | features.Resizable = true // Usually resizable unless explicitly disabled | |
| 2045 | - | } | |
| 2046 | - | ||
| 2047 | - | view.SetWindowFeatures(features) | |
| 2048 | - | windowTypeStr := "tab" | |
| 2049 | - | if isPopup { | |
| 2050 | - | windowTypeStr = "popup" | |
| 2051 | - | } | |
| 2052 | - | log.Printf("[workspace] Applied %s window features from intent: size=%dx%d, toolbar=%t, location=%t, menubar=%t, resizable=%t", | |
| 2053 | - | windowTypeStr, features.Width, features.Height, features.ToolbarVisible, features.LocationbarVisible, features.MenubarVisible, features.Resizable) | |
| 2054 | - | } | |
| 2055 | - | ||
| 2056 | - | func (wm *WorkspaceManager) handleIntentAsTab(sourceNode *paneNode, url string, intent *messaging.WindowIntent) *webkit.WebView { | |
| 2057 | - | log.Printf("[workspace] Handling intent as tab: %s", url) | |
| 2058 | - | ||
| 2059 | - | webkitCfg, err := wm.app.buildWebkitConfig() | |
| 2060 | - | if err != nil { | |
| 2061 | - | log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err) | |
| 2062 | - | return nil | |
| 2063 | - | } | |
| 2064 | - | webkitCfg.CreateWindow = false | |
| 2065 | - | ||
| 2066 | - | newView, err := webkit.NewWebView(webkitCfg) | |
| 2067 | - | if err != nil { | |
| 2068 | - | log.Printf("[workspace] failed to create tab WebView: %v - allowing native popup", err) | |
| 2069 | - | return nil | |
| 2070 | - | } | |
| 2071 | - | ||
| 2072 | - | newPane, err := wm.createPane(newView) | |
| 2073 | - | if err != nil { | |
| 2074 | - | log.Printf("[workspace] failed to create tab pane: %v - allowing native popup", err) | |
| 2075 | - | return nil | |
| 2076 | - | } | |
| 2077 | - | ||
| 2078 | - | direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement) | |
| 2079 | - | if direction == "" { | |
| 2080 | - | direction = "right" | |
| 2081 | - | } | |
| 2082 | - | ||
| 2083 | - | if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil { | |
| 2084 | - | log.Printf("[workspace] tab pane insertion failed: %v - allowing native popup", err) | |
| 2085 | - | return nil | |
| 2086 | - | } | |
| 2087 | - | ||
| 2088 | - | node := wm.viewToNode[newView] | |
| 2089 | - | if node != nil { | |
| 2090 | - | node.windowType = webkit.WindowTypeTab | |
| 2091 | - | node.isRelated = false | |
| 2092 | - | ||
| 2093 | - | // Apply window features from JavaScript intent | |
| 2094 | - | wm.applyWindowFeatures(newView, intent, false) | |
| 2095 | - | } | |
| 2096 | - | ||
| 2097 | - | wm.ensureGUIInPane(newPane) | |
| 2098 | - | ||
| 2099 | - | if url != "" { | |
| 2100 | - | if err := newView.LoadURL(url); err != nil { | |
| 2101 | - | log.Printf("[workspace] failed to load tab URL: %v", err) | |
| 2102 | - | } | |
| 2103 | - | if err := newView.Show(); err != nil { | |
| 2104 | - | log.Printf("[workspace] failed to show tab WebView: %v", err) | |
| 2105 | - | } | |
| 2106 | - | } | |
| 2107 | - | ||
| 2108 | - | log.Printf("[workspace] Created tab pane for URL: %s", url) | |
| 2109 | - | return newView | |
| 2110 | - | } | |
| 2111 | - | ||
| 2112 | - | // handleIntentAsPopup creates a related popup pane based on window.open intent | |
| 2113 | - | func (wm *WorkspaceManager) handleIntentAsPopup(sourceNode *paneNode, url string, intent *messaging.WindowIntent) *webkit.WebView { | |
| 2114 | - | log.Printf("[workspace] Handling intent as popup: %s", url) | |
| 2115 | - | ||
| 2116 | - | webkitCfg, err := wm.app.buildWebkitConfig() | |
| 2117 | - | if err != nil { | |
| 2118 | - | log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err) | |
| 2119 | - | return nil | |
| 2120 | - | } | |
| 2121 | - | webkitCfg.CreateWindow = false | |
| 2122 | - | ||
| 2123 | - | newView, err := webkit.NewWebViewWithRelated(webkitCfg, sourceNode.pane.webView) | |
| 2124 | - | if err != nil { | |
| 2125 | - | log.Printf("[workspace] failed to create popup WebView: %v - allowing native popup", err) | |
| 2126 | - | return nil | |
| 2127 | - | } | |
| 2128 | - | ||
| 2129 | - | // Log the parent-popup WebView ID relationship for OAuth auto-close | |
| 2130 | - | parentWebViewID := sourceNode.pane.webView.ID() | |
| 2131 | - | popupWebViewID := newView.ID() | |
| 2132 | - | log.Printf("[workspace] Created popup WebView: parentID=%s popupID=%s url=%s", parentWebViewID, popupWebViewID, url) | |
| 2133 | - | ||
| 2134 | - | // Store popup WebView ID in parent's localStorage for OAuth callback lookup | |
| 2135 | - | storeScript := fmt.Sprintf(` | |
| 2136 | - | try { | |
| 2137 | - | const parentWebViewId = '%s'; | |
| 2138 | - | const popupWebViewId = '%s'; | |
| 2139 | - | const popupMapping = { | |
| 2140 | - | parentId: parentWebViewId, | |
| 2141 | - | popupId: popupWebViewId, | |
| 2142 | - | timestamp: Date.now(), | |
| 2143 | - | url: '%s' | |
| 2144 | - | }; | |
| 2145 | - | localStorage.setItem('popup_mapping_' + parentWebViewId, JSON.stringify(popupMapping)); | |
| 2146 | - | console.log('[workspace] Stored popup mapping:', popupMapping); | |
| 2147 | - | } catch(e) { | |
| 2148 | - | console.warn('[workspace] Failed to store popup mapping:', e); | |
| 2149 | - | } | |
| 2150 | - | `, parentWebViewID, popupWebViewID, url) | |
| 2151 | - | ||
| 2152 | - | // Inject into parent WebView so it can find its popup later | |
| 2153 | - | if err := sourceNode.pane.webView.InjectScript(storeScript); err != nil { | |
| 2154 | - | log.Printf("[workspace] Failed to inject popup mapping script into parent: %v", err) | |
| 2155 | - | } | |
| 2156 | - | ||
| 2157 | - | newPane, err := wm.createPane(newView) | |
| 2158 | - | if err != nil { | |
| 2159 | - | log.Printf("[workspace] failed to create popup pane: %v - allowing native popup", err) | |
| 2160 | - | return nil | |
| 2161 | - | } | |
| 2162 | - | ||
| 2163 | - | direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement) | |
| 2164 | - | if direction == "" { | |
| 2165 | - | direction = "right" | |
| 2166 | - | } | |
| 2167 | - | ||
| 2168 | - | if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil { | |
| 2169 | - | log.Printf("[workspace] popup pane insertion failed: %v - allowing native popup", err) | |
| 2170 | - | return nil | |
| 2171 | - | } | |
| 2172 | - | ||
| 2173 | - | node := wm.viewToNode[newView] | |
| 2174 | - | var requestID string | |
| 2175 | - | if node != nil { | |
| 2176 | - | node.windowType = webkit.WindowTypePopup | |
| 2177 | - | node.isRelated = true | |
| 2178 | - | node.parentPane = sourceNode | |
| 2179 | - | node.isPopup = true | |
| 2180 | - | node.autoClose = wm.shouldAutoClose(url) | |
| 2181 | - | ||
| 2182 | - | // Store requestID for deduplication cleanup | |
| 2183 | - | if intent != nil { | |
| 2184 | - | requestID = intent.RequestID | |
| 2185 | - | } | |
| 2186 | - | ||
| 2187 | - | // Apply window features from JavaScript intent | |
| 2188 | - | wm.applyWindowFeatures(newView, intent, true) | |
| 2189 | - | } | |
| 2190 | - | ||
| 2191 | - | // Register close handler for popup auto-close | |
| 2192 | - | newView.RegisterCloseHandler(func() { | |
| 2193 | - | log.Printf("[workspace] Popup requesting close via window.close()") | |
| 2194 | - | ||
| 2195 | - | // Clear the RequestID from deduplicator to allow new popups with same ID | |
| 2196 | - | if requestID != "" && wm.paneDeduplicator != nil { | |
| 2197 | - | wm.paneDeduplicator.ClearRequestID(requestID) | |
| 2198 | - | } | |
| 2199 | - | ||
| 2200 | - | if n := wm.viewToNode[newView]; n != nil && n.isPopup { | |
| 2201 | - | time.AfterFunc(200*time.Millisecond, func() { | |
| 2202 | - | webkit.IdleAdd(func() bool { | |
| 2203 | - | if err := wm.closePane(n); err != nil { | |
| 2204 | - | log.Printf("[workspace] Failed to close popup pane: %v", err) | |
| 2205 | - | } | |
| 2206 | - | return false | |
| 2207 | - | }) | |
| 2208 | - | }) | |
| 2209 | - | } | |
| 2210 | - | }) | |
| 2211 | - | ||
| 2212 | - | // URL-based auto-close for OAuth popups | |
| 2213 | - | if node != nil && node.isPopup && node.autoClose { | |
| 2214 | - | wm.registerOAuthAutoClose(newView, url) | |
| 2215 | - | } | |
| 2216 | - | ||
| 2217 | - | wm.ensureGUIInPane(newPane) | |
| 2218 | - | ||
| 2219 | - | if url != "" { | |
| 2220 | - | if err := newView.LoadURL(url); err != nil { | |
| 2221 | - | log.Printf("[workspace] failed to load popup URL: %v", err) | |
| 2222 | - | } | |
| 2223 | - | if err := newView.Show(); err != nil { | |
| 2224 | - | log.Printf("[workspace] failed to show popup WebView: %v", err) | |
| 2225 | - | } | |
| 2226 | - | } | |
| 2227 | - | ||
| 2228 | - | log.Printf("[workspace] Created popup pane for URL: %s", url) | |
| 2229 | - | return newView | |
| 2230 | - | } | |
| 2231 | - | ||
| 2232 | - | // insertIndependentPane inserts a new independent pane next to the source | |
| 2233 | - | func (wm *WorkspaceManager) insertIndependentPane(sourceNode *paneNode, webView *webkit.WebView, url string) error { | |
| 2234 | - | newPane, err := wm.createPane(webView) | |
| 2235 | - | if err != nil { | |
| 2236 | - | return err | |
| 2237 | - | } | |
| 2238 | - | direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement) | |
| 2239 | - | if direction == "" { | |
| 2240 | - | direction = "right" | |
| 2241 | - | } | |
| 2242 | - | if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil { // reuse insertion primitive | |
| 2243 | - | return err | |
| 2244 | - | } | |
| 2245 | - | node := wm.viewToNode[webView] | |
| 2246 | - | if node != nil { | |
| 2247 | - | node.windowType = webkit.WindowTypeTab | |
| 2248 | - | node.isRelated = false | |
| 2249 | - | } | |
| 2250 | - | if url != "" { | |
| 2251 | - | _ = webView.LoadURL(url) | |
| 2252 | - | } | |
| 2253 | - | return nil | |
| 2254 | - | } | |
| 2255 | - | ||
| 2256 | - | // configureRelatedPopup creates a related view and inserts it | |
| 2257 | - | func (wm *WorkspaceManager) configureRelatedPopup(sourceNode *paneNode, webView *webkit.WebView, url string, feat *webkit.WindowFeatures) { | |
| 2258 | - | // Use the WebView that was already created and returned to WebKit | |
| 2259 | - | related := webView | |
| 2260 | - | newPane, err := wm.createPane(related) | |
| 2261 | - | if err != nil { | |
| 2262 | - | log.Printf("[workspace] failed to create related popup pane: %v", err) | |
| 2263 | - | return | |
| 2264 | - | } | |
| 2265 | - | direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement) | |
| 2266 | - | if direction == "" { | |
| 2267 | - | direction = "right" | |
| 2268 | - | } | |
| 2269 | - | if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil { | |
| 2270 | - | log.Printf("[workspace] failed to insert related popup pane: %v", err) | |
| 2271 | - | return | |
| 2272 | - | } | |
| 2273 | - | node := wm.viewToNode[related] | |
| 2274 | - | if node != nil { | |
| 2275 | - | node.windowType = webkit.WindowTypePopup | |
| 2276 | - | node.windowFeatures = feat | |
| 2277 | - | node.isRelated = true | |
| 2278 | - | node.parentPane = sourceNode | |
| 2279 | - | node.isPopup = true | |
| 2280 | - | // Heuristic + config for auto-close intent | |
| 2281 | - | node.autoClose = wm.shouldAutoClose(url) | |
| 2282 | - | } | |
| 2283 | - | // Pipe into existing auto-close flow only for popups (confirmed by detection) | |
| 2284 | - | related.RegisterCloseHandler(func() { | |
| 2285 | - | log.Printf("[workspace] Popup requesting close via window.close()") | |
| 2286 | - | if n := wm.viewToNode[related]; n != nil && n.isPopup { | |
| 2287 | - | time.AfterFunc(200*time.Millisecond, func() { | |
| 2288 | - | webkit.IdleAdd(func() bool { | |
| 2289 | - | if err := wm.closePane(n); err != nil { | |
| 2290 | - | log.Printf("[workspace] Failed to close popup pane: %v", err) | |
| 2291 | - | } | |
| 2292 | - | return false | |
| 2293 | - | }) | |
| 2294 | - | }) | |
| 2295 | - | } | |
| 2296 | - | }) | |
| 2297 | - | ||
| 2298 | - | // URL-based fallback: if providers don't call window.close(), auto-close on OAuth callback URLs | |
| 2299 | - | if node != nil && node.isPopup && node.autoClose { | |
| 2300 | - | wm.registerOAuthAutoClose(related, url) | |
| 2301 | - | } | |
| 2302 | - | if url != "" { | |
| 2303 | - | _ = related.LoadURL(url) | |
| 2304 | - | } | |
| 2305 | - | } | |
| 2306 | - | ||
| 2307 | - | // shouldAutoClose checks simple OAuth-like URL patterns and config flag | |
| 2308 | - | func (wm *WorkspaceManager) shouldAutoClose(url string) bool { | |
| 2309 | - | log.Printf("[workspace] shouldAutoClose called for URL: %s", url) | |
| 2310 | - | ||
| 2311 | - | if wm == nil || wm.app == nil || wm.app.config == nil { | |
| 2312 | - | log.Printf("[workspace] shouldAutoClose: missing config, returning true") | |
| 2313 | - | return true | |
| 2314 | - | } | |
| 2315 | - | if !wm.app.config.Workspace.Popups.OAuthAutoClose { | |
| 2316 | - | log.Printf("[workspace] shouldAutoClose: OAuthAutoClose disabled in config, returning false") | |
| 2317 | - | return false | |
| 2318 | - | } | |
| 2319 | - | ||
| 2320 | - | u := strings.ToLower(url) | |
| 2321 | - | log.Printf("[workspace] shouldAutoClose: checking lowercase URL: %s", u) | |
| 2322 | - | ||
| 2323 | - | // RFC 6749 compliant OAuth 2.0 URL patterns | |
| 2324 | - | oauthPatterns := []string{ | |
| 2325 | - | // Standard OAuth endpoints | |
| 2326 | - | "oauth", "authorize", "authorization", | |
| 2327 | - | // Standard callback/redirect patterns | |
| 2328 | - | "callback", "redirect", "auth/callback", | |
| 2329 | - | // OpenID Connect patterns | |
| 2330 | - | "oidc", "openid", | |
| 2331 | - | // Common OAuth parameter indicators | |
| 2332 | - | "response_type=", "client_id=", "redirect_uri=", "scope=", "state=", | |
| 2333 | - | // Standard OAuth response parameters | |
| 2334 | - | "code=", "access_token=", "id_token=", "token_type=", | |
| 2335 | - | // Error response parameters | |
| 2336 | - | "error=", "error_description=", | |
| 2337 | - | } | |
| 2338 | - | ||
| 2339 | - | // Check for OAuth patterns in URL | |
| 2340 | - | for _, pattern := range oauthPatterns { | |
| 2341 | - | if strings.Contains(u, pattern) { | |
| 2342 | - | log.Printf("[workspace] shouldAutoClose: MATCHED pattern '%s' in URL, returning true", pattern) | |
| 2343 | - | return true | |
| 2344 | - | } | |
| 2345 | - | } | |
| 2346 | - | ||
| 2347 | - | log.Printf("[workspace] shouldAutoClose: no OAuth patterns matched, returning false") | |
| 2348 | - | return false | |
| 2349 | - | } | |
| 2350 | - | ||
| 2351 | - | // isPopupVerificationPage determines if a URL is a popup verification page that should redirect instead of close | |
| 2352 | - | ||
| 2353 | - | // RunOnUI schedules a function; here simply executes inline as GTK main loop is single-threaded | |
| 2354 | - | func (wm *WorkspaceManager) RunOnUI(fn func()) { | |
| 2355 | - | if fn != nil { | |
| 2356 | - | fn() | |
| 2357 | - | } | |
| 2358 | - | } | |
| 2359 | - | ||
| 2360 | - | // handleDetectedWindowType handles window type detection from smart detection path | |
| 2361 | - | func (wm *WorkspaceManager) handleDetectedWindowType(sourceNode *paneNode, webView *webkit.WebView, url string, windowType webkit.WindowType, features *webkit.WindowFeatures) { | |
| 2362 | - | if wm.viewToNode[webView] != nil { | |
| 2363 | - | return // Already placed | |
| 2364 | - | } | |
| 2365 | - | ||
| 2366 | - | log.Printf("[workspace] Smart detection result: type=%d url=%s", windowType, url) | |
| 2367 | - | ||
| 2368 | - | switch windowType { | |
| 2369 | - | case webkit.WindowTypeTab: | |
| 2370 | - | // For tabs, create a NEW independent WebView (can't use the related one) | |
| 2371 | - | webkitCfg, err := wm.app.buildWebkitConfig() | |
| 2372 | - | if err != nil { | |
| 2373 | - | log.Printf("[workspace] failed to build webkit config for tab: %v", err) | |
| 2374 | - | return | |
| 2375 | - | } | |
| 2376 | - | webkitCfg.CreateWindow = false | |
| 2377 | - | ||
| 2378 | - | // Create independent WebView like handleIntentAsTab does | |
| 2379 | - | independentView, err := webkit.NewWebView(webkitCfg) | |
| 2380 | - | if err != nil { | |
| 2381 | - | log.Printf("[workspace] failed to create independent tab WebView: %v", err) | |
| 2382 | - | return | |
| 2383 | - | } | |
| 2384 | - | ||
| 2385 | - | // The related webView was just for detection - we don't use it for tabs | |
| 2386 | - | // Insert the new independent view as a tab | |
| 2387 | - | if err := wm.insertIndependentPane(sourceNode, independentView, url); err != nil { | |
| 2388 | - | log.Printf("[workspace] Failed to insert independent pane: %v", err) | |
| 2389 | - | } | |
| 2390 | - | ||
| 2391 | - | case webkit.WindowTypePopup: | |
| 2392 | - | // For popups, use the related WebView we already created | |
| 2393 | - | wm.configureRelatedPopup(sourceNode, webView, url, features) | |
| 2394 | - | default: | |
| 2395 | - | // Fallback to popup behavior | |
| 2396 | - | wm.configureRelatedPopup(sourceNode, webView, url, features) | |
| 2397 | - | } | |
| 2398 | - | } | |
| 2399 | - | ||
| 2400 | - | // insertPopupPane inserts a pre-created popup pane into the workspace | |
| 2401 | - | func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPane, direction string) error { | |
| 2402 | - | if target == nil { | |
| 2403 | - | return errors.New("insert target cannot be nil") | |
| 2404 | - | } | |
| 2405 | - | ||
| 2406 | - | // Accept both leaf panes and stacked panes as valid targets | |
| 2407 | - | if !target.isLeaf && !target.isStacked { | |
| 2408 | - | return errors.New("insert target must be a leaf pane or stacked pane") | |
| 2409 | - | } | |
| 2410 | - | ||
| 2411 | - | // For leaf panes, require a pane | |
| 2412 | - | if target.isLeaf && target.pane == nil { | |
| 2413 | - | return errors.New("leaf target missing pane") | |
| 2414 | - | } | |
| 2415 | - | ||
| 2416 | - | // For stacked panes, we trust the caller - production stacks have varied structures | |
| 2417 | - | // Do not validate internal stack structure here | |
| 2418 | - | ||
| 2419 | - | if newPane == nil || newPane.webView == nil { | |
| 2420 | - | return errors.New("new pane missing webview") | |
| 2421 | - | } | |
| 2422 | - | ||
| 2423 | - | if handler := newPane.MessageHandler(); handler != nil { | |
| 2424 | - | handler.SetWorkspaceObserver(wm) | |
| 2425 | - | } | |
| 2426 | - | ||
| 2427 | - | newContainer := newPane.webView.RootWidget() | |
| 2428 | - | if newContainer == 0 { | |
| 2429 | - | return errors.New("new pane missing container") | |
| 2430 | - | } | |
| 2431 | - | ||
| 2432 | - | webkit.WidgetSetHExpand(newContainer, true) | |
| 2433 | - | webkit.WidgetSetVExpand(newContainer, true) | |
| 2434 | - | webkit.WidgetRealizeInContainer(newContainer) | |
| 2435 | - | ||
| 2436 | - | // Also realize the WebView widget itself for proper popup rendering | |
| 2437 | - | webViewWidget := newPane.webView.Widget() | |
| 2438 | - | if webViewWidget != 0 { | |
| 2439 | - | webkit.WidgetRealizeInContainer(webViewWidget) | |
| 2440 | - | } | |
| 2441 | - | ||
| 2442 | - | if target.container == nil { | |
| 2443 | - | return errors.New("existing pane missing container") | |
| 2444 | - | } | |
| 2445 | - | ||
| 2446 | - | orientation, existingFirst := mapDirection(direction) | |
| 2447 | - | log.Printf("[workspace] inserting popup direction=%s orientation=%v existingFirst=%v target.parent=%p", direction, orientation, existingFirst, target.parent) | |
| 2448 | - | ||
| 2449 | - | paned := webkit.NewPaned(orientation) | |
| 2450 | - | if paned == 0 { | |
| 2451 | - | return errors.New("failed to create GtkPaned") | |
| 2452 | - | } | |
| 2453 | - | webkit.WidgetSetHExpand(paned, true) | |
| 2454 | - | webkit.WidgetSetVExpand(paned, true) | |
| 2455 | - | webkit.PanedSetResizeStart(paned, true) | |
| 2456 | - | webkit.PanedSetResizeEnd(paned, true) | |
| 2457 | - | ||
| 2458 | - | newLeaf := &paneNode{ | |
| 2459 | - | pane: newPane, | |
| 2460 | - | isLeaf: true, | |
| 2461 | - | } | |
| 2462 | - | wm.initializePaneWidgets(newLeaf, newContainer) | |
| 2463 | - | ||
| 2464 | - | split := &paneNode{ | |
| 2465 | - | parent: target.parent, | |
| 2466 | - | orientation: orientation, | |
| 2467 | - | isLeaf: false, | |
| 2468 | - | } | |
| 2469 | - | wm.initializePaneWidgets(split, paned) | |
| 2470 | - | ||
| 2471 | - | parent := split.parent | |
| 2472 | - | ||
| 2473 | - | // Detach existing container from its current GTK parent before inserting into new paned. | |
| 2474 | - | existingContainer := target.getContainerPtr() | |
| 2475 | - | if parent == nil { | |
| 2476 | - | // Target is the root - remove it from the window | |
| 2477 | - | log.Printf("[workspace] removing existing container=%#x from window", existingContainer) | |
| 2478 | - | if wm.window != nil { | |
| 2479 | - | wm.window.SetChild(0) | |
| 2480 | - | } | |
| 2481 | - | } else if parent.container != nil { | |
| 2482 | - | // Target has a parent paned - unparent it from there | |
| 2483 | - | log.Printf("[workspace] unparenting existing container from parent paned") | |
| 2484 | - | err := parent.container.Execute(func(parentPtr uintptr) error { | |
| 2485 | - | log.Printf("[workspace] executing unparent: parentPtr=%#x", parentPtr) | |
| 2486 | - | if parent.left == target { | |
| 2487 | - | webkit.PanedSetStartChild(parentPtr, 0) | |
| 2488 | - | } else if parent.right == target { | |
| 2489 | - | webkit.PanedSetEndChild(parentPtr, 0) | |
| 2490 | - | } | |
| 2491 | - | webkit.WidgetQueueAllocate(parentPtr) | |
| 2492 | - | return nil | |
| 2493 | - | }) | |
| 2494 | - | if err != nil { | |
| 2495 | - | log.Printf("[workspace] error during parent unparent: %v", err) | |
| 2496 | - | } | |
| 2497 | - | } | |
| 2498 | - | ||
| 2499 | - | // Add both containers to the new paned | |
| 2500 | - | if existingFirst { | |
| 2501 | - | split.left = target | |
| 2502 | - | split.right = newLeaf | |
| 2503 | - | webkit.PanedSetStartChild(paned, existingContainer) | |
| 2504 | - | webkit.PanedSetEndChild(paned, newContainer) | |
| 2505 | - | log.Printf("[workspace] added existing=%#x as start child, new=%#x as end child", existingContainer, newContainer) | |
| 2506 | - | } else { | |
| 2507 | - | split.left = newLeaf | |
| 2508 | - | split.right = target | |
| 2509 | - | webkit.PanedSetStartChild(paned, newContainer) | |
| 2510 | - | webkit.PanedSetEndChild(paned, existingContainer) | |
| 2511 | - | log.Printf("[workspace] added new=%#x as start child, existing=%#x as end child", newContainer, existingContainer) | |
| 2512 | - | } | |
| 2513 | - | ||
| 2514 | - | target.parent = split | |
| 2515 | - | newLeaf.parent = split | |
| 2516 | - | ||
| 2517 | - | if parent == nil { | |
| 2518 | - | wm.root = split | |
| 2519 | - | if wm.window != nil { | |
| 2520 | - | wm.window.SetChild(paned) | |
| 2521 | - | webkit.WidgetQueueAllocate(paned) | |
| 2522 | - | log.Printf("[workspace] paned set as window child: paned=%#x", paned) | |
| 2523 | - | } | |
| 2524 | - | } else { | |
| 2525 | - | err := parent.container.Execute(func(parentPtr uintptr) error { | |
| 2526 | - | log.Printf("[workspace] executing parent reparent: parentPtr=%#x, paned=%#x", parentPtr, paned) | |
| 2527 | - | if parent.left == target { | |
| 2528 | - | parent.left = split | |
| 2529 | - | webkit.PanedSetStartChild(parentPtr, paned) | |
| 2530 | - | } else if parent.right == target { | |
| 2531 | - | parent.right = split | |
| 2532 | - | webkit.PanedSetEndChild(parentPtr, paned) | |
| 2533 | - | } | |
| 2534 | - | webkit.WidgetQueueAllocate(parentPtr) | |
| 2535 | - | webkit.WidgetQueueAllocate(paned) | |
| 2536 | - | log.Printf("[workspace] paned inserted into parent successfully") | |
| 2537 | - | return nil | |
| 2538 | - | }) | |
| 2539 | - | if err != nil { | |
| 2540 | - | log.Printf("[workspace] error during parent reparent: %v", err) | |
| 2541 | - | } | |
| 2542 | - | } | |
| 2543 | - | ||
| 2544 | - | webkit.WidgetShow(paned) | |
| 2545 | - | ||
| 2546 | - | wm.viewToNode[newPane.webView] = newLeaf | |
| 2547 | - | wm.ensureHover(newLeaf) | |
| 2548 | - | wm.ensureHover(target) | |
| 2549 | - | wm.app.panes = append(wm.app.panes, newPane) | |
| 2550 | - | if newPane.zoomController != nil { | |
| 2551 | - | newPane.zoomController.ApplyInitialZoom() | |
| 2552 | - | } | |
| 2553 | - | ||
| 2554 | - | // Update CSS classes for all panes now that we have multiple panes | |
| 2555 | - | wm.ensurePaneBaseClasses() | |
| 2556 | - | ||
| 2557 | - | webkit.IdleAdd(func() bool { | |
| 2558 | - | if newContainer != 0 { | |
| 2559 | - | webkit.WidgetShow(newContainer) | |
| 2560 | - | } | |
| 2561 | - | wm.currentlyFocused = newLeaf | |
| 2562 | - | wm.focusManager.SetActivePane(newLeaf) | |
| 2563 | - | return false | |
| 2564 | - | }) | |
| 2565 | - | ||
| 2566 | - | return nil | |
| 2567 | - | } | |
| 2568 | - | ||
| 2569 | - | // ensureGUIInPane lazily loads GUI components into a pane when it gains focus | |
| 2570 | - | // ensureGUIInPane is now a no-op since GUI is injected globally by WebKit | |
| 2571 | - | // This prevents duplicate GUI injection that was causing duplicate log messages | |
| 2572 | - | func (wm *WorkspaceManager) ensureGUIInPane(pane *BrowserPane) { | |
| 2573 | - | if pane == nil { | |
| 2574 | - | return | |
| 2575 | - | } | |
| 2576 | - | ||
| 2577 | - | // GUI is already injected globally via WebKit's enableUserContentManager | |
| 2578 | - | // Just mark the pane as having GUI to prevent unnecessary calls | |
| 2579 | - | if !pane.HasGUI() { | |
| 2580 | - | pane.SetHasGUI(true) | |
| 2581 | - | pane.SetGUIComponent("manager", true) | |
| 2582 | - | pane.SetGUIComponent("omnibox", true) | |
| 2583 | - | } | |
| 2584 | - | } | |
| 2585 | - | ||
| 2586 | - | func mapDirection(direction string) (webkit.Orientation, bool) { | |
| 2587 | - | switch direction { | |
| 2588 | - | case "left": | |
| 2589 | - | return webkit.OrientationHorizontal, false | |
| 2590 | - | case "up": | |
| 2591 | - | return webkit.OrientationVertical, false | |
| 2592 | - | case "down": | |
| 2593 | - | return webkit.OrientationVertical, true | |
| 2594 | - | default: | |
| 2595 | - | return webkit.OrientationHorizontal, true | |
| 2596 | - | } | |
| 2597 | - | } | |
| 2598 | - | ||
| 2599 | - | // Helper methods to support clean pane removal from workspace tracking | |
| 2600 | - | // These methods implement the interface expected by BrowserPane.CleanupFromWorkspace() | |
| 2601 | - | ||
| 2602 | - | // removeFromMaps removes a WebView from all workspace tracking maps | |
| 2603 | - | func (wm *WorkspaceManager) removeFromMaps(webView *webkit.WebView) { | |
| 2604 | - | if wm == nil || webView == nil { | |
| 2605 | - | return | |
| 2606 | - | } | |
| 2607 | - | ||
| 2608 | - | delete(wm.viewToNode, webView) | |
| 2609 | - | delete(wm.lastSplitMsg, webView) | |
| 2610 | - | delete(wm.lastExitMsg, webView) | |
| 2611 | - | log.Printf("[workspace] removed webview %p from tracking maps", webView) | |
| 2612 | - | } | |
| 2613 | - | ||
| 2614 | - | // removeFromAppPanes removes a BrowserPane from the app.panes slice | |
| 2615 | - | func (wm *WorkspaceManager) removeFromAppPanes(pane *BrowserPane) { | |
| 2616 | - | if wm == nil || wm.app == nil || pane == nil { | |
| 2617 | - | return | |
| 2618 | - | } | |
| 2619 | - | ||
| 2620 | - | for i, p := range wm.app.panes { | |
| 2621 | - | if p == pane { | |
| 2622 | - | wm.app.panes = append(wm.app.panes[:i], wm.app.panes[i+1:]...) | |
| 2623 | - | log.Printf("[workspace] removed pane from app.panes slice, remaining: %d", len(wm.app.panes)) | |
| 2624 | - | return | |
| 2625 | - | } | |
| 2626 | - | } | |
| 2627 | - | } | |
M internal/app/browser/workspace_navigation.go +12 -2
| @@ -114,11 +114,21 @@ func (wm *WorkspaceManager) detachHover(node *paneNode) { | |||
| 114 | 114 | if wm == nil || node == nil || node.hoverToken == 0 { | |
| 115 | 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 | 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 | 130 | return nil | |
| 120 | 131 | }) | |
| 121 | - | node.hoverToken = 0 | |
| 122 | 132 | } | |
| 123 | 133 | ||
| 124 | 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 | 4 | import ( | |
| 5 | 5 | "errors" | |
| 6 | + | "fmt" | |
| 6 | 7 | "log" | |
| 7 | 8 | ||
| 8 | 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 | 141 | // closeCurrentPane closes the currently focused pane | |
| 105 | 142 | func (wm *WorkspaceManager) closeCurrentPane() { | |
| 106 | 143 | if wm == nil || wm.GetActiveNode() == nil { | |
| 107 | 144 | return | |
| 108 | 145 | } | |
| 109 | - | if err := wm.closePane(wm.GetActiveNode()); err != nil { | |
| 146 | + | if err := wm.BulletproofClosePane(wm.GetActiveNode()); err != nil { | |
| 110 | 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 | 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 | 326 | // Detach existing container from its current GTK parent before inserting into new paned. | |
| 287 | 327 | existingContainer := target.getContainerPtr() | |
| 288 | 328 | if parent == nil { | |
| @@ -366,6 +406,11 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa | |||
| 366 | 406 | ||
| 367 | 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 | 414 | webkit.IdleAdd(func() bool { | |
| 370 | 415 | if newContainer != 0 { | |
| 371 | 416 | webkit.WidgetShow(newContainer) | |
| @@ -374,6 +419,25 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa | |||
| 374 | 419 | if wm.focusStateMachine != nil { | |
| 375 | 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 | 441 | wm.SetActivePane(newLeaf, SourceSplit) | |
| 378 | 442 | return false | |
| 379 | 443 | }) | |
| @@ -589,7 +653,9 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane | |||
| 589 | 653 | // The originalTarget is still in the stack and should not interfere with the new split pane | |
| 590 | 654 | if originalTarget == target { | |
| 591 | 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 | 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 | 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 | 673 | webkit.IdleAdd(func() bool { | |
| 603 | 674 | if newContainer != 0 { | |
| 604 | 675 | webkit.WidgetShow(newContainer) | |
| @@ -607,6 +678,24 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane | |||
| 607 | 678 | if wm.focusStateMachine != nil { | |
| 608 | 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 | 699 | wm.SetActivePane(newLeaf, SourceSplit) | |
| 611 | 700 | return false | |
| 612 | 701 | }) | |
| @@ -615,17 +704,19 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane | |||
| 615 | 704 | } | |
| 616 | 705 | ||
| 617 | 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 | 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 | 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 | 717 | // Handle closing panes in a stack | |
| 627 | 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 | 722 | if node.parent != nil && node.parent.container != nil { | |
| @@ -724,8 +815,8 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error { | |||
| 724 | 815 | wm.root = nil | |
| 725 | 816 | wm.mainPane = nil | |
| 726 | 817 | ||
| 727 | - | webkit.QuitMainLoop() | |
| 728 | - | return nil | |
| 818 | + | webkit.QuitMainLoop() | |
| 819 | + | return nil, nil | |
| 729 | 820 | } | |
| 730 | 821 | } | |
| 731 | 822 | } | |
| @@ -737,14 +828,14 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error { | |||
| 737 | 828 | log.Printf("[workspace] failed to destroy webview: %v", err) | |
| 738 | 829 | } | |
| 739 | 830 | webkit.QuitMainLoop() | |
| 740 | - | return nil | |
| 831 | + | return nil, nil | |
| 741 | 832 | } | |
| 742 | 833 | ||
| 743 | 834 | parent := node.parent | |
| 744 | 835 | if parent == nil { | |
| 745 | 836 | // This is the root pane (no parent in tree structure) | |
| 746 | 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 | 841 | // Check if we can find a replacement root | |
| @@ -758,7 +849,7 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error { | |||
| 758 | 849 | log.Printf("[workspace] failed to destroy webview: %v", err) | |
| 759 | 850 | } | |
| 760 | 851 | webkit.QuitMainLoop() | |
| 761 | - | return nil | |
| 852 | + | return nil, nil | |
| 762 | 853 | } | |
| 763 | 854 | ||
| 764 | 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 | 869 | // Set replacement as new root | |
| 779 | 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 | 875 | if wm.window != nil && replacement.container != nil { | |
| 784 | 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 | 881 | wm.window.SetChild(containerPtr) | |
| 786 | 882 | webkit.WidgetQueueAllocate(containerPtr) | |
| 787 | 883 | webkit.WidgetShow(containerPtr) | |
| @@ -818,7 +914,7 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error { | |||
| 818 | 914 | wm.updateMainPane() | |
| 819 | 915 | // Update CSS classes after pane count changes | |
| 820 | 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 | 920 | var sibling *paneNode | |
| @@ -828,7 +924,68 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error { | |||
| 828 | 924 | sibling = parent.left | |
| 829 | 925 | } | |
| 830 | 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 | 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 | 1001 | if wm.GetActiveNode() == node { | |
| 845 | 1002 | } | |
| 846 | 1003 | ||
| 847 | - | grand := parent.parent | |
| 1004 | + | promoted = sibling | |
| 848 | 1005 | if grand == nil { | |
| 849 | 1006 | // Parent is the root node. Promote sibling to become the new root. | |
| 850 | 1007 | log.Printf("[workspace] promoting sibling to root: container=%#x, isLeaf=%v", sibling.container, sibling.isLeaf) | |
| 851 | 1008 | ||
| 852 | 1009 | // Update tree structure first | |
| 853 | 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 | 1014 | if wm.window != nil && sibling.container != nil { | |
| 858 | 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 | 1032 | wm.window.SetChild(containerPtr) | |
| 860 | 1033 | webkit.WidgetQueueAllocate(containerPtr) | |
| 861 | 1034 | webkit.WidgetShow(containerPtr) | |
| 1035 | + | ||
| 1036 | + | parentAfter := webkit.WidgetGetParent(containerPtr) | |
| 1037 | + | log.Printf("[workspace] root promotion: sibling parent after=%#x", parentAfter) | |
| 862 | 1038 | log.Printf("[workspace] successfully promoted sibling to root: %#x", containerPtr) | |
| 863 | 1039 | return nil | |
| 864 | 1040 | }) | |
| @@ -879,20 +1055,53 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error { | |||
| 879 | 1055 | // GTK4 handles unparenting automatically when we set the new paned child | |
| 880 | 1056 | grand.container.Execute(func(grandPtr uintptr) error { | |
| 881 | 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 | 1065 | if grand.left == sibling { | |
| 883 | 1066 | webkit.PanedSetStartChild(grandPtr, siblingPtr) | |
| 884 | 1067 | } else { | |
| 885 | 1068 | webkit.PanedSetEndChild(grandPtr, siblingPtr) | |
| 886 | 1069 | } | |
| 1070 | + | ||
| 1071 | + | webkit.WidgetQueueAllocate(grandPtr) | |
| 887 | 1072 | webkit.WidgetQueueAllocate(siblingPtr) | |
| 888 | 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 | 1083 | log.Printf("[workspace] successfully promoted sibling to parent position: %#x", siblingPtr) | |
| 890 | 1084 | return nil | |
| 891 | 1085 | }) | |
| 1086 | + | ||
| 1087 | + | // Also queue allocate the grandparent to ensure layout updates | |
| 892 | 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 | 1105 | // Find a suitable focus target | |
| 897 | 1106 | focusTarget := wm.leftmostLeaf(sibling) | |
| 898 | 1107 | if focusTarget != nil { | |
| @@ -922,5 +1131,5 @@ func (wm *WorkspaceManager) closePane(node *paneNode) error { | |||
| 922 | 1131 | wm.updateMainPane() | |
| 923 | 1132 | // Update CSS classes after pane count changes | |
| 924 | 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 | 155 | // Brief delay to allow any final redirects to complete | |
| 156 | 156 | time.AfterFunc(200*time.Millisecond, func() { | |
| 157 | 157 | webkit.IdleAdd(func() bool { | |
| 158 | - | if err := wm.closePane(node); err != nil { | |
| 158 | + | if err := wm.BulletproofClosePane(node); err != nil { | |
| 159 | 159 | log.Printf("[workspace] Failed to close popup pane: %v", err) | |
| 160 | 160 | } | |
| 161 | 161 | return false | |
| @@ -409,7 +409,7 @@ func (wm *WorkspaceManager) handleIntentAsPopup(sourceNode *paneNode, url string | |||
| 409 | 409 | if n := wm.viewToNode[newView]; n != nil && n.isPopup { | |
| 410 | 410 | time.AfterFunc(200*time.Millisecond, func() { | |
| 411 | 411 | webkit.IdleAdd(func() bool { | |
| 412 | - | if err := wm.closePane(n); err != nil { | |
| 412 | + | if err := wm.BulletproofClosePane(n); err != nil { | |
| 413 | 413 | log.Printf("[workspace] Failed to close popup pane: %v", err) | |
| 414 | 414 | } | |
| 415 | 415 | return false | |
| @@ -495,7 +495,7 @@ func (wm *WorkspaceManager) configureRelatedPopup(sourceNode *paneNode, webView | |||
| 495 | 495 | if n := wm.viewToNode[related]; n != nil && n.isPopup { | |
| 496 | 496 | time.AfterFunc(200*time.Millisecond, func() { | |
| 497 | 497 | webkit.IdleAdd(func() bool { | |
| 498 | - | if err := wm.closePane(n); err != nil { | |
| 498 | + | if err := wm.BulletproofClosePane(n); err != nil { | |
| 499 | 499 | log.Printf("[workspace] Failed to close popup pane: %v", err) | |
| 500 | 500 | } | |
| 501 | 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 | 21 | isPopup bool // Deprecated: use windowType instead | |
| 22 | 22 | ||
| 23 | 23 | // Window type tracking | |
| 24 | - | windowType webkit.WindowType // Tab or Popup | |
| 25 | - | windowFeatures *webkit.WindowFeatures // Features if popup | |
| 26 | - | isRelated bool // Shares context | |
| 27 | - | parentPane *paneNode // Parent for related views | |
| 28 | - | autoClose bool // Auto-close on OAuth success | |
| 29 | - | hoverToken uintptr | |
| 24 | + | windowType webkit.WindowType // Tab or Popup | |
| 25 | + | windowFeatures *webkit.WindowFeatures // Features if popup | |
| 26 | + | isRelated bool // Shares context | |
| 27 | + | parentPane *paneNode // Parent for related views | |
| 28 | + | autoClose bool // Auto-close on OAuth success | |
| 29 | + | hoverToken uintptr | |
| 30 | + | focusControllerToken uintptr | |
| 31 | + | pendingHoverReattach bool | |
| 32 | + | pendingFocusReattach bool | |
| 30 | 33 | ||
| 31 | 34 | // Stacked panes support with proper widget management | |
| 32 | 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 | 222 | // Apply CSS classes to border container (provides border styling) | |
| 223 | 223 | if ctx.borderContainer != nil { | |
| 224 | - | // Check if this container already has the active class to avoid redundant operations | |
| 225 | - | var alreadyActive bool | |
| 226 | - | ctx.borderContainer.Execute(func(containerPtr uintptr) error { | |
| 224 | + | var applied bool | |
| 225 | + | if err := ctx.borderContainer.Execute(func(containerPtr uintptr) error { | |
| 227 | 226 | // Safety check: verify container pointer is valid | |
| 228 | 227 | if containerPtr == 0 { | |
| 229 | 228 | return fmt.Errorf("invalid container pointer") | |
| 230 | 229 | } | |
| 231 | - | alreadyActive = webkit.WidgetHasCSSClass(containerPtr, activePaneClass) | |
| 232 | - | return nil | |
| 233 | - | }) | |
| 234 | - | ||
| 235 | - | // Only apply if not already active (prevents redundant GTK operations) | |
| 236 | - | if !alreadyActive { | |
| 237 | - | ctx.borderContainer.Execute(func(containerPtr uintptr) error { | |
| 238 | - | webkit.WidgetAddCSSClass(containerPtr, activePaneClass) | |
| 230 | + | if webkit.WidgetHasCSSClass(containerPtr, activePaneClass) { | |
| 239 | 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 | 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 | 251 | // Remove CSS classes from border container | |
| 254 | 252 | if ctx.borderContainer != nil { | |
| 255 | - | // Check if this container has the active class before attempting removal | |
| 256 | - | var hasActiveClass bool | |
| 257 | - | ctx.borderContainer.Execute(func(containerPtr uintptr) error { | |
| 253 | + | var removed bool | |
| 254 | + | if err := ctx.borderContainer.Execute(func(containerPtr uintptr) error { | |
| 258 | 255 | // Safety check: verify container pointer is valid | |
| 259 | 256 | if containerPtr == 0 { | |
| 260 | 257 | return fmt.Errorf("invalid container pointer") | |
| 261 | 258 | } | |
| 262 | - | hasActiveClass = webkit.WidgetHasCSSClass(containerPtr, activePaneClass) | |
| 263 | - | return nil | |
| 264 | - | }) | |
| 265 | - | ||
| 266 | - | // Only remove if it actually has the class (prevents redundant GTK operations) | |
| 267 | - | if hasActiveClass { | |
| 268 | - | ctx.borderContainer.Execute(func(containerPtr uintptr) error { | |
| 269 | - | webkit.WidgetRemoveCSSClass(containerPtr, activePaneClass) | |
| 259 | + | if !webkit.WidgetHasCSSClass(containerPtr, activePaneClass) { | |
| 270 | 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 | 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 | 1002 | return nil, errors.New("failed to initialize GTK") | |
| 1003 | 1003 | } | |
| 1004 | 1004 | ||
| 1005 | + | InitMainThread() | |
| 1006 | + | ||
| 1005 | 1007 | if cfg == nil { | |
| 1006 | 1008 | cfg = &Config{} | |
| 1007 | 1009 | } | |
| @@ -1458,6 +1460,8 @@ func NewWebViewWithRelated(cfg *Config, relatedView *WebView) (*WebView, error) | |||
| 1458 | 1460 | return nil, errors.New("failed to initialize GTK") | |
| 1459 | 1461 | } | |
| 1460 | 1462 | ||
| 1463 | + | InitMainThread() | |
| 1464 | + | ||
| 1461 | 1465 | if cfg == nil { | |
| 1462 | 1466 | cfg = &Config{} | |
| 1463 | 1467 | } | |
M pkg/webkit/window_cgo.go +2 -0
| @@ -27,6 +27,8 @@ func NewWindow(title string) (*Window, error) { | |||
| 27 | 27 | if C.gtk_init_check() == 0 { | |
| 28 | 28 | return nil, ErrNotImplemented | |
| 29 | 29 | } | |
| 30 | + | ||
| 31 | + | InitMainThread() | |
| 30 | 32 | // GTK4: gtk_window_new() returns GtkWindow*, no toplevel enum | |
| 31 | 33 | w := (*C.GtkWidget)(unsafe.Pointer(C.gtk_window_new())) | |
| 32 | 34 | if w == nil { | |
M pkg/webkit/workspace_widgets_cgo.go +196 -27
| @@ -10,6 +10,7 @@ package webkit | |||
| 10 | 10 | #include <glib.h> | |
| 11 | 11 | #include <glib-object.h> | |
| 12 | 12 | #include <graphene.h> | |
| 13 | + | #include <pthread.h> | |
| 13 | 14 | #include <stdint.h> | |
| 14 | 15 | #include <stdlib.h> | |
| 15 | 16 | ||
| @@ -22,6 +23,11 @@ extern void goHoverCallback(uintptr_t handle); | |||
| 22 | 23 | extern void goFocusEnterCallback(uintptr_t handle); | |
| 23 | 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 | 31 | static gboolean idle_callback_wrapper(gpointer data) { | |
| 26 | 32 | return goIdleCallback((uintptr_t)data); | |
| 27 | 33 | } | |
| @@ -81,6 +87,89 @@ static gboolean is_valid_widget(gpointer widget) { | |||
| 81 | 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 | 173 | static GtkWidget* paned_get_start_child(GtkWidget* paned) { | |
| 85 | 174 | if (!paned) return NULL; | |
| 86 | 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 | 327 | goFocusLeaveCallback(nodePtr); | |
| 239 | 328 | } | |
| 240 | 329 | ||
| 241 | - | static GtkEventController* widget_add_focus_controller(GtkWidget* widget, uintptr_t nodePtr) { | |
| 242 | - | if (!widget) { | |
| 243 | - | return NULL; | |
| 244 | - | } | |
| 245 | - | GtkEventController* focus = gtk_event_controller_focus_new(); | |
| 246 | - | if (!focus) { | |
| 247 | - | return NULL; | |
| 330 | + | // Widget allocation helper using modern GTK4 API | |
| 331 | + | static void widget_get_allocation_modern(GtkWidget* widget, int* x, int* y, int* width, int* height) { | |
| 332 | + | if (!widget || !x || !y || !width || !height) { | |
| 333 | + | *x = *y = *width = *height = 0; | |
| 334 | + | return; | |
| 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) { | |
| 257 | - | if (!widget || !controller) { | |
| 258 | - | return; | |
| 337 | + | graphene_rect_t bounds; | |
| 338 | + | // Compute bounds relative to the widget itself (pass widget as target) | |
| 339 | + | if (gtk_widget_compute_bounds(widget, widget, &bounds)) { | |
| 340 | + | // Extract integer coordinates from the graphene rect | |
| 341 | + | *x = (int)bounds.origin.x; | |
| 342 | + | *y = (int)bounds.origin.y; | |
| 343 | + | *width = (int)bounds.size.width; | |
| 344 | + | *height = (int)bounds.size.height; | |
| 345 | + | } else { | |
| 346 | + | // Fallback if compute_bounds fails | |
| 347 | + | *x = *y = 0; | |
| 348 | + | *width = gtk_widget_get_width(widget); | |
| 349 | + | *height = gtk_widget_get_height(widget); | |
| 259 | 350 | } | |
| 260 | - | gtk_widget_remove_controller(widget, controller); | |
| 261 | 351 | } | |
| 262 | 352 | */ | |
| 263 | 353 | import "C" | |
| @@ -282,6 +372,14 @@ type FocusCallbacks struct { | |||
| 282 | 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 | 383 | var ( | |
| 286 | 384 | hoverMu sync.Mutex | |
| 287 | 385 | hoverCallbacks = make(map[uintptr]func()) | |
| @@ -294,8 +392,26 @@ var ( | |||
| 294 | 392 | focusCallbacks = make(map[uintptr]FocusCallbacks) | |
| 295 | 393 | focusControllers = make(map[uintptr]uintptr) | |
| 296 | 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 | 415 | func widgetIsValid(widget uintptr) bool { | |
| 300 | 416 | if widget == 0 { | |
| 301 | 417 | return false | |
| @@ -441,22 +557,45 @@ func PanedSetPosition(paned uintptr, pos int) { | |||
| 441 | 557 | ||
| 442 | 558 | // WidgetUnparent detaches a widget from its current parent. | |
| 443 | 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 | 568 | if widget == 0 { | |
| 445 | - | return | |
| 569 | + | log.Printf("[workspace] WidgetUnparentChecked: widget=0") | |
| 570 | + | return false | |
| 446 | 571 | } | |
| 572 | + | ||
| 447 | 573 | if !widgetIsValid(widget) { | |
| 448 | - | log.Printf("[workspace] WidgetUnparent skipped invalid widget=%#x", widget) | |
| 449 | - | return | |
| 574 | + | log.Printf("[workspace] WidgetUnparentChecked: widget=%#x invalid before unparent", widget) | |
| 575 | + | return false | |
| 450 | 576 | } | |
| 451 | - | before := widgetRefCount(widget) | |
| 452 | - | log.Printf("[workspace] WidgetUnparent widget=%#x ref_before=%d", widget, before) | |
| 577 | + | ||
| 578 | + | parentBefore := WidgetGetParent(widget) | |
| 579 | + | if parentBefore == 0 { | |
| 580 | + | log.Printf("[workspace] WidgetUnparentChecked: widget=%#x has no parent", widget) | |
| 581 | + | return true | |
| 582 | + | } | |
| 583 | + | ||
| 584 | + | refBefore := widgetRefCount(widget) | |
| 585 | + | log.Printf("[workspace] WidgetUnparentChecked: widget=%#x parent=%#x ref_before=%d", widget, parentBefore, refBefore) | |
| 586 | + | ||
| 453 | 587 | C.gtk_widget_unparent((*C.GtkWidget)(unsafe.Pointer(widget))) | |
| 454 | - | if widgetIsValid(widget) { | |
| 455 | - | after := widgetRefCount(widget) | |
| 456 | - | log.Printf("[workspace] WidgetUnparent widget=%#x ref_after=%d", widget, after) | |
| 457 | - | } else { | |
| 458 | - | log.Printf("[workspace] WidgetUnparent widget=%#x now invalid", widget) | |
| 588 | + | ||
| 589 | + | if !widgetIsValid(widget) { | |
| 590 | + | log.Printf("[workspace] WidgetUnparentChecked: widget=%#x invalid after unparent", widget) | |
| 591 | + | return true | |
| 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 | 601 | // WidgetSetHExpand configures horizontal expand for a widget. | |
| @@ -491,6 +630,14 @@ func WidgetSetVExpand(widget uintptr, expand bool) { | |||
| 491 | 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 | 641 | // WidgetShow makes the widget visible. | |
| 495 | 642 | func WidgetShow(widget uintptr) { | |
| 496 | 643 | if widget == 0 { | |
| @@ -650,6 +797,11 @@ func IdleAdd(fn func() bool) { | |||
| 650 | 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 | 805 | //export goIdleCallback | |
| 654 | 806 | func goIdleCallback(handle uintptr) C.gboolean { | |
| 655 | 807 | if fn, ok := idleCallbacks[handle]; ok { | |
| @@ -955,3 +1107,20 @@ func goFocusLeaveCallback(handle C.uintptr_t) { | |||
| 955 | 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 | 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 | 31 | type widgetStub struct { | |
| 24 | 32 | startChild uintptr | |
| 25 | 33 | endChild uintptr | |
| @@ -271,6 +279,8 @@ func WidgetQueueAllocate(widget uintptr) {} | |||
| 271 | 279 | ||
| 272 | 280 | func WidgetRealizeInContainer(widget uintptr) {} | |
| 273 | 281 | ||
| 282 | + | func WidgetResetSizeRequest(widget uintptr) {} | |
| 283 | + | ||
| 274 | 284 | func WidgetHookDestroy(widget uintptr) {} | |
| 275 | 285 | ||
| 276 | 286 | func WidgetIsValid(widget uintptr) bool { | |
| @@ -491,3 +501,27 @@ func LabelSetMaxWidthChars(label uintptr, nChars int) {} | |||
| 491 | 501 | func (w *WebView) SetTestContainer(container uintptr) { | |
| 492 | 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 | + | } | |