fix(workspace): simplify closePane to prevent GTK crashes and let GTK auto-manage parenting
Major changes: - Simplified closePane() to let GTK4 auto-handle widget unparenting/reparenting instead of manual manipulation which caused use-after-free crashes - Key principle: PanedSetStartChild/EndChild and window.SetChild automatically unparent widgets from their current parents - no need for manual detachment - Unparent closed pane BEFORE promoting sibling to avoid paned with single child - Remove complex manual GTK widget hierarchy manipulation that caused crashes Other improvements: - Simplify insertPopupPane to use uintptr directly instead of WidgetContainer - Add IdleGuarded for omnibox opening to prevent accessing destroyed widgets - Cleanup stacked panes and workspace utility functions - Remove obsolete widget.go and documentation files Known issues: - OAuth popup windows still compress parent pane - needs WidgetSetSizeRequest and WidgetQueueResize GTK methods to force proper layout recalculation
- commit
- d7d17483ffdd2891baecec85ae32331393b8f53b
- author
- Brice · · 2025-10-03 20:29 UTC
- parent
- efcc10f
- signature
- unsigned
28 files · +1291 -5801
A config.yaml +214 -0
| @@ -0,0 +1,214 @@ | |||
| 1 | + | apisecurity: | |
| 2 | + | requiretoken: false | |
| 3 | + | token: '' | |
| 4 | + | appearance: | |
| 5 | + | default_font_size: 16 | |
| 6 | + | defaultfontsize: 16 | |
| 7 | + | monospace_font: Fira Code | |
| 8 | + | monospacefont: Fira Code | |
| 9 | + | sans_font: Fira Sans | |
| 10 | + | sansfont: Fira Sans | |
| 11 | + | serif_font: Fira Sans | |
| 12 | + | seriffont: Fira Sans | |
| 13 | + | codec_preferences: | |
| 14 | + | av1_hardware_only: false | |
| 15 | + | av1_max_resolution: 1080p | |
| 16 | + | block_vp8: true | |
| 17 | + | block_vp9: false | |
| 18 | + | custom_user_agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like | |
| 19 | + | Gecko) Chrome/120.0.0.0 Safari/537.36 | |
| 20 | + | disable_twitch_codec_control: true | |
| 21 | + | disable_vp9_hardware: false | |
| 22 | + | force_av1: false | |
| 23 | + | preferred_codecs: av1,h264 | |
| 24 | + | queue_buffer_time_sec: 10 | |
| 25 | + | video_buffer_size_mb: 16 | |
| 26 | + | codecpreferences: | |
| 27 | + | av1hardwareonly: false | |
| 28 | + | av1maxresolution: 1080p | |
| 29 | + | blockvp8: true | |
| 30 | + | blockvp9: false | |
| 31 | + | customuseragent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like | |
| 32 | + | Gecko) Chrome/120.0.0.0 Safari/537.36 | |
| 33 | + | disabletwitchcodeccontrol: true | |
| 34 | + | disablevp9hardware: false | |
| 35 | + | forceav1: false | |
| 36 | + | preferredcodecs: av1,h264 | |
| 37 | + | queuebuffertimesec: 10 | |
| 38 | + | videobuffersizemb: 16 | |
| 39 | + | database: | |
| 40 | + | max_connections: 1 | |
| 41 | + | max_idle_time: 300000000000 | |
| 42 | + | maxconnections: 1 | |
| 43 | + | maxidletime: 300000000000 | |
| 44 | + | path: '' | |
| 45 | + | query_timeout: 30000000000 | |
| 46 | + | querytimeout: 30000000000 | |
| 47 | + | debug: | |
| 48 | + | enable_css_debug: false | |
| 49 | + | enable_filtering_debug: false | |
| 50 | + | enable_focus_debug: false | |
| 51 | + | enable_focus_metrics: false | |
| 52 | + | enable_general_debug: false | |
| 53 | + | enable_pane_close_debug: false | |
| 54 | + | enable_script_debug: false | |
| 55 | + | enable_webkit_debug: false | |
| 56 | + | enable_webview_debug: false | |
| 57 | + | enable_workspace_debug: false | |
| 58 | + | enablecssdebug: false | |
| 59 | + | enablefilteringdebug: false | |
| 60 | + | enablefocusdebug: false | |
| 61 | + | enablefocusmetrics: false | |
| 62 | + | enablegeneraldebug: false | |
| 63 | + | enablepaneclosedebug: false | |
| 64 | + | enablescriptdebug: false | |
| 65 | + | enablewebkitdebug: false | |
| 66 | + | enablewebviewdebug: false | |
| 67 | + | enableworkspacedebug: false | |
| 68 | + | logwebkitcrashes: true | |
| 69 | + | webkitdebugcategories: Network:preconnectTo,ContentFilters | |
| 70 | + | dmenu: | |
| 71 | + | date_format: 2006-01-02 15:04 | |
| 72 | + | dateformat: 2006-01-02 15:04 | |
| 73 | + | history_prefix: "\U0001F552" | |
| 74 | + | historyprefix: "\U0001F552" | |
| 75 | + | max_history_items: 20 | |
| 76 | + | maxhistoryitems: 20 | |
| 77 | + | shortcut_prefix: "\U0001F50D" | |
| 78 | + | shortcutprefix: "\U0001F50D" | |
| 79 | + | show_last_visited: true | |
| 80 | + | show_visit_count: true | |
| 81 | + | showlastvisited: true | |
| 82 | + | showvisitcount: true | |
| 83 | + | sort_by_visit_count: true | |
| 84 | + | sortbyvisitcount: true | |
| 85 | + | url_prefix: "\U0001F310" | |
| 86 | + | urlprefix: "\U0001F310" | |
| 87 | + | history: | |
| 88 | + | cleanup_interval_days: 1 | |
| 89 | + | cleanupintervaldays: 1 | |
| 90 | + | max_entries: 10000 | |
| 91 | + | maxentries: 10000 | |
| 92 | + | retention_period_days: 365 | |
| 93 | + | retentionperioddays: 365 | |
| 94 | + | logging: | |
| 95 | + | capture_c_output: false | |
| 96 | + | capture_stderr: false | |
| 97 | + | capture_stdout: false | |
| 98 | + | captureconsole: false | |
| 99 | + | capturecoutput: false | |
| 100 | + | capturestderr: false | |
| 101 | + | capturestdout: false | |
| 102 | + | compress: true | |
| 103 | + | debug_file: debug.log | |
| 104 | + | debugfile: debug.log | |
| 105 | + | enable_file_log: true | |
| 106 | + | enablefilelog: true | |
| 107 | + | filename: '' | |
| 108 | + | format: text | |
| 109 | + | level: info | |
| 110 | + | log_dir: /home/brice/.local/state/dumber/logs | |
| 111 | + | logdir: /home/brice/.local/state/dumber/logs | |
| 112 | + | max_age: 7 | |
| 113 | + | max_backups: 3 | |
| 114 | + | max_size: 100 | |
| 115 | + | maxage: 7 | |
| 116 | + | maxbackups: 3 | |
| 117 | + | maxsize: 100 | |
| 118 | + | verbose_webkit: false | |
| 119 | + | verbosewebkit: false | |
| 120 | + | rendering_mode: gpu | |
| 121 | + | renderingmode: gpu | |
| 122 | + | search_shortcuts: | |
| 123 | + | g: | |
| 124 | + | description: Google search | |
| 125 | + | url: https://www.google.com/search?q=%s | |
| 126 | + | gh: | |
| 127 | + | description: GitHub search | |
| 128 | + | url: https://github.com/search?q=%s | |
| 129 | + | w: | |
| 130 | + | description: Wikipedia search | |
| 131 | + | url: https://en.wikipedia.org/wiki/%s | |
| 132 | + | yt: | |
| 133 | + | description: YouTube search | |
| 134 | + | url: https://www.youtube.com/results?search_query=%s | |
| 135 | + | searchshortcuts: | |
| 136 | + | g: | |
| 137 | + | description: Google search | |
| 138 | + | url: https://www.google.com/search?q=%s | |
| 139 | + | gh: | |
| 140 | + | description: GitHub search | |
| 141 | + | url: https://github.com/search?q=%s | |
| 142 | + | w: | |
| 143 | + | description: Wikipedia search | |
| 144 | + | url: https://en.wikipedia.org/wiki/%s | |
| 145 | + | yt: | |
| 146 | + | description: YouTube search | |
| 147 | + | url: https://www.youtube.com/results?search_query=%s | |
| 148 | + | use_dom_zoom: false | |
| 149 | + | usedomzoom: false | |
| 150 | + | video_acceleration: | |
| 151 | + | auto_detect_gpu: true | |
| 152 | + | enable_all_drivers: true | |
| 153 | + | enable_vaapi: true | |
| 154 | + | legacy_vaapi: false | |
| 155 | + | vaapi_driver_name: vdpau | |
| 156 | + | videoacceleration: | |
| 157 | + | autodetectgpu: true | |
| 158 | + | enablealldrivers: true | |
| 159 | + | enablevaapi: true | |
| 160 | + | legacyvaapi: false | |
| 161 | + | vaapidrivername: '' | |
| 162 | + | webkit_memory: | |
| 163 | + | cache_model: web_browser | |
| 164 | + | conservative_threshold: 0.4 | |
| 165 | + | enable_gc_interval: 120 | |
| 166 | + | enable_memory_monitoring: true | |
| 167 | + | enable_page_cache: true | |
| 168 | + | kill_threshold: 0.8 | |
| 169 | + | memory_limit_mb: 0 | |
| 170 | + | poll_interval_seconds: 45 | |
| 171 | + | process_recycle_threshold: 50 | |
| 172 | + | strict_threshold: 0.6 | |
| 173 | + | webkitmemory: | |
| 174 | + | cachemodel: web_browser | |
| 175 | + | conservativethreshold: 0.4 | |
| 176 | + | enablegcinterval: 120 | |
| 177 | + | enablememorymonitoring: true | |
| 178 | + | enablepagecache: true | |
| 179 | + | killthreshold: 0.8 | |
| 180 | + | memorylimitmb: 0 | |
| 181 | + | pollintervalseconds: 45 | |
| 182 | + | processrecyclethreshold: 50 | |
| 183 | + | strictthreshold: 0.6 | |
| 184 | + | workspace: | |
| 185 | + | enable_zellij_controls: true | |
| 186 | + | pane_mode: | |
| 187 | + | action_bindings: | |
| 188 | + | arrowdown: split-down | |
| 189 | + | arrowleft: split-left | |
| 190 | + | arrowright: split-right | |
| 191 | + | arrowup: split-up | |
| 192 | + | d: split-down | |
| 193 | + | enter: confirm | |
| 194 | + | escape: cancel | |
| 195 | + | l: split-left | |
| 196 | + | r: split-right | |
| 197 | + | u: split-up | |
| 198 | + | x: close-pane | |
| 199 | + | activation_shortcut: cmdorctrl+p | |
| 200 | + | timeout_ms: 3000 | |
| 201 | + | popups: | |
| 202 | + | follow_pane_context: true | |
| 203 | + | open_in_new_pane: true | |
| 204 | + | placement: right | |
| 205 | + | styling: | |
| 206 | + | border_color: '@theme_selected_bg_color' | |
| 207 | + | border_radius: 0 | |
| 208 | + | border_width: 2 | |
| 209 | + | transition_duration: 120 | |
| 210 | + | tabs: | |
| 211 | + | close_tab: cmdorctrl+w | |
| 212 | + | new_tab: cmdorctrl+t | |
| 213 | + | next_tab: cmdorctrl+tab | |
| 214 | + | previous_tab: cmdorctrl+shift+tab | |
A dev/run-debug.fish +15 -0
| @@ -0,0 +1,15 @@ | |||
| 1 | + | #!/usr/bin/env fish | |
| 2 | + | # dev/run-debug.fish - launch ./dist/dumber with verbose debug instrumentation | |
| 3 | + | ||
| 4 | + | # Enable core dumps so we get a crash backtrace if GLib aborts | |
| 5 | + | ulimit -c unlimited | |
| 6 | + | ||
| 7 | + | # Go runtime crash dumps | |
| 8 | + | set -gx GOTRACEBACK crash | |
| 9 | + | ||
| 10 | + | # Make GLib fatal on warnings/criticals and emit stack traces | |
| 11 | + | set -gx G_DEBUG "fatal-criticals,fatal-warnings,stack-trace-on-fatal" | |
| 12 | + | set -gx G_MESSAGES_DEBUG all | |
| 13 | + | ||
| 14 | + | # Execute the browser with any arguments forwarded | |
| 15 | + | ./dist/dumber $argv | |
D docs/GO_CLEANUP_PLAN.md +0 -216
| @@ -1,216 +0,0 @@ | |||
| 1 | - | # Go-Idiomatic Cleanup Plan (2025 Standards) | |
| 2 | - | ||
| 3 | - | ## Overview | |
| 4 | - | This document outlines the plan to refactor the browser package to follow Go best practices and idioms as of 2025, focusing on simplicity and maintainability. | |
| 5 | - | ||
| 6 | - | ## Core Principles (Based on Current Go Best Practices) | |
| 7 | - | 1. **"Accept interfaces, return structs"** - Define interfaces where they're used | |
| 8 | - | 2. **Small, focused interfaces** - 1-3 methods max (like io.Reader, io.Writer) | |
| 9 | - | 3. **No "manager" suffix** - Go prefers descriptive type names | |
| 10 | - | 4. **Avoid premature abstraction** - Only create interfaces when needed | |
| 11 | - | 5. **Keep it simple** - Go values simplicity over clever abstractions | |
| 12 | - | ||
| 13 | - | ## Phase 1: Simplify File Structure (Following stdlib patterns) | |
| 14 | - | ||
| 15 | - | ### Current Problem: | |
| 16 | - | - 15 `workspace_*` files (Java-style naming) | |
| 17 | - | - Too many abstractions without clear benefit | |
| 18 | - | - "Manager" suffix everywhere (not idiomatic) | |
| 19 | - | ||
| 20 | - | ### New Structure (12 files total): | |
| 21 | - | ``` | |
| 22 | - | browser/ | |
| 23 | - | ├── doc.go # Package documentation | |
| 24 | - | ├── browser.go # BrowserApp and main types | |
| 25 | - | ├── pane.go # paneNode, BrowserPane types | |
| 26 | - | ├── tree.go # Tree operations (split, close, navigation) | |
| 27 | - | ├── focus.go # Focus handling (simplified) | |
| 28 | - | ├── widget.go # Widget wrapping and safety | |
| 29 | - | ├── stack.go # Stacked pane operations | |
| 30 | - | ├── validation.go # Tree and geometry validation | |
| 31 | - | ├── state.go # State capture/restore | |
| 32 | - | ├── shortcuts.go # Keyboard shortcuts | |
| 33 | - | ├── webview.go # WebView helpers | |
| 34 | - | └── popup.go # Popup handling | |
| 35 | - | ``` | |
| 36 | - | ||
| 37 | - | ## Phase 2: Define Minimal Interfaces (Only Where Needed) | |
| 38 | - | ||
| 39 | - | ### In pane.go (consumer-defined): | |
| 40 | - | ```go | |
| 41 | - | // Viewer is what a pane needs from a webview | |
| 42 | - | type Viewer interface { | |
| 43 | - | LoadURL(string) error | |
| 44 | - | Widget() uintptr | |
| 45 | - | } | |
| 46 | - | ||
| 47 | - | // Focusable is what can receive focus | |
| 48 | - | type Focusable interface { | |
| 49 | - | Focus() error | |
| 50 | - | IsFocused() bool | |
| 51 | - | } | |
| 52 | - | ``` | |
| 53 | - | ||
| 54 | - | ### In tree.go (for testing): | |
| 55 | - | ```go | |
| 56 | - | // TreeWalker visits nodes in the tree | |
| 57 | - | type TreeWalker interface { | |
| 58 | - | Visit(*paneNode) error | |
| 59 | - | } | |
| 60 | - | ``` | |
| 61 | - | ||
| 62 | - | **NO MEGA-INTERFACES** - Avoid TreeManager, FocusManager, etc. | |
| 63 | - | ||
| 64 | - | ## Phase 3: Rename Types (Remove "Manager" Suffix) | |
| 65 | - | ||
| 66 | - | ### Renames: | |
| 67 | - | - `WorkspaceManager` → `Workspace` | |
| 68 | - | - `FocusStateMachine` → `FocusController` | |
| 69 | - | - `StackedPaneManager` → `StackController` | |
| 70 | - | - `TreeValidator` → `Validator` | |
| 71 | - | - `GeometryValidator` → (merge into Validator) | |
| 72 | - | - `WidgetTransactionManager` → `WidgetTx` | |
| 73 | - | - `StateTombstoneManager` → `StateCapture` | |
| 74 | - | ||
| 75 | - | ## Phase 4: Consolidate by Functionality | |
| 76 | - | ||
| 77 | - | ### tree.go (~1500 lines): | |
| 78 | - | Merge: | |
| 79 | - | - workspace_pane_ops.go (core operations) | |
| 80 | - | - workspace_tree_rebalancer.go (rebalancing) | |
| 81 | - | - workspace_navigation.go (tree navigation) | |
| 82 | - | ||
| 83 | - | ### focus.go (~600 lines): | |
| 84 | - | Simplify focus_state_machine.go: | |
| 85 | - | - Remove complex state machine | |
| 86 | - | - Use simple focused pointer + validation | |
| 87 | - | - Move CSS to widget.go | |
| 88 | - | ||
| 89 | - | ### widget.go (~700 lines): | |
| 90 | - | Merge: | |
| 91 | - | - Current widget.go | |
| 92 | - | - workspace_widget_transaction.go | |
| 93 | - | - Widget parts from workspace_utils.go | |
| 94 | - | ||
| 95 | - | ### validation.go (~800 lines): | |
| 96 | - | Merge: | |
| 97 | - | - workspace_tree_validator.go | |
| 98 | - | - workspace_geometry_validator.go | |
| 99 | - | - Validation from workspace_bulletproof_operations.go | |
| 100 | - | ||
| 101 | - | ### state.go (~600 lines): | |
| 102 | - | Simplify workspace_state_tombstone.go: | |
| 103 | - | - Remove complex tombstone system | |
| 104 | - | - Simple state snapshots | |
| 105 | - | ||
| 106 | - | ## Phase 5: Simplify Workspace Type | |
| 107 | - | ||
| 108 | - | ### Current WorkspaceManager (too complex): | |
| 109 | - | ```go | |
| 110 | - | type WorkspaceManager struct { | |
| 111 | - | // 20+ fields, multiple mutexes, nested managers | |
| 112 | - | } | |
| 113 | - | ``` | |
| 114 | - | ||
| 115 | - | ### New Workspace (simplified): | |
| 116 | - | ```go | |
| 117 | - | type Workspace struct { | |
| 118 | - | app *BrowserApp | |
| 119 | - | root *paneNode | |
| 120 | - | focus *paneNode // Currently focused pane | |
| 121 | - | views map[*webkit.WebView]*paneNode | |
| 122 | - | ||
| 123 | - | mu sync.RWMutex // Single mutex | |
| 124 | - | } | |
| 125 | - | ||
| 126 | - | // Methods directly on Workspace, no delegation | |
| 127 | - | func (w *Workspace) Split(target *paneNode, dir Direction) (*paneNode, error) | |
| 128 | - | func (w *Workspace) Close(target *paneNode) error | |
| 129 | - | func (w *Workspace) Focus(target *paneNode) error | |
| 130 | - | ``` | |
| 131 | - | ||
| 132 | - | ## Phase 6: Remove Overengineering | |
| 133 | - | ||
| 134 | - | ### Remove: | |
| 135 | - | - Complex state machine for focus (use simple pointer) | |
| 136 | - | - Transaction system (use defer for cleanup) | |
| 137 | - | - Concurrency controller (use sync.Mutex) | |
| 138 | - | - Multiple validation layers (validate once) | |
| 139 | - | - Bulletproof wrappers (handle errors normally) | |
| 140 | - | ||
| 141 | - | ### Simplify to: | |
| 142 | - | ```go | |
| 143 | - | // Instead of complex transactions | |
| 144 | - | func (w *Workspace) Split(target *paneNode, dir Direction) (*paneNode, error) { | |
| 145 | - | w.mu.Lock() | |
| 146 | - | defer w.mu.Unlock() | |
| 147 | - | ||
| 148 | - | // Validate once | |
| 149 | - | if err := w.validate(); err != nil { | |
| 150 | - | return nil, err | |
| 151 | - | } | |
| 152 | - | ||
| 153 | - | // Do the split | |
| 154 | - | newNode := w.doSplit(target, dir) | |
| 155 | - | ||
| 156 | - | // Update focus | |
| 157 | - | w.focus = newNode | |
| 158 | - | ||
| 159 | - | return newNode, nil | |
| 160 | - | } | |
| 161 | - | ``` | |
| 162 | - | ||
| 163 | - | ## Benefits of This Approach: | |
| 164 | - | ||
| 165 | - | 1. **Go-idiomatic**: Follows stdlib patterns | |
| 166 | - | 2. **Simpler**: 40% less code, easier to understand | |
| 167 | - | 3. **No over-abstraction**: Interfaces only where needed | |
| 168 | - | 4. **Clear ownership**: Each file has single responsibility | |
| 169 | - | 5. **Testable**: Small interfaces easy to mock | |
| 170 | - | 6. **Maintainable**: Less indirection, clearer flow | |
| 171 | - | ||
| 172 | - | ## What We're NOT Doing: | |
| 173 | - | - Creating interface for everything | |
| 174 | - | - Using "Manager" suffix | |
| 175 | - | - Complex dependency injection | |
| 176 | - | - Abstracting GTK (until actually needed) | |
| 177 | - | - Multiple layers of validation | |
| 178 | - | ||
| 179 | - | ## Implementation Order: | |
| 180 | - | 1. Rename files (remove workspace_ prefix) | |
| 181 | - | 2. Merge related files by functionality | |
| 182 | - | 3. Simplify types (remove Manager suffix) | |
| 183 | - | 4. Remove unnecessary abstractions | |
| 184 | - | 5. Test that everything still works | |
| 185 | - | ||
| 186 | - | This follows Go's philosophy: **"Clear is better than clever"** | |
| 187 | - | ||
| 188 | - | ## File Consolidation Map | |
| 189 | - | ||
| 190 | - | ### Current → New: | |
| 191 | - | ``` | |
| 192 | - | workspace_manager.go → browser.go (merge with existing) | |
| 193 | - | workspace_types.go → pane.go (merge with existing) | |
| 194 | - | workspace_pane_ops.go → tree.go | |
| 195 | - | workspace_tree_rebalancer.go → tree.go (merge) | |
| 196 | - | workspace_navigation.go → tree.go (merge) | |
| 197 | - | focus_state_machine.go → focus.go (simplified) | |
| 198 | - | widget.go → widget.go (keep, expand) | |
| 199 | - | workspace_widget_transaction.go → widget.go (merge) | |
| 200 | - | workspace_utils.go → widget.go (merge widget parts) | |
| 201 | - | workspace_tree_validator.go → validation.go | |
| 202 | - | workspace_geometry_validator.go → validation.go (merge) | |
| 203 | - | workspace_bulletproof_operations.go → validation.go (merge) | |
| 204 | - | workspace_state_tombstone.go → state.go (simplified) | |
| 205 | - | workspace_stack_lifecycle.go → state.go (merge) | |
| 206 | - | stacked_panes.go → stack.go (rename) | |
| 207 | - | workspace_css.go → widget.go (merge CSS parts) | |
| 208 | - | workspace_popup.go → popup.go (rename) | |
| 209 | - | workspace_concurrency.go → REMOVE (use standard sync) | |
| 210 | - | shortcuts.go → shortcuts.go (keep) | |
| 211 | - | window_shortcuts.go → shortcuts.go (merge) | |
| 212 | - | webview.go → webview.go (keep) | |
| 213 | - | interfaces.go → REMOVE (define interfaces where used) | |
| 214 | - | ``` | |
| 215 | - | ||
| 216 | - | ### Result: 24 files → 12 files (50% reduction)⏎̸ | |
D docs/PANE_ARCHITECTURE.md +0 -742
| @@ -1,742 +0,0 @@ | |||
| 1 | - | # Binary Tree / Pane Handling Architecture Documentation | |
| 2 | - | ||
| 3 | - | This document provides an in-depth analysis of the binary tree and pane handling system in the Dumber browser application, based on thorough examination of the actual codebase. | |
| 4 | - | ||
| 5 | - | ## Table of Contents | |
| 6 | - | ||
| 7 | - | 1. [Overall Architecture](#overall-architecture) | |
| 8 | - | 2. [Core Data Structures](#core-data-structures) | |
| 9 | - | 3. [Component Hierarchy](#component-hierarchy) | |
| 10 | - | 4. [Binary Tree Operations](#binary-tree-operations) | |
| 11 | - | 5. [Widget Layer Integration](#widget-layer-integration) | |
| 12 | - | 6. [Focus Management System](#focus-management-system) | |
| 13 | - | 7. [Safety and Validation Systems](#safety-and-validation-systems) | |
| 14 | - | 8. [Operation Flow Diagrams](#operation-flow-diagrams) | |
| 15 | - | ||
| 16 | - | ## Overall Architecture | |
| 17 | - | ||
| 18 | - | The pane handling system implements a sophisticated binary tree structure for managing browser panes (similar to tmux/Zellij), with multiple layers of abstraction and safety systems. | |
| 19 | - | ||
| 20 | - | ```mermaid | |
| 21 | - | graph TB | |
| 22 | - | subgraph "Application Layer" | |
| 23 | - | BrowserApp[BrowserApp] | |
| 24 | - | BrowserPane[BrowserPane] | |
| 25 | - | end | |
| 26 | - | ||
| 27 | - | subgraph "Workspace Management Layer" | |
| 28 | - | WorkspaceManager[WorkspaceManager] | |
| 29 | - | paneNode[paneNode - Binary Tree Node] | |
| 30 | - | StackedPaneManager[StackedPaneManager] | |
| 31 | - | FocusStateMachine[FocusStateMachine] | |
| 32 | - | end | |
| 33 | - | ||
| 34 | - | subgraph "Safety Layer" | |
| 35 | - | TreeValidator[TreeValidator] | |
| 36 | - | GeometryValidator[GeometryValidator] | |
| 37 | - | WidgetTxManager[WidgetTransactionManager] | |
| 38 | - | ConcurrencyController[ConcurrencyController] | |
| 39 | - | TreeRebalancer[TreeRebalancer] | |
| 40 | - | StateTombstoneManager[StateTombstoneManager] | |
| 41 | - | end | |
| 42 | - | ||
| 43 | - | subgraph "Widget Layer" | |
| 44 | - | SafeWidget[SafeWidget Wrapper] | |
| 45 | - | WidgetRegistry[WidgetRegistry] | |
| 46 | - | end | |
| 47 | - | ||
| 48 | - | subgraph "GTK/WebKit Layer" | |
| 49 | - | GtkPaned[GtkPaned - Split Container] | |
| 50 | - | GtkBox[GtkBox - Stack Container] | |
| 51 | - | WebView[WebKitWebView] | |
| 52 | - | GTKWindow[GtkWindow] | |
| 53 | - | end | |
| 54 | - | ||
| 55 | - | BrowserApp --> WorkspaceManager | |
| 56 | - | WorkspaceManager --> paneNode | |
| 57 | - | WorkspaceManager --> StackedPaneManager | |
| 58 | - | WorkspaceManager --> FocusStateMachine | |
| 59 | - | WorkspaceManager --> TreeValidator | |
| 60 | - | WorkspaceManager --> GeometryValidator | |
| 61 | - | paneNode --> SafeWidget | |
| 62 | - | SafeWidget --> GtkPaned | |
| 63 | - | SafeWidget --> GtkBox | |
| 64 | - | SafeWidget --> WebView | |
| 65 | - | BrowserPane --> WebView | |
| 66 | - | ``` | |
| 67 | - | ||
| 68 | - | ## Core Data Structures | |
| 69 | - | ||
| 70 | - | ### paneNode Structure | |
| 71 | - | ||
| 72 | - | The `paneNode` is the fundamental building block of the binary tree: | |
| 73 | - | ||
| 74 | - | ```mermaid | |
| 75 | - | classDiagram | |
| 76 | - | class paneNode { | |
| 77 | - | +BrowserPane pane | |
| 78 | - | +paneNode parent | |
| 79 | - | +paneNode left | |
| 80 | - | +paneNode right | |
| 81 | - | +SafeWidget container | |
| 82 | - | +Orientation orientation | |
| 83 | - | +bool isLeaf | |
| 84 | - | +bool isPopup | |
| 85 | - | +WindowType windowType | |
| 86 | - | +WindowFeatures windowFeatures | |
| 87 | - | +bool isRelated | |
| 88 | - | +paneNode parentPane | |
| 89 | - | +bool autoClose | |
| 90 | - | +uintptr hoverToken | |
| 91 | - | +uintptr focusControllerToken | |
| 92 | - | +bool pendingHoverReattach | |
| 93 | - | +bool pendingFocusReattach | |
| 94 | - | +bool isStacked // TRUE: stack container, FALSE: regular pane or pane within stack | |
| 95 | - | +[]*paneNode stackedPanes // Non-empty only if isStacked=true | |
| 96 | - | +int activeStackIndex | |
| 97 | - | +SafeWidget titleBar | |
| 98 | - | +SafeWidget stackWrapper | |
| 99 | - | } | |
| 100 | - | ||
| 101 | - | class BrowserPane { | |
| 102 | - | +WebView webView | |
| 103 | - | +MessageHandler messageHandler | |
| 104 | - | +ZoomController zoomController | |
| 105 | - | +Cleanup() | |
| 106 | - | +CleanupFromWorkspace() | |
| 107 | - | } | |
| 108 | - | ||
| 109 | - | class SafeWidget { | |
| 110 | - | +uintptr ptr | |
| 111 | - | +string typeInfo | |
| 112 | - | +bool valid | |
| 113 | - | +Execute(func(uintptr) error) | |
| 114 | - | +Invalidate() | |
| 115 | - | +IsValid() bool | |
| 116 | - | } | |
| 117 | - | ||
| 118 | - | paneNode --> BrowserPane | |
| 119 | - | paneNode --> SafeWidget | |
| 120 | - | ``` | |
| 121 | - | ||
| 122 | - | ### WorkspaceManager Structure | |
| 123 | - | ||
| 124 | - | ```mermaid | |
| 125 | - | classDiagram | |
| 126 | - | class WorkspaceManager { | |
| 127 | - | +BrowserApp app | |
| 128 | - | +Window window | |
| 129 | - | +paneNode root | |
| 130 | - | +paneNode mainPane | |
| 131 | - | +map[WebView]paneNode viewToNode | |
| 132 | - | +map[WebView]time.Time lastSplitMsg | |
| 133 | - | +map[WebView]time.Time lastExitMsg | |
| 134 | - | +bool paneModeActive | |
| 135 | - | +int32 splitting | |
| 136 | - | +bool cssInitialized | |
| 137 | - | +func createWebViewFn | |
| 138 | - | +func createPaneFn | |
| 139 | - | +WebView paneModeSource | |
| 140 | - | +time.Time lastPaneModeEntry | |
| 141 | - | +sync.Mutex paneMutex | |
| 142 | - | +StackedPaneManager stackedPaneManager | |
| 143 | - | +FocusStateMachine focusStateMachine | |
| 144 | - | +sync.Mutex widgetMutex | |
| 145 | - | +WidgetRegistry widgetRegistry | |
| 146 | - | +TreeValidator treeValidator | |
| 147 | - | +WidgetTransactionManager widgetTxManager | |
| 148 | - | +ConcurrencyController concurrencyController | |
| 149 | - | +TreeRebalancer treeRebalancer | |
| 150 | - | +GeometryValidator geometryValidator | |
| 151 | - | +StackLifecycleManager stackLifecycleManager | |
| 152 | - | +StateTombstoneManager stateTombstoneManager | |
| 153 | - | } | |
| 154 | - | ``` | |
| 155 | - | ||
| 156 | - | ## Component Hierarchy | |
| 157 | - | ||
| 158 | - | The system follows a clear hierarchical structure: | |
| 159 | - | ||
| 160 | - | ```mermaid | |
| 161 | - | graph TD | |
| 162 | - | subgraph "Tree Structure" | |
| 163 | - | Root[Root paneNode] | |
| 164 | - | Split1[Split paneNode<br/>GtkPaned Horizontal] | |
| 165 | - | Split2[Split paneNode<br/>GtkPaned Vertical] | |
| 166 | - | Stack1[Stack paneNode<br/>GtkBox Container] | |
| 167 | - | Leaf1[Leaf paneNode<br/>WebView 1] | |
| 168 | - | Leaf2[Leaf paneNode<br/>WebView 2] | |
| 169 | - | Leaf3[Leaf paneNode<br/>WebView 3] | |
| 170 | - | Leaf4[Leaf paneNode<br/>WebView 4] | |
| 171 | - | Leaf5[Leaf paneNode<br/>WebView 5] | |
| 172 | - | end | |
| 173 | - | ||
| 174 | - | Root --> Split1 | |
| 175 | - | Root --> Leaf1 | |
| 176 | - | Split1 --> Split2 | |
| 177 | - | Split1 --> Stack1 | |
| 178 | - | Split2 --> Leaf2 | |
| 179 | - | Split2 --> Leaf3 | |
| 180 | - | Stack1 --> Leaf4 | |
| 181 | - | Stack1 --> Leaf5 | |
| 182 | - | ||
| 183 | - | style Root fill:#e1f5fe | |
| 184 | - | style Split1 fill:#f3e5f5 | |
| 185 | - | style Split2 fill:#f3e5f5 | |
| 186 | - | style Stack1 fill:#e8f5e8 | |
| 187 | - | style Leaf1 fill:#fff3e0 | |
| 188 | - | style Leaf2 fill:#fff3e0 | |
| 189 | - | style Leaf3 fill:#fff3e0 | |
| 190 | - | style Leaf4 fill:#fff3e0 | |
| 191 | - | style Leaf5 fill:#fff3e0 | |
| 192 | - | ``` | |
| 193 | - | ||
| 194 | - | ## Binary Tree Operations | |
| 195 | - | ||
| 196 | - | ### Stacked Pane Node Semantics | |
| 197 | - | ||
| 198 | - | **Critical Implementation Detail**: The `isStacked` flag has precise semantics that are essential for correct operation: | |
| 199 | - | ||
| 200 | - | - **Stack Container Node**: `isStacked=true`, `isLeaf=false`, `stackedPanes` contains the individual panes | |
| 201 | - | - **Panes Within Stack**: `isStacked=false`, `isLeaf=true`, `parent` points to the stack container | |
| 202 | - | ||
| 203 | - | Individual panes within a stack are **NOT** marked as stacked themselves. Only the container node managing the stack has `isStacked=true`. This allows: | |
| 204 | - | - Split validation to accept individual panes within stacks | |
| 205 | - | - Split operations to automatically target the stack container (not the individual pane) | |
| 206 | - | - Proper tree traversal and focus management | |
| 207 | - | ||
| 208 | - | **Splitting from Stacked Panes**: When splitting from a pane inside a stack, the system automatically: | |
| 209 | - | 1. Detects `target.parent.isStacked == true` | |
| 210 | - | 2. Promotes the split target to the stack container | |
| 211 | - | 3. Splits around the **entire stack**, not the individual pane | |
| 212 | - | 4. Creates a new sibling to the stack container in the tree | |
| 213 | - | ||
| 214 | - | This ensures stacks remain cohesive units in the tree structure. | |
| 215 | - | ||
| 216 | - | ### Split Operation Flow | |
| 217 | - | ||
| 218 | - | ```mermaid | |
| 219 | - | sequenceDiagram | |
| 220 | - | participant User | |
| 221 | - | participant WM as WorkspaceManager | |
| 222 | - | participant BP as BulletproofOps | |
| 223 | - | participant TV as TreeValidator | |
| 224 | - | participant GV as GeometryValidator | |
| 225 | - | participant GTK as GTK Layer | |
| 226 | - | ||
| 227 | - | User->>WM: Request split (target, direction) | |
| 228 | - | WM->>BP: BulletproofSplitNode() | |
| 229 | - | BP->>TV: ValidateTree(before_split) | |
| 230 | - | TV-->>BP: Validation result | |
| 231 | - | BP->>GV: ValidateSplit(target, direction) | |
| 232 | - | GV-->>BP: Geometry validation | |
| 233 | - | ||
| 234 | - | alt Validation passes | |
| 235 | - | BP->>WM: splitNode(target, direction) | |
| 236 | - | WM->>GTK: Create GtkPaned | |
| 237 | - | GTK-->>WM: Paned widget | |
| 238 | - | WM->>GTK: Reparent existing widget | |
| 239 | - | WM->>GTK: Add new WebView | |
| 240 | - | WM->>WM: Update tree structure | |
| 241 | - | WM-->>BP: New pane node | |
| 242 | - | BP->>TV: ValidateTree(after_split) | |
| 243 | - | BP-->>User: Success + new pane | |
| 244 | - | else Validation fails | |
| 245 | - | BP-->>User: Error message | |
| 246 | - | end | |
| 247 | - | ``` | |
| 248 | - | ||
| 249 | - | ### Close Operation Flow | |
| 250 | - | ||
| 251 | - | ```mermaid | |
| 252 | - | sequenceDiagram | |
| 253 | - | participant User | |
| 254 | - | participant WM as WorkspaceManager | |
| 255 | - | participant BP as BulletproofOps | |
| 256 | - | participant STM as StateTombstoneManager | |
| 257 | - | participant TV as TreeValidator | |
| 258 | - | participant TR as TreeRebalancer | |
| 259 | - | participant GTK as GTK Layer | |
| 260 | - | ||
| 261 | - | User->>WM: Request close (target) | |
| 262 | - | WM->>BP: BulletproofClosePane() | |
| 263 | - | BP->>STM: CaptureState("close") | |
| 264 | - | STM-->>BP: State tombstone | |
| 265 | - | BP->>TV: ValidateTree(before_close) | |
| 266 | - | TV-->>BP: Validation result | |
| 267 | - | ||
| 268 | - | alt Has sibling | |
| 269 | - | BP->>WM: closePane(target) | |
| 270 | - | WM->>WM: Find sibling/replacement node | |
| 271 | - | WM->>GTK: Unparent target widget | |
| 272 | - | WM->>WM: swapContainers (skips attachRoot if TreeRebalancer enabled) | |
| 273 | - | WM->>GTK: Promote sibling in tree structure | |
| 274 | - | WM->>WM: Update tree structure | |
| 275 | - | WM-->>BP: Return promoted node | |
| 276 | - | BP->>TV: ValidateTree(after_close) | |
| 277 | - | BP->>TR: RebalanceAfterClose(closed, promoted) | |
| 278 | - | TR->>TR: Execute promotion transaction | |
| 279 | - | TR->>GTK: WidgetResetSizeRequest(promoted) | |
| 280 | - | TR->>GTK: Attach to window/parent (handles double-attachment prevention) | |
| 281 | - | TR->>GTK: Queue allocation on ancestors | |
| 282 | - | TR->>TR: Validate geometry | |
| 283 | - | TR-->>BP: Promotion completed | |
| 284 | - | BP-->>User: Success | |
| 285 | - | else Last pane | |
| 286 | - | WM->>GTK: QuitMainLoop() | |
| 287 | - | WM-->>BP: Return nil (app exiting) | |
| 288 | - | else Error occurs | |
| 289 | - | BP->>STM: RestoreState(tombstone) | |
| 290 | - | BP-->>User: Error + rollback | |
| 291 | - | end | |
| 292 | - | ``` | |
| 293 | - | ||
| 294 | - | ### Stack Operation | |
| 295 | - | ||
| 296 | - | ```mermaid | |
| 297 | - | sequenceDiagram | |
| 298 | - | participant User | |
| 299 | - | participant WM as WorkspaceManager | |
| 300 | - | participant SPM as StackedPaneManager | |
| 301 | - | participant GTK as GTK Layer | |
| 302 | - | ||
| 303 | - | User->>WM: Request stack (target) | |
| 304 | - | WM->>SPM: StackPane(target) | |
| 305 | - | SPM->>GTK: Create GtkBox container | |
| 306 | - | GTK-->>SPM: Box widget | |
| 307 | - | SPM->>GTK: Create title bar | |
| 308 | - | SPM->>GTK: Add target to stack | |
| 309 | - | SPM->>GTK: Create new WebView | |
| 310 | - | SPM->>GTK: Add new pane to stack | |
| 311 | - | SPM->>SPM: Update stack navigation | |
| 312 | - | SPM-->>WM: New stacked node | |
| 313 | - | WM-->>User: Success | |
| 314 | - | ``` | |
| 315 | - | ||
| 316 | - | ## Widget Layer Integration | |
| 317 | - | ||
| 318 | - | ### SafeWidget Architecture | |
| 319 | - | ||
| 320 | - | ```mermaid | |
| 321 | - | graph TB | |
| 322 | - | subgraph "SafeWidget System" | |
| 323 | - | SW[SafeWidget] | |
| 324 | - | WR[WidgetRegistry] | |
| 325 | - | WT[WidgetTransaction] | |
| 326 | - | end | |
| 327 | - | ||
| 328 | - | subgraph "GTK Widgets" | |
| 329 | - | GP[GtkPaned] | |
| 330 | - | GB[GtkBox] | |
| 331 | - | WV[WebKitWebView] | |
| 332 | - | GL[GtkLabel] | |
| 333 | - | end | |
| 334 | - | ||
| 335 | - | SW --> WR | |
| 336 | - | SW --> WT | |
| 337 | - | SW --> GP | |
| 338 | - | SW --> GB | |
| 339 | - | SW --> WV | |
| 340 | - | SW --> GL | |
| 341 | - | ||
| 342 | - | WR -.-> |"Track lifetimes"| GP | |
| 343 | - | WR -.-> |"Track lifetimes"| GB | |
| 344 | - | WR -.-> |"Track lifetimes"| WV | |
| 345 | - | ||
| 346 | - | WT -.-> |"Atomic operations"| GP | |
| 347 | - | WT -.-> |"Atomic operations"| GB | |
| 348 | - | WT -.-> |"Atomic operations"| WV | |
| 349 | - | ``` | |
| 350 | - | ||
| 351 | - | ### Widget Lifecycle Management | |
| 352 | - | ||
| 353 | - | ```mermaid | |
| 354 | - | stateDiagram-v2 | |
| 355 | - | [*] --> Created | |
| 356 | - | Created --> Registered: Register with WidgetRegistry | |
| 357 | - | Registered --> Realized: WidgetRealizeInContainer() | |
| 358 | - | Realized --> Parented: Add to parent widget | |
| 359 | - | Parented --> Visible: WidgetShow() | |
| 360 | - | Visible --> Processing: Normal operation | |
| 361 | - | Processing --> Reparenting: During splits/closes | |
| 362 | - | Reparenting --> Unparented: WidgetUnparent() | |
| 363 | - | Unparented --> Parented: New parent assigned | |
| 364 | - | Processing --> Invalidated: Widget destroyed | |
| 365 | - | Invalidated --> [*] | |
| 366 | - | ||
| 367 | - | Reparenting --> ValidationFailed: Widget becomes invalid | |
| 368 | - | ValidationFailed --> Invalidated | |
| 369 | - | ``` | |
| 370 | - | ||
| 371 | - | ## Focus Management System | |
| 372 | - | ||
| 373 | - | ### Focus State Machine | |
| 374 | - | ||
| 375 | - | ```mermaid | |
| 376 | - | stateDiagram-v2 | |
| 377 | - | [*] --> Initializing | |
| 378 | - | Initializing --> Idle: System ready | |
| 379 | - | Idle --> Transitioning: Focus request received | |
| 380 | - | Transitioning --> Focused: Focus applied successfully | |
| 381 | - | Focused --> Idle: Focus settled | |
| 382 | - | Transitioning --> Reconciling: Focus conflict detected | |
| 383 | - | Reconciling --> Focused: Conflict resolved | |
| 384 | - | Reconciling --> Idle: Fallback to previous state | |
| 385 | - | ||
| 386 | - | note right of Transitioning: Priority queue processes requests | |
| 387 | - | note right of Reconciling: CSS class synchronization | |
| 388 | - | note right of Focused: GTK focus + CSS classes applied | |
| 389 | - | ``` | |
| 390 | - | ||
| 391 | - | ### Focus Request Processing | |
| 392 | - | ||
| 393 | - | ```mermaid | |
| 394 | - | graph TB | |
| 395 | - | subgraph "Focus Request Flow" | |
| 396 | - | FR[Focus Request] | |
| 397 | - | DD[Request Deduplicator] | |
| 398 | - | PQ[Priority Queue] | |
| 399 | - | FSM[Focus State Machine] | |
| 400 | - | GC[GTK Controllers] | |
| 401 | - | CSS[CSS Classes] | |
| 402 | - | end | |
| 403 | - | ||
| 404 | - | FR --> DD | |
| 405 | - | DD --> |"Not duplicate"| PQ | |
| 406 | - | DD --> |"Duplicate"| Discard[Discard] | |
| 407 | - | PQ --> FSM | |
| 408 | - | FSM --> GC | |
| 409 | - | FSM --> CSS | |
| 410 | - | ||
| 411 | - | subgraph "Priority Levels" | |
| 412 | - | PU[Urgent: 100] | |
| 413 | - | PH[High: 90] | |
| 414 | - | PN[Normal: 50] | |
| 415 | - | PL[Low: 10] | |
| 416 | - | end | |
| 417 | - | ||
| 418 | - | PQ --> PU | |
| 419 | - | PQ --> PH | |
| 420 | - | PQ --> PN | |
| 421 | - | PQ --> PL | |
| 422 | - | ``` | |
| 423 | - | ||
| 424 | - | ## Safety and Validation Systems | |
| 425 | - | ||
| 426 | - | ### Tree Validation | |
| 427 | - | ||
| 428 | - | ```mermaid | |
| 429 | - | graph TB | |
| 430 | - | subgraph "Tree Validation System" | |
| 431 | - | TV[TreeValidator] | |
| 432 | - | VS[Validation Suite] | |
| 433 | - | ||
| 434 | - | subgraph "Validation Checks" | |
| 435 | - | PC[Parent-Child Consistency] | |
| 436 | - | RC[Root Node Validation] | |
| 437 | - | LC[Leaf Node Validation] | |
| 438 | - | SC[Stack Consistency] | |
| 439 | - | WC[Widget Consistency] | |
| 440 | - | end | |
| 441 | - | end | |
| 442 | - | ||
| 443 | - | TV --> VS | |
| 444 | - | VS --> PC | |
| 445 | - | VS --> RC | |
| 446 | - | VS --> LC | |
| 447 | - | VS --> SC | |
| 448 | - | VS --> WC | |
| 449 | - | ||
| 450 | - | PC -.-> |"Bidirectional links"| TreeStructure[Tree Structure] | |
| 451 | - | RC -.-> |"Single root"| TreeStructure | |
| 452 | - | LC -.-> |"Valid panes"| TreeStructure | |
| 453 | - | SC -.-> |"Stack integrity"| TreeStructure | |
| 454 | - | WC -.-> |"Widget pointers"| TreeStructure | |
| 455 | - | ``` | |
| 456 | - | ||
| 457 | - | ### Bulletproof Operation Wrapper | |
| 458 | - | ||
| 459 | - | ```mermaid | |
| 460 | - | sequenceDiagram | |
| 461 | - | participant Client | |
| 462 | - | participant BP as BulletproofOps | |
| 463 | - | participant STM as StateTombstone | |
| 464 | - | participant GV as GeometryValidator | |
| 465 | - | participant TV as TreeValidator | |
| 466 | - | participant CC as ConcurrencyController | |
| 467 | - | participant TR as TreeRebalancer | |
| 468 | - | participant Core as CoreOperation | |
| 469 | - | ||
| 470 | - | Client->>BP: Operation request | |
| 471 | - | BP->>STM: Capture state tombstone | |
| 472 | - | BP->>GV: Validate geometry constraints | |
| 473 | - | BP->>TV: Validate tree before operation | |
| 474 | - | ||
| 475 | - | alt On GTK main thread | |
| 476 | - | BP->>Core: Execute directly | |
| 477 | - | else Off main thread | |
| 478 | - | BP->>CC: Submit to concurrency controller | |
| 479 | - | CC->>Core: Execute on main thread | |
| 480 | - | end | |
| 481 | - | ||
| 482 | - | Core-->>BP: Operation result | |
| 483 | - | ||
| 484 | - | alt Success | |
| 485 | - | BP->>TV: Validate tree after operation | |
| 486 | - | BP->>TR: Rebalance if needed | |
| 487 | - | BP-->>Client: Success | |
| 488 | - | else Failure | |
| 489 | - | BP->>STM: Restore state from tombstone | |
| 490 | - | BP-->>Client: Error + rollback | |
| 491 | - | end | |
| 492 | - | ``` | |
| 493 | - | ||
| 494 | - | ## Operation Flow Diagrams | |
| 495 | - | ||
| 496 | - | ### Complete Split Operation | |
| 497 | - | ||
| 498 | - | ```mermaid | |
| 499 | - | flowchart TD | |
| 500 | - | Start([Split Request]) --> V1{Tree Valid?} | |
| 501 | - | V1 -->|No| Error1[Return Error] | |
| 502 | - | V1 -->|Yes| V2{Geometry Valid?} | |
| 503 | - | V2 -->|No| Error2[Return Error] | |
| 504 | - | V2 -->|Yes| Capture[Capture State Tombstone] | |
| 505 | - | ||
| 506 | - | Capture --> Thread{On GTK Thread?} | |
| 507 | - | Thread -->|Yes| Direct[Execute Directly] | |
| 508 | - | Thread -->|No| Queue[Submit to Concurrency Controller] | |
| 509 | - | Queue --> Execute[Execute on Main Thread] | |
| 510 | - | Direct --> Execute | |
| 511 | - | ||
| 512 | - | Execute --> CreatePaned[Create GtkPaned Widget] | |
| 513 | - | CreatePaned --> CreateWebView[Create New WebView] | |
| 514 | - | CreateWebView --> DetachControllers[Safely Detach GTK Controllers] | |
| 515 | - | DetachControllers --> UnparentTarget[Unparent Target Widget] | |
| 516 | - | UnparentTarget --> SetupPaned[Configure Paned Properties] | |
| 517 | - | SetupPaned --> AddChildren[Add Both Widgets to Paned] | |
| 518 | - | AddChildren --> UpdateTree[Update Tree Structure] | |
| 519 | - | UpdateTree --> AttachPaned[Attach Paned to Parent/Window] | |
| 520 | - | AttachPaned --> ReattachControllers[Reattach GTK Controllers] | |
| 521 | - | ReattachControllers --> ShowWidgets[Show All Widgets] | |
| 522 | - | ShowWidgets --> UpdateCSS[Update CSS Classes] | |
| 523 | - | UpdateCSS --> SetFocus[Set Focus to New Pane] | |
| 524 | - | SetFocus --> Validate[Validate Tree Structure] | |
| 525 | - | Validate --> Rebalance[Tree Rebalancing Check] | |
| 526 | - | Rebalance --> Success([Return New Pane]) | |
| 527 | - | ||
| 528 | - | Execute -->|Error| Rollback[Restore from Tombstone] | |
| 529 | - | Rollback --> Error3[Return Error] | |
| 530 | - | ||
| 531 | - | style Start fill:#e1f5fe | |
| 532 | - | style Success fill:#e8f5e8 | |
| 533 | - | style Error1 fill:#ffebee | |
| 534 | - | style Error2 fill:#ffebee | |
| 535 | - | style Error3 fill:#ffebee | |
| 536 | - | ``` | |
| 537 | - | ||
| 538 | - | ### Complete Close Operation | |
| 539 | - | ||
| 540 | - | ```mermaid | |
| 541 | - | flowchart TD | |
| 542 | - | Start([Close Request]) --> Stack{In Stack?} | |
| 543 | - | Stack -->|Yes| StackClose[Use Stack Lifecycle Manager] | |
| 544 | - | Stack -->|No| ValidateTree[Validate Tree Structure] | |
| 545 | - | ||
| 546 | - | ValidateTree --> Capture[Capture State Tombstone] | |
| 547 | - | Capture --> CheckLast{Last Pane?} | |
| 548 | - | ||
| 549 | - | CheckLast -->|Yes| QuitApp[Quit Application - Return nil] | |
| 550 | - | CheckLast -->|No| CheckRoot{Is Root?} | |
| 551 | - | ||
| 552 | - | CheckRoot -->|Yes| FindReplacement[Find Replacement Root] | |
| 553 | - | CheckRoot -->|No| FindSibling[Find Sibling Node] | |
| 554 | - | ||
| 555 | - | FindReplacement --> HasReplacement{Has Replacement?} | |
| 556 | - | HasReplacement -->|No| QuitApp | |
| 557 | - | HasReplacement -->|Yes| PromoteRoot[Promote Replacement to Root] | |
| 558 | - | ||
| 559 | - | FindSibling --> ValidateWidgets[Validate Widget State] | |
| 560 | - | ValidateWidgets --> CheckGrand{Has Grandparent?} | |
| 561 | - | ||
| 562 | - | CheckGrand -->|Yes| PromoteToParent[Promote Sibling to Parent Position] | |
| 563 | - | CheckGrand -->|No| PromoteToRoot | |
| 564 | - | ||
| 565 | - | PromoteRoot --> TrackPromoted[Track Promoted Node] | |
| 566 | - | PromoteToParent --> TrackPromoted | |
| 567 | - | ||
| 568 | - | TrackPromoted --> UnparentFromCurrent[Unparent from Current Parent] | |
| 569 | - | UnparentFromCurrent --> AttachToNew[Attach to New Parent/Window] | |
| 570 | - | AttachToNew --> UpdateTreeRefs[Update Tree References] | |
| 571 | - | UpdateTreeRefs --> CleanupOld[Cleanup Old Parent Node] | |
| 572 | - | CleanupOld --> FindFocus[Find Focus Target] | |
| 573 | - | FindFocus --> SetFocus[Set Focus] | |
| 574 | - | SetFocus --> CleanupPane[Cleanup Closed Pane] | |
| 575 | - | CleanupPane --> DestroyWebView[Destroy WebView] | |
| 576 | - | DestroyWebView --> UpdateMain[Update Main Pane Reference] | |
| 577 | - | UpdateMain --> ReturnPromoted[Return Promoted Node] | |
| 578 | - | ReturnPromoted --> PromotionTx[Execute Promotion Transaction] | |
| 579 | - | PromotionTx --> ResetSize[WidgetResetSizeRequest] | |
| 580 | - | ResetSize --> ReattachWidget[Reattach with Expansion] | |
| 581 | - | ReattachWidget --> QueueAllocation[Queue Allocation on Ancestors] | |
| 582 | - | QueueAllocation --> ValidateGeometry[Validate Final Geometry] | |
| 583 | - | ValidateGeometry --> Success([Success with Promoted Node]) | |
| 584 | - | ||
| 585 | - | StackClose --> Success | |
| 586 | - | QuitApp --> SuccessNil([Success - App Exiting]) | |
| 587 | - | ||
| 588 | - | style Start fill:#e1f5fe | |
| 589 | - | style Success fill:#e8f5e8 | |
| 590 | - | style SuccessNil fill:#fff3e0 | |
| 591 | - | style TrackPromoted fill:#e3f2fd | |
| 592 | - | style PromotionTx fill:#f3e5f5 | |
| 593 | - | ``` | |
| 594 | - | ||
| 595 | - | ## CSS Class Management | |
| 596 | - | ||
| 597 | - | The system uses CSS classes for visual feedback and state indication: | |
| 598 | - | ||
| 599 | - | ```mermaid | |
| 600 | - | graph TB | |
| 601 | - | subgraph "CSS Class System" | |
| 602 | - | Base[.workspace-pane] | |
| 603 | - | Multi[.workspace-multi-pane] | |
| 604 | - | Active[.workspace-pane-active] | |
| 605 | - | Outline[.workspace-pane-active-outline] | |
| 606 | - | Stack[.stacked-pane-container] | |
| 607 | - | end | |
| 608 | - | ||
| 609 | - | subgraph "Application Logic" | |
| 610 | - | Single[Single Pane] --> Base | |
| 611 | - | Multiple[Multiple Panes] --> Base | |
| 612 | - | Multiple --> Multi | |
| 613 | - | Focused[Focused Pane] --> Active | |
| 614 | - | Focused --> Outline | |
| 615 | - | StackContainer[Stack Container] --> Stack | |
| 616 | - | end | |
| 617 | - | ||
| 618 | - | subgraph "Visual Effects" | |
| 619 | - | Base --> BasicStyling[Basic pane styling] | |
| 620 | - | Multi --> BorderStyling[Pane borders in multi-pane mode] | |
| 621 | - | Active --> FocusHighlight[Focus highlight] | |
| 622 | - | Outline --> FocusOutline[Focus outline border] | |
| 623 | - | Stack --> StackStyling[Stack container styling] | |
| 624 | - | end | |
| 625 | - | ``` | |
| 626 | - | ||
| 627 | - | ## Enhanced Promotion System | |
| 628 | - | ||
| 629 | - | The system now includes a sophisticated promotion transaction system that ensures proper widget layout after pane closures: | |
| 630 | - | ||
| 631 | - | ### Promotion Transaction Flow | |
| 632 | - | ||
| 633 | - | ```mermaid | |
| 634 | - | sequenceDiagram | |
| 635 | - | participant BP as BulletproofOps | |
| 636 | - | participant WM as WorkspaceManager | |
| 637 | - | participant TR as TreeRebalancer | |
| 638 | - | participant WT as WidgetTransaction | |
| 639 | - | participant GTK as GTK Layer | |
| 640 | - | ||
| 641 | - | BP->>WM: closePane(node) | |
| 642 | - | WM->>WM: Identify promoted node | |
| 643 | - | WM->>GTK: Update tree structure | |
| 644 | - | WM-->>BP: Return promoted node | |
| 645 | - | ||
| 646 | - | BP->>TR: RebalanceAfterClose(closed, promoted) | |
| 647 | - | TR->>WT: Begin promotion transaction | |
| 648 | - | ||
| 649 | - | Note over TR,WT: Widget size constraint reset | |
| 650 | - | TR->>WT: Add WidgetResetSizeRequest operation | |
| 651 | - | ||
| 652 | - | Note over TR,WT: Widget reattachment with expansion | |
| 653 | - | TR->>WT: Add reattachment operation | |
| 654 | - | ||
| 655 | - | Note over TR,WT: Ancestor allocation propagation | |
| 656 | - | TR->>WT: Add allocation queue operations | |
| 657 | - | ||
| 658 | - | Note over TR,WT: Geometry validation | |
| 659 | - | TR->>WT: Add validation operation | |
| 660 | - | ||
| 661 | - | WT->>GTK: Execute all operations atomically | |
| 662 | - | GTK-->>WT: Operations completed | |
| 663 | - | WT->>WT: Commit transaction | |
| 664 | - | WT-->>TR: Transaction success | |
| 665 | - | TR-->>BP: Promotion completed | |
| 666 | - | ``` | |
| 667 | - | ||
| 668 | - | ### Key Promotion Features | |
| 669 | - | ||
| 670 | - | - **Size Constraint Reset**: `WidgetResetSizeRequest()` clears stale size constraints | |
| 671 | - | - **Proper Expansion**: Ensures promoted widgets can expand to fill available space | |
| 672 | - | - **Ancestor Propagation**: Queues allocation updates up the widget hierarchy | |
| 673 | - | - **Geometry Validation**: Validates final widget bounds after promotion | |
| 674 | - | - **Transaction Safety**: All operations are atomic with rollback capability | |
| 675 | - | ||
| 676 | - | ## Key Files and Their Responsibilities | |
| 677 | - | ||
| 678 | - | | File | Purpose | Key Components | | |
| 679 | - | |------|---------|----------------| | |
| 680 | - | | `workspace_types.go:8` | Core data structures | `paneNode`, CSS constants | | |
| 681 | - | | `workspace_manager.go:18` | Main coordinator | `WorkspaceManager`, initialization | | |
| 682 | - | | `workspace_pane_ops.go:707` | Tree operations | Split, close, stack operations with promoted node tracking | | |
| 683 | - | | `workspace_tree_rebalancer.go:92` | Tree balancing | Rebalancing algorithms, promotion transactions | | |
| 684 | - | | `workspace_bulletproof_operations.go:158` | Safety wrapper | Bulletproof operation wrappers with promotion handling | | |
| 685 | - | | `workspace_concurrency.go:454` | Async operations | Concurrency controller with promoted node results | | |
| 686 | - | | `focus_state_machine.go:186` | Focus management | State machine, priority queue | | |
| 687 | - | | `workspace_widgets_cgo.go:633` | GTK integration | Widget operations, WidgetResetSizeRequest | | |
| 688 | - | ||
| 689 | - | ## Performance Characteristics | |
| 690 | - | ||
| 691 | - | - **Tree Height**: O(log n) for balanced trees, O(n) worst case | |
| 692 | - | - **Split Operation**: O(log n) average case with rebalancing | |
| 693 | - | - **Close Operation**: O(1) tree updates + O(k) widget operations where k = ancestor depth | |
| 694 | - | - **Focus Changes**: O(1) with priority queue and deduplication | |
| 695 | - | - **Widget Operations**: Atomic transactions prevent partial state | |
| 696 | - | - **Promotion Transactions**: Batched GTK operations minimize UI thread overhead | |
| 697 | - | - **Memory Usage**: Each pane node ~200 bytes + GTK widget overhead | |
| 698 | - | ||
| 699 | - | ### Recent Performance Improvements | |
| 700 | - | ||
| 701 | - | - **Promoted Node Tracking**: Eliminates redundant tree traversals during rebalancing | |
| 702 | - | - **Batched Widget Operations**: Ancestor allocation updates processed in single transaction | |
| 703 | - | - **Size Constraint Management**: Explicit constraint reset prevents GTK layout conflicts | |
| 704 | - | - **Async Consistency**: Identical behavior between sync and async operation paths | |
| 705 | - | ||
| 706 | - | ### Widget Safety Enhancements | |
| 707 | - | ||
| 708 | - | Recent improvements prevent GTK assertion failures and widget corruption: | |
| 709 | - | ||
| 710 | - | - **CSS Class Safety**: All CSS class operations verify `WidgetIsValid()` before modification to prevent GTK bloom filter corruption when widgets are destroyed mid-operation | |
| 711 | - | - **Paned Child Clearing**: Before destroying paned widgets, verifies `WidgetGetParent(child) == paned_ptr` to avoid unparenting already-reparented children | |
| 712 | - | - **Double-Attachment Prevention**: `swapContainers` skips `attachRoot` when `TreeRebalancer` is enabled, delegating widget attachment to the promotion transaction | |
| 713 | - | - **Unparent Verification**: Stack container creation includes explicit unparent checks and logging for freshly-created WebView widgets | |
| 714 | - | ||
| 715 | - | ## Thread Safety | |
| 716 | - | ||
| 717 | - | The system implements comprehensive thread safety: | |
| 718 | - | ||
| 719 | - | - **Widget Operations**: All GTK operations marshaled to main thread | |
| 720 | - | - **Focus Management**: Queue-based serialization with priority | |
| 721 | - | - **State Changes**: Mutex protection for critical sections | |
| 722 | - | - **Tree Modifications**: Atomic operations with rollback capability | |
| 723 | - | ||
| 724 | - | ## Known Constraints and Design Decisions | |
| 725 | - | ||
| 726 | - | ### Stacked Pane Management | |
| 727 | - | ||
| 728 | - | 1. **Individual panes within stacks have `isStacked=false`**: Only the container node has `isStacked=true`. This enables split validation and proper tree operations. | |
| 729 | - | ||
| 730 | - | 2. **Splitting targets the stack container**: When splitting from a pane inside a stack, the operation automatically promotes to split the entire stack container, not the individual pane. | |
| 731 | - | ||
| 732 | - | 3. **Widget attachment coordination**: When `TreeRebalancer` is enabled, `closePane` delegates widget attachment to the rebalancer's promotion transaction to prevent double-attachment race conditions. | |
| 733 | - | ||
| 734 | - | ### GTK Widget Lifecycle | |
| 735 | - | ||
| 736 | - | 1. **Reparenting invalidates container references**: When widgets are reparented via `PanedSetStartChild/EndChild` or `window.SetChild`, GTK automatically unparents them from the old container. Code must check `WidgetGetParent(child) == old_parent` before attempting cleanup. | |
| 737 | - | ||
| 738 | - | 2. **CSS operations require validity checks**: CSS class manipulation on destroyed widgets corrupts GTK's internal bloom filter. All CSS operations verify `WidgetIsValid()` before proceeding. | |
| 739 | - | ||
| 740 | - | 3. **WebView containers may have implicit parents**: Freshly created WebView widgets may have internal GTK parent references that require explicit unparenting before box/paned insertion. | |
| 741 | - | ||
| 742 | - | This architecture provides a robust, efficient, and safe pane management system that can handle complex window layouts while maintaining excellent performance and user experience.⏎̸ | |
D docs/PANE_CLOSE_REFACTORING_PLAN.md +0 -491
| @@ -1,491 +0,0 @@ | |||
| 1 | - | # Detailed Pane Close Refactoring Plan | |
| 2 | - | ||
| 3 | - | ## Problem Analysis Summary | |
| 4 | - | ||
| 5 | - | The current implementation in `internal/app/browser/workspace_pane_ops.go:707`+ exhibits **three interlocked failure modes**: | |
| 6 | - | 1. **Multiple Pane Closure**: The promotion branch (around `workspace_pane_ops.go:990`) iterates over invalid parent/child references, causing both siblings to be detached when `wm.app.panes` still counts them. This yields double-close behaviour during rapid shortcuts. | |
| 7 | - | 2. **Invalid Widget Promotion**: The root promotion path (roughly `workspace_pane_ops.go:878-948`) manipulates widgets after `Cleanup()` invalidates their `SafeWidget` guard, which leads to sporadic `GTK_IS_WIDGET` assertions. | |
| 8 | - | 3. **Race Conditions**: Cleanup (`ensureCleanup`) and hover-detach run while asynchronous `IdleAdd` callbacks still reference the old nodes, so hover/focus reattachment runs against freed memory during reproduction of issue #1429. | |
| 9 | - | ||
| 10 | - | > Baseline reproduction: trigger repeated `closePane` via Ctrl+W on a layout with nested root + stack; log spew shows `[workspace] close aborted` alongside panics in `ensureSafeWidget`. | |
| 11 | - | ||
| 12 | - | ### Environment Constraints | |
| 13 | - | - `stackedPaneManager.CloseStackedPane` is tightly coupled to `wm.stackedPaneManager`, so any refactor must either reimplement its logic or provide compatibility shims. | |
| 14 | - | - `BulletproofClosePane` wraps `closePane`, retrying on specific errors; changes to panic/zero-return semantics must preserve that contract. | |
| 15 | - | - Hover/focus controller lifecycles (`wm.ensureHover`, `focusStateMachine.attachGTKController`) must remain intact after tree restructuring. | |
| 16 | - | ||
| 17 | - | ### Preconditions & Guardrails | |
| 18 | - | 1. Capture baseline behaviour with `go test ./tests/workspace/...` (currently incomplete; we will add targeted tests in Phase 5). | |
| 19 | - | 2. Add temporary structured logging with `log.Printf("[pane-close] ...")` gated behind an environment toggle to avoid production noise. | |
| 20 | - | 3. Ensure unit test scaffolding can construct `WorkspaceManager` with fake `webkit` bindings (see `tests/browser/fakes`). | |
| 21 | - | ||
| 22 | - | ## PHASE 0: Stabilise & Instrument | |
| 23 | - | ||
| 24 | - | 1. Wrap `BulletproofClosePane` with structured logging (log key: `pane-close-stage`) guarded by `wm.debugInstrumentation` flag. | |
| 25 | - | 2. Add `WorkspaceDiagnostics` helper capturing tree snapshots (`wm.dumpTreeState`) before and after `closePane`. | |
| 26 | - | 3. Add regression harness in `tests/browser/workspace_close_test.go` that constructs a representative tree and exercises edge cases (root-only, nested split, stack + split). | |
| 27 | - | 4. Verify reproduction steps still fail prior to refactor to ensure harness is meaningful. | |
| 28 | - | ||
| 29 | - | ## PHASE 1: New Simplified Architecture | |
| 30 | - | ||
| 31 | - | ### 1.1 Keep File Layout, Replace Implementation In-Place | |
| 32 | - | ``` | |
| 33 | - | internal/app/browser/workspace_pane_ops.go # Replace existing closePane implementation with new logic | |
| 34 | - | internal/app/browser/workspace_types.go # Extend paneNode metadata | |
| 35 | - | ``` | |
| 36 | - | ||
| 37 | - | ### 1.2 Core Data Structure Changes | |
| 38 | - | ```go | |
| 39 | - | // Add to paneNode struct | |
| 40 | - | type paneNode struct { | |
| 41 | - | // ... existing fields ... | |
| 42 | - | ||
| 43 | - | widgetValid bool // Guard flagged before GTK destruction | |
| 44 | - | cleanupGeneration uint // Helps assert that asynchronous callbacks skip stale nodes | |
| 45 | - | } | |
| 46 | - | ||
| 47 | - | // Add to WorkspaceManager | |
| 48 | - | type WorkspaceManager struct { | |
| 49 | - | // ... existing fields ... | |
| 50 | - | ||
| 51 | - | cleanupCounter uint | |
| 52 | - | } | |
| 53 | - | ``` | |
| 54 | - | ||
| 55 | - | ## PHASE 2: Detailed New closePane Implementation | |
| 56 | - | ||
| 57 | - | ### 2.1 Main Close Function (60-80 lines total) | |
| 58 | - | ```go | |
| 59 | - | func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) { | |
| 60 | - | ctx := wm.beginClose(node) | |
| 61 | - | defer ctx.finish() | |
| 62 | - | ||
| 63 | - | // STEP 1: Basic validation (quick fail) | |
| 64 | - | if ctx.err != nil { | |
| 65 | - | return nil, ctx.err | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | // STEP 2: Handle stacked panes via compatibility shim | |
| 69 | - | if node.parent != nil && node.parent.isStacked { | |
| 70 | - | return wm.closeStackedPaneCompat(node) | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | // STEP 3: Handle trivial exit cases | |
| 74 | - | if ctx.remaining == 1 { | |
| 75 | - | return wm.cleanupAndExit(node) | |
| 76 | - | } | |
| 77 | - | if node == wm.root { | |
| 78 | - | return wm.promoteNewRoot(ctx, node) | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | // STEP 4: Promote sibling in-place | |
| 82 | - | parent := node.parent | |
| 83 | - | sibling := wm.getSibling(node) | |
| 84 | - | grandparent := parent.parent | |
| 85 | - | wm.ensureWidgets(grandparent, parent, sibling) | |
| 86 | - | ||
| 87 | - | wm.promoteSibling(grandparent, parent, sibling) | |
| 88 | - | ||
| 89 | - | // STEP 5: GTK updates leverage auto-unparenting | |
| 90 | - | wm.swapContainers(grandparent, sibling) | |
| 91 | - | ||
| 92 | - | // STEP 6: Cleanup & focus | |
| 93 | - | wm.cleanupPane(node, ctx.Generation()) | |
| 94 | - | wm.decommissionParent(parent, ctx.Generation()) | |
| 95 | - | wm.setFocusToLeaf(sibling) | |
| 96 | - | ||
| 97 | - | return sibling, nil | |
| 98 | - | } | |
| 99 | - | ``` | |
| 100 | - | ||
| 101 | - | ### 2.2 Helper Functions with Exact Implementation | |
| 102 | - | ||
| 103 | - | #### getSibling - CORRECTED LOGIC | |
| 104 | - | ```go | |
| 105 | - | func (wm *WorkspaceManager) getSibling(node *paneNode) *paneNode { | |
| 106 | - | if node.parent == nil { | |
| 107 | - | return nil | |
| 108 | - | } | |
| 109 | - | // FIXED: Simple, clear sibling identification | |
| 110 | - | if node.parent.left == node { | |
| 111 | - | return node.parent.right | |
| 112 | - | } | |
| 113 | - | return node.parent.left | |
| 114 | - | } | |
| 115 | - | ``` | |
| 116 | - | ||
| 117 | - | #### beginClose / closeContext | |
| 118 | - | ```go | |
| 119 | - | type closeContext struct { | |
| 120 | - | wm *WorkspaceManager | |
| 121 | - | target *paneNode | |
| 122 | - | remaining int | |
| 123 | - | err error | |
| 124 | - | generation uint | |
| 125 | - | } | |
| 126 | - | ||
| 127 | - | func (wm *WorkspaceManager) beginClose(node *paneNode) closeContext { | |
| 128 | - | ctx := closeContext{wm: wm, target: node, generation: wm.nextCleanupGeneration()} | |
| 129 | - | switch { | |
| 130 | - | case wm == nil: | |
| 131 | - | ctx.err = errors.New("workspace manager nil") | |
| 132 | - | case node == nil || !node.isLeaf: | |
| 133 | - | ctx.err = errors.New("invalid close target") | |
| 134 | - | case node.pane == nil || node.pane.webView == nil: | |
| 135 | - | ctx.err = errors.New("close target missing webview") | |
| 136 | - | default: | |
| 137 | - | ctx.remaining = len(wm.app.panes) | |
| 138 | - | } | |
| 139 | - | return ctx | |
| 140 | - | } | |
| 141 | - | ||
| 142 | - | func (ctx closeContext) Generation() uint { return ctx.generation } | |
| 143 | - | ||
| 144 | - | func (ctx closeContext) finish() { | |
| 145 | - | if ctx.err == nil { | |
| 146 | - | ctx.wm.updateMainPane() | |
| 147 | - | } | |
| 148 | - | } | |
| 149 | - | ``` | |
| 150 | - | ||
| 151 | - | #### ensureWidgets | |
| 152 | - | ```go | |
| 153 | - | func (wm *WorkspaceManager) ensureWidgets(nodes ...*paneNode) { | |
| 154 | - | for _, n := range nodes { | |
| 155 | - | if n == nil || n.container == nil { | |
| 156 | - | continue | |
| 157 | - | } | |
| 158 | - | if n.container.IsValid() { | |
| 159 | - | continue | |
| 160 | - | } | |
| 161 | - | ptr := n.container.Ptr() | |
| 162 | - | n.container = wm.widgetRegistry.Recover(ptr, n.container.typeInfo) | |
| 163 | - | } | |
| 164 | - | } | |
| 165 | - | ``` | |
| 166 | - | ||
| 167 | - | #### promoteSibling | |
| 168 | - | ```go | |
| 169 | - | func (wm *WorkspaceManager) promoteSibling(grand *paneNode, parent *paneNode, sibling *paneNode) { | |
| 170 | - | if grand == nil { | |
| 171 | - | wm.root = sibling | |
| 172 | - | sibling.parent = nil | |
| 173 | - | return | |
| 174 | - | } | |
| 175 | - | sibling.parent = grand | |
| 176 | - | if grand.left == parent { | |
| 177 | - | grand.left = sibling | |
| 178 | - | } else { | |
| 179 | - | grand.right = sibling | |
| 180 | - | } | |
| 181 | - | } | |
| 182 | - | ``` | |
| 183 | - | ||
| 184 | - | #### swapContainers | |
| 185 | - | ```go | |
| 186 | - | func (wm *WorkspaceManager) swapContainers(grand *paneNode, sibling *paneNode) { | |
| 187 | - | if grand == nil { | |
| 188 | - | wm.attachRoot(sibling) | |
| 189 | - | return | |
| 190 | - | } | |
| 191 | - | grand.container.Execute(func(gPtr uintptr) error { | |
| 192 | - | sibling.container.Execute(func(sPtr uintptr) error { | |
| 193 | - | if grand.left == sibling { | |
| 194 | - | webkit.PanedSetStartChild(gPtr, sPtr) | |
| 195 | - | } else { | |
| 196 | - | webkit.PanedSetEndChild(gPtr, sPtr) | |
| 197 | - | } | |
| 198 | - | return nil | |
| 199 | - | }) | |
| 200 | - | return nil | |
| 201 | - | }) | |
| 202 | - | } | |
| 203 | - | ``` | |
| 204 | - | ||
| 205 | - | #### decommissionParent | |
| 206 | - | ```go | |
| 207 | - | func (wm *WorkspaceManager) decommissionParent(parent *paneNode, generation uint) { | |
| 208 | - | if parent == nil { | |
| 209 | - | return | |
| 210 | - | } | |
| 211 | - | wm.cleanupPane(parent, generation) | |
| 212 | - | } | |
| 213 | - | ``` | |
| 214 | - | ||
| 215 | - | #### nextCleanupGeneration | |
| 216 | - | ```go | |
| 217 | - | func (wm *WorkspaceManager) nextCleanupGeneration() uint { | |
| 218 | - | wm.cleanupCounter++ | |
| 219 | - | return wm.cleanupCounter | |
| 220 | - | } | |
| 221 | - | ``` | |
| 222 | - | ||
| 223 | - | #### attachRoot | |
| 224 | - | ```go | |
| 225 | - | func (wm *WorkspaceManager) attachRoot(root *paneNode) { | |
| 226 | - | if root == nil || root.container == nil || wm.window == nil { | |
| 227 | - | return | |
| 228 | - | } | |
| 229 | - | root.container.Execute(func(ptr uintptr) error { | |
| 230 | - | wm.window.SetChild(ptr) | |
| 231 | - | webkit.WidgetQueueAllocate(ptr) | |
| 232 | - | webkit.WidgetShow(ptr) | |
| 233 | - | return nil | |
| 234 | - | }) | |
| 235 | - | } | |
| 236 | - | ``` | |
| 237 | - | ||
| 238 | - | #### promoteNewRoot - Handle Root Replacement | |
| 239 | - | ```go | |
| 240 | - | func (wm *WorkspaceManager) promoteNewRoot(ctx closeContext, oldRoot *paneNode) (*paneNode, error) { | |
| 241 | - | candidate := wm.findReplacementRoot(oldRoot) | |
| 242 | - | if candidate == nil { | |
| 243 | - | return wm.cleanupAndExit(oldRoot) | |
| 244 | - | } | |
| 245 | - | ||
| 246 | - | sibling := wm.getSibling(candidate) | |
| 247 | - | if sibling != nil { | |
| 248 | - | wm.promoteSibling(candidate.parent.parent, candidate.parent, sibling) | |
| 249 | - | } | |
| 250 | - | ||
| 251 | - | candidate.parent = nil | |
| 252 | - | wm.root = candidate | |
| 253 | - | ||
| 254 | - | wm.attachRoot(candidate) | |
| 255 | - | ||
| 256 | - | wm.cleanupPane(oldRoot, ctx.Generation()) | |
| 257 | - | return candidate, nil | |
| 258 | - | } | |
| 259 | - | ``` | |
| 260 | - | ||
| 261 | - | #### cleanupPane - Safe Cleanup | |
| 262 | - | ```go | |
| 263 | - | func (wm *WorkspaceManager) cleanupPane(node *paneNode, generation uint) { | |
| 264 | - | if node == nil { | |
| 265 | - | return | |
| 266 | - | } | |
| 267 | - | if !node.widgetValid { | |
| 268 | - | return | |
| 269 | - | } | |
| 270 | - | ||
| 271 | - | node.widgetValid = false | |
| 272 | - | node.cleanupGeneration = generation | |
| 273 | - | ||
| 274 | - | if node.pane != nil { | |
| 275 | - | node.pane.CleanupFromWorkspace(wm) | |
| 276 | - | if node.pane.webView != nil { | |
| 277 | - | node.pane.webView.Destroy() | |
| 278 | - | } | |
| 279 | - | } | |
| 280 | - | ||
| 281 | - | if node.container != nil { | |
| 282 | - | node.container.Invalidate() | |
| 283 | - | node.container = nil | |
| 284 | - | } | |
| 285 | - | ||
| 286 | - | node.parent = nil | |
| 287 | - | node.left = nil | |
| 288 | - | node.right = nil | |
| 289 | - | } | |
| 290 | - | ``` | |
| 291 | - | ||
| 292 | - | ## PHASE 3: Stack Container Simplification | |
| 293 | - | ||
| 294 | - | ### 3.1 Compatibility Layer for Stacked Panes | |
| 295 | - | ```go | |
| 296 | - | func (wm *WorkspaceManager) closeStackedPaneCompat(node *paneNode) (*paneNode, error) { | |
| 297 | - | stack := node.parent | |
| 298 | - | ||
| 299 | - | // Find index | |
| 300 | - | index := -1 | |
| 301 | - | for i, pane := range stack.stackedPanes { | |
| 302 | - | if pane == node { | |
| 303 | - | index = i | |
| 304 | - | break | |
| 305 | - | } | |
| 306 | - | } | |
| 307 | - | ||
| 308 | - | if index == -1 { | |
| 309 | - | return nil, errors.New("pane not in stack") | |
| 310 | - | } | |
| 311 | - | ||
| 312 | - | // Remove from array | |
| 313 | - | stack.stackedPanes = append( | |
| 314 | - | stack.stackedPanes[:index], | |
| 315 | - | stack.stackedPanes[index+1:]... | |
| 316 | - | ) | |
| 317 | - | ||
| 318 | - | // If only one pane left, unstack it | |
| 319 | - | if len(stack.stackedPanes) == 1 { | |
| 320 | - | remaining := stack.stackedPanes[0] | |
| 321 | - | ||
| 322 | - | // Replace stack with remaining pane in tree | |
| 323 | - | remaining.parent = stack.parent | |
| 324 | - | if stack.parent != nil { | |
| 325 | - | if stack.parent.left == stack { | |
| 326 | - | stack.parent.left = remaining | |
| 327 | - | } else { | |
| 328 | - | stack.parent.right = remaining | |
| 329 | - | } | |
| 330 | - | ||
| 331 | - | // Update GTK widgets | |
| 332 | - | stack.parent.container.Execute(func(pPtr uintptr) error { | |
| 333 | - | remaining.container.Execute(func(rPtr uintptr) error { | |
| 334 | - | if stack.parent.left == remaining { | |
| 335 | - | webkit.PanedSetStartChild(pPtr, rPtr) | |
| 336 | - | } else { | |
| 337 | - | webkit.PanedSetEndChild(pPtr, rPtr) | |
| 338 | - | } | |
| 339 | - | return nil | |
| 340 | - | }) | |
| 341 | - | return nil | |
| 342 | - | }) | |
| 343 | - | } else { | |
| 344 | - | // Stack was root | |
| 345 | - | wm.root = remaining | |
| 346 | - | remaining.container.Execute(func(ptr uintptr) error { | |
| 347 | - | wm.window.SetChild(ptr) | |
| 348 | - | return nil | |
| 349 | - | }) | |
| 350 | - | } | |
| 351 | - | ||
| 352 | - | // Cleanup stack container | |
| 353 | - | wm.cleanupPane(stack, wm.nextCleanupGeneration()) | |
| 354 | - | ||
| 355 | - | return remaining, nil | |
| 356 | - | } | |
| 357 | - | ||
| 358 | - | // Update active index if needed | |
| 359 | - | if stack.activeStackIndex >= len(stack.stackedPanes) { | |
| 360 | - | stack.activeStackIndex = len(stack.stackedPanes) - 1 | |
| 361 | - | } | |
| 362 | - | ||
| 363 | - | // Remove widget from GTK box | |
| 364 | - | stack.container.Execute(func(boxPtr uintptr) error { | |
| 365 | - | node.container.Execute(func(nodePtr uintptr) error { | |
| 366 | - | webkit.BoxRemove(boxPtr, nodePtr) | |
| 367 | - | return nil | |
| 368 | - | }) | |
| 369 | - | return nil | |
| 370 | - | }) | |
| 371 | - | ||
| 372 | - | // Show new active pane | |
| 373 | - | if stack.activeStackIndex >= 0 { | |
| 374 | - | active := stack.stackedPanes[stack.activeStackIndex] | |
| 375 | - | active.container.Execute(func(ptr uintptr) error { | |
| 376 | - | webkit.WidgetShow(ptr) | |
| 377 | - | return nil | |
| 378 | - | }) | |
| 379 | - | } | |
| 380 | - | ||
| 381 | - | // Cleanup closed pane | |
| 382 | - | wm.cleanupPane(node, wm.nextCleanupGeneration()) | |
| 383 | - | ||
| 384 | - | return stack, nil | |
| 385 | - | } | |
| 386 | - | ``` | |
| 387 | - | ||
| 388 | - | ## PHASE 4: GTK4 Widget Lifecycle Rules | |
| 389 | - | ||
| 390 | - | ### 4.1 Key GTK4 Rules to Follow | |
| 391 | - | 1. **Auto-unparenting**: When calling `gtk_paned_set_start_child(paned, new_child)`, GTK4 automatically: | |
| 392 | - | - Unparents the old child (if any) | |
| 393 | - | - Unparents new_child from its current parent (if any) | |
| 394 | - | - Sets new_child as the start child | |
| 395 | - | ||
| 396 | - | 2. **Widget Validity**: Always check `GTK_IS_WIDGET(widget)` before operations | |
| 397 | - | ||
| 398 | - | 3. **Reference Counting**: GTK4 uses reference counting - widgets are destroyed when ref count reaches 0 | |
| 399 | - | ||
| 400 | - | ### 4.2 Safe Widget Operations Pattern | |
| 401 | - | ```go | |
| 402 | - | // NEVER do this: | |
| 403 | - | webkit.WidgetUnparent(widget) // Manual unparent | |
| 404 | - | webkit.PanedSetStartChild(paned, widget) // Then set | |
| 405 | - | ||
| 406 | - | // ALWAYS do this: | |
| 407 | - | webkit.PanedSetStartChild(paned, widget) // GTK handles unparenting | |
| 408 | - | ||
| 409 | - | // For boxes: | |
| 410 | - | webkit.BoxRemove(box, widget) // Removes and unparents | |
| 411 | - | // OR | |
| 412 | - | webkit.BoxAppend(box, widget) // Auto-unparents from old parent | |
| 413 | - | ``` | |
| 414 | - | ||
| 415 | - | ## PHASE 5: Migration Strategy | |
| 416 | - | ||
| 417 | - | ### 5.1 Step-by-Step Migration | |
| 418 | - | 1. Land instrumentation (`beginClose` logging, `dumpTreeState`) behind `DebugPaneClose` flag. | |
| 419 | - | 2. Extend data structures (`widgetValid`, `cleanupGeneration`, `cleanupCounter`). | |
| 420 | - | 3. Introduce helper scaffolding (`closeContext`, `ensureWidgets`, `promoteSibling`, `swapContainers`). | |
| 421 | - | 4. Replace core `closePane` logic and keep compatibility helpers stubbed. | |
| 422 | - | 5. Rewrite stacked close handling to call `closeStackedPaneCompat` while keeping `StackedPaneManager` API surface intact. | |
| 423 | - | 6. Add regression tests + golden tree dumps; run `go test ./tests/browser/...` and `make build`. | |
| 424 | - | 7. Remove verbose logging, keep assertions + generation checks. | |
| 425 | - | ||
| 426 | - | ## PHASE 6: Validation & Safety | |
| 427 | - | ||
| 428 | - | ### 6.1 Add Assertions | |
| 429 | - | ```go | |
| 430 | - | // Add to every widget operation: | |
| 431 | - | if !webkit.WidgetIsValid(ptr) { | |
| 432 | - | return fmt.Errorf("invalid widget %#x", ptr) | |
| 433 | - | } | |
| 434 | - | ||
| 435 | - | // Before tree operations: | |
| 436 | - | if err := wm.validateTreeConsistency(); err != nil { | |
| 437 | - | return nil, fmt.Errorf("tree inconsistent before close: %w", err) | |
| 438 | - | } | |
| 439 | - | ``` | |
| 440 | - | ||
| 441 | - | ### 6.2 Comprehensive Logging | |
| 442 | - | ```go | |
| 443 | - | // Before operation: | |
| 444 | - | log.Printf("[pane-close] start node=%p parent=%p sibling=%p", | |
| 445 | - | node, node.parent, sibling) | |
| 446 | - | ||
| 447 | - | // After tree update: | |
| 448 | - | log.Printf("[pane-close] tree updated new_root=%p promoted=%p gen=%d", | |
| 449 | - | wm.root, promoted, ctx.Generation()) | |
| 450 | - | ||
| 451 | - | // After GTK update: | |
| 452 | - | log.Printf("[pane-close] gtk updated widget=%#x parent=%#x", | |
| 453 | - | widgetPtr, parentPtr) | |
| 454 | - | ``` | |
| 455 | - | ||
| 456 | - | ## Expected Results | |
| 457 | - | ||
| 458 | - | ### Before (Current Issues): | |
| 459 | - | - 400+ lines of defensive code | |
| 460 | - | - Segfaults on complex layouts | |
| 461 | - | - Multiple panes closed incorrectly | |
| 462 | - | - Widget corruption errors | |
| 463 | - | ||
| 464 | - | ### After (New Implementation): | |
| 465 | - | - ≤200 lines of cohesive logic | |
| 466 | - | - Clean GTK4 lifecycle adherence | |
| 467 | - | - Predictable single-pane closure | |
| 468 | - | - Deterministic hover/focus cleanup using generations | |
| 469 | - | ||
| 470 | - | ## Implementation Order | |
| 471 | - | 1. Land instrumentation + regression harness (Phase 0) | |
| 472 | - | 2. Introduce data-structure additions (`widgetValid`, generation counter) | |
| 473 | - | 3. Implement new `closePane` + helper set in-place (Phase 2) | |
| 474 | - | 4. Wire stacked pane compatibility shim (Phase 3) | |
| 475 | - | 5. Run tests (`go test ./tests/browser/...`) and `make build` | |
| 476 | - | 6. Trim temporary logging once confidence is regained | |
| 477 | - | ||
| 478 | - | ## Key Files to Modify | |
| 479 | - | ||
| 480 | - | 1. `internal/app/browser/workspace_types.go` - Add `widgetValid` and `cleanupGeneration` | |
| 481 | - | 2. `internal/app/browser/workspace_manager.go` - Introduce `cleanupCounter` + helper | |
| 482 | - | 3. `internal/app/browser/workspace_pane_ops.go` - Replace `closePane`, add helpers, call into compat shim | |
| 483 | - | 4. `internal/app/browser/stacked_panes.go` - Point manager to `closeStackedPaneCompat` | |
| 484 | - | 5. `tests/browser/workspace_close_test.go` - Add regression scenarios | |
| 485 | - | ||
| 486 | - | ## Critical Success Metrics | |
| 487 | - | ||
| 488 | - | 1. **Stability**: No segfaults in any close scenario | |
| 489 | - | 2. **Correctness**: Only requested pane closes (never multiple) | |
| 490 | - | 3. **Performance**: Close operation completes in <50ms | |
| 491 | - | 4. **Maintainability**: Code is <200 lines and easily understood | |
D docs/WORKSPACE_SIMPLIFICATION_PLAN.md +0 -448
| @@ -1,448 +0,0 @@ | |||
| 1 | - | # Workspace Architecture Simplification Plan | |
| 2 | - | ||
| 3 | - | **Status**: Approved for implementation | |
| 4 | - | **Date**: 2025-09-30 | |
| 5 | - | **Goal**: Remove over-engineering, simplify architecture, improve naming | |
| 6 | - | ||
| 7 | - | ## Why Simplify | |
| 8 | - | ||
| 9 | - | Current architecture has ~2000 lines of defensive code that add complexity without measurable benefit: | |
| 10 | - | - "Bulletproof" terminology is confusing | |
| 11 | - | - ConcurrencyController bypassed 90% of the time (operations already on main thread) | |
| 12 | - | - Widget transaction batching adds overhead GTK4 already handles | |
| 13 | - | - Validation runs on every operation despite rarely catching issues | |
| 14 | - | - 5-layer call chain for simple operations | |
| 15 | - | ||
| 16 | - | **This is unreleased code** - we can refactor cleanly without backward compatibility concerns. | |
| 17 | - | ||
| 18 | - | --- | |
| 19 | - | ||
| 20 | - | ## Goals | |
| 21 | - | ||
| 22 | - | ✅ **40-50% less code** (~2000 lines removed) | |
| 23 | - | ✅ **Clearer architecture** (3 layers instead of 5) | |
| 24 | - | ✅ **Better naming** (SplitPane not BulletproofSplitNode) | |
| 25 | - | ✅ **Faster operations** (remove overhead) | |
| 26 | - | ✅ **Same correctness** (keep core algorithms) | |
| 27 | - | ||
| 28 | - | --- | |
| 29 | - | ||
| 30 | - | ## Phase 1: Rename Everything 🏷️ | |
| 31 | - | ||
| 32 | - | ### Method Renaming | |
| 33 | - | ```go | |
| 34 | - | // Public API (used by application code) | |
| 35 | - | BulletproofSplitNode → SplitPane | |
| 36 | - | BulletproofClosePane → ClosePane | |
| 37 | - | BulletproofStackPane → StackPane | |
| 38 | - | ||
| 39 | - | // Internal implementations (unchanged) | |
| 40 | - | splitNode → splitNode (stays private) | |
| 41 | - | closePane → closePane (stays private) | |
| 42 | - | ``` | |
| 43 | - | ||
| 44 | - | ### File Renaming | |
| 45 | - | ``` | |
| 46 | - | workspace_bulletproof_operations.go → workspace_operations.go | |
| 47 | - | workspace_tree_rebalancer.go → workspace_layout.go | |
| 48 | - | ``` | |
| 49 | - | ||
| 50 | - | ### Call Sites to Update | |
| 51 | - | - `workspace_manager.go:270` - split operation | |
| 52 | - | - `workspace_manager.go:401` - close operation | |
| 53 | - | - `workspace_pane_ops.go:182` - close current pane | |
| 54 | - | - `workspace_popup.go:158,412,498` - popup close operations | |
| 55 | - | ||
| 56 | - | --- | |
| 57 | - | ||
| 58 | - | ## Phase 2: Delete ConcurrencyController 🔥 | |
| 59 | - | ||
| 60 | - | ### Why Delete | |
| 61 | - | - **Reality**: 90% of operations already on GTK main thread | |
| 62 | - | - **Check cost**: `webkit.IsMainThread()` is 1 CPU instruction (pthread_self) | |
| 63 | - | - **Complexity**: 454 lines for edge case marshalling | |
| 64 | - | - **Better approach**: Direct IdleAdd for off-thread calls | |
| 65 | - | ||
| 66 | - | ### Replacement Pattern | |
| 67 | - | ```go | |
| 68 | - | func (wm *WorkspaceManager) SplitPane(target *paneNode, direction string) (*paneNode, error) { | |
| 69 | - | if !webkit.IsMainThread() { | |
| 70 | - | return wm.marshalToMainThread(func() (*paneNode, error) { | |
| 71 | - | return wm.splitPaneImpl(target, direction) | |
| 72 | - | }) | |
| 73 | - | } | |
| 74 | - | return wm.splitPaneImpl(target, direction) | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | func marshalToMainThread[T any](fn func() (T, error)) (T, error) { | |
| 78 | - | var result T | |
| 79 | - | var err error | |
| 80 | - | done := make(chan struct{}) | |
| 81 | - | ||
| 82 | - | webkit.IdleAdd(func() bool { | |
| 83 | - | result, err = fn() | |
| 84 | - | close(done) | |
| 85 | - | return false | |
| 86 | - | }) | |
| 87 | - | ||
| 88 | - | <-done | |
| 89 | - | return result, err | |
| 90 | - | } | |
| 91 | - | ``` | |
| 92 | - | ||
| 93 | - | ### Delete | |
| 94 | - | - **File**: `workspace_concurrency.go` (454 lines) | |
| 95 | - | - **Fields**: Remove from WorkspaceManager: | |
| 96 | - | - `concurrencyController *ConcurrencyController` | |
| 97 | - | - **References**: Update all operation calls | |
| 98 | - | ||
| 99 | - | --- | |
| 100 | - | ||
| 101 | - | ## Phase 3: Delete WidgetTransactionManager 🎯 | |
| 102 | - | ||
| 103 | - | ### Why Delete | |
| 104 | - | - **GTK4 already batches**: No need to manually batch widget ops | |
| 105 | - | - **Overhead**: Closure allocation, operation queuing, commit phase | |
| 106 | - | - **Direct calls work**: GTK4 docs say direct calls are fine | |
| 107 | - | ||
| 108 | - | ### Simplified Pattern | |
| 109 | - | ```go | |
| 110 | - | // Old (complex): | |
| 111 | - | tx := wm.widgetTxManager.BeginTransaction("promotion") | |
| 112 | - | tx.AddOperation(&WidgetOperation{ | |
| 113 | - | Execute: func() error { | |
| 114 | - | webkit.WidgetResetSizeRequest(ptr) | |
| 115 | - | webkit.WidgetSetHExpand(ptr, true) | |
| 116 | - | return nil | |
| 117 | - | }, | |
| 118 | - | }) | |
| 119 | - | tx.Execute() | |
| 120 | - | tx.Commit() | |
| 121 | - | ||
| 122 | - | // New (direct): | |
| 123 | - | webkit.WidgetResetSizeRequest(ptr) | |
| 124 | - | webkit.WidgetSetHExpand(ptr, true) | |
| 125 | - | webkit.WidgetQueueAllocate(ptr) | |
| 126 | - | ``` | |
| 127 | - | ||
| 128 | - | ### Delete | |
| 129 | - | - **File**: `workspace_widget_transaction.go` (400 lines) | |
| 130 | - | - **Fields**: Remove from WorkspaceManager: | |
| 131 | - | - `widgetTxManager *WidgetTransactionManager` | |
| 132 | - | - **Dependencies**: Update TreeRebalancer to call GTK directly | |
| 133 | - | ||
| 134 | - | --- | |
| 135 | - | ||
| 136 | - | ## Phase 4: Simplify TreeRebalancer 🌲 | |
| 137 | - | ||
| 138 | - | ### Current Problem | |
| 139 | - | 230-line promotion transaction for what should be 5 GTK calls | |
| 140 | - | ||
| 141 | - | ### New Implementation | |
| 142 | - | ```go | |
| 143 | - | func (wm *WorkspaceManager) promoteNodeAfterClose(node *paneNode) error { | |
| 144 | - | if node == nil || node.container == nil { | |
| 145 | - | return errors.New("invalid promotion target") | |
| 146 | - | } | |
| 147 | - | ||
| 148 | - | ptr := node.container.Ptr() | |
| 149 | - | if !webkit.WidgetIsValid(ptr) { | |
| 150 | - | return errors.New("widget destroyed during promotion") | |
| 151 | - | } | |
| 152 | - | ||
| 153 | - | // Step 1: Clear stale size constraints | |
| 154 | - | webkit.WidgetResetSizeRequest(ptr) | |
| 155 | - | webkit.WidgetSetHExpand(ptr, true) | |
| 156 | - | webkit.WidgetSetVExpand(ptr, true) | |
| 157 | - | ||
| 158 | - | // Step 2: Attach to new parent | |
| 159 | - | if node.parent == nil { | |
| 160 | - | wm.attachToWindow(ptr) | |
| 161 | - | } else { | |
| 162 | - | wm.attachToPaned(node.parent.container.Ptr(), ptr, node.parent.left == node) | |
| 163 | - | } | |
| 164 | - | ||
| 165 | - | // Step 3: Request layout recalculation | |
| 166 | - | webkit.WidgetQueueAllocate(ptr) | |
| 167 | - | if node.parent != nil { | |
| 168 | - | webkit.WidgetQueueAllocate(node.parent.container.Ptr()) | |
| 169 | - | } | |
| 170 | - | ||
| 171 | - | return nil | |
| 172 | - | } | |
| 173 | - | ``` | |
| 174 | - | ||
| 175 | - | ### Changes to TreeRebalancer | |
| 176 | - | - Remove `executePromotion` (230 lines of transaction code) | |
| 177 | - | - Replace with direct GTK calls (~30 lines) | |
| 178 | - | - Keep tree metrics calculation (useful for debugging) | |
| 179 | - | - Keep rotation logic (not used yet, but correct for future) | |
| 180 | - | ||
| 181 | - | --- | |
| 182 | - | ||
| 183 | - | ## Phase 5: Make Validation Opt-In 📊 | |
| 184 | - | ||
| 185 | - | ### Debug Mode Levels | |
| 186 | - | ```go | |
| 187 | - | type DebugLevel int | |
| 188 | - | ||
| 189 | - | const ( | |
| 190 | - | DebugOff DebugLevel = iota // Production: no validation | |
| 191 | - | DebugBasic // Development: basic checks | |
| 192 | - | DebugFull // Testing: full validation | |
| 193 | - | ) | |
| 194 | - | ||
| 195 | - | // Read from environment | |
| 196 | - | func getDebugLevel() DebugLevel { | |
| 197 | - | switch os.Getenv("DUMBER_DEBUG_WORKSPACE") { | |
| 198 | - | case "off", "0": | |
| 199 | - | return DebugOff | |
| 200 | - | case "basic", "1": | |
| 201 | - | return DebugBasic | |
| 202 | - | case "full", "2": | |
| 203 | - | return DebugFull | |
| 204 | - | default: | |
| 205 | - | return DebugBasic // Safe default for development | |
| 206 | - | } | |
| 207 | - | } | |
| 208 | - | ``` | |
| 209 | - | ||
| 210 | - | ### Conditional Validation | |
| 211 | - | ```go | |
| 212 | - | func (wm *WorkspaceManager) SplitPane(target *paneNode, direction string) (*paneNode, error) { | |
| 213 | - | // Quick validation (always run - cheap) | |
| 214 | - | if target == nil || !target.isLeaf { | |
| 215 | - | return nil, errors.New("split target must be leaf pane") | |
| 216 | - | } | |
| 217 | - | ||
| 218 | - | // Expensive validation (debug only) | |
| 219 | - | if wm.debugLevel >= DebugBasic { | |
| 220 | - | if err := wm.geometryValidator.ValidateSplit(target, direction); err != nil { | |
| 221 | - | return nil, fmt.Errorf("geometry validation: %w", err) | |
| 222 | - | } | |
| 223 | - | } | |
| 224 | - | ||
| 225 | - | if wm.debugLevel == DebugFull { | |
| 226 | - | wm.treeValidator.ValidateTree(wm.root, "before_split") | |
| 227 | - | } | |
| 228 | - | ||
| 229 | - | // Actual operation | |
| 230 | - | newNode, err := wm.splitPaneImpl(target, direction) | |
| 231 | - | ||
| 232 | - | if wm.debugLevel == DebugFull { | |
| 233 | - | wm.treeValidator.ValidateTree(wm.root, "after_split") | |
| 234 | - | } | |
| 235 | - | ||
| 236 | - | return newNode, err | |
| 237 | - | } | |
| 238 | - | ``` | |
| 239 | - | ||
| 240 | - | ### Keep Components (Made Optional) | |
| 241 | - | - `TreeValidator` - Useful for debugging tree corruption | |
| 242 | - | - `GeometryValidator` - Catches size constraint issues | |
| 243 | - | - `StateTombstoneManager` - Useful for rollback during development | |
| 244 | - | ||
| 245 | - | --- | |
| 246 | - | ||
| 247 | - | ## Phase 6: Simplify SafeWidget 🔧 | |
| 248 | - | ||
| 249 | - | ### Current Problem | |
| 250 | - | Every widget operation wrapped in closure: | |
| 251 | - | ```go | |
| 252 | - | node.container.Execute(func(ptr uintptr) error { | |
| 253 | - | webkit.WidgetShow(ptr) | |
| 254 | - | return nil | |
| 255 | - | }) | |
| 256 | - | ``` | |
| 257 | - | ||
| 258 | - | ### Simplified Approach | |
| 259 | - | ```go | |
| 260 | - | // SafeWidget keeps validity tracking but removes closure overhead | |
| 261 | - | type SafeWidget struct { | |
| 262 | - | ptr uintptr | |
| 263 | - | typeInfo string | |
| 264 | - | valid atomic.Bool | |
| 265 | - | } | |
| 266 | - | ||
| 267 | - | func (sw *SafeWidget) Ptr() uintptr { | |
| 268 | - | if sw.valid.Load() { | |
| 269 | - | return sw.ptr | |
| 270 | - | } | |
| 271 | - | return 0 | |
| 272 | - | } | |
| 273 | - | ||
| 274 | - | func (sw *SafeWidget) IsValid() bool { | |
| 275 | - | return sw.valid.Load() && webkit.WidgetIsValid(sw.ptr) | |
| 276 | - | } | |
| 277 | - | ||
| 278 | - | // Usage becomes direct: | |
| 279 | - | if ptr := node.container.Ptr(); ptr != 0 { | |
| 280 | - | webkit.WidgetShow(ptr) | |
| 281 | - | } | |
| 282 | - | ``` | |
| 283 | - | ||
| 284 | - | ### Migration | |
| 285 | - | - Remove `Execute()` method from SafeWidget | |
| 286 | - | - Update all call sites to use Ptr() + direct calls | |
| 287 | - | - Keep Invalidate() for cleanup tracking | |
| 288 | - | ||
| 289 | - | --- | |
| 290 | - | ||
| 291 | - | ## Phase 7: File Consolidation 📁 | |
| 292 | - | ||
| 293 | - | ### Delete These Files | |
| 294 | - | 1. `workspace_concurrency.go` - 454 lines | |
| 295 | - | 2. `workspace_widget_transaction.go` - 400 lines | |
| 296 | - | 3. `workspace_stack_lifecycle.go` - Merge into pane_ops (not heavily used) | |
| 297 | - | ||
| 298 | - | ### Rename These Files | |
| 299 | - | 1. `workspace_bulletproof_operations.go` → `workspace_operations.go` | |
| 300 | - | 2. `workspace_tree_rebalancer.go` → `workspace_layout.go` | |
| 301 | - | ||
| 302 | - | ### Final Structure (14 files, -2000 lines) | |
| 303 | - | ``` | |
| 304 | - | Core: | |
| 305 | - | workspace_types.go - Data structures | |
| 306 | - | workspace_manager.go - Manager initialization | |
| 307 | - | workspace_operations.go - Public API (SplitPane, ClosePane, StackPane) | |
| 308 | - | workspace_pane_ops.go - Private implementations | |
| 309 | - | workspace_layout.go - Tree rebalancing (simplified) | |
| 310 | - | ||
| 311 | - | Features: | |
| 312 | - | workspace_stacked_panes.go - Stack management | |
| 313 | - | workspace_focus.go - Focus state machine | |
| 314 | - | workspace_popup.go - Popup handling | |
| 315 | - | workspace_navigation.go - Keyboard navigation | |
| 316 | - | ||
| 317 | - | Utilities: | |
| 318 | - | workspace_utils.go - Helper functions | |
| 319 | - | workspace_css.go - CSS class management | |
| 320 | - | ||
| 321 | - | Debug (opt-in): | |
| 322 | - | workspace_tree_validator.go - Tree validation | |
| 323 | - | workspace_geometry_validator.go - Geometry checks | |
| 324 | - | workspace_state_tombstone.go - State snapshots | |
| 325 | - | workspace_debug.go - Debug helpers | |
| 326 | - | workspace_diagnostics.go - Diagnostics | |
| 327 | - | ``` | |
| 328 | - | ||
| 329 | - | --- | |
| 330 | - | ||
| 331 | - | ## Implementation Order | |
| 332 | - | ||
| 333 | - | ### Day 1: Safe Renames (2-3 hours) | |
| 334 | - | 1. ✅ Phase 1: Rename methods and files | |
| 335 | - | 2. ✅ Update all call sites | |
| 336 | - | 3. ✅ Update docs (PANE_ARCHITECTURE.md) | |
| 337 | - | 4. ✅ Commit: "refactor(workspace): rename bulletproof to standard names" | |
| 338 | - | ||
| 339 | - | ### Day 2: Remove Layers (4-5 hours) | |
| 340 | - | 5. ✅ Phase 2: Delete ConcurrencyController | |
| 341 | - | 6. ✅ Phase 3: Delete WidgetTransactionManager | |
| 342 | - | 7. ✅ Phase 6: Simplify SafeWidget | |
| 343 | - | 8. ✅ Commit: "refactor(workspace): remove transaction overhead" | |
| 344 | - | ||
| 345 | - | ### Day 3: Simplify Logic (3-4 hours) | |
| 346 | - | 9. ✅ Phase 4: Simplify TreeRebalancer promotion | |
| 347 | - | 10. ✅ Phase 5: Make validation opt-in | |
| 348 | - | 11. ✅ Commit: "refactor(workspace): simplify promotion and validation" | |
| 349 | - | ||
| 350 | - | ### Day 4: Cleanup (2 hours) | |
| 351 | - | 12. ✅ Phase 7: Consolidate files | |
| 352 | - | 13. ✅ Update imports everywhere | |
| 353 | - | 14. ✅ Commit: "refactor(workspace): consolidate files" | |
| 354 | - | ||
| 355 | - | ### Day 5: Test & Validate (3-4 hours) | |
| 356 | - | 15. ✅ Run with DUMBER_DEBUG_WORKSPACE=full | |
| 357 | - | 16. ✅ Test all split/close/stack operations | |
| 358 | - | 17. ✅ Verify no GTK warnings | |
| 359 | - | 18. ✅ Check geometry with debug tools | |
| 360 | - | 19. ✅ Commit: "test(workspace): validate simplification" | |
| 361 | - | ||
| 362 | - | **Total: 14-18 hours over 5 sessions** | |
| 363 | - | ||
| 364 | - | --- | |
| 365 | - | ||
| 366 | - | ## Testing Strategy | |
| 367 | - | ||
| 368 | - | ### Environment Setup | |
| 369 | - | ```bash | |
| 370 | - | # Full validation during refactoring | |
| 371 | - | export DUMBER_DEBUG_WORKSPACE=full | |
| 372 | - | ||
| 373 | - | # After validation passes, test production mode | |
| 374 | - | export DUMBER_DEBUG_WORKSPACE=off | |
| 375 | - | ``` | |
| 376 | - | ||
| 377 | - | ### Test Cases | |
| 378 | - | 1. **Splits**: All 4 directions (left/right/up/down) | |
| 379 | - | 2. **Closes**: | |
| 380 | - | - Non-root with sibling promotion | |
| 381 | - | - Root pane replacement | |
| 382 | - | - Last pane (app exit) | |
| 383 | - | - Pane within stack | |
| 384 | - | 3. **Stacks**: Create, navigate tabs, close tabs | |
| 385 | - | 4. **Stress Tests**: | |
| 386 | - | - Rapid split/close cycles | |
| 387 | - | - Split from stacked pane | |
| 388 | - | - Deep nesting (8+ levels) | |
| 389 | - | - Focus changes during operations | |
| 390 | - | ||
| 391 | - | ### Success Criteria | |
| 392 | - | - ✅ All operations work identically to before | |
| 393 | - | - ✅ No GTK warnings/errors in logs | |
| 394 | - | - ✅ Widget sizes correct after promotion | |
| 395 | - | - ✅ Tree structure valid (DebugFull validation passes) | |
| 396 | - | - ✅ ~2000 lines removed | |
| 397 | - | - ✅ Faster operation latency (measure with debug timers) | |
| 398 | - | ||
| 399 | - | --- | |
| 400 | - | ||
| 401 | - | ## Risk Mitigation | |
| 402 | - | ||
| 403 | - | ### Branch Strategy | |
| 404 | - | ```bash | |
| 405 | - | git checkout -b refactor/simplify-workspace | |
| 406 | - | # Commit after each phase | |
| 407 | - | # Squash before merge to main | |
| 408 | - | ``` | |
| 409 | - | ||
| 410 | - | ### If Issues Found | |
| 411 | - | 1. **Broken operation**: Revert specific phase commit | |
| 412 | - | 2. **GTK crash**: Enable DebugFull, check validation logs | |
| 413 | - | 3. **Widget sizing wrong**: Check promotion logic, compare with old TreeRebalancer | |
| 414 | - | 4. **Focus loss**: Check focus state machine integration | |
| 415 | - | ||
| 416 | - | ### No Rollback Code Needed | |
| 417 | - | - This is unreleased software | |
| 418 | - | - Clean refactor is better than feature flags | |
| 419 | - | - Git history provides rollback if critical issue found | |
| 420 | - | ||
| 421 | - | --- | |
| 422 | - | ||
| 423 | - | ## Expected Outcome | |
| 424 | - | ||
| 425 | - | ### Code Quality | |
| 426 | - | - **Before**: 19 files, ~8000 lines, 5-layer architecture | |
| 427 | - | - **After**: 14 files, ~6000 lines, 3-layer architecture | |
| 428 | - | - **Complexity**: -40% (measured by cyclomatic complexity) | |
| 429 | - | ||
| 430 | - | ### Performance | |
| 431 | - | - **Operation latency**: -20% (remove transaction/validation overhead) | |
| 432 | - | - **Memory usage**: -15% (fewer allocations) | |
| 433 | - | - **Code clarity**: Massively improved (direct call chains) | |
| 434 | - | ||
| 435 | - | ### Maintainability | |
| 436 | - | - ✅ New developers understand flow in 30 minutes vs 3 hours | |
| 437 | - | - ✅ Adding features requires touching fewer files | |
| 438 | - | - ✅ Debugging shows clear stack traces | |
| 439 | - | - ✅ No mysterious "bulletproof" terminology | |
| 440 | - | ||
| 441 | - | --- | |
| 442 | - | ||
| 443 | - | ## Notes | |
| 444 | - | ||
| 445 | - | - Keep all tree algorithms identical (they work correctly) | |
| 446 | - | - GTK4 bindings are solid - trust them | |
| 447 | - | - Validation is development tool, not production requirement | |
| 448 | - | - Direct is better than defensive when APIs are stable⏎̸ | |
M internal/app/browser/browser.go +8 -0
| @@ -110,6 +110,7 @@ func (app *BrowserApp) Initialize() error { | |||
| 110 | 110 | log.Printf("Warning: failed to initialize WebKit log capture: %v", err) | |
| 111 | 111 | } else { | |
| 112 | 112 | defer webkit.StopWebKitLogCapture() | |
| 113 | + | webkit.StartWebKitOutputCapture() | |
| 113 | 114 | } | |
| 114 | 115 | } | |
| 115 | 116 | ||
| @@ -220,6 +221,13 @@ func (app *BrowserApp) cleanup() { | |||
| 220 | 221 | // setupOutputCapture initializes stdout/stderr capture if configured | |
| 221 | 222 | func (app *BrowserApp) setupOutputCapture() error { | |
| 222 | 223 | if app.config.Logging.CaptureStdout || app.config.Logging.CaptureStderr { | |
| 224 | + | // Capturing stdout/stderr works for non-GTK builds, but in native GTK mode | |
| 225 | + | // it interferes with WebKit's own pipe management and crashes immediately. | |
| 226 | + | if webkit.IsNativeAvailable() { | |
| 227 | + | log.Printf("Warning: stdout/stderr capture is not supported in native GTK mode; skipping") | |
| 228 | + | return nil | |
| 229 | + | } | |
| 230 | + | ||
| 223 | 231 | log.Printf("Warning: stdout/stderr capture is experimental and may interfere with normal operations") | |
| 224 | 232 | if logger := logging.GetLogger(); logger != nil { | |
| 225 | 233 | outputCapture := logging.NewOutputCapture(logger) | |
M internal/app/browser/focus_state_machine.go +6 -10
| @@ -529,17 +529,11 @@ func (fsm *FocusStateMachine) findTopLeftPane(leaves []*paneNode) *paneNode { | |||
| 529 | 529 | var bestScore float64 = 1e9 | |
| 530 | 530 | ||
| 531 | 531 | for _, leaf := range leaves { | |
| 532 | - | if leaf.container == nil { | |
| 532 | + | if leaf.container == 0 || !webkit.WidgetIsValid(leaf.container) { | |
| 533 | 533 | continue | |
| 534 | 534 | } | |
| 535 | 535 | ||
| 536 | - | var bounds webkit.WidgetBounds | |
| 537 | - | var ok bool | |
| 538 | - | leaf.container.Execute(func(containerPtr uintptr) error { | |
| 539 | - | bounds, ok = webkit.WidgetGetBounds(containerPtr) | |
| 540 | - | return nil | |
| 541 | - | }) | |
| 542 | - | ||
| 536 | + | bounds, ok := webkit.WidgetGetBounds(leaf.container) | |
| 543 | 537 | if !ok { | |
| 544 | 538 | continue | |
| 545 | 539 | } | |
| @@ -570,7 +564,8 @@ func (fsm *FocusStateMachine) applyInitialFocus(node *paneNode) error { | |||
| 570 | 564 | ||
| 571 | 565 | // Apply initial visual border | |
| 572 | 566 | if fsm.wm != nil { | |
| 573 | - | fsm.wm.applyActivePaneBorder(node) | |
| 567 | + | ctx := fsm.wm.determineBorderContext(node) | |
| 568 | + | fsm.wm.applyActivePaneBorder(ctx) | |
| 574 | 569 | } | |
| 575 | 570 | ||
| 576 | 571 | // Update state | |
| @@ -736,7 +731,8 @@ func (fsm *FocusStateMachine) executeFocusChange(request FocusRequest) error { | |||
| 736 | 731 | fsm.wm.removeActivePaneBorder(oldPane) | |
| 737 | 732 | } | |
| 738 | 733 | // Add border to new pane | |
| 739 | - | fsm.wm.applyActivePaneBorder(newPane) | |
| 734 | + | ctx := fsm.wm.determineBorderContext(newPane) | |
| 735 | + | fsm.wm.applyActivePaneBorder(ctx) | |
| 740 | 736 | } | |
| 741 | 737 | ||
| 742 | 738 | // Update state | |
M internal/app/browser/stacked_panes.go +190 -201
| @@ -130,13 +130,10 @@ func (spm *StackedPaneManager) addPaneToExistingStack(target, newLeaf *paneNode) | |||
| 130 | 130 | stackNode.stackedPanes[insertIndex] = newLeaf // Insert new pane | |
| 131 | 131 | ||
| 132 | 132 | // Unparent the new container before adding it to the stack (only if it has a parent) | |
| 133 | - | if newLeaf.container != nil { | |
| 134 | - | newLeaf.container.Execute(func(containerPtr uintptr) error { | |
| 135 | - | if webkit.WidgetGetParent(containerPtr) != 0 { | |
| 136 | - | webkit.WidgetUnparent(containerPtr) | |
| 137 | - | } | |
| 138 | - | return nil | |
| 139 | - | }) | |
| 133 | + | if newLeaf.container != 0 && webkit.WidgetIsValid(newLeaf.container) { | |
| 134 | + | if webkit.WidgetGetParent(newLeaf.container) != 0 { | |
| 135 | + | webkit.WidgetUnparent(newLeaf.container) | |
| 136 | + | } | |
| 140 | 137 | } | |
| 141 | 138 | ||
| 142 | 139 | // Insert widgets at correct position for Zellij-style layout | |
| @@ -147,28 +144,28 @@ func (spm *StackedPaneManager) addPaneToExistingStack(target, newLeaf *paneNode) | |||
| 147 | 144 | if insertIndex > 0 { | |
| 148 | 145 | // Insert after the previous pane's container | |
| 149 | 146 | prevPane := stackNode.stackedPanes[insertIndex-1] | |
| 150 | - | if prevPane.container != nil { | |
| 151 | - | insertAfterWidget = prevPane.container.Ptr() | |
| 147 | + | if prevPane.container != 0 { | |
| 148 | + | insertAfterWidget = prevPane.container | |
| 152 | 149 | } | |
| 153 | 150 | } | |
| 154 | 151 | ||
| 155 | 152 | if insertAfterWidget != 0 { | |
| 156 | 153 | // Insert titleBar after the previous pane's container | |
| 157 | - | if stackNode.stackWrapper != nil && newLeaf.titleBar != nil { | |
| 158 | - | webkit.BoxInsertChildAfter(stackNode.stackWrapper.Ptr(), newLeaf.titleBar.Ptr(), insertAfterWidget) | |
| 154 | + | if stackNode.stackWrapper != 0 && newLeaf.titleBar != 0 { | |
| 155 | + | webkit.BoxInsertChildAfter(stackNode.stackWrapper, newLeaf.titleBar, insertAfterWidget) | |
| 159 | 156 | } | |
| 160 | 157 | // Insert container after the newly inserted titleBar | |
| 161 | - | if stackNode.stackWrapper != nil && newLeaf.container != nil && newLeaf.titleBar != nil { | |
| 162 | - | webkit.BoxInsertChildAfter(stackNode.stackWrapper.Ptr(), newLeaf.container.Ptr(), newLeaf.titleBar.Ptr()) | |
| 158 | + | if stackNode.stackWrapper != 0 && newLeaf.container != 0 && newLeaf.titleBar != 0 { | |
| 159 | + | webkit.BoxInsertChildAfter(stackNode.stackWrapper, newLeaf.container, newLeaf.titleBar) | |
| 163 | 160 | } | |
| 164 | 161 | log.Printf("[workspace] inserted widgets at position %d (after widget %#x)", insertIndex, insertAfterWidget) | |
| 165 | 162 | } else { | |
| 166 | 163 | // Insert at the beginning (insertIndex = 0) | |
| 167 | - | if stackNode.stackWrapper != nil && newLeaf.container != nil { | |
| 168 | - | webkit.BoxPrepend(stackNode.stackWrapper.Ptr(), newLeaf.container.Ptr()) | |
| 164 | + | if stackNode.stackWrapper != 0 && newLeaf.container != 0 { | |
| 165 | + | webkit.BoxPrepend(stackNode.stackWrapper, newLeaf.container) | |
| 169 | 166 | } | |
| 170 | - | if stackNode.stackWrapper != nil && newLeaf.titleBar != nil { | |
| 171 | - | webkit.BoxPrepend(stackNode.stackWrapper.Ptr(), newLeaf.titleBar.Ptr()) | |
| 167 | + | if stackNode.stackWrapper != 0 && newLeaf.titleBar != 0 { | |
| 168 | + | webkit.BoxPrepend(stackNode.stackWrapper, newLeaf.titleBar) | |
| 172 | 169 | } | |
| 173 | 170 | log.Printf("[workspace] prepended widgets at position 0") | |
| 174 | 171 | } | |
| @@ -218,8 +215,8 @@ func (spm *StackedPaneManager) convertToStackedContainer(target, newLeaf *paneNo | |||
| 218 | 215 | ||
| 219 | 216 | // Build the complete stack structure with hidden widgets | |
| 220 | 217 | webkit.BoxAppend(stackInternalBox, titleBar) | |
| 221 | - | if existingContainer != nil { | |
| 222 | - | webkit.BoxAppend(stackInternalBox, existingContainer.Ptr()) | |
| 218 | + | if existingContainer != 0 { | |
| 219 | + | webkit.BoxAppend(stackInternalBox, existingContainer) | |
| 223 | 220 | } | |
| 224 | 221 | ||
| 225 | 222 | // Immediately reattach the stack wrapper to minimize visibility gap | |
| @@ -267,27 +264,24 @@ func (spm *StackedPaneManager) convertToStackedContainer(target, newLeaf *paneNo | |||
| 267 | 264 | ||
| 268 | 265 | // Unparent the new container before adding it to the stack | |
| 269 | 266 | // This is critical: even freshly created WebViews might have internal parent refs | |
| 270 | - | if newLeaf.container != nil { | |
| 271 | - | newLeaf.container.Execute(func(containerPtr uintptr) error { | |
| 272 | - | parent := webkit.WidgetGetParent(containerPtr) | |
| 273 | - | if parent != 0 { | |
| 274 | - | log.Printf("[workspace] unparenting new pane container %#x from parent %#x before stack append", containerPtr, parent) | |
| 275 | - | webkit.WidgetUnparent(containerPtr) | |
| 276 | - | // Verify unparent succeeded | |
| 277 | - | if finalParent := webkit.WidgetGetParent(containerPtr); finalParent != 0 { | |
| 278 | - | log.Printf("[workspace] WARNING: container %#x still has parent %#x after unparent", containerPtr, finalParent) | |
| 279 | - | } | |
| 267 | + | if newLeaf.container != 0 && webkit.WidgetIsValid(newLeaf.container) { | |
| 268 | + | parent := webkit.WidgetGetParent(newLeaf.container) | |
| 269 | + | if parent != 0 { | |
| 270 | + | log.Printf("[workspace] unparenting new pane container %#x from parent %#x before stack append", newLeaf.container, parent) | |
| 271 | + | webkit.WidgetUnparent(newLeaf.container) | |
| 272 | + | // Verify unparent succeeded | |
| 273 | + | if finalParent := webkit.WidgetGetParent(newLeaf.container); finalParent != 0 { | |
| 274 | + | log.Printf("[workspace] WARNING: container %#x still has parent %#x after unparent", newLeaf.container, finalParent) | |
| 280 | 275 | } | |
| 281 | - | return nil | |
| 282 | - | }) | |
| 276 | + | } | |
| 283 | 277 | } | |
| 284 | 278 | ||
| 285 | 279 | // Add the new widgets to the internal stack box | |
| 286 | - | if stackNode.stackWrapper != nil && newLeaf.titleBar != nil { | |
| 287 | - | webkit.BoxAppend(stackNode.stackWrapper.Ptr(), newLeaf.titleBar.Ptr()) | |
| 280 | + | if stackNode.stackWrapper != 0 && newLeaf.titleBar != 0 { | |
| 281 | + | webkit.BoxAppend(stackNode.stackWrapper, newLeaf.titleBar) | |
| 288 | 282 | } | |
| 289 | - | if stackNode.stackWrapper != nil && newLeaf.container != nil { | |
| 290 | - | webkit.BoxAppend(stackNode.stackWrapper.Ptr(), newLeaf.container.Ptr()) | |
| 283 | + | if stackNode.stackWrapper != 0 && newLeaf.container != 0 { | |
| 284 | + | webkit.BoxAppend(stackNode.stackWrapper, newLeaf.container) | |
| 291 | 285 | } | |
| 292 | 286 | ||
| 293 | 287 | return stackNode, insertIndex, nil | |
| @@ -301,24 +295,18 @@ func (spm *StackedPaneManager) detachFromParent(target *paneNode, parent *paneNo | |||
| 301 | 295 | spm.wm.window.SetChild(0) | |
| 302 | 296 | } | |
| 303 | 297 | // Unparent if it has a GTK parent | |
| 304 | - | if target.container != nil { | |
| 305 | - | target.container.Execute(func(containerPtr uintptr) error { | |
| 306 | - | if webkit.WidgetGetParent(containerPtr) != 0 { | |
| 307 | - | webkit.WidgetUnparent(containerPtr) | |
| 308 | - | } | |
| 309 | - | return nil | |
| 310 | - | }) | |
| 298 | + | if target.container != 0 && webkit.WidgetIsValid(target.container) { | |
| 299 | + | if webkit.WidgetGetParent(target.container) != 0 { | |
| 300 | + | webkit.WidgetUnparent(target.container) | |
| 301 | + | } | |
| 311 | 302 | } | |
| 312 | - | } else if parent.container != nil { | |
| 303 | + | } else if parent.container != 0 && webkit.WidgetIsValid(parent.container) { | |
| 313 | 304 | // Target has a parent paned - remove it (automatically unparents in GTK4) | |
| 314 | - | parent.container.Execute(func(panedPtr uintptr) error { | |
| 315 | - | if parent.left == target { | |
| 316 | - | webkit.PanedSetStartChild(panedPtr, 0) | |
| 317 | - | } else if parent.right == target { | |
| 318 | - | webkit.PanedSetEndChild(panedPtr, 0) | |
| 319 | - | } | |
| 320 | - | return nil | |
| 321 | - | }) | |
| 305 | + | if parent.left == target { | |
| 306 | + | webkit.PanedSetStartChild(parent.container, 0) | |
| 307 | + | } else if parent.right == target { | |
| 308 | + | webkit.PanedSetEndChild(parent.container, 0) | |
| 309 | + | } | |
| 322 | 310 | } | |
| 323 | 311 | } | |
| 324 | 312 | ||
| @@ -328,15 +316,12 @@ func (spm *StackedPaneManager) reattachToParent(container uintptr, target *paneN | |||
| 328 | 316 | if spm.wm.window != nil { | |
| 329 | 317 | spm.wm.window.SetChild(container) | |
| 330 | 318 | } | |
| 331 | - | } else if parent.container != nil { | |
| 332 | - | parent.container.Execute(func(panedPtr uintptr) error { | |
| 333 | - | if parent.left == target { | |
| 334 | - | webkit.PanedSetStartChild(panedPtr, container) | |
| 335 | - | } else if parent.right == target { | |
| 336 | - | webkit.PanedSetEndChild(panedPtr, container) | |
| 337 | - | } | |
| 338 | - | return nil | |
| 339 | - | }) | |
| 319 | + | } else if parent.container != 0 && webkit.WidgetIsValid(parent.container) { | |
| 320 | + | if parent.left == target { | |
| 321 | + | webkit.PanedSetStartChild(parent.container, container) | |
| 322 | + | } else if parent.right == target { | |
| 323 | + | webkit.PanedSetEndChild(parent.container, container) | |
| 324 | + | } | |
| 340 | 325 | } | |
| 341 | 326 | } | |
| 342 | 327 | ||
| @@ -357,7 +342,7 @@ func (spm *StackedPaneManager) finalizeStackCreation(stackNode, newLeaf *paneNod | |||
| 357 | 342 | currentActiveIndex := stackNode.activeStackIndex | |
| 358 | 343 | if currentActiveIndex >= 0 && currentActiveIndex < len(stackNode.stackedPanes) && currentActiveIndex != insertIndex { | |
| 359 | 344 | currentlyActivePane := stackNode.stackedPanes[currentActiveIndex] | |
| 360 | - | if currentlyActivePane.pane != nil && currentlyActivePane.pane.webView != nil && currentlyActivePane.titleBar != nil { | |
| 345 | + | if currentlyActivePane.pane != nil && currentlyActivePane.pane.webView != nil && currentlyActivePane.titleBar != 0 { | |
| 361 | 346 | currentTitle := currentlyActivePane.pane.webView.GetTitle() | |
| 362 | 347 | if currentTitle != "" { | |
| 363 | 348 | spm.updateTitleBarLabel(currentlyActivePane, currentTitle) | |
| @@ -410,20 +395,20 @@ func (spm *StackedPaneManager) UpdateStackVisibility(stackNode *paneNode) { | |||
| 410 | 395 | for i, pane := range stackNode.stackedPanes { | |
| 411 | 396 | if i == activeIndex { | |
| 412 | 397 | // Active pane: show container, NEVER show title bar | |
| 413 | - | if pane.container != nil { | |
| 414 | - | webkit.WidgetSetVisible(pane.container.Ptr(), true) | |
| 398 | + | if pane.container != 0 { | |
| 399 | + | webkit.WidgetSetVisible(pane.container, true) | |
| 415 | 400 | } | |
| 416 | - | if pane.titleBar != nil { | |
| 417 | - | webkit.WidgetSetVisible(pane.titleBar.Ptr(), false) // ABSOLUTE RULE: never visible for active pane | |
| 401 | + | if pane.titleBar != 0 { | |
| 402 | + | webkit.WidgetSetVisible(pane.titleBar, false) // ABSOLUTE RULE: never visible for active pane | |
| 418 | 403 | } | |
| 419 | 404 | log.Printf("[workspace] active pane %d: container=visible, titleBar=HIDDEN", i) | |
| 420 | 405 | } else { | |
| 421 | 406 | // Inactive panes: hide container, show title bar | |
| 422 | - | if pane.container != nil { | |
| 423 | - | webkit.WidgetSetVisible(pane.container.Ptr(), false) | |
| 407 | + | if pane.container != 0 { | |
| 408 | + | webkit.WidgetSetVisible(pane.container, false) | |
| 424 | 409 | } | |
| 425 | - | if pane.titleBar != nil { | |
| 426 | - | webkit.WidgetSetVisible(pane.titleBar.Ptr(), true) | |
| 410 | + | if pane.titleBar != 0 { | |
| 411 | + | webkit.WidgetSetVisible(pane.titleBar, true) | |
| 427 | 412 | } | |
| 428 | 413 | } | |
| 429 | 414 | } | |
| @@ -486,7 +471,7 @@ func (spm *StackedPaneManager) NavigateStack(direction string) bool { | |||
| 486 | 471 | // Update title bar for the pane transitioning from ACTIVE to INACTIVE | |
| 487 | 472 | // This ensures the collapsed pane shows its current page title (Zellij-style layout) | |
| 488 | 473 | currentActivePane := stackNode.stackedPanes[currentIndex] | |
| 489 | - | if currentActivePane.pane != nil && currentActivePane.pane.webView != nil && currentActivePane.titleBar != nil { | |
| 474 | + | if currentActivePane.pane != nil && currentActivePane.pane.webView != nil && currentActivePane.titleBar != 0 { | |
| 490 | 475 | currentTitle := currentActivePane.pane.webView.GetTitle() | |
| 491 | 476 | if currentTitle != "" { | |
| 492 | 477 | spm.updateTitleBarLabel(currentActivePane, currentTitle) | |
| @@ -561,7 +546,7 @@ func (spm *StackedPaneManager) UpdateTitleBar(webView *webkit.WebView, title str | |||
| 561 | 546 | } | |
| 562 | 547 | ||
| 563 | 548 | // Check if this pane is in a stack and has a title bar | |
| 564 | - | if node.parent != nil && node.parent.isStacked && node.titleBar != nil { | |
| 549 | + | if node.parent != nil && node.parent.isStacked && node.titleBar != 0 { | |
| 565 | 550 | // CRITICAL: Only update title bar for INACTIVE panes | |
| 566 | 551 | // Active panes should never show their title bar | |
| 567 | 552 | stackNode := node.parent | |
| @@ -589,7 +574,7 @@ func (spm *StackedPaneManager) UpdateTitleBar(webView *webkit.WebView, title str | |||
| 589 | 574 | ||
| 590 | 575 | // updateTitleBarLabel updates the label widget within a title bar by recreating it | |
| 591 | 576 | func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string) { | |
| 592 | - | if node == nil || node.titleBar == nil || node.parent == nil || !node.parent.isStacked { | |
| 577 | + | if node == nil || node.titleBar == 0 || node.parent == nil || !node.parent.isStacked { | |
| 593 | 578 | return | |
| 594 | 579 | } | |
| 595 | 580 | ||
| @@ -607,7 +592,7 @@ func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string) | |||
| 607 | 592 | } | |
| 608 | 593 | ||
| 609 | 594 | // Replace the old title bar in the stack | |
| 610 | - | if node.parent != nil && node.parent.isStacked && node.parent.stackWrapper != nil { | |
| 595 | + | if node.parent != nil && node.parent.isStacked && node.parent.stackWrapper != 0 { | |
| 611 | 596 | // Find the correct insertion position based on the pane's index in the stack | |
| 612 | 597 | paneIndex := -1 | |
| 613 | 598 | for i, stackedPane := range node.parent.stackedPanes { | |
| @@ -618,11 +603,8 @@ func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string) | |||
| 618 | 603 | } | |
| 619 | 604 | ||
| 620 | 605 | // Remove old title bar | |
| 621 | - | if node.titleBar != nil { | |
| 622 | - | node.titleBar.Execute(func(titleBarPtr uintptr) error { | |
| 623 | - | webkit.WidgetUnparent(titleBarPtr) | |
| 624 | - | return nil | |
| 625 | - | }) | |
| 606 | + | if node.titleBar != 0 && webkit.WidgetIsValid(node.titleBar) { | |
| 607 | + | webkit.WidgetUnparent(node.titleBar) | |
| 626 | 608 | } | |
| 627 | 609 | ||
| 628 | 610 | // Update the node's title bar reference | |
| @@ -631,14 +613,14 @@ func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string) | |||
| 631 | 613 | // Insert the new title bar at the correct position | |
| 632 | 614 | if paneIndex == 0 { | |
| 633 | 615 | // First pane - insert at the beginning | |
| 634 | - | if node.parent.stackWrapper != nil { | |
| 635 | - | webkit.BoxPrepend(node.parent.stackWrapper.Ptr(), newTitleBar) | |
| 616 | + | if node.parent.stackWrapper != 0 { | |
| 617 | + | webkit.BoxPrepend(node.parent.stackWrapper, newTitleBar) | |
| 636 | 618 | } | |
| 637 | 619 | } else { | |
| 638 | 620 | // Insert after the previous pane's container | |
| 639 | 621 | prevPane := node.parent.stackedPanes[paneIndex-1] | |
| 640 | - | if node.parent.stackWrapper != nil && prevPane.container != nil { | |
| 641 | - | webkit.BoxInsertChildAfter(node.parent.stackWrapper.Ptr(), newTitleBar, prevPane.container.Ptr()) | |
| 622 | + | if node.parent.stackWrapper != 0 && prevPane.container != 0 { | |
| 623 | + | webkit.BoxInsertChildAfter(node.parent.stackWrapper, newTitleBar, prevPane.container) | |
| 642 | 624 | } | |
| 643 | 625 | } | |
| 644 | 626 | ||
| @@ -674,15 +656,18 @@ func (spm *StackedPaneManager) createTitleBarWithTitle(title string) uintptr { | |||
| 674 | 656 | return titleBox | |
| 675 | 657 | } | |
| 676 | 658 | ||
| 677 | - | // CloseStackedPane handles closing a pane that is part of a stack | |
| 659 | + | // CloseStackedPane handles closing a pane that is part of a stack. | |
| 660 | + | // The logic mirrors the regular close path: we rebuild the tree, update focus, | |
| 661 | + | // and let WorkspaceManager perform the actual cleanup/destroy work so that all | |
| 662 | + | // bookkeeping stays consistent. | |
| 678 | 663 | func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error { | |
| 679 | - | if node.parent == nil || !node.parent.isStacked { | |
| 664 | + | if node == nil || node.parent == nil || !node.parent.isStacked { | |
| 680 | 665 | return errors.New("node is not part of a stacked pane") | |
| 681 | 666 | } | |
| 682 | 667 | ||
| 683 | 668 | stackNode := node.parent | |
| 684 | 669 | ||
| 685 | - | // Find the index of the node to be closed | |
| 670 | + | // Locate the pane inside the stack slice. | |
| 686 | 671 | nodeIndex := -1 | |
| 687 | 672 | for i, stackedPane := range stackNode.stackedPanes { | |
| 688 | 673 | if stackedPane == node { | |
| @@ -690,155 +675,162 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error { | |||
| 690 | 675 | break | |
| 691 | 676 | } | |
| 692 | 677 | } | |
| 693 | - | ||
| 694 | 678 | if nodeIndex == -1 { | |
| 695 | 679 | return errors.New("node not found in stack") | |
| 696 | 680 | } | |
| 697 | 681 | ||
| 698 | 682 | log.Printf("[workspace] closing stacked pane: index=%d stackSize=%d", nodeIndex, len(stackNode.stackedPanes)) | |
| 699 | 683 | ||
| 700 | - | // Clean up the pane | |
| 701 | - | spm.wm.detachHover(node) | |
| 702 | - | node.pane.CleanupFromWorkspace(spm.wm) | |
| 684 | + | // Detach hover/focus controllers before tearing down widgets to avoid GTK | |
| 685 | + | // callbacks referencing freed memory during destruction. | |
| 686 | + | if spm.wm != nil { | |
| 687 | + | spm.wm.detachHover(node) | |
| 688 | + | spm.wm.detachFocus(node) | |
| 689 | + | } | |
| 703 | 690 | ||
| 704 | - | // Remove the pane's widgets from the stack container | |
| 705 | - | var stackBox *SafeWidget = stackNode.stackWrapper | |
| 706 | - | if stackBox == nil { | |
| 691 | + | // Remove the pane widgets from the stack container before mutating the tree. | |
| 692 | + | stackBox := stackNode.stackWrapper | |
| 693 | + | if stackBox == 0 { | |
| 707 | 694 | stackBox = stackNode.container | |
| 708 | 695 | } | |
| 709 | - | ||
| 710 | - | if stackBox != nil { | |
| 711 | - | if node.titleBar != nil { | |
| 712 | - | webkit.BoxRemove(stackBox.Ptr(), node.titleBar.Ptr()) | |
| 696 | + | if stackBox != 0 { | |
| 697 | + | if node.titleBar != 0 { | |
| 698 | + | webkit.BoxRemove(stackBox, node.titleBar) | |
| 713 | 699 | } | |
| 714 | - | if node.container != nil { | |
| 715 | - | webkit.BoxRemove(stackBox.Ptr(), node.container.Ptr()) | |
| 700 | + | if node.container != 0 { | |
| 701 | + | webkit.BoxRemove(stackBox, node.container) | |
| 716 | 702 | } | |
| 717 | 703 | } | |
| 718 | 704 | ||
| 719 | - | // Clean up the pane | |
| 720 | - | node.pane.Cleanup() | |
| 721 | - | ||
| 722 | - | // Remove from the stack | |
| 705 | + | // Remove the pane from the stack slice. | |
| 723 | 706 | stackNode.stackedPanes = append(stackNode.stackedPanes[:nodeIndex], stackNode.stackedPanes[nodeIndex+1:]...) | |
| 724 | 707 | ||
| 725 | - | // Handle the remaining panes in the stack | |
| 726 | - | if len(stackNode.stackedPanes) == 0 { | |
| 727 | - | // No panes left in stack - this should not happen normally | |
| 728 | - | log.Printf("[workspace] warning: empty stack after closing pane") | |
| 729 | - | return errors.New("stack became empty") | |
| 730 | - | } else if len(stackNode.stackedPanes) == 1 { | |
| 731 | - | // Only one pane left - convert back to a regular pane | |
| 708 | + | switch len(stackNode.stackedPanes) { | |
| 709 | + | case 0: | |
| 710 | + | // Stack container is now empty: remove it from the layout entirely. | |
| 711 | + | parent := stackNode.parent | |
| 712 | + | if parent == nil { | |
| 713 | + | // Stack was the root of the workspace — closing this pane exits the app. | |
| 714 | + | if stackNode.container != 0 && webkit.WidgetIsValid(stackNode.container) { | |
| 715 | + | if webkit.WidgetGetParent(stackNode.container) != 0 { | |
| 716 | + | webkit.WidgetUnparent(stackNode.container) | |
| 717 | + | } | |
| 718 | + | } | |
| 719 | + | if spm.wm.window != nil { | |
| 720 | + | spm.wm.window.SetChild(0) | |
| 721 | + | } | |
| 722 | + | _, err := spm.wm.cleanupAndExit(node) | |
| 723 | + | return err | |
| 724 | + | } | |
| 725 | + | ||
| 726 | + | // Detach the empty stack container from its GtkPaned parent. | |
| 727 | + | if parent.container != 0 && webkit.WidgetIsValid(parent.container) { | |
| 728 | + | if parent.left == stackNode { | |
| 729 | + | webkit.PanedSetStartChild(parent.container, 0) | |
| 730 | + | } else if parent.right == stackNode { | |
| 731 | + | webkit.PanedSetEndChild(parent.container, 0) | |
| 732 | + | } | |
| 733 | + | webkit.WidgetQueueAllocate(parent.container) | |
| 734 | + | } | |
| 735 | + | if stackNode.container != 0 && webkit.WidgetIsValid(stackNode.container) { | |
| 736 | + | if webkit.WidgetGetParent(stackNode.container) != 0 { | |
| 737 | + | webkit.WidgetUnparent(stackNode.container) | |
| 738 | + | } | |
| 739 | + | } | |
| 740 | + | ||
| 741 | + | sibling := spm.wm.getSibling(stackNode) | |
| 742 | + | grandparent := parent.parent | |
| 743 | + | ||
| 744 | + | spm.wm.promoteSibling(grandparent, parent, sibling) | |
| 745 | + | spm.wm.swapContainers(grandparent, sibling) | |
| 746 | + | ||
| 747 | + | generation := spm.wm.nextCleanupGeneration() | |
| 748 | + | spm.wm.cleanupPane(node, generation) | |
| 749 | + | spm.wm.decommissionParent(parent, generation) | |
| 750 | + | ||
| 751 | + | if sibling != nil { | |
| 752 | + | spm.wm.setFocusToLeaf(sibling) | |
| 753 | + | } else { | |
| 754 | + | spm.wm.updateMainPane() | |
| 755 | + | } | |
| 756 | + | ||
| 757 | + | stackNode.parent = nil | |
| 758 | + | stackNode.container = 0 | |
| 759 | + | stackNode.stackWrapper = 0 | |
| 760 | + | stackNode.stackedPanes = nil | |
| 761 | + | ||
| 762 | + | return nil | |
| 763 | + | ||
| 764 | + | case 1: | |
| 765 | + | // Promote the remaining pane back to a regular leaf node. | |
| 732 | 766 | lastPane := stackNode.stackedPanes[0] | |
| 733 | 767 | parent := stackNode.parent | |
| 734 | 768 | lastPaneContainer := lastPane.container | |
| 735 | 769 | ||
| 736 | - | // Remove the title bar since we're going back to single pane | |
| 737 | - | if lastPane.titleBar != nil && stackBox != nil { | |
| 738 | - | webkit.BoxRemove(stackBox.Ptr(), lastPane.titleBar.Ptr()) | |
| 770 | + | if lastPane.titleBar != 0 && stackBox != 0 { | |
| 771 | + | webkit.BoxRemove(stackBox, lastPane.titleBar) | |
| 739 | 772 | } | |
| 740 | - | ||
| 741 | - | // Unparent the pane container from stack wrapper | |
| 742 | - | if stackBox != nil && lastPaneContainer != nil { | |
| 743 | - | webkit.BoxRemove(stackBox.Ptr(), lastPaneContainer.Ptr()) | |
| 773 | + | if stackBox != 0 && lastPaneContainer != 0 { | |
| 774 | + | webkit.BoxRemove(stackBox, lastPaneContainer) | |
| 775 | + | } | |
| 776 | + | if stackNode.container != 0 && webkit.WidgetIsValid(stackNode.container) { | |
| 777 | + | if webkit.WidgetGetParent(stackNode.container) != 0 { | |
| 778 | + | webkit.WidgetUnparent(stackNode.container) | |
| 779 | + | } | |
| 744 | 780 | } | |
| 745 | 781 | ||
| 746 | - | // CRITICAL FIX: Replace the stackNode completely with lastPane | |
| 747 | - | // Update parent child references FIRST | |
| 748 | 782 | if parent == nil { | |
| 749 | 783 | spm.wm.root = lastPane | |
| 750 | - | if spm.wm.window != nil && lastPaneContainer != nil { | |
| 751 | - | spm.wm.window.SetChild(lastPaneContainer.Ptr()) | |
| 784 | + | lastPane.parent = nil | |
| 785 | + | if spm.wm.window != nil && lastPaneContainer != 0 { | |
| 786 | + | spm.wm.window.SetChild(lastPaneContainer) | |
| 787 | + | webkit.WidgetQueueAllocate(lastPaneContainer) | |
| 788 | + | webkit.WidgetShow(lastPaneContainer) | |
| 752 | 789 | } | |
| 753 | 790 | } else { | |
| 754 | 791 | if parent.left == stackNode { | |
| 755 | 792 | parent.left = lastPane | |
| 756 | - | if parent.container != nil && lastPaneContainer != nil { | |
| 757 | - | parent.container.Execute(func(panedPtr uintptr) error { | |
| 758 | - | webkit.PanedSetStartChild(panedPtr, lastPaneContainer.Ptr()) | |
| 759 | - | return nil | |
| 760 | - | }) | |
| 761 | - | } | |
| 762 | 793 | } else if parent.right == stackNode { | |
| 763 | 794 | parent.right = lastPane | |
| 764 | - | if parent.container != nil && lastPaneContainer != nil { | |
| 765 | - | parent.container.Execute(func(panedPtr uintptr) error { | |
| 766 | - | webkit.PanedSetEndChild(panedPtr, lastPaneContainer.Ptr()) | |
| 767 | - | return nil | |
| 768 | - | }) | |
| 795 | + | } | |
| 796 | + | lastPane.parent = parent | |
| 797 | + | if parent.container != 0 && webkit.WidgetIsValid(parent.container) && lastPaneContainer != 0 { | |
| 798 | + | if parent.left == lastPane { | |
| 799 | + | webkit.PanedSetStartChild(parent.container, lastPaneContainer) | |
| 800 | + | } else { | |
| 801 | + | webkit.PanedSetEndChild(parent.container, lastPaneContainer) | |
| 769 | 802 | } | |
| 803 | + | webkit.WidgetQueueAllocate(parent.container) | |
| 804 | + | } | |
| 805 | + | if lastPaneContainer != 0 { | |
| 806 | + | webkit.WidgetSetVisible(lastPaneContainer, true) | |
| 770 | 807 | } | |
| 771 | 808 | } | |
| 772 | 809 | ||
| 773 | - | // Convert lastPane back to regular pane | |
| 774 | - | lastPane.parent = parent | |
| 775 | 810 | lastPane.isStacked = false | |
| 776 | 811 | lastPane.stackedPanes = nil | |
| 777 | - | lastPane.titleBar = nil | |
| 778 | - | ||
| 779 | - | // CRITICAL: Ensure the container is visible after reparenting | |
| 780 | - | // The container may have been hidden during stack operations | |
| 781 | - | if lastPaneContainer != nil { | |
| 782 | - | webkit.WidgetSetVisible(lastPaneContainer.Ptr(), true) | |
| 783 | - | } | |
| 784 | - | ||
| 785 | - | // Update the viewToNode mapping to point to the lastPane, not stackNode | |
| 812 | + | lastPane.titleBar = 0 | |
| 786 | 813 | spm.wm.viewToNode[lastPane.pane.webView] = lastPane | |
| 787 | 814 | ||
| 788 | - | // FIXED: Focus the remaining pane if any pane from this stack was currently focused | |
| 789 | - | // Check if currentlyFocused is part of this stack (including the pane being closed) | |
| 790 | - | shouldFocus := false | |
| 791 | - | currentlyFocused := spm.wm.GetActiveNode() | |
| 792 | - | if currentlyFocused != nil { | |
| 793 | - | log.Printf("[workspace] DEBUG: currentlyFocused=%p, node being closed=%p, lastPane=%p", | |
| 794 | - | currentlyFocused, node, lastPane) | |
| 795 | - | // Check if currentlyFocused is the pane being closed | |
| 796 | - | if currentlyFocused == node { | |
| 797 | - | shouldFocus = true | |
| 798 | - | log.Printf("[workspace] DEBUG: shouldFocus=true (closing focused pane)") | |
| 799 | - | } else if currentlyFocused.parent == stackNode { | |
| 800 | - | // Check if currentlyFocused is another pane in this stack | |
| 801 | - | shouldFocus = true | |
| 802 | - | log.Printf("[workspace] DEBUG: shouldFocus=true (focused pane in same stack)") | |
| 803 | - | } | |
| 804 | - | } | |
| 805 | - | ||
| 806 | - | if shouldFocus { | |
| 807 | - | log.Printf("[workspace] focusing remaining pane after stack conversion") | |
| 815 | + | generation := spm.wm.nextCleanupGeneration() | |
| 816 | + | spm.wm.cleanupPane(node, generation) | |
| 808 | 817 | ||
| 809 | - | // Log widget state before focus operations | |
| 810 | - | if lastPaneWidget := lastPane.pane.webView.Widget(); lastPaneWidget != 0 { | |
| 811 | - | visible := webkit.WidgetGetVisible(lastPaneWidget) | |
| 812 | - | log.Printf("[workspace] Widget state before focus: widget=%#x visible=%v", | |
| 813 | - | lastPaneWidget, visible) | |
| 814 | - | } | |
| 818 | + | spm.wm.SetActivePane(lastPane, SourceClose) | |
| 819 | + | if lastPaneWidget := lastPane.pane.webView.Widget(); lastPaneWidget != 0 { | |
| 820 | + | webkit.WidgetShow(lastPaneWidget) | |
| 821 | + | webkit.WidgetGrabFocus(lastPaneWidget) | |
| 822 | + | } | |
| 815 | 823 | ||
| 816 | - | // Mirror the exact logic from finalizeStackCreation that works perfectly: | |
| 817 | - | // 1. First call SetActivePane (this does all the focus work) | |
| 818 | - | // 2. Then set currentlyFocused | |
| 819 | - | spm.wm.SetActivePane(lastPane, SourceClose) | |
| 820 | - | ||
| 821 | - | // CRITICAL: Ensure widget visibility and focus after reparenting | |
| 822 | - | // The widget may need explicit show/focus calls after being reparented | |
| 823 | - | if lastPaneWidget := lastPane.pane.webView.Widget(); lastPaneWidget != 0 { | |
| 824 | - | log.Printf("[workspace] Ensuring widget visibility after reparenting") | |
| 825 | - | webkit.WidgetShow(lastPaneWidget) | |
| 826 | - | webkit.WidgetGrabFocus(lastPaneWidget) | |
| 827 | - | ||
| 828 | - | // Log widget state after focus operations | |
| 829 | - | visible := webkit.WidgetGetVisible(lastPaneWidget) | |
| 830 | - | log.Printf("[workspace] Widget state after focus: widget=%#x visible=%v", | |
| 831 | - | lastPaneWidget, visible) | |
| 832 | - | } | |
| 824 | + | stackNode.parent = nil | |
| 825 | + | stackNode.container = 0 | |
| 826 | + | stackNode.stackWrapper = 0 | |
| 827 | + | stackNode.stackedPanes = nil | |
| 833 | 828 | ||
| 834 | - | log.Printf("[workspace] DEBUG: applied finalizeStackCreation focus logic in reverse") | |
| 835 | - | } else { | |
| 836 | - | log.Printf("[workspace] DEBUG: not focusing remaining pane (shouldFocus=false)") | |
| 837 | - | } | |
| 829 | + | log.Printf("[workspace] converted stack back to regular pane") | |
| 830 | + | return nil | |
| 838 | 831 | ||
| 839 | - | log.Printf("[workspace] converted single-pane stack back to regular pane") | |
| 840 | - | } else { | |
| 841 | - | // Multiple panes remain in stack - adjust active index | |
| 832 | + | default: | |
| 833 | + | // Still multiple panes in stack — adjust active index and visibility. | |
| 842 | 834 | if stackNode.activeStackIndex >= nodeIndex && stackNode.activeStackIndex > 0 { | |
| 843 | 835 | stackNode.activeStackIndex-- | |
| 844 | 836 | } | |
| @@ -846,20 +838,17 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error { | |||
| 846 | 838 | stackNode.activeStackIndex = len(stackNode.stackedPanes) - 1 | |
| 847 | 839 | } | |
| 848 | 840 | ||
| 849 | - | // Update visibility for remaining panes | |
| 850 | 841 | spm.UpdateStackVisibility(stackNode) | |
| 851 | - | ||
| 852 | - | // Focus the new active pane if we closed the currently active one | |
| 853 | 842 | if spm.wm.GetActiveNode() == node { | |
| 854 | - | newActivePaneInStack := stackNode.stackedPanes[stackNode.activeStackIndex] | |
| 855 | - | spm.wm.SetActivePane(newActivePaneInStack, SourceClose) | |
| 843 | + | newActivePane := stackNode.stackedPanes[stackNode.activeStackIndex] | |
| 844 | + | spm.wm.SetActivePane(newActivePane, SourceClose) | |
| 856 | 845 | } | |
| 857 | 846 | ||
| 847 | + | generation := spm.wm.nextCleanupGeneration() | |
| 848 | + | spm.wm.cleanupPane(node, generation) | |
| 849 | + | ||
| 858 | 850 | log.Printf("[workspace] closed pane from stack: remaining=%d activeIndex=%d", | |
| 859 | 851 | len(stackNode.stackedPanes), stackNode.activeStackIndex) | |
| 852 | + | return nil | |
| 860 | 853 | } | |
| 861 | - | ||
| 862 | - | log.Printf("[workspace] stacked pane closed; panes remaining=%d", len(spm.wm.app.panes)) | |
| 863 | - | ||
| 864 | - | return nil | |
| 865 | 854 | } | |
D internal/app/browser/widget.go +0 -257
| @@ -1,257 +0,0 @@ | |||
| 1 | - | package browser | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "fmt" | |
| 6 | - | "sync" | |
| 7 | - | "time" | |
| 8 | - | ||
| 9 | - | "github.com/bnema/dumber/pkg/webkit" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | // SafeWidget provides thread-safe access to GTK widgets with validation | |
| 13 | - | type SafeWidget struct { | |
| 14 | - | ptr uintptr | |
| 15 | - | typeInfo string | |
| 16 | - | registry *WidgetRegistry | |
| 17 | - | isInvalidated bool | |
| 18 | - | mu sync.RWMutex | |
| 19 | - | } | |
| 20 | - | ||
| 21 | - | // Execute safely executes a function with the widget pointer, ensuring thread safety and validation | |
| 22 | - | // CRITICAL FIX: Don't hold mutex during GTK operations to prevent deadlock | |
| 23 | - | func (w *SafeWidget) Execute(fn func(uintptr) error) error { | |
| 24 | - | if w == nil || w.registry == nil { | |
| 25 | - | return fmt.Errorf("SafeWidget is nil or has no registry") | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | w.mu.RLock() | |
| 29 | - | if w.isInvalidated { | |
| 30 | - | w.mu.RUnlock() | |
| 31 | - | return fmt.Errorf("widget %s permanently invalidated", w.String()) | |
| 32 | - | } | |
| 33 | - | ptr := w.ptr | |
| 34 | - | w.mu.RUnlock() | |
| 35 | - | ||
| 36 | - | if !w.IsValid() { | |
| 37 | - | return fmt.Errorf("widget %s is no longer valid", w.String()) | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | if err := fn(ptr); err != nil { | |
| 41 | - | w.markInvalidIfNeeded(ptr) | |
| 42 | - | return err | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | if !w.IsValid() { | |
| 46 | - | w.markInvalidIfNeeded(ptr) | |
| 47 | - | return fmt.Errorf("widget %s became invalid during execution", w.String()) | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | return nil | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | // ExecuteWithTimeout executes a function with timeout to prevent indefinite blocking | |
| 54 | - | func (w *SafeWidget) ExecuteWithTimeout(fn func(uintptr) error, timeout time.Duration) error { | |
| 55 | - | if w == nil || w.registry == nil { | |
| 56 | - | return fmt.Errorf("SafeWidget is nil or has no registry") | |
| 57 | - | } | |
| 58 | - | ||
| 59 | - | // Create context with timeout | |
| 60 | - | ctx, cancel := context.WithTimeout(context.Background(), timeout) | |
| 61 | - | defer cancel() | |
| 62 | - | ||
| 63 | - | // Channel for result | |
| 64 | - | resultChan := make(chan error, 1) | |
| 65 | - | ||
| 66 | - | w.mu.RLock() | |
| 67 | - | if w.isInvalidated { | |
| 68 | - | w.mu.RUnlock() | |
| 69 | - | return fmt.Errorf("widget %s permanently invalidated", w.String()) | |
| 70 | - | } | |
| 71 | - | ptr := w.ptr | |
| 72 | - | w.mu.RUnlock() | |
| 73 | - | ||
| 74 | - | if !w.IsValid() { | |
| 75 | - | return fmt.Errorf("widget %s is no longer valid", w.String()) | |
| 76 | - | } | |
| 77 | - | ||
| 78 | - | // Phase 2: Execute function in goroutine with timeout | |
| 79 | - | go func() { | |
| 80 | - | resultChan <- fn(ptr) | |
| 81 | - | }() | |
| 82 | - | ||
| 83 | - | // Wait for result or timeout | |
| 84 | - | select { | |
| 85 | - | case err := <-resultChan: | |
| 86 | - | if err != nil { | |
| 87 | - | w.markInvalidIfNeeded(ptr) | |
| 88 | - | return err | |
| 89 | - | } | |
| 90 | - | ||
| 91 | - | if !w.IsValid() { | |
| 92 | - | w.markInvalidIfNeeded(ptr) | |
| 93 | - | return fmt.Errorf("widget %s became invalid during execution", w.String()) | |
| 94 | - | } | |
| 95 | - | ||
| 96 | - | return nil | |
| 97 | - | case <-ctx.Done(): | |
| 98 | - | return fmt.Errorf("widget operation timed out after %v", timeout) | |
| 99 | - | } | |
| 100 | - | } | |
| 101 | - | ||
| 102 | - | // IsValid checks if the underlying GTK widget is still valid | |
| 103 | - | func (w *SafeWidget) IsValid() bool { | |
| 104 | - | if w == nil || w.ptr == 0 { | |
| 105 | - | return false | |
| 106 | - | } | |
| 107 | - | ||
| 108 | - | w.mu.RLock() | |
| 109 | - | if w.isInvalidated { | |
| 110 | - | w.mu.RUnlock() | |
| 111 | - | return false | |
| 112 | - | } | |
| 113 | - | ptr := w.ptr | |
| 114 | - | w.mu.RUnlock() | |
| 115 | - | ||
| 116 | - | if !webkit.WidgetIsValid(ptr) { | |
| 117 | - | w.Invalidate() | |
| 118 | - | return false | |
| 119 | - | } | |
| 120 | - | ||
| 121 | - | return true | |
| 122 | - | } | |
| 123 | - | ||
| 124 | - | // Invalidate marks the widget as permanently invalid | |
| 125 | - | func (w *SafeWidget) Invalidate() { | |
| 126 | - | if w == nil { | |
| 127 | - | return | |
| 128 | - | } | |
| 129 | - | ||
| 130 | - | w.mu.Lock() | |
| 131 | - | defer w.mu.Unlock() | |
| 132 | - | ||
| 133 | - | w.isInvalidated = true | |
| 134 | - | } | |
| 135 | - | ||
| 136 | - | // markInvalidIfNeeded cleans up registry state when a widget becomes invalid during execution | |
| 137 | - | func (w *SafeWidget) markInvalidIfNeeded(ptr uintptr) { | |
| 138 | - | if w == nil { | |
| 139 | - | return | |
| 140 | - | } | |
| 141 | - | ||
| 142 | - | if w.IsValid() { | |
| 143 | - | return | |
| 144 | - | } | |
| 145 | - | ||
| 146 | - | w.Invalidate() | |
| 147 | - | if w.registry != nil { | |
| 148 | - | w.registry.Unregister(ptr) | |
| 149 | - | } | |
| 150 | - | } | |
| 151 | - | ||
| 152 | - | // String returns a string representation of the SafeWidget for debugging | |
| 153 | - | func (w *SafeWidget) String() string { | |
| 154 | - | if w == nil { | |
| 155 | - | return "SafeWidget(nil)" | |
| 156 | - | } | |
| 157 | - | ||
| 158 | - | w.mu.RLock() | |
| 159 | - | typeInfo := w.typeInfo | |
| 160 | - | ptr := w.ptr | |
| 161 | - | w.mu.RUnlock() | |
| 162 | - | ||
| 163 | - | return fmt.Sprintf("SafeWidget(%s@%#x)", typeInfo, ptr) | |
| 164 | - | } | |
| 165 | - | ||
| 166 | - | // Ptr returns the raw uintptr to the GTK widget | |
| 167 | - | func (w *SafeWidget) Ptr() uintptr { | |
| 168 | - | if w == nil { | |
| 169 | - | return 0 | |
| 170 | - | } | |
| 171 | - | ||
| 172 | - | w.mu.RLock() | |
| 173 | - | defer w.mu.RUnlock() | |
| 174 | - | ||
| 175 | - | return w.ptr | |
| 176 | - | } | |
| 177 | - | ||
| 178 | - | // WidgetRegistry manages a collection of SafeWidget instances with thread safety | |
| 179 | - | type WidgetRegistry struct { | |
| 180 | - | mu sync.RWMutex | |
| 181 | - | widgets map[uintptr]*SafeWidget | |
| 182 | - | } | |
| 183 | - | ||
| 184 | - | // NewWidgetRegistry creates a new widget registry | |
| 185 | - | func NewWidgetRegistry() *WidgetRegistry { | |
| 186 | - | return &WidgetRegistry{ | |
| 187 | - | widgets: make(map[uintptr]*SafeWidget), | |
| 188 | - | } | |
| 189 | - | } | |
| 190 | - | ||
| 191 | - | // Register creates a new SafeWidget and registers it in the registry | |
| 192 | - | func (r *WidgetRegistry) Register(ptr uintptr, typeInfo string) *SafeWidget { | |
| 193 | - | if r == nil || ptr == 0 { | |
| 194 | - | return nil | |
| 195 | - | } | |
| 196 | - | ||
| 197 | - | r.mu.Lock() | |
| 198 | - | defer r.mu.Unlock() | |
| 199 | - | ||
| 200 | - | // Check if widget is already registered | |
| 201 | - | if existing, exists := r.widgets[ptr]; exists { | |
| 202 | - | return existing | |
| 203 | - | } | |
| 204 | - | ||
| 205 | - | // Create new SafeWidget | |
| 206 | - | widget := &SafeWidget{ | |
| 207 | - | ptr: ptr, | |
| 208 | - | typeInfo: typeInfo, | |
| 209 | - | registry: r, | |
| 210 | - | } | |
| 211 | - | ||
| 212 | - | r.widgets[ptr] = widget | |
| 213 | - | return widget | |
| 214 | - | } | |
| 215 | - | ||
| 216 | - | // Unregister removes a widget from the registry | |
| 217 | - | func (r *WidgetRegistry) Unregister(ptr uintptr) { | |
| 218 | - | if r == nil || ptr == 0 { | |
| 219 | - | return | |
| 220 | - | } | |
| 221 | - | ||
| 222 | - | r.mu.Lock() | |
| 223 | - | defer r.mu.Unlock() | |
| 224 | - | ||
| 225 | - | if widget, exists := r.widgets[ptr]; exists { | |
| 226 | - | widget.Invalidate() | |
| 227 | - | delete(r.widgets, ptr) | |
| 228 | - | } | |
| 229 | - | } | |
| 230 | - | ||
| 231 | - | // Cleanup removes all invalid widgets from the registry | |
| 232 | - | func (r *WidgetRegistry) Cleanup() { | |
| 233 | - | if r == nil { | |
| 234 | - | return | |
| 235 | - | } | |
| 236 | - | ||
| 237 | - | r.mu.Lock() | |
| 238 | - | defer r.mu.Unlock() | |
| 239 | - | ||
| 240 | - | for ptr, widget := range r.widgets { | |
| 241 | - | if !widget.IsValid() { | |
| 242 | - | delete(r.widgets, ptr) | |
| 243 | - | } | |
| 244 | - | } | |
| 245 | - | } | |
| 246 | - | ||
| 247 | - | // Count returns the number of registered widgets | |
| 248 | - | func (r *WidgetRegistry) Count() int { | |
| 249 | - | if r == nil { | |
| 250 | - | return 0 | |
| 251 | - | } | |
| 252 | - | ||
| 253 | - | r.mu.RLock() | |
| 254 | - | defer r.mu.RUnlock() | |
| 255 | - | ||
| 256 | - | return len(r.widgets) | |
| 257 | - | } | |
M internal/app/browser/workspace_diagnostics.go +2 -2
| @@ -134,8 +134,8 @@ func describePaneNode(node *paneNode) string { | |||
| 134 | 134 | parts = append(parts, fmt.Sprintf("split orientation=%d", node.orientation)) | |
| 135 | 135 | } | |
| 136 | 136 | ||
| 137 | - | if node.container != nil { | |
| 138 | - | parts = append(parts, fmt.Sprintf("widget=%#x", node.container.Ptr())) | |
| 137 | + | if node.container != 0 { | |
| 138 | + | parts = append(parts, fmt.Sprintf("widget=%#x", node.container)) | |
| 139 | 139 | } | |
| 140 | 140 | ||
| 141 | 141 | if node.parent != nil { | |
M internal/app/browser/workspace_geometry_validator.go +54 -441
| @@ -1,19 +1,18 @@ | |||
| 1 | - | // workspace_geometry_validator.go - Geometry validation for split operations inspired by Zellij | |
| 1 | + | // workspace_geometry_validator.go - Simplified geometry validation for split operations | |
| 2 | 2 | package browser | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "fmt" | |
| 6 | 6 | "log" | |
| 7 | - | "time" | |
| 8 | 7 | ||
| 9 | 8 | "github.com/bnema/dumber/pkg/webkit" | |
| 10 | 9 | ) | |
| 11 | 10 | ||
| 12 | 11 | // GeometryValidator validates pane geometry constraints for splits and operations | |
| 13 | 12 | type GeometryValidator struct { | |
| 14 | - | minPaneWidth int // Minimum width for a pane in pixels | |
| 15 | - | minPaneHeight int // Minimum height for a pane in pixels | |
| 16 | - | debugMode bool // Enable debug logging | |
| 13 | + | minPaneWidth int | |
| 14 | + | minPaneHeight int | |
| 15 | + | debugMode bool | |
| 17 | 16 | } | |
| 18 | 17 | ||
| 19 | 18 | // PaneGeometry represents the geometry of a pane | |
| @@ -25,285 +24,145 @@ type PaneGeometry struct { | |||
| 25 | 24 | IsValid bool | |
| 26 | 25 | } | |
| 27 | 26 | ||
| 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 | 27 | // ValidationResult represents the result of a geometry validation | |
| 37 | 28 | type ValidationResult struct { | |
| 38 | 29 | IsValid bool | |
| 39 | 30 | Reason string | |
| 40 | - | Constraints SplitConstraints | |
| 41 | 31 | Geometry PaneGeometry | |
| 42 | - | RequiresRevalidation bool // True if validation should be retried after widget allocation | |
| 32 | + | RequiresRevalidation bool | |
| 43 | 33 | } | |
| 44 | 34 | ||
| 45 | - | // NewGeometryValidator creates a new geometry validator with Zellij-inspired constraints | |
| 35 | + | // NewGeometryValidator creates a new geometry validator | |
| 46 | 36 | func NewGeometryValidator() *GeometryValidator { | |
| 47 | 37 | return &GeometryValidator{ | |
| 48 | - | minPaneWidth: 300, // Inspired by Zellij's MIN_TERMINAL_WIDTH * character_width | |
| 49 | - | minPaneHeight: 200, // Inspired by Zellij's MIN_TERMINAL_HEIGHT * character_height | |
| 38 | + | minPaneWidth: 300, // Minimum width for usable pane | |
| 39 | + | minPaneHeight: 200, // Minimum height for usable pane | |
| 50 | 40 | debugMode: false, | |
| 51 | 41 | } | |
| 52 | 42 | } | |
| 53 | 43 | ||
| 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 | 44 | // SetDebugMode enables or disables debug logging | |
| 62 | 45 | func (gv *GeometryValidator) SetDebugMode(debug bool) { | |
| 63 | 46 | gv.debugMode = debug | |
| 64 | 47 | } | |
| 65 | 48 | ||
| 66 | 49 | // GetPaneGeometry extracts geometry information from a pane node | |
| 67 | - | // CRITICAL FIX: Use timeout to prevent indefinite blocking on GTK calls | |
| 68 | 50 | func (gv *GeometryValidator) GetPaneGeometry(node *paneNode) PaneGeometry { | |
| 69 | 51 | geometry := PaneGeometry{IsValid: false} | |
| 70 | 52 | ||
| 71 | - | if node == nil || node.container == nil { | |
| 53 | + | if node == nil || node.container == 0 { | |
| 72 | 54 | if gv.debugMode { | |
| 73 | 55 | log.Printf("[geometry] Invalid node or missing container") | |
| 74 | 56 | } | |
| 75 | 57 | return geometry | |
| 76 | 58 | } | |
| 77 | 59 | ||
| 78 | - | // CRITICAL FIX: If not on main thread, marshal GTK calls to prevent deadlock | |
| 79 | - | if !webkit.IsMainThread() { | |
| 80 | - | // Use IdleAdd to marshal geometry query to main thread | |
| 81 | - | resultChan := make(chan PaneGeometry, 1) | |
| 82 | - | ||
| 83 | - | webkit.IdleAdd(func() bool { | |
| 84 | - | // Execute on main thread | |
| 85 | - | mainThreadGeometry := gv.getGeometryOnMainThread(node) | |
| 86 | - | resultChan <- mainThreadGeometry | |
| 87 | - | return false // Remove idle callback | |
| 88 | - | }) | |
| 89 | - | ||
| 90 | - | // Wait for result with timeout | |
| 91 | - | select { | |
| 92 | - | case result := <-resultChan: | |
| 93 | - | return result | |
| 94 | - | case <-time.After(100 * time.Millisecond): | |
| 95 | - | log.Printf("[geometry] Geometry query timed out (100ms), assuming invalid") | |
| 96 | - | return geometry | |
| 97 | - | } | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | // We're on main thread, execute directly but with timeout protection | |
| 101 | - | return gv.getGeometryWithTimeout(node, 50*time.Millisecond) | |
| 102 | - | } | |
| 103 | - | ||
| 104 | - | // getGeometryOnMainThread executes geometry query on main thread (must be called from main thread) | |
| 105 | - | func (gv *GeometryValidator) getGeometryOnMainThread(node *paneNode) PaneGeometry { | |
| 106 | - | geometry := PaneGeometry{IsValid: false} | |
| 107 | - | ||
| 108 | - | // Get geometry from GTK widget (now safe since we're on main thread) | |
| 109 | - | err := node.container.Execute(func(ptr uintptr) error { | |
| 110 | - | // Get widget allocation | |
| 111 | - | allocation := webkit.WidgetGetAllocation(ptr) | |
| 112 | - | ||
| 113 | - | geometry.X = allocation.X | |
| 114 | - | geometry.Y = allocation.Y | |
| 115 | - | geometry.Width = allocation.Width | |
| 116 | - | geometry.Height = allocation.Height | |
| 117 | - | geometry.IsValid = true | |
| 118 | - | ||
| 119 | - | if gv.debugMode { | |
| 120 | - | log.Printf("[geometry] Pane geometry: %dx%d at (%d,%d)", | |
| 121 | - | geometry.Width, geometry.Height, geometry.X, geometry.Y) | |
| 122 | - | } | |
| 123 | - | ||
| 124 | - | return nil | |
| 125 | - | }) | |
| 126 | - | ||
| 127 | - | if err != nil { | |
| 128 | - | log.Printf("[geometry] Failed to get pane geometry: %v", err) | |
| 129 | - | geometry.IsValid = false | |
| 130 | - | } | |
| 131 | - | ||
| 132 | - | return geometry | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | // getGeometryWithTimeout gets geometry with timeout protection | |
| 136 | - | func (gv *GeometryValidator) getGeometryWithTimeout(node *paneNode, timeout time.Duration) PaneGeometry { | |
| 137 | - | geometry := PaneGeometry{IsValid: false} | |
| 138 | - | ||
| 139 | - | // Use ExecuteWithTimeout to prevent indefinite blocking | |
| 140 | - | err := node.container.ExecuteWithTimeout(func(ptr uintptr) error { | |
| 141 | - | // Get widget allocation | |
| 142 | - | allocation := webkit.WidgetGetAllocation(ptr) | |
| 143 | - | ||
| 144 | - | geometry.X = allocation.X | |
| 145 | - | geometry.Y = allocation.Y | |
| 146 | - | geometry.Width = allocation.Width | |
| 147 | - | geometry.Height = allocation.Height | |
| 148 | - | geometry.IsValid = true | |
| 60 | + | // Get widget allocation directly - GTK operations are always on main thread | |
| 61 | + | allocation := webkit.WidgetGetAllocation(node.container) | |
| 149 | 62 | ||
| 150 | - | if gv.debugMode { | |
| 151 | - | log.Printf("[geometry] Pane geometry: %dx%d at (%d,%d)", | |
| 152 | - | geometry.Width, geometry.Height, geometry.X, geometry.Y) | |
| 153 | - | } | |
| 154 | - | ||
| 155 | - | return nil | |
| 156 | - | }, timeout) | |
| 63 | + | geometry.X = allocation.X | |
| 64 | + | geometry.Y = allocation.Y | |
| 65 | + | geometry.Width = allocation.Width | |
| 66 | + | geometry.Height = allocation.Height | |
| 67 | + | geometry.IsValid = true | |
| 157 | 68 | ||
| 158 | - | if err != nil { | |
| 159 | - | log.Printf("[geometry] Failed to get pane geometry: %v", err) | |
| 160 | - | geometry.IsValid = false | |
| 69 | + | if gv.debugMode { | |
| 70 | + | log.Printf("[geometry] Pane geometry: %dx%d at (%d,%d)", | |
| 71 | + | geometry.Width, geometry.Height, geometry.X, geometry.Y) | |
| 161 | 72 | } | |
| 162 | 73 | ||
| 163 | 74 | return geometry | |
| 164 | 75 | } | |
| 165 | 76 | ||
| 166 | - | // ValidateHorizontalSplit validates if a pane can be split horizontally | |
| 167 | - | func (gv *GeometryValidator) ValidateHorizontalSplit(node *paneNode) ValidationResult { | |
| 77 | + | // prepareGeometryResult centralizes the common geometry validation logic used by | |
| 78 | + | // split/stack checks. It returns the computed geometry, a populated | |
| 79 | + | // ValidationResult (for early exits), and a boolean that indicates whether the | |
| 80 | + | // caller should continue with additional constraints. | |
| 81 | + | func (gv *GeometryValidator) prepareGeometryResult(node *paneNode) (PaneGeometry, ValidationResult, bool) { | |
| 168 | 82 | geometry := gv.GetPaneGeometry(node) | |
| 169 | - | ||
| 170 | - | result := ValidationResult{ | |
| 171 | - | Geometry: geometry, | |
| 172 | - | Constraints: SplitConstraints{ | |
| 173 | - | MinResultingWidth: gv.minPaneWidth, | |
| 174 | - | MinResultingHeight: gv.minPaneHeight, | |
| 175 | - | MaxSplitRatio: 0.8, | |
| 176 | - | MinSplitRatio: 0.2, | |
| 177 | - | }, | |
| 178 | - | } | |
| 83 | + | result := ValidationResult{Geometry: geometry} | |
| 179 | 84 | ||
| 180 | 85 | if !geometry.IsValid { | |
| 181 | - | // Check if this is a zero allocation during startup | |
| 182 | 86 | if geometry.Width == 0 && geometry.Height == 0 { | |
| 183 | - | // Allow operation but flag for re-validation | |
| 184 | 87 | result.IsValid = true | |
| 185 | - | result.Reason = "geometry pending allocation, allowing horizontal split" | |
| 88 | + | result.Reason = "geometry pending allocation" | |
| 186 | 89 | result.RequiresRevalidation = true | |
| 187 | - | log.Printf("[geometry] Allowing horizontal split with zero allocation - will revalidate after first allocation") | |
| 188 | - | return result | |
| 189 | 90 | } else { | |
| 190 | 91 | result.IsValid = false | |
| 191 | 92 | result.Reason = "could not determine pane geometry" | |
| 192 | - | return result | |
| 193 | 93 | } | |
| 94 | + | return geometry, result, false | |
| 194 | 95 | } | |
| 195 | 96 | ||
| 196 | - | // Check if the pane is wide enough to split horizontally | |
| 197 | - | // Each resulting pane must be at least minPaneWidth wide | |
| 198 | - | requiredWidth := gv.minPaneWidth * 2 | |
| 97 | + | return geometry, result, true | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // ValidateHorizontalSplit validates if a pane can be split horizontally | |
| 101 | + | func (gv *GeometryValidator) ValidateHorizontalSplit(node *paneNode) ValidationResult { | |
| 102 | + | geometry, result, ok := gv.prepareGeometryResult(node) | |
| 103 | + | if !ok { | |
| 104 | + | return result | |
| 105 | + | } | |
| 199 | 106 | ||
| 107 | + | requiredWidth := gv.minPaneWidth * 2 | |
| 200 | 108 | if geometry.Width < requiredWidth { | |
| 201 | 109 | result.IsValid = false | |
| 202 | - | result.Reason = fmt.Sprintf("pane width %d is less than required %d for horizontal split", | |
| 110 | + | result.Reason = fmt.Sprintf("pane width %d < required %d for horizontal split", | |
| 203 | 111 | geometry.Width, requiredWidth) | |
| 204 | 112 | return result | |
| 205 | 113 | } | |
| 206 | 114 | ||
| 207 | - | // Check height constraints | |
| 208 | 115 | if geometry.Height < gv.minPaneHeight { | |
| 209 | 116 | result.IsValid = false | |
| 210 | - | result.Reason = fmt.Sprintf("pane height %d is less than minimum %d", | |
| 117 | + | result.Reason = fmt.Sprintf("pane height %d < minimum %d", | |
| 211 | 118 | geometry.Height, gv.minPaneHeight) | |
| 212 | 119 | return result | |
| 213 | 120 | } | |
| 214 | 121 | ||
| 215 | 122 | result.IsValid = true | |
| 216 | 123 | 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 | 124 | return result | |
| 224 | 125 | } | |
| 225 | 126 | ||
| 226 | 127 | // ValidateVerticalSplit validates if a pane can be split vertically | |
| 227 | 128 | func (gv *GeometryValidator) ValidateVerticalSplit(node *paneNode) ValidationResult { | |
| 228 | - | geometry := gv.GetPaneGeometry(node) | |
| 229 | - | ||
| 230 | - | result := ValidationResult{ | |
| 231 | - | Geometry: geometry, | |
| 232 | - | Constraints: SplitConstraints{ | |
| 233 | - | MinResultingWidth: gv.minPaneWidth, | |
| 234 | - | MinResultingHeight: gv.minPaneHeight, | |
| 235 | - | MaxSplitRatio: 0.8, | |
| 236 | - | MinSplitRatio: 0.2, | |
| 237 | - | }, | |
| 238 | - | } | |
| 239 | - | ||
| 240 | - | if !geometry.IsValid { | |
| 241 | - | // Check if this is a zero allocation during startup | |
| 242 | - | if geometry.Width == 0 && geometry.Height == 0 { | |
| 243 | - | // Allow operation but flag for re-validation | |
| 244 | - | result.IsValid = true | |
| 245 | - | result.Reason = "geometry pending allocation, allowing vertical split" | |
| 246 | - | result.RequiresRevalidation = true | |
| 247 | - | log.Printf("[geometry] Allowing vertical split with zero allocation - will revalidate after first allocation") | |
| 248 | - | return result | |
| 249 | - | } else { | |
| 250 | - | result.IsValid = false | |
| 251 | - | result.Reason = "could not determine pane geometry" | |
| 252 | - | return result | |
| 253 | - | } | |
| 129 | + | geometry, result, ok := gv.prepareGeometryResult(node) | |
| 130 | + | if !ok { | |
| 131 | + | return result | |
| 254 | 132 | } | |
| 255 | 133 | ||
| 256 | - | // Check if the pane is tall enough to split vertically | |
| 257 | - | // Each resulting pane must be at least minPaneHeight tall | |
| 258 | 134 | requiredHeight := gv.minPaneHeight * 2 | |
| 259 | - | ||
| 260 | 135 | if geometry.Height < requiredHeight { | |
| 261 | 136 | result.IsValid = false | |
| 262 | - | result.Reason = fmt.Sprintf("pane height %d is less than required %d for vertical split", | |
| 137 | + | result.Reason = fmt.Sprintf("pane height %d < required %d for vertical split", | |
| 263 | 138 | geometry.Height, requiredHeight) | |
| 264 | 139 | return result | |
| 265 | 140 | } | |
| 266 | 141 | ||
| 267 | - | // Check width constraints | |
| 268 | 142 | if geometry.Width < gv.minPaneWidth { | |
| 269 | 143 | result.IsValid = false | |
| 270 | - | result.Reason = fmt.Sprintf("pane width %d is less than minimum %d", | |
| 144 | + | result.Reason = fmt.Sprintf("pane width %d < minimum %d", | |
| 271 | 145 | geometry.Width, gv.minPaneWidth) | |
| 272 | 146 | return result | |
| 273 | 147 | } | |
| 274 | 148 | ||
| 275 | 149 | result.IsValid = true | |
| 276 | 150 | 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 | 151 | return result | |
| 284 | 152 | } | |
| 285 | 153 | ||
| 286 | - | // ValidateSplit validates a split operation based on direction (inspired by Zellij's split logic) | |
| 154 | + | // ValidateSplit validates a split operation based on direction | |
| 287 | 155 | func (gv *GeometryValidator) ValidateSplit(node *paneNode, direction string) ValidationResult { | |
| 288 | 156 | if node == nil { | |
| 289 | - | return ValidationResult{ | |
| 290 | - | IsValid: false, | |
| 291 | - | Reason: "node is nil", | |
| 292 | - | } | |
| 157 | + | return ValidationResult{IsValid: false, Reason: "node is nil"} | |
| 293 | 158 | } | |
| 294 | 159 | ||
| 295 | 160 | if node.isStacked { | |
| 296 | - | return ValidationResult{ | |
| 297 | - | IsValid: false, | |
| 298 | - | Reason: "cannot split stacked pane container directly", | |
| 299 | - | } | |
| 161 | + | return ValidationResult{IsValid: false, Reason: "cannot split stacked pane container"} | |
| 300 | 162 | } | |
| 301 | 163 | ||
| 302 | 164 | if !node.isLeaf { | |
| 303 | - | return ValidationResult{ | |
| 304 | - | IsValid: false, | |
| 305 | - | Reason: "can only split leaf panes", | |
| 306 | - | } | |
| 165 | + | return ValidationResult{IsValid: false, Reason: "can only split leaf panes"} | |
| 307 | 166 | } | |
| 308 | 167 | ||
| 309 | 168 | switch direction { | |
| @@ -312,285 +171,39 @@ func (gv *GeometryValidator) ValidateSplit(node *paneNode, direction string) Val | |||
| 312 | 171 | case "up", "down": | |
| 313 | 172 | return gv.ValidateVerticalSplit(node) | |
| 314 | 173 | default: | |
| 315 | - | return ValidationResult{ | |
| 316 | - | IsValid: false, | |
| 317 | - | Reason: fmt.Sprintf("unknown split direction: %s", direction), | |
| 318 | - | } | |
| 319 | - | } | |
| 320 | - | } | |
| 321 | - | ||
| 322 | - | // DetermineBestSplitDirection determines the best split direction for a pane (like Zellij's algorithm) | |
| 323 | - | func (gv *GeometryValidator) DetermineBestSplitDirection(node *paneNode) (string, ValidationResult) { | |
| 324 | - | geometry := gv.GetPaneGeometry(node) | |
| 325 | - | ||
| 326 | - | if !geometry.IsValid { | |
| 327 | - | return "", ValidationResult{ | |
| 328 | - | IsValid: false, | |
| 329 | - | Reason: "could not determine pane geometry", | |
| 330 | - | } | |
| 331 | - | } | |
| 332 | - | ||
| 333 | - | // Calculate aspect ratio and available space | |
| 334 | - | aspectRatio := float64(geometry.Width) / float64(geometry.Height) | |
| 335 | - | ||
| 336 | - | // Check which splits are possible | |
| 337 | - | horizontalValid := gv.ValidateHorizontalSplit(node) | |
| 338 | - | verticalValid := gv.ValidateVerticalSplit(node) | |
| 339 | - | ||
| 340 | - | if gv.debugMode { | |
| 341 | - | log.Printf("[geometry] Determining best split: aspect=%.2f, horizontal=%v, vertical=%v", | |
| 342 | - | aspectRatio, horizontalValid.IsValid, verticalValid.IsValid) | |
| 343 | - | } | |
| 344 | - | ||
| 345 | - | // If only one direction is valid, use it | |
| 346 | - | if horizontalValid.IsValid && !verticalValid.IsValid { | |
| 347 | - | return "right", horizontalValid | |
| 348 | - | } | |
| 349 | - | if verticalValid.IsValid && !horizontalValid.IsValid { | |
| 350 | - | return "down", verticalValid | |
| 351 | - | } | |
| 352 | - | ||
| 353 | - | // If neither is valid, return error | |
| 354 | - | if !horizontalValid.IsValid && !verticalValid.IsValid { | |
| 355 | - | return "", ValidationResult{ | |
| 356 | - | IsValid: false, | |
| 357 | - | Reason: "pane is too small for any split operation", | |
| 358 | - | } | |
| 359 | - | } | |
| 360 | - | ||
| 361 | - | // Both directions are valid - choose based on aspect ratio and space | |
| 362 | - | // Prefer horizontal split for wide panes, vertical for tall panes | |
| 363 | - | const aspectThreshold = 1.5 | |
| 364 | - | ||
| 365 | - | if aspectRatio > aspectThreshold { | |
| 366 | - | // Wide pane - prefer horizontal split | |
| 367 | - | return "right", horizontalValid | |
| 368 | - | } else if aspectRatio < (1.0 / aspectThreshold) { | |
| 369 | - | // Tall pane - prefer vertical split | |
| 370 | - | return "down", verticalValid | |
| 371 | - | } else { | |
| 372 | - | // Nearly square - prefer the direction with more available space | |
| 373 | - | horizontalSpace := geometry.Width - (gv.minPaneWidth * 2) | |
| 374 | - | verticalSpace := geometry.Height - (gv.minPaneHeight * 2) | |
| 375 | - | ||
| 376 | - | if horizontalSpace > verticalSpace { | |
| 377 | - | return "right", horizontalValid | |
| 378 | - | } else { | |
| 379 | - | return "down", verticalValid | |
| 380 | - | } | |
| 174 | + | return ValidationResult{IsValid: false, Reason: fmt.Sprintf("unknown split direction: %s", direction)} | |
| 381 | 175 | } | |
| 382 | 176 | } | |
| 383 | 177 | ||
| 384 | 178 | // ValidateStackOperation validates if a pane can be converted to a stack | |
| 385 | 179 | func (gv *GeometryValidator) ValidateStackOperation(node *paneNode) ValidationResult { | |
| 386 | - | geometry := gv.GetPaneGeometry(node) | |
| 387 | - | ||
| 388 | - | result := ValidationResult{ | |
| 389 | - | Geometry: geometry, | |
| 390 | - | Constraints: SplitConstraints{ | |
| 391 | - | MinResultingWidth: gv.minPaneWidth, | |
| 392 | - | MinResultingHeight: gv.minPaneHeight + 30, // Extra space for title bar | |
| 393 | - | }, | |
| 394 | - | } | |
| 395 | - | ||
| 396 | - | if !geometry.IsValid { | |
| 397 | - | // Check if this is a zero allocation during startup | |
| 398 | - | if geometry.Width == 0 && geometry.Height == 0 { | |
| 399 | - | // Allow operation but flag for re-validation | |
| 400 | - | result.IsValid = true | |
| 401 | - | result.Reason = "geometry pending allocation, allowing stack operation" | |
| 402 | - | result.RequiresRevalidation = true | |
| 403 | - | log.Printf("[geometry] Allowing stack operation with zero allocation - will revalidate after first allocation") | |
| 404 | - | return result | |
| 405 | - | } else { | |
| 406 | - | result.IsValid = false | |
| 407 | - | result.Reason = "could not determine pane geometry" | |
| 408 | - | return result | |
| 409 | - | } | |
| 180 | + | geometry, result, ok := gv.prepareGeometryResult(node) | |
| 181 | + | if !ok { | |
| 182 | + | return result | |
| 410 | 183 | } | |
| 411 | 184 | ||
| 412 | - | // Check if pane is already stacked | |
| 413 | 185 | if node.isStacked { | |
| 414 | 186 | result.IsValid = false | |
| 415 | 187 | result.Reason = "pane is already stacked" | |
| 416 | 188 | return result | |
| 417 | 189 | } | |
| 418 | 190 | ||
| 419 | - | // Check if pane is large enough for stacking (needs space for title bars) | |
| 420 | 191 | minStackHeight := gv.minPaneHeight + 30 // Extra space for title bar | |
| 421 | - | ||
| 422 | 192 | if geometry.Height < minStackHeight { | |
| 423 | 193 | result.IsValid = false | |
| 424 | - | result.Reason = fmt.Sprintf("pane height %d is less than required %d for stacking", | |
| 194 | + | result.Reason = fmt.Sprintf("pane height %d < required %d for stacking", | |
| 425 | 195 | geometry.Height, minStackHeight) | |
| 426 | 196 | return result | |
| 427 | 197 | } | |
| 428 | 198 | ||
| 429 | 199 | if geometry.Width < gv.minPaneWidth { | |
| 430 | 200 | result.IsValid = false | |
| 431 | - | result.Reason = fmt.Sprintf("pane width %d is less than minimum %d", | |
| 201 | + | result.Reason = fmt.Sprintf("pane width %d < minimum %d", | |
| 432 | 202 | geometry.Width, gv.minPaneWidth) | |
| 433 | 203 | return result | |
| 434 | 204 | } | |
| 435 | 205 | ||
| 436 | 206 | result.IsValid = true | |
| 437 | 207 | result.Reason = "stack operation is valid" | |
| 438 | - | ||
| 439 | - | if gv.debugMode { | |
| 440 | - | log.Printf("[geometry] Stack validation: %s", result.Reason) | |
| 441 | - | } | |
| 442 | - | ||
| 443 | 208 | return result | |
| 444 | 209 | } | |
| 445 | - | ||
| 446 | - | // ValidateWorkspaceLayout validates the overall workspace layout | |
| 447 | - | func (gv *GeometryValidator) ValidateWorkspaceLayout(root *paneNode) []ValidationResult { | |
| 448 | - | var results []ValidationResult | |
| 449 | - | ||
| 450 | - | if root == nil { | |
| 451 | - | results = append(results, ValidationResult{ | |
| 452 | - | IsValid: false, | |
| 453 | - | Reason: "workspace root is nil", | |
| 454 | - | }) | |
| 455 | - | return results | |
| 456 | - | } | |
| 457 | - | ||
| 458 | - | // Validate each pane in the workspace | |
| 459 | - | leaves := gv.collectAllLeaves(root) | |
| 460 | - | ||
| 461 | - | for i, leaf := range leaves { | |
| 462 | - | geometry := gv.GetPaneGeometry(leaf) | |
| 463 | - | ||
| 464 | - | result := ValidationResult{ | |
| 465 | - | Geometry: geometry, | |
| 466 | - | } | |
| 467 | - | ||
| 468 | - | if !geometry.IsValid { | |
| 469 | - | result.IsValid = false | |
| 470 | - | result.Reason = fmt.Sprintf("leaf pane %d has invalid geometry", i) | |
| 471 | - | } else if geometry.Width < gv.minPaneWidth { | |
| 472 | - | result.IsValid = false | |
| 473 | - | result.Reason = fmt.Sprintf("leaf pane %d width %d below minimum %d", | |
| 474 | - | i, geometry.Width, gv.minPaneWidth) | |
| 475 | - | } else if geometry.Height < gv.minPaneHeight { | |
| 476 | - | result.IsValid = false | |
| 477 | - | result.Reason = fmt.Sprintf("leaf pane %d height %d below minimum %d", | |
| 478 | - | i, geometry.Height, gv.minPaneHeight) | |
| 479 | - | } else { | |
| 480 | - | result.IsValid = true | |
| 481 | - | result.Reason = fmt.Sprintf("leaf pane %d geometry is valid", i) | |
| 482 | - | } | |
| 483 | - | ||
| 484 | - | results = append(results, result) | |
| 485 | - | } | |
| 486 | - | ||
| 487 | - | return results | |
| 488 | - | } | |
| 489 | - | ||
| 490 | - | // collectAllLeaves collects all leaf panes in the workspace | |
| 491 | - | func (gv *GeometryValidator) collectAllLeaves(node *paneNode) []*paneNode { | |
| 492 | - | if node == nil { | |
| 493 | - | return nil | |
| 494 | - | } | |
| 495 | - | ||
| 496 | - | var leaves []*paneNode | |
| 497 | - | ||
| 498 | - | if node.isLeaf { | |
| 499 | - | leaves = append(leaves, node) | |
| 500 | - | } else if node.isStacked { | |
| 501 | - | // For stacked panes, only include the active pane | |
| 502 | - | if node.activeStackIndex >= 0 && node.activeStackIndex < len(node.stackedPanes) { | |
| 503 | - | leaves = append(leaves, node.stackedPanes[node.activeStackIndex]) | |
| 504 | - | } | |
| 505 | - | } else { | |
| 506 | - | // Branch node - collect from children | |
| 507 | - | leaves = append(leaves, gv.collectAllLeaves(node.left)...) | |
| 508 | - | leaves = append(leaves, gv.collectAllLeaves(node.right)...) | |
| 509 | - | } | |
| 510 | - | ||
| 511 | - | return leaves | |
| 512 | - | } | |
| 513 | - | ||
| 514 | - | // GetOptimalSplitRatio calculates the optimal split ratio based on available space | |
| 515 | - | func (gv *GeometryValidator) GetOptimalSplitRatio(geometry PaneGeometry, direction string) float64 { | |
| 516 | - | // Default to 50/50 split | |
| 517 | - | defaultRatio := 0.5 | |
| 518 | - | ||
| 519 | - | switch direction { | |
| 520 | - | case "left", "right": | |
| 521 | - | // For horizontal splits, consider width | |
| 522 | - | availableWidth := geometry.Width - (gv.minPaneWidth * 2) | |
| 523 | - | if availableWidth <= 0 { | |
| 524 | - | return defaultRatio | |
| 525 | - | } | |
| 526 | - | // If there's plenty of space, use 50/50, otherwise bias toward existing content | |
| 527 | - | return defaultRatio | |
| 528 | - | ||
| 529 | - | case "up", "down": | |
| 530 | - | // For vertical splits, consider height | |
| 531 | - | availableHeight := geometry.Height - (gv.minPaneHeight * 2) | |
| 532 | - | if availableHeight <= 0 { | |
| 533 | - | return defaultRatio | |
| 534 | - | } | |
| 535 | - | // If there's plenty of space, use 50/50, otherwise bias toward existing content | |
| 536 | - | return defaultRatio | |
| 537 | - | ||
| 538 | - | default: | |
| 539 | - | return defaultRatio | |
| 540 | - | } | |
| 541 | - | } | |
| 542 | - | ||
| 543 | - | // GetGeometryStats returns statistics about the workspace geometry | |
| 544 | - | func (gv *GeometryValidator) GetGeometryStats(root *paneNode) map[string]interface{} { | |
| 545 | - | results := gv.ValidateWorkspaceLayout(root) | |
| 546 | - | ||
| 547 | - | totalPanes := len(results) | |
| 548 | - | validPanes := 0 | |
| 549 | - | totalArea := 0 | |
| 550 | - | minWidth := int(^uint(0) >> 1) // Max int | |
| 551 | - | maxWidth := 0 | |
| 552 | - | minHeight := int(^uint(0) >> 1) // Max int | |
| 553 | - | maxHeight := 0 | |
| 554 | - | ||
| 555 | - | for _, result := range results { | |
| 556 | - | if result.IsValid { | |
| 557 | - | validPanes++ | |
| 558 | - | } | |
| 559 | - | ||
| 560 | - | if result.Geometry.IsValid { | |
| 561 | - | totalArea += result.Geometry.Width * result.Geometry.Height | |
| 562 | - | ||
| 563 | - | if result.Geometry.Width < minWidth { | |
| 564 | - | minWidth = result.Geometry.Width | |
| 565 | - | } | |
| 566 | - | if result.Geometry.Width > maxWidth { | |
| 567 | - | maxWidth = result.Geometry.Width | |
| 568 | - | } | |
| 569 | - | if result.Geometry.Height < minHeight { | |
| 570 | - | minHeight = result.Geometry.Height | |
| 571 | - | } | |
| 572 | - | if result.Geometry.Height > maxHeight { | |
| 573 | - | maxHeight = result.Geometry.Height | |
| 574 | - | } | |
| 575 | - | } | |
| 576 | - | } | |
| 577 | - | ||
| 578 | - | if totalPanes == 0 { | |
| 579 | - | return map[string]interface{}{ | |
| 580 | - | "total_panes": 0, | |
| 581 | - | } | |
| 582 | - | } | |
| 583 | - | ||
| 584 | - | return map[string]interface{}{ | |
| 585 | - | "total_panes": totalPanes, | |
| 586 | - | "valid_panes": validPanes, | |
| 587 | - | "validity_rate": float64(validPanes) / float64(totalPanes), | |
| 588 | - | "total_area": totalArea, | |
| 589 | - | "min_width": minWidth, | |
| 590 | - | "max_width": maxWidth, | |
| 591 | - | "min_height": minHeight, | |
| 592 | - | "max_height": maxHeight, | |
| 593 | - | "min_pane_width": gv.minPaneWidth, | |
| 594 | - | "min_pane_height": gv.minPaneHeight, | |
| 595 | - | } | |
| 596 | - | } | |
M internal/app/browser/workspace_manager.go +30 -23
| @@ -48,54 +48,59 @@ type WorkspaceManager struct { | |||
| 48 | 48 | stackedPaneManager *StackedPaneManager | |
| 49 | 49 | focusStateMachine *FocusStateMachine | |
| 50 | 50 | ||
| 51 | - | // Widget operation synchronization to prevent GTK race conditions | |
| 52 | - | widgetMutex sync.Mutex | |
| 53 | - | widgetRegistry *WidgetRegistry | |
| 54 | - | ||
| 55 | 51 | // Focus debouncing to prevent rapid oscillation from any source | |
| 56 | 52 | lastFocusTime time.Time | |
| 57 | 53 | lastFocusTarget *paneNode | |
| 58 | 54 | focusDebounce time.Duration | |
| 59 | 55 | ||
| 60 | - | // ENHANCED COMPONENTS: Validation and safety systems (opt-in for debugging) | |
| 61 | - | treeValidator *TreeValidator | |
| 62 | - | treeRebalancer *TreeRebalancer | |
| 63 | - | geometryValidator *GeometryValidator | |
| 64 | - | stackLifecycleManager *StackLifecycleManager | |
| 65 | - | stateTombstoneManager *StateTombstoneManager | |
| 56 | + | // Validation and safety systems (debug-only, controlled by DUMBER_DEBUG_WORKSPACE env) | |
| 57 | + | treeValidator *TreeValidator // Debug-only tree validation | |
| 58 | + | treeRebalancer *TreeRebalancer // Rebalance after close operations | |
| 59 | + | geometryValidator *GeometryValidator // Validate split constraints | |
| 60 | + | stackLifecycleManager *StackLifecycleManager // Stack pane lifecycle | |
| 66 | 61 | ||
| 67 | 62 | // Debug instrumentation helpers | |
| 63 | + | debugLevel DebugLevel | |
| 68 | 64 | debugPaneClose bool | |
| 69 | 65 | diagnostics *WorkspaceDiagnostics | |
| 70 | 66 | ||
| 71 | - | // Enhanced pane close refactoring fields | |
| 67 | + | // Cleanup tracking | |
| 72 | 68 | cleanupCounter uint | |
| 69 | + | pendingIdle map[uintptr][]*paneNode | |
| 73 | 70 | } | |
| 74 | 71 | ||
| 75 | 72 | // Workspace navigation shortcuts are now handled globally by WindowShortcutHandler | |
| 76 | 73 | ||
| 77 | 74 | // NewWorkspaceManager builds a workspace manager rooted at the provided pane. | |
| 78 | 75 | func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManager { | |
| 76 | + | debugLevel := getDebugLevel() | |
| 77 | + | ||
| 79 | 78 | manager := &WorkspaceManager{ | |
| 80 | 79 | app: app, | |
| 81 | 80 | window: rootPane.webView.Window(), | |
| 82 | 81 | viewToNode: make(map[*webkit.WebView]*paneNode), | |
| 83 | 82 | lastSplitMsg: make(map[*webkit.WebView]time.Time), | |
| 84 | 83 | lastExitMsg: make(map[*webkit.WebView]time.Time), | |
| 85 | - | paneDeduplicator: messaging.NewPaneRequestDeduplicator(), // NEW: Initialize deduplicator | |
| 86 | - | widgetRegistry: NewWidgetRegistry(), // Initialize widget registry | |
| 87 | - | focusDebounce: 150 * time.Millisecond, // 150ms focus debouncing for all sources | |
| 84 | + | paneDeduplicator: messaging.NewPaneRequestDeduplicator(), | |
| 85 | + | focusDebounce: 150 * time.Millisecond, | |
| 86 | + | debugLevel: debugLevel, | |
| 88 | 87 | } | |
| 89 | 88 | ||
| 90 | - | // Initialize enhanced components first | |
| 91 | - | manager.treeValidator = NewTreeValidator(true, false) // enabled, debug off | |
| 92 | - | manager.geometryValidator = NewGeometryValidator() | |
| 93 | - | manager.stateTombstoneManager = NewStateTombstoneManager(manager) | |
| 89 | + | // Initialize validation components (opt-in based on debug level) | |
| 90 | + | if debugLevel >= DebugBasic { | |
| 91 | + | manager.treeValidator = NewTreeValidator(true, debugLevel == DebugFull) | |
| 92 | + | manager.geometryValidator = NewGeometryValidator() | |
| 93 | + | manager.geometryValidator.SetDebugMode(debugLevel == DebugFull) | |
| 94 | + | } else { | |
| 95 | + | // Production mode: disable validators | |
| 96 | + | manager.treeValidator = NewTreeValidator(false, false) | |
| 97 | + | manager.geometryValidator = NewGeometryValidator() | |
| 98 | + | } | |
| 94 | 99 | ||
| 95 | - | // Initialize tree rebalancer | |
| 100 | + | // Initialize tree rebalancer (always needed for proper close operations) | |
| 96 | 101 | manager.treeRebalancer = NewTreeRebalancer(manager, manager.treeValidator) | |
| 97 | 102 | ||
| 98 | - | // Initialize stack lifecycle manager with enhanced components | |
| 103 | + | // Initialize stack lifecycle manager | |
| 99 | 104 | manager.stackLifecycleManager = NewStackLifecycleManager(manager, manager.treeValidator) | |
| 100 | 105 | ||
| 101 | 106 | // Initialize existing specialized managers (now enhanced with bulletproof components) | |
| @@ -109,9 +114,11 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag | |||
| 109 | 114 | if err != nil { | |
| 110 | 115 | return nil, err | |
| 111 | 116 | } | |
| 112 | - | // Keep CreateWindow = true for popup WebViews to ensure proper window features initialization | |
| 113 | - | // This prevents WindowFeatures optional crashes when WebKit accesses popup properties | |
| 114 | - | cfg.CreateWindow = true | |
| 117 | + | // Pane WebViews are embedded inside the main application window, so they do not | |
| 118 | + | // need their own toplevel GtkWindow. Avoid creating per-pane windows to keep GTK | |
| 119 | + | // ownership simple (Epiphany follows the same pattern) and to prevent premature | |
| 120 | + | // swapchain destruction when panes are closed. | |
| 121 | + | cfg.CreateWindow = false | |
| 115 | 122 | return webkit.NewWebView(cfg) | |
| 116 | 123 | } | |
| 117 | 124 | manager.createPaneFn = func(view *webkit.WebView) (*BrowserPane, error) { | |
M internal/app/browser/workspace_navigation.go +33 -47
| @@ -89,19 +89,15 @@ func (wm *WorkspaceManager) ensureHover(node *paneNode) { | |||
| 89 | 89 | if wm == nil || node == nil || !node.isLeaf { | |
| 90 | 90 | return | |
| 91 | 91 | } | |
| 92 | - | if node.container == nil || node.hoverToken != 0 { | |
| 92 | + | if node.container == 0 || node.hoverToken != 0 { | |
| 93 | 93 | return | |
| 94 | 94 | } | |
| 95 | 95 | ||
| 96 | - | var token uintptr | |
| 97 | - | node.container.Execute(func(containerPtr uintptr) error { | |
| 98 | - | token = webkit.WidgetAddHoverHandler(containerPtr, func() { | |
| 99 | - | if wm == nil { | |
| 100 | - | return | |
| 101 | - | } | |
| 102 | - | wm.SetActivePane(node, SourceMouse) | |
| 103 | - | }) | |
| 104 | - | return nil | |
| 96 | + | token := webkit.WidgetAddHoverHandler(node.container, func() { | |
| 97 | + | if wm == nil { | |
| 98 | + | return | |
| 99 | + | } | |
| 100 | + | wm.SetActivePane(node, SourceMouse) | |
| 105 | 101 | }) | |
| 106 | 102 | node.hoverToken = token | |
| 107 | 103 | if token == 0 { | |
| @@ -115,7 +111,7 @@ func (wm *WorkspaceManager) detachHover(node *paneNode) { | |||
| 115 | 111 | return | |
| 116 | 112 | } | |
| 117 | 113 | ||
| 118 | - | if node.container == nil || !node.container.IsValid() { | |
| 114 | + | if node.container == 0 || !webkit.WidgetIsValid(node.container) { | |
| 119 | 115 | node.hoverToken = 0 | |
| 120 | 116 | return | |
| 121 | 117 | } | |
| @@ -123,12 +119,22 @@ func (wm *WorkspaceManager) detachHover(node *paneNode) { | |||
| 123 | 119 | token := node.hoverToken | |
| 124 | 120 | node.hoverToken = 0 | |
| 125 | 121 | ||
| 126 | - | node.container.Execute(func(containerPtr uintptr) error { | |
| 127 | - | if containerPtr != 0 && webkit.WidgetIsValid(containerPtr) { | |
| 128 | - | webkit.WidgetRemoveHoverHandler(containerPtr, token) | |
| 129 | - | } | |
| 130 | - | return nil | |
| 131 | - | }) | |
| 122 | + | if webkit.WidgetIsValid(node.container) { | |
| 123 | + | webkit.WidgetRemoveHoverHandler(node.container, token) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // detachFocus removes the GTK focus controller attached to a pane, if any. | |
| 128 | + | func (wm *WorkspaceManager) detachFocus(node *paneNode) { | |
| 129 | + | if wm == nil || node == nil || node.focusControllerToken == 0 { | |
| 130 | + | return | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | if wm.focusStateMachine != nil { | |
| 134 | + | wm.focusStateMachine.detachGTKController(node, node.focusControllerToken) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | node.focusControllerToken = 0 | |
| 132 | 138 | } | |
| 133 | 139 | ||
| 134 | 140 | // FocusNeighbor moves focus to the nearest pane in the requested direction using the | |
| @@ -229,7 +235,7 @@ func (wm *WorkspaceManager) navigateStack(direction string) bool { | |||
| 229 | 235 | // focusAdjacent uses geometry-based calculations to find and focus adjacent panes | |
| 230 | 236 | func (wm *WorkspaceManager) focusAdjacent(direction string) bool { | |
| 231 | 237 | currentFocused := wm.GetActiveNode() | |
| 232 | - | if currentFocused == nil || !currentFocused.isLeaf || currentFocused.container == nil { | |
| 238 | + | if currentFocused == nil || !currentFocused.isLeaf || currentFocused.container == 0 { | |
| 233 | 239 | return false | |
| 234 | 240 | } | |
| 235 | 241 | ||
| @@ -238,12 +244,7 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool { | |||
| 238 | 244 | return true | |
| 239 | 245 | } | |
| 240 | 246 | ||
| 241 | - | var currentBounds webkit.WidgetBounds | |
| 242 | - | var ok bool | |
| 243 | - | currentFocused.container.Execute(func(containerPtr uintptr) error { | |
| 244 | - | currentBounds, ok = webkit.WidgetGetBounds(containerPtr) | |
| 245 | - | return nil | |
| 246 | - | }) | |
| 247 | + | currentBounds, ok := webkit.WidgetGetBounds(currentFocused.container) | |
| 247 | 248 | if !ok { | |
| 248 | 249 | log.Printf("[workspace] unable to compute bounds for active pane") | |
| 249 | 250 | return false | |
| @@ -258,16 +259,11 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool { | |||
| 258 | 259 | var debugCandidates []string | |
| 259 | 260 | ||
| 260 | 261 | for _, candidate := range leaves { | |
| 261 | - | if candidate == nil || candidate == currentFocused || candidate.container == nil { | |
| 262 | + | if candidate == nil || candidate == currentFocused || candidate.container == 0 { | |
| 262 | 263 | continue | |
| 263 | 264 | } | |
| 264 | - | var bounds webkit.WidgetBounds | |
| 265 | - | var boundsOk bool | |
| 266 | - | candidate.container.Execute(func(containerPtr uintptr) error { | |
| 267 | - | bounds, boundsOk = webkit.WidgetGetBounds(containerPtr) | |
| 268 | - | return nil | |
| 269 | - | }) | |
| 270 | - | ok := boundsOk | |
| 265 | + | ||
| 266 | + | bounds, ok := webkit.WidgetGetBounds(candidate.container) | |
| 271 | 267 | if !ok { | |
| 272 | 268 | continue | |
| 273 | 269 | } | |
| @@ -324,16 +320,11 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool { | |||
| 324 | 320 | ||
| 325 | 321 | // structuralNeighbor finds neighbors based on the tree structure rather than geometry | |
| 326 | 322 | func (wm *WorkspaceManager) structuralNeighbor(node *paneNode, direction string) *paneNode { | |
| 327 | - | if node == nil || node.container == nil { | |
| 323 | + | if node == nil || node.container == 0 { | |
| 328 | 324 | return nil | |
| 329 | 325 | } | |
| 330 | 326 | ||
| 331 | - | var refBounds webkit.WidgetBounds | |
| 332 | - | var ok bool | |
| 333 | - | node.container.Execute(func(containerPtr uintptr) error { | |
| 334 | - | refBounds, ok = webkit.WidgetGetBounds(containerPtr) | |
| 335 | - | return nil | |
| 336 | - | }) | |
| 327 | + | refBounds, ok := webkit.WidgetGetBounds(node.container) | |
| 337 | 328 | if !ok { | |
| 338 | 329 | return nil | |
| 339 | 330 | } | |
| @@ -379,16 +370,11 @@ func (wm *WorkspaceManager) closestLeafFromSubtree(node *paneNode, cx, cy float6 | |||
| 379 | 370 | bestScore := math.MaxFloat64 | |
| 380 | 371 | var best *paneNode | |
| 381 | 372 | for _, leaf := range leaves { | |
| 382 | - | if leaf == nil || leaf.container == nil { | |
| 373 | + | if leaf == nil || leaf.container == 0 { | |
| 383 | 374 | continue | |
| 384 | 375 | } | |
| 385 | - | var bounds webkit.WidgetBounds | |
| 386 | - | var boundsOk bool | |
| 387 | - | leaf.container.Execute(func(containerPtr uintptr) error { | |
| 388 | - | bounds, boundsOk = webkit.WidgetGetBounds(containerPtr) | |
| 389 | - | return nil | |
| 390 | - | }) | |
| 391 | - | ok := boundsOk | |
| 376 | + | ||
| 377 | + | bounds, ok := webkit.WidgetGetBounds(leaf.container) | |
| 392 | 378 | if !ok { | |
| 393 | 379 | continue | |
| 394 | 380 | } | |
M internal/app/browser/workspace_operations.go +82 -116
| @@ -16,44 +16,42 @@ func (wm *WorkspaceManager) SplitPane(target *paneNode, direction string) (*pane | |||
| 16 | 16 | ||
| 17 | 17 | log.Printf("[workspace] Starting split operation: target=%p direction=%s", target, direction) | |
| 18 | 18 | ||
| 19 | - | // Step 1: Capture state tombstone for rollback | |
| 20 | - | tombstone, err := wm.stateTombstoneManager.CaptureState("split") | |
| 21 | - | if err != nil { | |
| 22 | - | log.Printf("[workspace] Failed to capture state tombstone: %v", err) | |
| 23 | - | // Continue anyway - tombstone is for rollback safety | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | // Step 2: Validate geometry constraints | |
| 27 | - | validation := wm.geometryValidator.ValidateSplit(target, direction) | |
| 28 | - | if !validation.IsValid { | |
| 29 | - | return nil, fmt.Errorf("split validation failed: %s", validation.Reason) | |
| 30 | - | } | |
| 19 | + | // Step 1: Validate geometry constraints (only in DebugBasic+) | |
| 20 | + | if wm.debugLevel >= DebugBasic { | |
| 21 | + | validation := wm.geometryValidator.ValidateSplit(target, direction) | |
| 22 | + | if !validation.IsValid { | |
| 23 | + | if wm.debugLevel == DebugFull { | |
| 24 | + | return nil, fmt.Errorf("split validation failed: %s", validation.Reason) | |
| 25 | + | } | |
| 26 | + | log.Printf("[workspace] WARNING: Split validation failed but allowing operation: %s", validation.Reason) | |
| 27 | + | } | |
| 31 | 28 | ||
| 32 | - | // Log if re-validation will be needed due to pending widget allocation | |
| 33 | - | if validation.RequiresRevalidation { | |
| 34 | - | log.Printf("[workspace] Split validation passed with pending allocation - operation will proceed") | |
| 35 | - | } | |
| 29 | + | // Log if re-validation will be needed due to pending widget allocation | |
| 30 | + | if validation.RequiresRevalidation { | |
| 31 | + | log.Printf("[workspace] Split validation passed with pending allocation - operation will proceed") | |
| 32 | + | } | |
| 36 | 33 | ||
| 37 | - | // Step 3: Validate tree invariants before operation | |
| 38 | - | if err := wm.treeValidator.ValidateTree(wm.root, "before_split"); err != nil { | |
| 39 | - | return nil, fmt.Errorf("tree validation failed before split: %w", err) | |
| 34 | + | // Step 3: Validate tree invariants before operation | |
| 35 | + | if err := wm.treeValidator.ValidateTree(wm.root, "before_split"); err != nil { | |
| 36 | + | if wm.debugLevel == DebugFull { | |
| 37 | + | return nil, fmt.Errorf("tree validation failed before split: %w", err) | |
| 38 | + | } | |
| 39 | + | log.Printf("[workspace] WARNING: Tree validation failed before split but allowing operation: %v", err) | |
| 40 | + | } | |
| 40 | 41 | } | |
| 41 | 42 | ||
| 42 | - | // Step 4: Execute directly if we're already on the GTK main thread | |
| 43 | + | // Step 2: Execute directly if we're already on the GTK main thread | |
| 43 | 44 | if webkit.IsMainThread() { | |
| 44 | 45 | log.Printf("[workspace] Already on main thread, executing split directly") | |
| 45 | 46 | newNode, err := wm.splitNode(target, direction) | |
| 46 | 47 | if err != nil { | |
| 47 | - | if tombstone != nil { | |
| 48 | - | if rollbackErr := wm.stateTombstoneManager.RestoreState(tombstone.ID); rollbackErr != nil { | |
| 49 | - | log.Printf("[workspace] Rollback failed after split failure: %v", rollbackErr) | |
| 50 | - | } | |
| 51 | - | } | |
| 52 | 48 | return nil, err | |
| 53 | 49 | } | |
| 54 | 50 | ||
| 55 | - | if err := wm.treeValidator.ValidateTree(wm.root, "after_split"); err != nil { | |
| 56 | - | log.Printf("[workspace] Tree validation failed after split: %v", err) | |
| 51 | + | if wm.debugLevel >= DebugBasic { | |
| 52 | + | if err := wm.treeValidator.ValidateTree(wm.root, "after_split"); err != nil { | |
| 53 | + | log.Printf("[workspace] Tree validation failed after split: %v", err) | |
| 54 | + | } | |
| 57 | 55 | } | |
| 58 | 56 | ||
| 59 | 57 | // Tree rebalancing is only needed after close promotions. Splits have correct allocation from GTK. | |
| @@ -62,24 +60,20 @@ func (wm *WorkspaceManager) SplitPane(target *paneNode, direction string) (*pane | |||
| 62 | 60 | return newNode, nil | |
| 63 | 61 | } | |
| 64 | 62 | ||
| 65 | - | // Step 5: Not on main thread, marshal via IdleAdd | |
| 63 | + | // Step 3: Not on main thread, marshal via IdleAdd | |
| 66 | 64 | log.Printf("[workspace] Not on main thread, marshalling split via IdleAdd") | |
| 67 | 65 | var newNode *paneNode | |
| 68 | 66 | var splitErr error | |
| 69 | 67 | done := make(chan struct{}) | |
| 70 | 68 | ||
| 71 | - | webkit.IdleAdd(func() bool { | |
| 69 | + | _ = webkit.IdleAdd(func() bool { | |
| 72 | 70 | newNode, splitErr = wm.splitNode(target, direction) | |
| 73 | - | if splitErr != nil { | |
| 74 | - | if tombstone != nil { | |
| 75 | - | if rollbackErr := wm.stateTombstoneManager.RestoreState(tombstone.ID); rollbackErr != nil { | |
| 76 | - | log.Printf("[workspace] Rollback failed after split failure: %v", rollbackErr) | |
| 71 | + | if splitErr == nil { | |
| 72 | + | if wm.debugLevel >= DebugBasic { | |
| 73 | + | if verr := wm.treeValidator.ValidateTree(wm.root, "after_split"); verr != nil { | |
| 74 | + | log.Printf("[workspace] Tree validation failed after split: %v", verr) | |
| 77 | 75 | } | |
| 78 | 76 | } | |
| 79 | - | } else { | |
| 80 | - | if verr := wm.treeValidator.ValidateTree(wm.root, "after_split"); verr != nil { | |
| 81 | - | log.Printf("[workspace] Tree validation failed after split: %v", verr) | |
| 82 | - | } | |
| 83 | 77 | } | |
| 84 | 78 | close(done) | |
| 85 | 79 | return false | |
| @@ -105,15 +99,14 @@ func (wm *WorkspaceManager) ClosePane(node *paneNode) error { | |||
| 105 | 99 | wm.paneCloseLogf("start bulletproof close node=%p", node) | |
| 106 | 100 | wm.dumpTreeState("before_close") | |
| 107 | 101 | ||
| 108 | - | // Step 1: Capture state tombstone for rollback | |
| 109 | - | tombstone, err := wm.stateTombstoneManager.CaptureState("close") | |
| 110 | - | if err != nil { | |
| 111 | - | log.Printf("[workspace] Failed to capture state tombstone: %v", err) | |
| 112 | - | } | |
| 113 | - | ||
| 114 | - | // Step 2: Validate tree invariants before operation | |
| 115 | - | if err := wm.treeValidator.ValidateTree(wm.root, "before_close"); err != nil { | |
| 116 | - | return fmt.Errorf("tree validation failed before close: %w", err) | |
| 102 | + | // Step 1: Validate tree invariants before operation (only in DebugBasic+) | |
| 103 | + | if wm.debugLevel >= DebugBasic { | |
| 104 | + | if err := wm.treeValidator.ValidateTree(wm.root, "before_close"); err != nil { | |
| 105 | + | if wm.debugLevel == DebugFull { | |
| 106 | + | return fmt.Errorf("tree validation failed before close: %w", err) | |
| 107 | + | } | |
| 108 | + | log.Printf("[workspace] WARNING: Tree validation failed before close but allowing operation: %v", err) | |
| 109 | + | } | |
| 117 | 110 | } | |
| 118 | 111 | ||
| 119 | 112 | // Step 3: Check if this is a stacked pane and use enhanced lifecycle management | |
| @@ -131,16 +124,13 @@ func (wm *WorkspaceManager) ClosePane(node *paneNode) error { | |||
| 131 | 124 | if err != nil { | |
| 132 | 125 | wm.paneCloseLogf("closePane failed node=%p err=%v", node, err) | |
| 133 | 126 | wm.dumpTreeState("after_close_error") | |
| 134 | - | if tombstone != nil { | |
| 135 | - | if rollbackErr := wm.stateTombstoneManager.RestoreState(tombstone.ID); rollbackErr != nil { | |
| 136 | - | log.Printf("[workspace] Rollback failed after close failure: %v", rollbackErr) | |
| 137 | - | } | |
| 138 | - | } | |
| 139 | 127 | return err | |
| 140 | 128 | } | |
| 141 | 129 | ||
| 142 | - | if err := wm.treeValidator.ValidateTree(wm.root, "after_close"); err != nil { | |
| 143 | - | log.Printf("[workspace] Tree validation failed after close: %v", err) | |
| 130 | + | if wm.debugLevel >= DebugBasic { | |
| 131 | + | if err := wm.treeValidator.ValidateTree(wm.root, "after_close"); err != nil { | |
| 132 | + | log.Printf("[workspace] Tree validation failed after close: %v", err) | |
| 133 | + | } | |
| 144 | 134 | } | |
| 145 | 135 | wm.paneCloseLogf("closePane succeeded node=%p promoted=%p root=%p", node, promoted, wm.root) | |
| 146 | 136 | wm.dumpTreeState("after_close_success") | |
| @@ -161,20 +151,17 @@ func (wm *WorkspaceManager) ClosePane(node *paneNode) error { | |||
| 161 | 151 | var closeErr error | |
| 162 | 152 | done := make(chan struct{}) | |
| 163 | 153 | ||
| 164 | - | webkit.IdleAdd(func() bool { | |
| 154 | + | _ = webkit.IdleAdd(func() bool { | |
| 165 | 155 | wm.paneCloseLogf("invoking closePane node=%p", node) | |
| 166 | 156 | promoted, closeErr = wm.closePane(node) | |
| 167 | 157 | if closeErr != nil { | |
| 168 | 158 | wm.paneCloseLogf("closePane failed node=%p err=%v", node, closeErr) | |
| 169 | 159 | wm.dumpTreeState("after_close_error") | |
| 170 | - | if tombstone != nil { | |
| 171 | - | if rollbackErr := wm.stateTombstoneManager.RestoreState(tombstone.ID); rollbackErr != nil { | |
| 172 | - | log.Printf("[workspace] Rollback failed after close failure: %v", rollbackErr) | |
| 173 | - | } | |
| 174 | - | } | |
| 175 | 160 | } else { | |
| 176 | - | if verr := wm.treeValidator.ValidateTree(wm.root, "after_close"); verr != nil { | |
| 177 | - | log.Printf("[workspace] Tree validation failed after close: %v", verr) | |
| 161 | + | if wm.debugLevel >= DebugBasic { | |
| 162 | + | if verr := wm.treeValidator.ValidateTree(wm.root, "after_close"); verr != nil { | |
| 163 | + | log.Printf("[workspace] Tree validation failed after close: %v", verr) | |
| 164 | + | } | |
| 178 | 165 | } | |
| 179 | 166 | wm.paneCloseLogf("closePane succeeded node=%p promoted=%p root=%p", node, promoted, wm.root) | |
| 180 | 167 | wm.dumpTreeState("after_close_success") | |
| @@ -207,67 +194,64 @@ func (wm *WorkspaceManager) StackPane(target *paneNode) (*paneNode, error) { | |||
| 207 | 194 | ||
| 208 | 195 | log.Printf("[workspace] Starting stack operation: target=%p", target) | |
| 209 | 196 | ||
| 210 | - | // Step 1: Validate stack operation constraints | |
| 211 | - | validation := wm.geometryValidator.ValidateStackOperation(target) | |
| 212 | - | if !validation.IsValid { | |
| 213 | - | return nil, fmt.Errorf("stack validation failed: %s", validation.Reason) | |
| 214 | - | } | |
| 215 | - | ||
| 216 | - | // Log if re-validation will be needed due to pending widget allocation | |
| 217 | - | if validation.RequiresRevalidation { | |
| 218 | - | log.Printf("[workspace] Stack validation passed with pending allocation - operation will proceed") | |
| 219 | - | } | |
| 197 | + | // Step 1: Validate stack operation constraints (only in DebugBasic+) | |
| 198 | + | if wm.debugLevel >= DebugBasic { | |
| 199 | + | validation := wm.geometryValidator.ValidateStackOperation(target) | |
| 200 | + | if !validation.IsValid { | |
| 201 | + | if wm.debugLevel == DebugFull { | |
| 202 | + | return nil, fmt.Errorf("stack validation failed: %s", validation.Reason) | |
| 203 | + | } | |
| 204 | + | log.Printf("[workspace] WARNING: Stack validation failed but allowing operation: %s", validation.Reason) | |
| 205 | + | } | |
| 220 | 206 | ||
| 221 | - | // Step 2: Capture state tombstone for rollback | |
| 222 | - | tombstone, err := wm.stateTombstoneManager.CaptureState("stack") | |
| 223 | - | if err != nil { | |
| 224 | - | log.Printf("[workspace] Failed to capture state tombstone: %v", err) | |
| 207 | + | // Log if re-validation will be needed due to pending widget allocation | |
| 208 | + | if validation.RequiresRevalidation { | |
| 209 | + | log.Printf("[workspace] Stack validation passed with pending allocation - operation will proceed") | |
| 210 | + | } | |
| 225 | 211 | } | |
| 226 | 212 | ||
| 227 | - | // Step 3: Validate tree invariants before operation | |
| 228 | - | if err := wm.treeValidator.ValidateTree(wm.root, "before_stack"); err != nil { | |
| 229 | - | return nil, fmt.Errorf("tree validation failed before stack: %w", err) | |
| 213 | + | // Step 2: Validate tree invariants before operation (only in DebugBasic+) | |
| 214 | + | if wm.debugLevel >= DebugBasic { | |
| 215 | + | if err := wm.treeValidator.ValidateTree(wm.root, "before_stack"); err != nil { | |
| 216 | + | if wm.debugLevel == DebugFull { | |
| 217 | + | return nil, fmt.Errorf("tree validation failed before stack: %w", err) | |
| 218 | + | } | |
| 219 | + | log.Printf("[workspace] WARNING: Tree validation failed before stack but allowing operation: %v", err) | |
| 220 | + | } | |
| 230 | 221 | } | |
| 231 | 222 | ||
| 232 | - | // Step 4: Execute directly if already on the GTK main thread | |
| 223 | + | // Step 3: Execute directly if already on the GTK main thread | |
| 233 | 224 | if webkit.IsMainThread() { | |
| 234 | 225 | log.Printf("[workspace] Already on main thread, executing stack directly") | |
| 235 | 226 | newNode, err := wm.stackedPaneManager.StackPane(target) | |
| 236 | 227 | if err != nil { | |
| 237 | - | if tombstone != nil { | |
| 238 | - | if rollbackErr := wm.stateTombstoneManager.RestoreState(tombstone.ID); rollbackErr != nil { | |
| 239 | - | log.Printf("[workspace] Rollback failed after stack failure: %v", rollbackErr) | |
| 240 | - | } | |
| 241 | - | } | |
| 242 | 228 | return nil, err | |
| 243 | 229 | } | |
| 244 | 230 | ||
| 245 | - | if err := wm.treeValidator.ValidateTree(wm.root, "after_stack"); err != nil { | |
| 246 | - | log.Printf("[workspace] Tree validation failed after stack: %v", err) | |
| 231 | + | if wm.debugLevel >= DebugBasic { | |
| 232 | + | if err := wm.treeValidator.ValidateTree(wm.root, "after_stack"); err != nil { | |
| 233 | + | log.Printf("[workspace] Tree validation failed after stack: %v", err) | |
| 234 | + | } | |
| 247 | 235 | } | |
| 248 | 236 | ||
| 249 | 237 | log.Printf("[workspace] Stack operation completed successfully (direct execution): newNode=%p", newNode) | |
| 250 | 238 | return newNode, nil | |
| 251 | 239 | } | |
| 252 | 240 | ||
| 253 | - | // Step 5: Not on main thread, marshal via IdleAdd | |
| 241 | + | // Step 4: Not on main thread, marshal via IdleAdd | |
| 254 | 242 | log.Printf("[workspace] Not on main thread, marshalling stack via IdleAdd") | |
| 255 | 243 | var newNode *paneNode | |
| 256 | 244 | var stackErr error | |
| 257 | 245 | done := make(chan struct{}) | |
| 258 | 246 | ||
| 259 | - | webkit.IdleAdd(func() bool { | |
| 247 | + | _ = webkit.IdleAdd(func() bool { | |
| 260 | 248 | newNode, stackErr = wm.stackedPaneManager.StackPane(target) | |
| 261 | - | if stackErr != nil { | |
| 262 | - | if tombstone != nil { | |
| 263 | - | if rollbackErr := wm.stateTombstoneManager.RestoreState(tombstone.ID); rollbackErr != nil { | |
| 264 | - | log.Printf("[workspace] Rollback failed after stack failure: %v", rollbackErr) | |
| 249 | + | if stackErr == nil { | |
| 250 | + | if wm.debugLevel >= DebugBasic { | |
| 251 | + | if verr := wm.treeValidator.ValidateTree(wm.root, "after_stack"); verr != nil { | |
| 252 | + | log.Printf("[workspace] Tree validation failed after stack: %v", verr) | |
| 265 | 253 | } | |
| 266 | 254 | } | |
| 267 | - | } else { | |
| 268 | - | if verr := wm.treeValidator.ValidateTree(wm.root, "after_stack"); verr != nil { | |
| 269 | - | log.Printf("[workspace] Tree validation failed after stack: %v", verr) | |
| 270 | - | } | |
| 271 | 255 | } | |
| 272 | 256 | close(done) | |
| 273 | 257 | return false | |
| @@ -324,19 +308,11 @@ func (wm *WorkspaceManager) GetEnhancedStats() map[string]interface{} { | |||
| 324 | 308 | stats["tree_validation"] = wm.treeValidator.GetValidationStats() | |
| 325 | 309 | } | |
| 326 | 310 | ||
| 327 | - | ||
| 328 | - | ||
| 329 | 311 | if wm.treeRebalancer != nil { | |
| 330 | 312 | stats["tree_rebalancing"] = wm.treeRebalancer.GetRebalancingStats() | |
| 331 | 313 | } | |
| 332 | 314 | ||
| 333 | - | if wm.geometryValidator != nil { | |
| 334 | - | stats["geometry"] = wm.geometryValidator.GetGeometryStats(wm.root) | |
| 335 | - | } | |
| 336 | - | ||
| 337 | - | if wm.stateTombstoneManager != nil { | |
| 338 | - | stats["tombstones"] = wm.stateTombstoneManager.GetTombstoneStats() | |
| 339 | - | } | |
| 315 | + | // Geometry validator no longer tracks stats - simplified | |
| 340 | 316 | ||
| 341 | 317 | return stats | |
| 342 | 318 | } | |
| @@ -352,16 +328,7 @@ func (wm *WorkspaceManager) ValidateWorkspaceIntegrity() error { | |||
| 352 | 328 | } | |
| 353 | 329 | } | |
| 354 | 330 | ||
| 355 | - | // Geometry validation | |
| 356 | - | if wm.geometryValidator != nil { | |
| 357 | - | results := wm.geometryValidator.ValidateWorkspaceLayout(wm.root) | |
| 358 | - | for i, result := range results { | |
| 359 | - | if !result.IsValid { | |
| 360 | - | log.Printf("[workspace] Geometry validation failed for pane %d: %s", i, result.Reason) | |
| 361 | - | // Don't fail the entire check for geometry issues | |
| 362 | - | } | |
| 363 | - | } | |
| 364 | - | } | |
| 331 | + | // Geometry validation simplified - ValidateWorkspaceLayout removed | |
| 365 | 332 | ||
| 366 | 333 | log.Printf("[workspace] Workspace integrity check completed successfully") | |
| 367 | 334 | return nil | |
| @@ -371,7 +338,6 @@ func (wm *WorkspaceManager) ValidateWorkspaceIntegrity() error { | |||
| 371 | 338 | func (wm *WorkspaceManager) ShutdownEnhancedComponents() { | |
| 372 | 339 | log.Printf("[workspace] Shutting down enhanced validation components") | |
| 373 | 340 | ||
| 374 | - | ||
| 375 | 341 | // Other components don't require explicit shutdown currently | |
| 376 | 342 | log.Printf("[workspace] Enhanced validation components shutdown complete") | |
| 377 | 343 | } | |
M internal/app/browser/workspace_pane_ops.go +270 -185
| @@ -128,12 +128,15 @@ func (wm *WorkspaceManager) clonePaneState(_ *paneNode, target *paneNode) { | |||
| 128 | 128 | target.pane.webView.RegisterURIChangedHandler(func(uri string) { | |
| 129 | 129 | if uri == blankURL { | |
| 130 | 130 | // Defer omnibox opening to allow page to fully initialize | |
| 131 | - | webkit.IdleAdd(func() bool { | |
| 131 | + | wm.scheduleIdleGuarded(func() bool { | |
| 132 | + | if target == nil || target.pane == nil || target.pane.webView == nil { | |
| 133 | + | return false | |
| 134 | + | } | |
| 132 | 135 | if injErr := target.pane.webView.InjectScript("window.__dumber_omnibox?.open('omnibox');"); injErr != nil { | |
| 133 | 136 | log.Printf("[workspace] failed to open omnibox: %v", injErr) | |
| 134 | 137 | } | |
| 135 | 138 | return false // Remove idle callback | |
| 136 | - | }) | |
| 139 | + | }, target) | |
| 137 | 140 | } | |
| 138 | 141 | }) | |
| 139 | 142 | } | |
| @@ -325,7 +328,7 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa | |||
| 325 | 328 | webkit.WidgetRealizeInContainer(webViewWidget) | |
| 326 | 329 | } | |
| 327 | 330 | ||
| 328 | - | if target.container == nil { | |
| 331 | + | if target.container == 0 { | |
| 329 | 332 | return errors.New("existing pane missing container") | |
| 330 | 333 | } | |
| 331 | 334 | ||
| @@ -360,28 +363,24 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa | |||
| 360 | 363 | wm.safelyDetachControllersBeforeReparent(target) | |
| 361 | 364 | ||
| 362 | 365 | // Detach existing container from its current GTK parent before inserting into new paned. | |
| 363 | - | existingContainer := target.getContainerPtr() | |
| 366 | + | existingContainer := target.container | |
| 364 | 367 | if parent == nil { | |
| 365 | 368 | // Target is the root - remove it from the window | |
| 366 | 369 | log.Printf("[workspace] removing existing container=%#x from window", existingContainer) | |
| 367 | 370 | if wm.window != nil { | |
| 368 | 371 | wm.window.SetChild(0) | |
| 369 | 372 | } | |
| 370 | - | } else if parent.container != nil { | |
| 373 | + | } else if parent.container != 0 { | |
| 371 | 374 | // Target has a parent paned - unparent it from there | |
| 372 | 375 | log.Printf("[workspace] unparenting existing container from parent paned") | |
| 373 | - | err := parent.container.Execute(func(parentPtr uintptr) error { | |
| 374 | - | log.Printf("[workspace] executing unparent: parentPtr=%#x", parentPtr) | |
| 376 | + | if webkit.WidgetIsValid(parent.container) { | |
| 377 | + | log.Printf("[workspace] executing unparent: parentPtr=%#x", parent.container) | |
| 375 | 378 | if parent.left == target { | |
| 376 | - | webkit.PanedSetStartChild(parentPtr, 0) | |
| 379 | + | webkit.PanedSetStartChild(parent.container, 0) | |
| 377 | 380 | } else if parent.right == target { | |
| 378 | - | webkit.PanedSetEndChild(parentPtr, 0) | |
| 381 | + | webkit.PanedSetEndChild(parent.container, 0) | |
| 379 | 382 | } | |
| 380 | - | webkit.WidgetQueueAllocate(parentPtr) | |
| 381 | - | return nil | |
| 382 | - | }) | |
| 383 | - | if err != nil { | |
| 384 | - | log.Printf("[workspace] error during parent unparent: %v", err) | |
| 383 | + | webkit.WidgetQueueAllocate(parent.container) | |
| 385 | 384 | } | |
| 386 | 385 | } | |
| 387 | 386 | ||
| @@ -411,22 +410,18 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa | |||
| 411 | 410 | log.Printf("[workspace] paned set as window child: paned=%#x", paned) | |
| 412 | 411 | } | |
| 413 | 412 | } else { | |
| 414 | - | err := parent.container.Execute(func(parentPtr uintptr) error { | |
| 415 | - | log.Printf("[workspace] executing parent reparent: parentPtr=%#x, paned=%#x", parentPtr, paned) | |
| 413 | + | if webkit.WidgetIsValid(parent.container) { | |
| 414 | + | log.Printf("[workspace] executing parent reparent: parentPtr=%#x, paned=%#x", parent.container, paned) | |
| 416 | 415 | if parent.left == target { | |
| 417 | 416 | parent.left = split | |
| 418 | - | webkit.PanedSetStartChild(parentPtr, paned) | |
| 417 | + | webkit.PanedSetStartChild(parent.container, paned) | |
| 419 | 418 | } else if parent.right == target { | |
| 420 | 419 | parent.right = split | |
| 421 | - | webkit.PanedSetEndChild(parentPtr, paned) | |
| 420 | + | webkit.PanedSetEndChild(parent.container, paned) | |
| 422 | 421 | } | |
| 423 | - | webkit.WidgetQueueAllocate(parentPtr) | |
| 422 | + | webkit.WidgetQueueAllocate(parent.container) | |
| 424 | 423 | webkit.WidgetQueueAllocate(paned) | |
| 425 | 424 | log.Printf("[workspace] paned inserted into parent successfully") | |
| 426 | - | return nil | |
| 427 | - | }) | |
| 428 | - | if err != nil { | |
| 429 | - | log.Printf("[workspace] error during parent reparent: %v", err) | |
| 430 | 425 | } | |
| 431 | 426 | } | |
| 432 | 427 | ||
| @@ -446,9 +441,12 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa | |||
| 446 | 441 | if target != nil && target.isStacked { | |
| 447 | 442 | reattachTargets = append(reattachTargets, target.stackedPanes...) | |
| 448 | 443 | } | |
| 449 | - | ||
| 450 | - | webkit.IdleAdd(func() bool { | |
| 451 | - | if newContainer != 0 { | |
| 444 | + | guardNodes := append([]*paneNode{newLeaf}, reattachTargets...) | |
| 445 | + | wm.scheduleIdleGuarded(func() bool { | |
| 446 | + | if newLeaf == nil || !newLeaf.widgetValid { | |
| 447 | + | return false | |
| 448 | + | } | |
| 449 | + | if newContainer != 0 && webkit.WidgetIsValid(newContainer) { | |
| 452 | 450 | webkit.WidgetShow(newContainer) | |
| 453 | 451 | } | |
| 454 | 452 | // Attach GTK focus controller to new pane | |
| @@ -457,7 +455,7 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa | |||
| 457 | 455 | } | |
| 458 | 456 | ||
| 459 | 457 | for _, candidate := range reattachTargets { | |
| 460 | - | if candidate == nil { | |
| 458 | + | if candidate == nil || !candidate.widgetValid { | |
| 461 | 459 | continue | |
| 462 | 460 | } | |
| 463 | 461 | if candidate.pendingHoverReattach { | |
| @@ -476,7 +474,7 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa | |||
| 476 | 474 | ||
| 477 | 475 | wm.SetActivePane(newLeaf, SourceSplit) | |
| 478 | 476 | return false | |
| 479 | - | }) | |
| 477 | + | }, guardNodes...) | |
| 480 | 478 | ||
| 481 | 479 | return nil | |
| 482 | 480 | } | |
| @@ -499,7 +497,7 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane | |||
| 499 | 497 | ||
| 500 | 498 | // Determine what we're actually splitting | |
| 501 | 499 | var splitTarget *paneNode | |
| 502 | - | var splitTargetContainer *SafeWidget | |
| 500 | + | var splitTargetContainer uintptr | |
| 503 | 501 | ||
| 504 | 502 | if stackContainer != nil { | |
| 505 | 503 | // Case: splitting from inside a stack - we split around the entire stack | |
| @@ -536,28 +534,26 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane | |||
| 536 | 534 | webkit.WidgetRealizeInContainer(newContainer) | |
| 537 | 535 | ||
| 538 | 536 | // Use the determined split target container | |
| 539 | - | if splitTargetContainer == nil { | |
| 537 | + | if splitTargetContainer == 0 { | |
| 540 | 538 | return nil, errors.New("split target missing container") | |
| 541 | 539 | } | |
| 542 | 540 | ||
| 543 | 541 | // Only apply GTK operations if this is a simple widget (leaf pane) | |
| 544 | 542 | // For stacked containers, we need to be more careful | |
| 545 | 543 | if splitTarget.isLeaf { | |
| 546 | - | splitTargetContainer.Execute(func(containerPtr uintptr) error { | |
| 547 | - | webkit.WidgetSetHExpand(containerPtr, true) | |
| 548 | - | webkit.WidgetSetVExpand(containerPtr, true) | |
| 549 | - | webkit.WidgetRealizeInContainer(containerPtr) | |
| 550 | - | return nil | |
| 551 | - | }) | |
| 544 | + | if webkit.WidgetIsValid(splitTargetContainer) { | |
| 545 | + | webkit.WidgetSetHExpand(splitTargetContainer, true) | |
| 546 | + | webkit.WidgetSetVExpand(splitTargetContainer, true) | |
| 547 | + | webkit.WidgetRealizeInContainer(splitTargetContainer) | |
| 548 | + | } | |
| 552 | 549 | } else if splitTarget.isStacked { | |
| 553 | 550 | // Stack containers need the same setup as regular panes for proper splitting | |
| 554 | - | splitTargetContainer.Execute(func(containerPtr uintptr) error { | |
| 555 | - | webkit.WidgetSetHExpand(containerPtr, true) | |
| 556 | - | webkit.WidgetSetVExpand(containerPtr, true) | |
| 557 | - | webkit.WidgetRealizeInContainer(containerPtr) | |
| 558 | - | log.Printf("[workspace] configured stack wrapper for split: %#x", containerPtr) | |
| 559 | - | return nil | |
| 560 | - | }) | |
| 551 | + | if webkit.WidgetIsValid(splitTargetContainer) { | |
| 552 | + | webkit.WidgetSetHExpand(splitTargetContainer, true) | |
| 553 | + | webkit.WidgetSetVExpand(splitTargetContainer, true) | |
| 554 | + | webkit.WidgetRealizeInContainer(splitTargetContainer) | |
| 555 | + | log.Printf("[workspace] configured stack wrapper for split: %#x", splitTargetContainer) | |
| 556 | + | } | |
| 561 | 557 | } | |
| 562 | 558 | ||
| 563 | 559 | orientation, existingFirst := mapDirection(direction) | |
| @@ -588,6 +584,17 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane | |||
| 588 | 584 | ||
| 589 | 585 | parent := split.parent | |
| 590 | 586 | ||
| 587 | + | // Clear CSS classes before reparenting to avoid GTK bloom filter corruption | |
| 588 | + | if splitTargetContainer != 0 && webkit.WidgetIsValid(splitTargetContainer) { | |
| 589 | + | // Remove active border class if present (prevents GTK bloom filter corruption during unparent) | |
| 590 | + | if webkit.WidgetHasCSSClass(splitTargetContainer, activePaneClass) { | |
| 591 | + | webkit.WidgetRemoveCSSClass(splitTargetContainer, activePaneClass) | |
| 592 | + | } | |
| 593 | + | } | |
| 594 | + | ||
| 595 | + | // Prevent GTK from auto-dropping controllers while the widget is temporarily unparented. | |
| 596 | + | wm.safelyDetachControllersBeforeReparent(splitTarget) | |
| 597 | + | ||
| 591 | 598 | // Detach split target container from its current GTK parent before inserting into new paned. | |
| 592 | 599 | if parent == nil { | |
| 593 | 600 | // Split target is the root - remove it from the window | |
| @@ -596,25 +603,23 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane | |||
| 596 | 603 | wm.window.SetChild(0) | |
| 597 | 604 | } | |
| 598 | 605 | // For root widgets, only unparent if they actually have a GTK parent | |
| 599 | - | splitTargetContainer.Execute(func(containerPtr uintptr) error { | |
| 600 | - | if webkit.WidgetGetParent(containerPtr) != 0 { | |
| 601 | - | webkit.WidgetUnparent(containerPtr) | |
| 606 | + | if webkit.WidgetIsValid(splitTargetContainer) { | |
| 607 | + | if webkit.WidgetGetParent(splitTargetContainer) != 0 { | |
| 608 | + | webkit.WidgetUnparent(splitTargetContainer) | |
| 602 | 609 | } | |
| 603 | - | return nil | |
| 604 | - | }) | |
| 605 | - | } else if parent.container != nil { | |
| 610 | + | } | |
| 611 | + | } else if parent.container != 0 { | |
| 606 | 612 | // Split target has a parent paned - remove it (automatically unparents in GTK4) | |
| 607 | 613 | log.Printf("[workspace] unparenting split target container=%#x from parent paned=%#x", splitTargetContainer, parent.container) | |
| 608 | - | parent.container.Execute(func(panedPtr uintptr) error { | |
| 614 | + | if webkit.WidgetIsValid(parent.container) { | |
| 609 | 615 | if parent.left == splitTarget { | |
| 610 | - | webkit.PanedSetStartChild(panedPtr, 0) | |
| 616 | + | webkit.PanedSetStartChild(parent.container, 0) | |
| 611 | 617 | } else if parent.right == splitTarget { | |
| 612 | - | webkit.PanedSetEndChild(panedPtr, 0) | |
| 618 | + | webkit.PanedSetEndChild(parent.container, 0) | |
| 613 | 619 | } | |
| 614 | 620 | // GTK4 automatically unparents when we set paned child to 0 - no manual unparent needed | |
| 615 | - | webkit.WidgetQueueAllocate(panedPtr) | |
| 616 | - | return nil | |
| 617 | - | }) | |
| 621 | + | webkit.WidgetQueueAllocate(parent.container) | |
| 622 | + | } | |
| 618 | 623 | } | |
| 619 | 624 | ||
| 620 | 625 | // Set up the tree structure first | |
| @@ -644,19 +649,17 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane | |||
| 644 | 649 | ||
| 645 | 650 | // Add both containers to the new paned | |
| 646 | 651 | if existingFirst { | |
| 647 | - | splitTargetContainer.Execute(func(splitPtr uintptr) error { | |
| 648 | - | webkit.PanedSetStartChild(paned, splitPtr) | |
| 652 | + | if webkit.WidgetIsValid(splitTargetContainer) { | |
| 653 | + | webkit.PanedSetStartChild(paned, splitTargetContainer) | |
| 649 | 654 | webkit.PanedSetEndChild(paned, newContainer) | |
| 650 | - | log.Printf("[workspace] added splitTarget=%#x as start child, new=%#x as end child", splitPtr, newContainer) | |
| 651 | - | return nil | |
| 652 | - | }) | |
| 655 | + | log.Printf("[workspace] added splitTarget=%#x as start child, new=%#x as end child", splitTargetContainer, newContainer) | |
| 656 | + | } | |
| 653 | 657 | } else { | |
| 654 | - | splitTargetContainer.Execute(func(splitPtr uintptr) error { | |
| 658 | + | if webkit.WidgetIsValid(splitTargetContainer) { | |
| 655 | 659 | webkit.PanedSetStartChild(paned, newContainer) | |
| 656 | - | webkit.PanedSetEndChild(paned, splitPtr) | |
| 657 | - | log.Printf("[workspace] added new=%#x as start child, splitTarget=%#x as end child", newContainer, splitPtr) | |
| 658 | - | return nil | |
| 659 | - | }) | |
| 660 | + | webkit.PanedSetEndChild(paned, splitTargetContainer) | |
| 661 | + | log.Printf("[workspace] added new=%#x as start child, splitTarget=%#x as end child", newContainer, splitTargetContainer) | |
| 662 | + | } | |
| 660 | 663 | } | |
| 661 | 664 | ||
| 662 | 665 | // Attach the new paned to its parent | |
| @@ -667,17 +670,16 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane | |||
| 667 | 670 | log.Printf("[workspace] paned set as window child: paned=%#x", paned) | |
| 668 | 671 | } | |
| 669 | 672 | } else { | |
| 670 | - | parent.container.Execute(func(parentPtr uintptr) error { | |
| 673 | + | if webkit.WidgetIsValid(parent.container) { | |
| 671 | 674 | if parent.left == split { | |
| 672 | - | webkit.PanedSetStartChild(parentPtr, paned) | |
| 675 | + | webkit.PanedSetStartChild(parent.container, paned) | |
| 673 | 676 | } else if parent.right == split { | |
| 674 | - | webkit.PanedSetEndChild(parentPtr, paned) | |
| 677 | + | webkit.PanedSetEndChild(parent.container, paned) | |
| 675 | 678 | } | |
| 676 | - | webkit.WidgetQueueAllocate(parentPtr) | |
| 679 | + | webkit.WidgetQueueAllocate(parent.container) | |
| 677 | 680 | webkit.WidgetQueueAllocate(paned) | |
| 678 | - | log.Printf("[workspace] paned inserted into parent=%#x", parentPtr) | |
| 679 | - | return nil | |
| 680 | - | }) | |
| 681 | + | log.Printf("[workspace] paned inserted into parent=%#x", parent.container) | |
| 682 | + | } | |
| 681 | 683 | } | |
| 682 | 684 | ||
| 683 | 685 | webkit.WidgetShow(paned) | |
| @@ -705,9 +707,12 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane | |||
| 705 | 707 | if splitTarget != nil && splitTarget.isStacked { | |
| 706 | 708 | reattachTargets = append(reattachTargets, splitTarget.stackedPanes...) | |
| 707 | 709 | } | |
| 708 | - | ||
| 709 | - | webkit.IdleAdd(func() bool { | |
| 710 | - | if newContainer != 0 { | |
| 710 | + | guardNodes := append([]*paneNode{newLeaf}, reattachTargets...) | |
| 711 | + | wm.scheduleIdleGuarded(func() bool { | |
| 712 | + | if newLeaf == nil || !newLeaf.widgetValid { | |
| 713 | + | return false | |
| 714 | + | } | |
| 715 | + | if newContainer != 0 && webkit.WidgetIsValid(newContainer) { | |
| 711 | 716 | webkit.WidgetShow(newContainer) | |
| 712 | 717 | } | |
| 713 | 718 | // Attach GTK focus controller to new pane | |
| @@ -716,7 +721,7 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane | |||
| 716 | 721 | } | |
| 717 | 722 | ||
| 718 | 723 | for _, candidate := range reattachTargets { | |
| 719 | - | if candidate == nil { | |
| 724 | + | if candidate == nil || !candidate.widgetValid { | |
| 720 | 725 | continue | |
| 721 | 726 | } | |
| 722 | 727 | if candidate.pendingHoverReattach { | |
| @@ -734,7 +739,7 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane | |||
| 734 | 739 | } | |
| 735 | 740 | wm.SetActivePane(newLeaf, SourceSplit) | |
| 736 | 741 | return false | |
| 737 | - | }) | |
| 742 | + | }, guardNodes...) | |
| 738 | 743 | ||
| 739 | 744 | return newLeaf, nil | |
| 740 | 745 | } | |
| @@ -758,21 +763,6 @@ func (wm *WorkspaceManager) nextCleanupGeneration() uint { | |||
| 758 | 763 | return wm.cleanupCounter | |
| 759 | 764 | } | |
| 760 | 765 | ||
| 761 | - | // ensureWidgets validates and recovers SafeWidget instances | |
| 762 | - | func (wm *WorkspaceManager) ensureWidgets(nodes ...*paneNode) { | |
| 763 | - | for _, n := range nodes { | |
| 764 | - | if n == nil || n.container == nil { | |
| 765 | - | continue | |
| 766 | - | } | |
| 767 | - | if n.container.IsValid() { | |
| 768 | - | continue | |
| 769 | - | } | |
| 770 | - | ptr := n.container.Ptr() | |
| 771 | - | typeInfo := n.container.typeInfo | |
| 772 | - | n.container = wm.widgetRegistry.Register(ptr, typeInfo) | |
| 773 | - | } | |
| 774 | - | } | |
| 775 | - | ||
| 776 | 766 | // promoteSibling promotes a sibling node to replace its parent in the tree | |
| 777 | 767 | func (wm *WorkspaceManager) promoteSibling(grand *paneNode, parent *paneNode, sibling *paneNode) { | |
| 778 | 768 | if grand == nil { | |
| @@ -798,53 +788,141 @@ func (wm *WorkspaceManager) swapContainers(grand *paneNode, sibling *paneNode) { | |||
| 798 | 788 | } | |
| 799 | 789 | return | |
| 800 | 790 | } | |
| 801 | - | grand.container.Execute(func(gPtr uintptr) error { | |
| 802 | - | sibling.container.Execute(func(sPtr uintptr) error { | |
| 803 | - | // GTK4 auto-unparents when setting paned child | |
| 804 | - | // But we should verify the widget is ready for reparenting | |
| 805 | - | if parent := webkit.WidgetGetParent(sPtr); parent != 0 && parent != gPtr { | |
| 806 | - | log.Printf("[workspace] widget %#x has unexpected parent %#x, expected %#x or 0", sPtr, parent, gPtr) | |
| 791 | + | // Clear CSS classes before reparenting to avoid GTK bloom filter corruption | |
| 792 | + | if sibling.container != 0 && webkit.WidgetIsValid(sibling.container) { | |
| 793 | + | if webkit.WidgetHasCSSClass(sibling.container, activePaneClass) { | |
| 794 | + | webkit.WidgetRemoveCSSClass(sibling.container, activePaneClass) | |
| 795 | + | } | |
| 796 | + | } | |
| 797 | + | ||
| 798 | + | if webkit.WidgetIsValid(grand.container) && webkit.WidgetIsValid(sibling.container) { | |
| 799 | + | // GTK4 PanedSetStartChild/EndChild auto-unparent from current parent | |
| 800 | + | // Log current parent for debugging, but this is normal GTK4 behavior | |
| 801 | + | if parent := webkit.WidgetGetParent(sibling.container); parent != 0 && parent != grand.container { | |
| 802 | + | log.Printf("[workspace] widget %#x reparenting from %#x to %#x (GTK4 will auto-unparent)", sibling.container, parent, grand.container) | |
| 803 | + | } | |
| 804 | + | ||
| 805 | + | if grand.left == sibling { | |
| 806 | + | webkit.PanedSetStartChild(grand.container, sibling.container) | |
| 807 | + | log.Printf("[workspace] swapContainers: set widget %#x as start child of paned %#x", sibling.container, grand.container) | |
| 808 | + | } else { | |
| 809 | + | webkit.PanedSetEndChild(grand.container, sibling.container) | |
| 810 | + | log.Printf("[workspace] swapContainers: set widget %#x as end child of paned %#x", sibling.container, grand.container) | |
| 811 | + | } | |
| 812 | + | webkit.WidgetQueueAllocate(grand.container) | |
| 813 | + | } | |
| 814 | + | } | |
| 815 | + | ||
| 816 | + | // cascadePromotion handles the case where a paned node has only one child | |
| 817 | + | // This happens when closing a pane leaves the grandparent with only one child | |
| 818 | + | func (wm *WorkspaceManager) cascadePromotion(singleChildPaned *paneNode) { | |
| 819 | + | if singleChildPaned == nil { | |
| 820 | + | return | |
| 821 | + | } | |
| 822 | + | ||
| 823 | + | // Find the single child | |
| 824 | + | var onlyChild *paneNode | |
| 825 | + | if singleChildPaned.left != nil && singleChildPaned.right == nil { | |
| 826 | + | onlyChild = singleChildPaned.left | |
| 827 | + | } else if singleChildPaned.right != nil && singleChildPaned.left == nil { | |
| 828 | + | onlyChild = singleChildPaned.right | |
| 829 | + | } else { | |
| 830 | + | // Either no children or two children - nothing to cascade | |
| 831 | + | return | |
| 832 | + | } | |
| 833 | + | ||
| 834 | + | log.Printf("[workspace] Cascading promotion: replacing paned %p with its only child %p", singleChildPaned, onlyChild) | |
| 835 | + | ||
| 836 | + | greatGrandparent := singleChildPaned.parent | |
| 837 | + | ||
| 838 | + | // Update tree structure | |
| 839 | + | onlyChild.parent = greatGrandparent | |
| 840 | + | if greatGrandparent == nil { | |
| 841 | + | // Single child becomes new root | |
| 842 | + | wm.root = onlyChild | |
| 843 | + | log.Printf("[workspace] Only child %p promoted to root", onlyChild) | |
| 844 | + | ||
| 845 | + | // Attach to window | |
| 846 | + | if onlyChild.container != 0 && webkit.WidgetIsValid(onlyChild.container) { | |
| 847 | + | // Unparent from old container if needed | |
| 848 | + | if parent := webkit.WidgetGetParent(onlyChild.container); parent != 0 { | |
| 849 | + | webkit.WidgetUnparent(onlyChild.container) | |
| 807 | 850 | } | |
| 808 | 851 | ||
| 809 | - | if grand.left == sibling { | |
| 810 | - | webkit.PanedSetStartChild(gPtr, sPtr) | |
| 811 | - | log.Printf("[workspace] swapContainers: set widget %#x as start child of paned %#x", sPtr, gPtr) | |
| 852 | + | wm.window.SetChild(onlyChild.container) | |
| 853 | + | webkit.WidgetSetHExpand(onlyChild.container, true) | |
| 854 | + | webkit.WidgetSetVExpand(onlyChild.container, true) | |
| 855 | + | webkit.WidgetQueueAllocate(onlyChild.container) | |
| 856 | + | webkit.WidgetShow(onlyChild.container) | |
| 857 | + | } | |
| 858 | + | ||
| 859 | + | // Cleanup the now-orphaned paned | |
| 860 | + | wm.decommissionParent(singleChildPaned, wm.cleanupCounter) | |
| 861 | + | } else { | |
| 862 | + | // Replace paned in great-grandparent | |
| 863 | + | if greatGrandparent.left == singleChildPaned { | |
| 864 | + | greatGrandparent.left = onlyChild | |
| 865 | + | } else { | |
| 866 | + | greatGrandparent.right = onlyChild | |
| 867 | + | } | |
| 868 | + | ||
| 869 | + | log.Printf("[workspace] Only child %p attached to great-grandparent %p", onlyChild, greatGrandparent) | |
| 870 | + | ||
| 871 | + | // Reparent widget in GTK | |
| 872 | + | if onlyChild.container != 0 && webkit.WidgetIsValid(onlyChild.container) && greatGrandparent.container != 0 && webkit.WidgetIsValid(greatGrandparent.container) { | |
| 873 | + | // Unparent from old container | |
| 874 | + | if parent := webkit.WidgetGetParent(onlyChild.container); parent != 0 { | |
| 875 | + | webkit.WidgetUnparent(onlyChild.container) | |
| 876 | + | } | |
| 877 | + | ||
| 878 | + | // Attach to great-grandparent | |
| 879 | + | if greatGrandparent.left == onlyChild { | |
| 880 | + | webkit.PanedSetStartChild(greatGrandparent.container, onlyChild.container) | |
| 812 | 881 | } else { | |
| 813 | - | webkit.PanedSetEndChild(gPtr, sPtr) | |
| 814 | - | log.Printf("[workspace] swapContainers: set widget %#x as end child of paned %#x", sPtr, gPtr) | |
| 882 | + | webkit.PanedSetEndChild(greatGrandparent.container, onlyChild.container) | |
| 815 | 883 | } | |
| 816 | - | webkit.WidgetQueueAllocate(gPtr) | |
| 817 | - | return nil | |
| 818 | - | }) | |
| 819 | - | return nil | |
| 820 | - | }) | |
| 884 | + | ||
| 885 | + | webkit.WidgetSetHExpand(onlyChild.container, true) | |
| 886 | + | webkit.WidgetSetVExpand(onlyChild.container, true) | |
| 887 | + | webkit.WidgetQueueAllocate(onlyChild.container) | |
| 888 | + | webkit.WidgetQueueAllocate(greatGrandparent.container) | |
| 889 | + | } | |
| 890 | + | ||
| 891 | + | // Cleanup the now-orphaned paned | |
| 892 | + | wm.decommissionParent(singleChildPaned, wm.cleanupCounter) | |
| 893 | + | ||
| 894 | + | // Check if great-grandparent now also has only one child (recursive case) | |
| 895 | + | if greatGrandparent != nil && ((greatGrandparent.left == nil) != (greatGrandparent.right == nil)) { | |
| 896 | + | log.Printf("[workspace] Great-grandparent %p also has only one child, continuing cascade", greatGrandparent) | |
| 897 | + | wm.cascadePromotion(greatGrandparent) | |
| 898 | + | } | |
| 899 | + | } | |
| 821 | 900 | } | |
| 822 | 901 | ||
| 823 | 902 | // attachRoot attaches a node as the new window root | |
| 824 | 903 | func (wm *WorkspaceManager) attachRoot(root *paneNode) { | |
| 825 | - | if root == nil || root.container == nil || wm.window == nil { | |
| 904 | + | if root == nil || root.container == 0 || wm.window == nil { | |
| 826 | 905 | return | |
| 827 | 906 | } | |
| 828 | - | root.container.Execute(func(ptr uintptr) error { | |
| 907 | + | if webkit.WidgetIsValid(root.container) { | |
| 829 | 908 | // Check if widget has a parent and unparent it first | |
| 830 | - | if parent := webkit.WidgetGetParent(ptr); parent != 0 { | |
| 831 | - | log.Printf("[workspace] unparenting widget %#x from parent %#x before window attach", ptr, parent) | |
| 832 | - | webkit.WidgetUnparent(ptr) | |
| 909 | + | if parent := webkit.WidgetGetParent(root.container); parent != 0 { | |
| 910 | + | log.Printf("[workspace] unparenting widget %#x from parent %#x before window attach", root.container, parent) | |
| 911 | + | webkit.WidgetUnparent(root.container) | |
| 833 | 912 | } | |
| 834 | 913 | ||
| 835 | 914 | // Now safe to set as window child | |
| 836 | - | wm.window.SetChild(ptr) | |
| 837 | - | webkit.WidgetQueueAllocate(ptr) | |
| 838 | - | webkit.WidgetShow(ptr) | |
| 915 | + | wm.window.SetChild(root.container) | |
| 916 | + | webkit.WidgetQueueAllocate(root.container) | |
| 917 | + | webkit.WidgetShow(root.container) | |
| 839 | 918 | ||
| 840 | 919 | // Verify attachment succeeded | |
| 841 | - | if finalParent := webkit.WidgetGetParent(ptr); finalParent == 0 { | |
| 842 | - | log.Printf("[workspace] WARNING: widget %#x has no parent after SetChild", ptr) | |
| 920 | + | if finalParent := webkit.WidgetGetParent(root.container); finalParent == 0 { | |
| 921 | + | log.Printf("[workspace] WARNING: widget %#x has no parent after SetChild", root.container) | |
| 843 | 922 | } else { | |
| 844 | - | log.Printf("[workspace] attachRoot successful: widget %#x now child of %#x", ptr, finalParent) | |
| 923 | + | log.Printf("[workspace] attachRoot successful: widget %#x now child of %#x", root.container, finalParent) | |
| 845 | 924 | } | |
| 846 | - | return nil | |
| 847 | - | }) | |
| 925 | + | } | |
| 848 | 926 | } | |
| 849 | 927 | ||
| 850 | 928 | // cleanupPane safely cleans up a pane node with generation tracking | |
| @@ -856,21 +934,28 @@ func (wm *WorkspaceManager) cleanupPane(node *paneNode, generation uint) { | |||
| 856 | 934 | return | |
| 857 | 935 | } | |
| 858 | 936 | ||
| 937 | + | wm.cancelIdleHandles(node) | |
| 938 | + | ||
| 859 | 939 | node.widgetValid = false | |
| 860 | 940 | node.cleanupGeneration = generation | |
| 861 | 941 | ||
| 862 | 942 | if node.pane != nil { | |
| 863 | - | node.pane.CleanupFromWorkspace(wm) | |
| 864 | - | if node.pane.webView != nil { | |
| 865 | - | node.pane.webView.Destroy() | |
| 943 | + | pane := node.pane | |
| 944 | + | pane.Cleanup() | |
| 945 | + | pane.CleanupFromWorkspace(wm) | |
| 946 | + | if pane.webView != nil { | |
| 947 | + | log.Printf("[workspace] releasing WebView reference: %p", pane.webView) | |
| 948 | + | pane.webView = nil | |
| 866 | 949 | } | |
| 950 | + | node.pane = nil | |
| 867 | 951 | } | |
| 868 | 952 | ||
| 869 | - | if node.container != nil { | |
| 870 | - | node.container.Invalidate() | |
| 871 | - | node.container = nil | |
| 953 | + | if node.container != 0 { | |
| 954 | + | // Container widget will be destroyed by GTK when unparented | |
| 955 | + | node.container = 0 | |
| 872 | 956 | } | |
| 873 | 957 | ||
| 958 | + | node.pendingIdleHandles = nil | |
| 874 | 959 | node.parent = nil | |
| 875 | 960 | node.left = nil | |
| 876 | 961 | node.right = nil | |
| @@ -882,30 +967,11 @@ func (wm *WorkspaceManager) decommissionParent(parent *paneNode, generation uint | |||
| 882 | 967 | return | |
| 883 | 968 | } | |
| 884 | 969 | ||
| 885 | - | // For branch nodes (paneds), ensure the GTK widget is destroyed properly | |
| 886 | - | if !parent.isLeaf && parent.container != nil { | |
| 887 | - | parent.container.Execute(func(ptr uintptr) error { | |
| 888 | - | // Clear any remaining children first to ensure clean destruction | |
| 889 | - | // Note: Children may already be reparented during promotion, so verify parent relationship | |
| 890 | - | if webkit.IsPaned(ptr) { | |
| 891 | - | startChild := webkit.PanedGetStartChild(ptr) | |
| 892 | - | endChild := webkit.PanedGetEndChild(ptr) | |
| 893 | - | ||
| 894 | - | // Only clear children that are still valid AND still parented to this paned | |
| 895 | - | // If the child was reparented during swapContainers, its parent will be different | |
| 896 | - | if startChild != 0 && webkit.WidgetIsValid(startChild) && webkit.WidgetGetParent(startChild) == ptr { | |
| 897 | - | webkit.PanedSetStartChild(ptr, 0) | |
| 898 | - | } | |
| 899 | - | if endChild != 0 && webkit.WidgetIsValid(endChild) && webkit.WidgetGetParent(endChild) == ptr { | |
| 900 | - | webkit.PanedSetEndChild(ptr, 0) | |
| 901 | - | } | |
| 902 | - | log.Printf("[workspace] cleared paned children before destruction: %#x", ptr) | |
| 903 | - | } | |
| 904 | - | // Hook widget destruction | |
| 905 | - | webkit.WidgetHookDestroy(ptr) | |
| 906 | - | log.Printf("[workspace] destroyed paned widget: %#x", ptr) | |
| 907 | - | return nil | |
| 908 | - | }) | |
| 970 | + | // For branch nodes (paneds), destroy the widget | |
| 971 | + | // Children have already been unparented during promotion/close, no need to clear them | |
| 972 | + | if !parent.isLeaf && parent.container != 0 && webkit.WidgetIsValid(parent.container) { | |
| 973 | + | webkit.WidgetHookDestroy(parent.container) | |
| 974 | + | log.Printf("[workspace] destroyed paned widget: %#x", parent.container) | |
| 909 | 975 | } | |
| 910 | 976 | ||
| 911 | 977 | wm.cleanupPane(parent, generation) | |
| @@ -937,9 +1003,6 @@ func (wm *WorkspaceManager) cleanupAndExit(node *paneNode) (*paneNode, error) { | |||
| 937 | 1003 | log.Printf("[workspace] closing final pane; exiting browser") | |
| 938 | 1004 | wm.cleanupPane(node, wm.nextCleanupGeneration()) | |
| 939 | 1005 | wm.detachHover(node) | |
| 940 | - | if err := node.pane.webView.Destroy(); err != nil { | |
| 941 | - | log.Printf("[workspace] failed to destroy webview: %v", err) | |
| 942 | - | } | |
| 943 | 1006 | webkit.QuitMainLoop() | |
| 944 | 1007 | return nil, nil | |
| 945 | 1008 | } | |
| @@ -967,6 +1030,10 @@ func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) { | |||
| 967 | 1030 | return wm.closeStackedPaneCompat(node) | |
| 968 | 1031 | } | |
| 969 | 1032 | ||
| 1033 | + | // Ensure we detach hover/focus controllers before the widget hierarchy changes. | |
| 1034 | + | wm.detachHover(node) | |
| 1035 | + | wm.detachFocus(node) | |
| 1036 | + | ||
| 970 | 1037 | // STEP 3: Handle trivial exit cases | |
| 971 | 1038 | if ctx.remaining == 1 { | |
| 972 | 1039 | return wm.cleanupAndExit(node) | |
| @@ -975,21 +1042,49 @@ func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) { | |||
| 975 | 1042 | return wm.promoteNewRoot(ctx, node) | |
| 976 | 1043 | } | |
| 977 | 1044 | ||
| 978 | - | // STEP 4: Promote sibling in-place | |
| 1045 | + | // STEP 4: Unparent closed pane FIRST to avoid leaving paned with single child | |
| 979 | 1046 | parent := node.parent | |
| 980 | 1047 | sibling := wm.getSibling(node) | |
| 981 | 1048 | grandparent := parent.parent | |
| 982 | - | wm.ensureWidgets(grandparent, parent, sibling) | |
| 983 | 1049 | ||
| 1050 | + | // Unparent the closed pane's container from the paned BEFORE promoting sibling | |
| 1051 | + | if node.container != 0 && webkit.WidgetIsValid(node.container) { | |
| 1052 | + | if containerParent := webkit.WidgetGetParent(node.container); containerParent == parent.container { | |
| 1053 | + | if parent.left == node { | |
| 1054 | + | webkit.PanedSetStartChild(parent.container, 0) | |
| 1055 | + | } else if parent.right == node { | |
| 1056 | + | webkit.PanedSetEndChild(parent.container, 0) | |
| 1057 | + | } | |
| 1058 | + | log.Printf("[workspace] unparented closed pane container: %#x", node.container) | |
| 1059 | + | } | |
| 1060 | + | } | |
| 1061 | + | ||
| 1062 | + | // STEP 5: Promote sibling in-place (tree structure) | |
| 984 | 1063 | wm.promoteSibling(grandparent, parent, sibling) | |
| 985 | 1064 | ||
| 986 | - | // STEP 5: GTK updates leverage auto-unparenting | |
| 1065 | + | // STEP 6: GTK handles all reparenting automatically | |
| 1066 | + | // PanedSetStartChild/EndChild and window.SetChild auto-unparent widgets | |
| 987 | 1067 | wm.swapContainers(grandparent, sibling) | |
| 988 | 1068 | ||
| 989 | - | // STEP 6: Cleanup & focus | |
| 1069 | + | // STEP 7: Check if grandparent now has only one child (cascade promotion needed) | |
| 1070 | + | if grandparent != nil && ((grandparent.left == nil) != (grandparent.right == nil)) { | |
| 1071 | + | // Grandparent has exactly one child - it should be promoted too | |
| 1072 | + | log.Printf("[workspace] Grandparent %p now has only one child, cascading promotion", grandparent) | |
| 1073 | + | wm.cascadePromotion(grandparent) | |
| 1074 | + | } | |
| 1075 | + | ||
| 1076 | + | // STEP 8: Cleanup & focus | |
| 990 | 1077 | wm.cleanupPane(node, ctx.Generation()) | |
| 991 | 1078 | wm.decommissionParent(parent, ctx.Generation()) | |
| 992 | - | wm.setFocusToLeaf(sibling) | |
| 1079 | + | if sibling != nil { | |
| 1080 | + | wm.scheduleIdleGuarded(func() bool { | |
| 1081 | + | if sibling == nil || !sibling.widgetValid { | |
| 1082 | + | return false | |
| 1083 | + | } | |
| 1084 | + | wm.setFocusToLeaf(sibling) | |
| 1085 | + | return false | |
| 1086 | + | }, sibling) | |
| 1087 | + | } | |
| 993 | 1088 | ||
| 994 | 1089 | return sibling, nil | |
| 995 | 1090 | } | |
| @@ -1031,24 +1126,19 @@ func (wm *WorkspaceManager) closeStackedPaneCompat(node *paneNode) (*paneNode, e | |||
| 1031 | 1126 | } | |
| 1032 | 1127 | ||
| 1033 | 1128 | // Update GTK widgets | |
| 1034 | - | stack.parent.container.Execute(func(pPtr uintptr) error { | |
| 1035 | - | remaining.container.Execute(func(rPtr uintptr) error { | |
| 1036 | - | if stack.parent.left == remaining { | |
| 1037 | - | webkit.PanedSetStartChild(pPtr, rPtr) | |
| 1038 | - | } else { | |
| 1039 | - | webkit.PanedSetEndChild(pPtr, rPtr) | |
| 1040 | - | } | |
| 1041 | - | return nil | |
| 1042 | - | }) | |
| 1043 | - | return nil | |
| 1044 | - | }) | |
| 1129 | + | if webkit.WidgetIsValid(stack.parent.container) && webkit.WidgetIsValid(remaining.container) { | |
| 1130 | + | if stack.parent.left == remaining { | |
| 1131 | + | webkit.PanedSetStartChild(stack.parent.container, remaining.container) | |
| 1132 | + | } else { | |
| 1133 | + | webkit.PanedSetEndChild(stack.parent.container, remaining.container) | |
| 1134 | + | } | |
| 1135 | + | } | |
| 1045 | 1136 | } else { | |
| 1046 | 1137 | // Stack was root | |
| 1047 | 1138 | wm.root = remaining | |
| 1048 | - | remaining.container.Execute(func(ptr uintptr) error { | |
| 1049 | - | wm.window.SetChild(ptr) | |
| 1050 | - | return nil | |
| 1051 | - | }) | |
| 1139 | + | if webkit.WidgetIsValid(remaining.container) { | |
| 1140 | + | wm.window.SetChild(remaining.container) | |
| 1141 | + | } | |
| 1052 | 1142 | } | |
| 1053 | 1143 | ||
| 1054 | 1144 | // Cleanup stack container | |
| @@ -1063,21 +1153,16 @@ func (wm *WorkspaceManager) closeStackedPaneCompat(node *paneNode) (*paneNode, e | |||
| 1063 | 1153 | } | |
| 1064 | 1154 | ||
| 1065 | 1155 | // Remove widget from GTK box | |
| 1066 | - | stack.container.Execute(func(boxPtr uintptr) error { | |
| 1067 | - | node.container.Execute(func(nodePtr uintptr) error { | |
| 1068 | - | webkit.BoxRemove(boxPtr, nodePtr) | |
| 1069 | - | return nil | |
| 1070 | - | }) | |
| 1071 | - | return nil | |
| 1072 | - | }) | |
| 1156 | + | if webkit.WidgetIsValid(stack.container) && webkit.WidgetIsValid(node.container) { | |
| 1157 | + | webkit.BoxRemove(stack.container, node.container) | |
| 1158 | + | } | |
| 1073 | 1159 | ||
| 1074 | 1160 | // Show new active pane | |
| 1075 | 1161 | if stack.activeStackIndex >= 0 { | |
| 1076 | 1162 | active := stack.stackedPanes[stack.activeStackIndex] | |
| 1077 | - | active.container.Execute(func(ptr uintptr) error { | |
| 1078 | - | webkit.WidgetShow(ptr) | |
| 1079 | - | return nil | |
| 1080 | - | }) | |
| 1163 | + | if webkit.WidgetIsValid(active.container) { | |
| 1164 | + | webkit.WidgetShow(active.container) | |
| 1165 | + | } | |
| 1081 | 1166 | } | |
| 1082 | 1167 | ||
| 1083 | 1168 | // Cleanup closed pane | |
M internal/app/browser/workspace_popup.go +15 -6
| @@ -154,12 +154,15 @@ func (wm *WorkspaceManager) HandlePopup(source *webkit.WebView, url string) *web | |||
| 154 | 154 | log.Printf("[workspace] Closing popup pane") | |
| 155 | 155 | // Brief delay to allow any final redirects to complete | |
| 156 | 156 | time.AfterFunc(200*time.Millisecond, func() { | |
| 157 | - | webkit.IdleAdd(func() bool { | |
| 157 | + | wm.scheduleIdleGuarded(func() bool { | |
| 158 | + | if node == nil || !node.widgetValid { | |
| 159 | + | return false | |
| 160 | + | } | |
| 158 | 161 | if err := wm.ClosePane(node); err != nil { | |
| 159 | 162 | log.Printf("[workspace] Failed to close popup pane: %v", err) | |
| 160 | 163 | } | |
| 161 | 164 | return false | |
| 162 | - | }) | |
| 165 | + | }, node) | |
| 163 | 166 | }) | |
| 164 | 167 | } else { | |
| 165 | 168 | log.Printf("[workspace] Could not find popup node for close handler") | |
| @@ -408,12 +411,15 @@ func (wm *WorkspaceManager) handleIntentAsPopup(sourceNode *paneNode, url string | |||
| 408 | 411 | ||
| 409 | 412 | if n := wm.viewToNode[newView]; n != nil && n.isPopup { | |
| 410 | 413 | time.AfterFunc(200*time.Millisecond, func() { | |
| 411 | - | webkit.IdleAdd(func() bool { | |
| 414 | + | wm.scheduleIdleGuarded(func() bool { | |
| 415 | + | if n == nil || !n.widgetValid { | |
| 416 | + | return false | |
| 417 | + | } | |
| 412 | 418 | if err := wm.ClosePane(n); err != nil { | |
| 413 | 419 | log.Printf("[workspace] Failed to close popup pane: %v", err) | |
| 414 | 420 | } | |
| 415 | 421 | return false | |
| 416 | - | }) | |
| 422 | + | }, n) | |
| 417 | 423 | }) | |
| 418 | 424 | } | |
| 419 | 425 | }) | |
| @@ -494,12 +500,15 @@ func (wm *WorkspaceManager) configureRelatedPopup(sourceNode *paneNode, webView | |||
| 494 | 500 | log.Printf("[workspace] Popup requesting close via window.close()") | |
| 495 | 501 | if n := wm.viewToNode[related]; n != nil && n.isPopup { | |
| 496 | 502 | time.AfterFunc(200*time.Millisecond, func() { | |
| 497 | - | webkit.IdleAdd(func() bool { | |
| 503 | + | wm.scheduleIdleGuarded(func() bool { | |
| 504 | + | if n == nil || !n.widgetValid { | |
| 505 | + | return false | |
| 506 | + | } | |
| 498 | 507 | if err := wm.ClosePane(n); err != nil { | |
| 499 | 508 | log.Printf("[workspace] Failed to close popup pane: %v", err) | |
| 500 | 509 | } | |
| 501 | 510 | return false | |
| 502 | - | }) | |
| 511 | + | }, n) | |
| 503 | 512 | }) | |
| 504 | 513 | } | |
| 505 | 514 | }) | |
M internal/app/browser/workspace_stack_lifecycle.go +18 -422
| @@ -1,21 +1,19 @@ | |||
| 1 | - | // workspace_stack_lifecycle.go - Enhanced stack container lifecycle management for bulletproof operations | |
| 1 | + | // workspace_stack_lifecycle.go - Stack pane lifecycle coordination without SafeWidget wrappers | |
| 2 | 2 | package browser | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "log" | |
| 8 | - | ||
| 9 | - | "github.com/bnema/dumber/pkg/webkit" | |
| 10 | 8 | ) | |
| 11 | 9 | ||
| 12 | - | // StackLifecycleManager manages the complete lifecycle of stack containers | |
| 10 | + | // StackLifecycleManager manages the lifecycle hooks around stacked pane operations. | |
| 13 | 11 | type StackLifecycleManager struct { | |
| 14 | 12 | wm *WorkspaceManager | |
| 15 | 13 | treeValidator *TreeValidator | |
| 16 | 14 | } | |
| 17 | 15 | ||
| 18 | - | // NewStackLifecycleManager creates a new stack lifecycle manager | |
| 16 | + | // NewStackLifecycleManager creates a new stack lifecycle manager. | |
| 19 | 17 | func NewStackLifecycleManager(wm *WorkspaceManager, treeValidator *TreeValidator) *StackLifecycleManager { | |
| 20 | 18 | return &StackLifecycleManager{ | |
| 21 | 19 | wm: wm, | |
| @@ -23,429 +21,44 @@ func NewStackLifecycleManager(wm *WorkspaceManager, treeValidator *TreeValidator | |||
| 23 | 21 | } | |
| 24 | 22 | } | |
| 25 | 23 | ||
| 26 | - | // CloseStackedPaneWithLifecycle handles closing a stacked pane with proper lifecycle management | |
| 24 | + | // CloseStackedPaneWithLifecycle validates the stack state and delegates the actual | |
| 25 | + | // close operation to the stacked pane manager. This keeps widget handling simple | |
| 26 | + | // (raw GTK pointers) while still giving us a single place for additional safety checks. | |
| 27 | 27 | func (slm *StackLifecycleManager) CloseStackedPaneWithLifecycle(node *paneNode) error { | |
| 28 | - | if node.parent == nil || !node.parent.isStacked { | |
| 29 | - | return errors.New("node is not part of a stacked pane") | |
| 28 | + | if node == nil { | |
| 29 | + | return errors.New("node is nil") | |
| 30 | 30 | } | |
| 31 | 31 | ||
| 32 | 32 | stackNode := node.parent | |
| 33 | + | if stackNode == nil || !stackNode.isStacked { | |
| 34 | + | return errors.New("node is not part of a stacked pane") | |
| 35 | + | } | |
| 33 | 36 | ||
| 34 | 37 | log.Printf("[stack-lifecycle] Closing stacked pane: node=%p stack=%p stackSize=%d", | |
| 35 | 38 | node, stackNode, len(stackNode.stackedPanes)) | |
| 36 | 39 | ||
| 37 | - | // Validate initial state | |
| 38 | 40 | if err := slm.validateStackState(stackNode, "before close"); err != nil { | |
| 39 | 41 | return fmt.Errorf("invalid stack state before close: %w", err) | |
| 40 | 42 | } | |
| 41 | 43 | ||
| 42 | - | // Find the index of the node to be closed | |
| 43 | - | nodeIndex := -1 | |
| 44 | - | for i, stackedPane := range stackNode.stackedPanes { | |
| 45 | - | if stackedPane == node { | |
| 46 | - | nodeIndex = i | |
| 47 | - | break | |
| 48 | - | } | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | if nodeIndex == -1 { | |
| 52 | - | return errors.New("node not found in stack") | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | // Determine what to do based on remaining stack size | |
| 56 | - | remainingSize := len(stackNode.stackedPanes) - 1 | |
| 57 | - | ||
| 58 | - | switch remainingSize { | |
| 59 | - | case 0: | |
| 60 | - | // Last pane in stack - remove entire stack container | |
| 61 | - | return slm.removeEmptyStackContainer(stackNode, node) | |
| 62 | - | case 1: | |
| 63 | - | // Two panes, closing one - convert remaining pane back to regular pane | |
| 64 | - | return slm.unstackLastPane(stackNode, node, nodeIndex) | |
| 65 | - | default: | |
| 66 | - | // Multiple panes remain - remove pane from stack | |
| 67 | - | return slm.removePaneFromStack(stackNode, node, nodeIndex) | |
| 68 | - | } | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | // removeEmptyStackContainer removes a stack container when the last pane is closed | |
| 72 | - | func (slm *StackLifecycleManager) removeEmptyStackContainer(stackNode *paneNode, closingNode *paneNode) error { | |
| 73 | - | log.Printf("[stack-lifecycle] Removing empty stack container: stack=%p", stackNode) | |
| 74 | - | ||
| 75 | - | // Clean up the closing pane first | |
| 76 | - | slm.cleanupPaneResources(closingNode) | |
| 77 | - | ||
| 78 | - | // Remove stack from tree structure | |
| 79 | - | if err := slm.removeStackFromTree(stackNode); err != nil { | |
| 80 | - | return fmt.Errorf("failed to remove stack from tree: %w", err) | |
| 81 | - | } | |
| 82 | - | ||
| 83 | - | // Validate final state | |
| 84 | - | if slm.treeValidator != nil { | |
| 85 | - | if err := slm.treeValidator.ValidateTree(slm.wm.root, "remove_empty_stack"); err != nil { | |
| 86 | - | log.Printf("[stack-lifecycle] Tree validation failed after removing empty stack: %v", err) | |
| 87 | - | } | |
| 88 | - | } | |
| 89 | - | ||
| 90 | - | log.Printf("[stack-lifecycle] Successfully removed empty stack container") | |
| 91 | - | return nil | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | // unstackLastPane converts a two-pane stack back to a regular pane when one is closed | |
| 95 | - | func (slm *StackLifecycleManager) unstackLastPane(stackNode *paneNode, closingNode *paneNode, closingIndex int) error { | |
| 96 | - | log.Printf("[stack-lifecycle] Unstacking last pane: stack=%p closingIndex=%d", stackNode, closingIndex) | |
| 97 | - | ||
| 98 | - | // Find the remaining pane | |
| 99 | - | var remainingPane *paneNode | |
| 100 | - | for i, pane := range stackNode.stackedPanes { | |
| 101 | - | if i != closingIndex && pane != closingNode { | |
| 102 | - | remainingPane = pane | |
| 103 | - | break | |
| 104 | - | } | |
| 105 | - | } | |
| 106 | - | ||
| 107 | - | if remainingPane == nil { | |
| 108 | - | return errors.New("could not find remaining pane in stack") | |
| 109 | - | } | |
| 110 | - | ||
| 111 | - | // Clean up the closing pane | |
| 112 | - | slm.cleanupPaneResources(closingNode) | |
| 113 | - | ||
| 114 | - | // Convert remaining pane back to regular pane | |
| 115 | - | if err := slm.convertStackToRegularPane(stackNode, remainingPane); err != nil { | |
| 116 | - | return fmt.Errorf("failed to convert stack to regular pane: %w", err) | |
| 117 | - | } | |
| 118 | - | ||
| 119 | - | // Validate final state | |
| 120 | - | if slm.treeValidator != nil { | |
| 121 | - | if err := slm.treeValidator.ValidateTree(slm.wm.root, "unstack_last"); err != nil { | |
| 122 | - | log.Printf("[stack-lifecycle] Tree validation failed after unstacking: %v", err) | |
| 123 | - | } | |
| 124 | - | } | |
| 125 | - | ||
| 126 | - | log.Printf("[stack-lifecycle] Successfully unstacked last pane") | |
| 127 | - | return nil | |
| 128 | - | } | |
| 129 | - | ||
| 130 | - | // removePaneFromStack removes a pane from a multi-pane stack | |
| 131 | - | func (slm *StackLifecycleManager) removePaneFromStack(stackNode *paneNode, closingNode *paneNode, closingIndex int) error { | |
| 132 | - | log.Printf("[stack-lifecycle] Removing pane from stack: pane=%p index=%d", closingNode, closingIndex) | |
| 133 | - | ||
| 134 | - | // Clean up the closing pane | |
| 135 | - | slm.cleanupPaneResources(closingNode) | |
| 136 | - | ||
| 137 | - | // Remove pane widgets from stack container | |
| 138 | - | if err := slm.removePaneWidgetsFromStack(stackNode, closingNode); err != nil { | |
| 139 | - | return fmt.Errorf("failed to remove pane widgets: %w", err) | |
| 44 | + | if slm.wm == nil || slm.wm.stackedPaneManager == nil { | |
| 45 | + | return errors.New("stacked pane manager not available") | |
| 140 | 46 | } | |
| 141 | 47 | ||
| 142 | - | // Update stack data structures | |
| 143 | - | slm.updateStackAfterRemoval(stackNode, closingIndex) | |
| 144 | - | ||
| 145 | - | // Update stack visibility after removal | |
| 146 | - | if slm.wm.stackedPaneManager != nil { | |
| 147 | - | slm.wm.stackedPaneManager.UpdateStackVisibility(stackNode) | |
| 48 | + | if err := slm.wm.stackedPaneManager.CloseStackedPane(node); err != nil { | |
| 49 | + | return err | |
| 148 | 50 | } | |
| 149 | 51 | ||
| 150 | - | // Validate final state | |
| 151 | 52 | if slm.treeValidator != nil { | |
| 152 | - | if err := slm.treeValidator.ValidateTree(slm.wm.root, "remove_from_stack"); err != nil { | |
| 153 | - | log.Printf("[stack-lifecycle] Tree validation failed after removing from stack: %v", err) | |
| 154 | - | } | |
| 155 | - | } | |
| 156 | - | ||
| 157 | - | log.Printf("[stack-lifecycle] Successfully removed pane from stack") | |
| 158 | - | return nil | |
| 159 | - | } | |
| 160 | - | ||
| 161 | - | // removeStackFromTree removes the stack container from the tree structure | |
| 162 | - | func (slm *StackLifecycleManager) removeStackFromTree(stackNode *paneNode) error { | |
| 163 | - | if stackNode == nil { | |
| 164 | - | return errors.New("stack node is nil") | |
| 165 | - | } | |
| 166 | - | ||
| 167 | - | parent := stackNode.parent | |
| 168 | - | ||
| 169 | - | // Unparent stack container when still valid | |
| 170 | - | if stackNode.container != nil { | |
| 171 | - | if !stackNode.container.IsValid() { | |
| 172 | - | log.Printf("[stack-lifecycle] stack container already invalid, skipping unparent: node=%p", stackNode) | |
| 173 | - | slm.invalidateSafeWidget(&stackNode.container) | |
| 174 | - | slm.invalidateSafeWidget(&stackNode.stackWrapper) | |
| 175 | - | } else { | |
| 176 | - | // Directly unparent the widget | |
| 177 | - | if err := stackNode.container.Execute(func(ptr uintptr) error { | |
| 178 | - | if webkit.WidgetGetParent(ptr) != 0 { | |
| 179 | - | webkit.WidgetUnparent(ptr) | |
| 180 | - | } | |
| 181 | - | return nil | |
| 182 | - | }); err != nil { | |
| 183 | - | return fmt.Errorf("failed to unparent stack container: %w", err) | |
| 184 | - | } | |
| 185 | - | ||
| 186 | - | // Cleanup stack container and wrapper | |
| 187 | - | slm.invalidateSafeWidget(&stackNode.container) | |
| 188 | - | slm.invalidateSafeWidget(&stackNode.stackWrapper) | |
| 189 | - | } | |
| 190 | - | } | |
| 191 | - | ||
| 192 | - | // Update tree structure | |
| 193 | - | if parent == nil { | |
| 194 | - | // Stack was root - clear root | |
| 195 | - | slm.wm.root = nil | |
| 196 | - | slm.wm.mainPane = nil | |
| 197 | - | } else { | |
| 198 | - | // Remove stack from parent | |
| 199 | - | if parent.left == stackNode { | |
| 200 | - | parent.left = nil | |
| 201 | - | } else if parent.right == stackNode { | |
| 202 | - | parent.right = nil | |
| 203 | - | } | |
| 204 | - | ||
| 205 | - | // If parent now has only one child, it needs restructuring | |
| 206 | - | if (parent.left == nil) != (parent.right == nil) { | |
| 207 | - | // Parent has exactly one child - promote the child | |
| 208 | - | var remainingChild *paneNode | |
| 209 | - | if parent.left != nil { | |
| 210 | - | remainingChild = parent.left | |
| 211 | - | } else { | |
| 212 | - | remainingChild = parent.right | |
| 213 | - | } | |
| 214 | - | ||
| 215 | - | if err := slm.promoteChildNode(parent, remainingChild); err != nil { | |
| 216 | - | return fmt.Errorf("failed to promote child node: %w", err) | |
| 217 | - | } | |
| 53 | + | if err := slm.treeValidator.ValidateTree(slm.wm.root, "after stack close"); err != nil { | |
| 54 | + | log.Printf("[stack-lifecycle] Tree validation failed after stack close: %v", err) | |
| 218 | 55 | } | |
| 219 | 56 | } | |
| 220 | 57 | ||
| 221 | 58 | return nil | |
| 222 | 59 | } | |
| 223 | 60 | ||
| 224 | - | // convertStackToRegularPane converts a stack container to a regular pane | |
| 225 | - | func (slm *StackLifecycleManager) convertStackToRegularPane(stackNode *paneNode, remainingPane *paneNode) error { | |
| 226 | - | parent := stackNode.parent | |
| 227 | - | ||
| 228 | - | // Remove remaining pane from stack container | |
| 229 | - | if remainingPane.container != nil && remainingPane.container.IsValid() { | |
| 230 | - | if err := remainingPane.container.Execute(func(ptr uintptr) error { | |
| 231 | - | if webkit.WidgetGetParent(ptr) != 0 { | |
| 232 | - | webkit.WidgetUnparent(ptr) | |
| 233 | - | } | |
| 234 | - | return nil | |
| 235 | - | }); err != nil { | |
| 236 | - | return fmt.Errorf("failed to unparent remaining pane: %w", err) | |
| 237 | - | } | |
| 238 | - | } | |
| 239 | - | ||
| 240 | - | // Update remaining pane properties | |
| 241 | - | remainingPane.parent = parent | |
| 242 | - | remainingPane.isStacked = false | |
| 243 | - | ||
| 244 | - | // Clear stack-related fields | |
| 245 | - | remainingPane.titleBar = nil | |
| 246 | - | ||
| 247 | - | // Update tree structure | |
| 248 | - | if parent == nil { | |
| 249 | - | // Stack was root - make remaining pane the new root | |
| 250 | - | slm.wm.root = remainingPane | |
| 251 | - | slm.wm.mainPane = remainingPane | |
| 252 | - | ||
| 253 | - | // Reparent to window | |
| 254 | - | if remainingPane.container != nil && remainingPane.container.IsValid() && slm.wm.window != nil { | |
| 255 | - | if err := remainingPane.container.Execute(func(ptr uintptr) error { | |
| 256 | - | slm.wm.window.SetChild(ptr) | |
| 257 | - | webkit.WidgetQueueAllocate(ptr) | |
| 258 | - | webkit.WidgetShow(ptr) | |
| 259 | - | return nil | |
| 260 | - | }); err != nil { | |
| 261 | - | return fmt.Errorf("failed to reparent to window: %w", err) | |
| 262 | - | } | |
| 263 | - | } | |
| 264 | - | } else { | |
| 265 | - | // Replace stack with remaining pane in parent | |
| 266 | - | if parent.left == stackNode { | |
| 267 | - | parent.left = remainingPane | |
| 268 | - | } else if parent.right == stackNode { | |
| 269 | - | parent.right = remainingPane | |
| 270 | - | } | |
| 271 | - | ||
| 272 | - | // Reparent to parent container | |
| 273 | - | if remainingPane.container != nil && remainingPane.container.IsValid() && parent.container != nil && parent.container.IsValid() { | |
| 274 | - | isStart := parent.left == remainingPane | |
| 275 | - | parentPtr := parent.container.Ptr() | |
| 276 | - | ||
| 277 | - | if err := remainingPane.container.Execute(func(ptr uintptr) error { | |
| 278 | - | // Unparent first if needed | |
| 279 | - | if webkit.WidgetGetParent(ptr) != 0 { | |
| 280 | - | webkit.WidgetUnparent(ptr) | |
| 281 | - | } | |
| 282 | - | ||
| 283 | - | // Reparent to parent | |
| 284 | - | if isStart { | |
| 285 | - | webkit.PanedSetStartChild(parentPtr, ptr) | |
| 286 | - | } else { | |
| 287 | - | webkit.PanedSetEndChild(parentPtr, ptr) | |
| 288 | - | } | |
| 289 | - | return nil | |
| 290 | - | }); err != nil { | |
| 291 | - | return fmt.Errorf("failed to reparent to parent: %w", err) | |
| 292 | - | } | |
| 293 | - | } | |
| 294 | - | } | |
| 295 | - | ||
| 296 | - | return nil | |
| 297 | - | } | |
| 298 | - | ||
| 299 | - | // removePaneWidgetsFromStack removes a pane's widgets from the stack container | |
| 300 | - | func (slm *StackLifecycleManager) removePaneWidgetsFromStack(stackNode *paneNode, closingNode *paneNode) error { | |
| 301 | - | // Remove title bar if it exists | |
| 302 | - | if closingNode.titleBar != nil { | |
| 303 | - | if !closingNode.titleBar.IsValid() { | |
| 304 | - | log.Printf("[stack-lifecycle] title bar already invalid, skipping removal: pane=%p", closingNode) | |
| 305 | - | slm.invalidateSafeWidget(&closingNode.titleBar) | |
| 306 | - | } else { | |
| 307 | - | if err := closingNode.titleBar.Execute(func(ptr uintptr) error { | |
| 308 | - | if webkit.WidgetGetParent(ptr) != 0 { | |
| 309 | - | webkit.WidgetUnparent(ptr) | |
| 310 | - | } | |
| 311 | - | return nil | |
| 312 | - | }); err != nil { | |
| 313 | - | return fmt.Errorf("failed to unparent titlebar: %w", err) | |
| 314 | - | } | |
| 315 | - | } | |
| 316 | - | } | |
| 317 | - | ||
| 318 | - | // Remove container | |
| 319 | - | if closingNode.container != nil { | |
| 320 | - | if !closingNode.container.IsValid() { | |
| 321 | - | log.Printf("[stack-lifecycle] pane container already invalid, skipping removal: pane=%p", closingNode) | |
| 322 | - | slm.invalidateSafeWidget(&closingNode.container) | |
| 323 | - | } else { | |
| 324 | - | if err := closingNode.container.Execute(func(ptr uintptr) error { | |
| 325 | - | if webkit.WidgetGetParent(ptr) != 0 { | |
| 326 | - | webkit.WidgetUnparent(ptr) | |
| 327 | - | } | |
| 328 | - | return nil | |
| 329 | - | }); err != nil { | |
| 330 | - | return fmt.Errorf("failed to unparent container: %w", err) | |
| 331 | - | } | |
| 332 | - | } | |
| 333 | - | } | |
| 334 | - | ||
| 335 | - | // Cleanup removed pane widgets | |
| 336 | - | slm.invalidateSafeWidget(&closingNode.titleBar) | |
| 337 | - | slm.invalidateSafeWidget(&closingNode.container) | |
| 338 | - | ||
| 339 | - | return nil | |
| 340 | - | } | |
| 341 | - | ||
| 342 | - | // updateStackAfterRemoval updates stack data structures after removing a pane | |
| 343 | - | func (slm *StackLifecycleManager) updateStackAfterRemoval(stackNode *paneNode, removedIndex int) { | |
| 344 | - | // Remove from stackedPanes slice | |
| 345 | - | stackNode.stackedPanes = append( | |
| 346 | - | stackNode.stackedPanes[:removedIndex], | |
| 347 | - | stackNode.stackedPanes[removedIndex+1:]..., | |
| 348 | - | ) | |
| 349 | - | ||
| 350 | - | // Update active index if necessary | |
| 351 | - | if stackNode.activeStackIndex >= removedIndex { | |
| 352 | - | if stackNode.activeStackIndex > 0 { | |
| 353 | - | stackNode.activeStackIndex-- | |
| 354 | - | } | |
| 355 | - | } | |
| 356 | - | ||
| 357 | - | // Ensure active index is valid | |
| 358 | - | if stackNode.activeStackIndex >= len(stackNode.stackedPanes) { | |
| 359 | - | stackNode.activeStackIndex = len(stackNode.stackedPanes) - 1 | |
| 360 | - | } | |
| 361 | - | if stackNode.activeStackIndex < 0 && len(stackNode.stackedPanes) > 0 { | |
| 362 | - | stackNode.activeStackIndex = 0 | |
| 363 | - | } | |
| 364 | - | ||
| 365 | - | log.Printf("[stack-lifecycle] Updated stack: remaining=%d activeIndex=%d", | |
| 366 | - | len(stackNode.stackedPanes), stackNode.activeStackIndex) | |
| 367 | - | } | |
| 368 | - | ||
| 369 | - | // promoteChildNode promotes a child node to replace its parent | |
| 370 | - | func (slm *StackLifecycleManager) promoteChildNode(parent *paneNode, child *paneNode) error { | |
| 371 | - | grandparent := parent.parent | |
| 372 | - | ||
| 373 | - | // Update child's parent pointer | |
| 374 | - | child.parent = grandparent | |
| 375 | - | ||
| 376 | - | // Update grandparent or root reference | |
| 377 | - | if grandparent == nil { | |
| 378 | - | // Child becomes new root | |
| 379 | - | slm.wm.root = child | |
| 380 | - | if child.isLeaf { | |
| 381 | - | slm.wm.mainPane = child | |
| 382 | - | } | |
| 383 | - | ||
| 384 | - | // Reparent to window | |
| 385 | - | if child.container != nil && child.container.IsValid() && slm.wm.window != nil { | |
| 386 | - | if err := child.container.Execute(func(ptr uintptr) error { | |
| 387 | - | slm.wm.window.SetChild(ptr) | |
| 388 | - | webkit.WidgetQueueAllocate(ptr) | |
| 389 | - | webkit.WidgetShow(ptr) | |
| 390 | - | return nil | |
| 391 | - | }); err != nil { | |
| 392 | - | return fmt.Errorf("failed to promote to root: %w", err) | |
| 393 | - | } | |
| 394 | - | } | |
| 395 | - | } else { | |
| 396 | - | // Update grandparent's child pointer | |
| 397 | - | if grandparent.left == parent { | |
| 398 | - | grandparent.left = child | |
| 399 | - | } else if grandparent.right == parent { | |
| 400 | - | grandparent.right = child | |
| 401 | - | } | |
| 402 | - | ||
| 403 | - | // Reparent widget to grandparent | |
| 404 | - | if child.container != nil && child.container.IsValid() && grandparent.container != nil && grandparent.container.IsValid() { | |
| 405 | - | isStart := grandparent.left == child | |
| 406 | - | grandparentPtr := grandparent.container.Ptr() | |
| 407 | - | ||
| 408 | - | if err := child.container.Execute(func(ptr uintptr) error { | |
| 409 | - | // Unparent first if needed | |
| 410 | - | if webkit.WidgetGetParent(ptr) != 0 { | |
| 411 | - | webkit.WidgetUnparent(ptr) | |
| 412 | - | } | |
| 413 | - | ||
| 414 | - | // Reparent to grandparent | |
| 415 | - | if isStart { | |
| 416 | - | webkit.PanedSetStartChild(grandparentPtr, ptr) | |
| 417 | - | } else { | |
| 418 | - | webkit.PanedSetEndChild(grandparentPtr, ptr) | |
| 419 | - | } | |
| 420 | - | return nil | |
| 421 | - | }); err != nil { | |
| 422 | - | return fmt.Errorf("failed to promote child: %w", err) | |
| 423 | - | } | |
| 424 | - | } | |
| 425 | - | } | |
| 426 | - | ||
| 427 | - | return nil | |
| 428 | - | } | |
| 429 | - | ||
| 430 | - | // cleanupPaneResources cleans up resources associated with a pane | |
| 431 | - | func (slm *StackLifecycleManager) cleanupPaneResources(node *paneNode) { | |
| 432 | - | // Detach hover | |
| 433 | - | if slm.wm != nil { | |
| 434 | - | slm.wm.detachHover(node) | |
| 435 | - | } | |
| 436 | - | ||
| 437 | - | // Clean up from workspace | |
| 438 | - | if node.pane != nil { | |
| 439 | - | node.pane.CleanupFromWorkspace(slm.wm) | |
| 440 | - | } | |
| 441 | - | ||
| 442 | - | // Clear references | |
| 443 | - | node.parent = nil | |
| 444 | - | node.titleBar = nil | |
| 445 | - | node.stackWrapper = nil | |
| 446 | - | } | |
| 447 | - | ||
| 448 | - | // validateStackState validates the current state of a stack | |
| 61 | + | // validateStackState validates the current state of a stack before we perform operations. | |
| 449 | 62 | func (slm *StackLifecycleManager) validateStackState(stackNode *paneNode, operation string) error { | |
| 450 | 63 | if stackNode == nil { | |
| 451 | 64 | return errors.New("stack node is nil") | |
| @@ -464,7 +77,6 @@ func (slm *StackLifecycleManager) validateStackState(stackNode *paneNode, operat | |||
| 464 | 77 | stackNode.activeStackIndex, len(stackNode.stackedPanes)) | |
| 465 | 78 | } | |
| 466 | 79 | ||
| 467 | - | // Check that all stacked panes have this node as parent | |
| 468 | 80 | for i, pane := range stackNode.stackedPanes { | |
| 469 | 81 | if pane == nil { | |
| 470 | 82 | return fmt.Errorf("stacked pane at index %d is nil", i) | |
| @@ -478,19 +90,3 @@ func (slm *StackLifecycleManager) validateStackState(stackNode *paneNode, operat | |||
| 478 | 90 | operation, len(stackNode.stackedPanes), stackNode.activeStackIndex) | |
| 479 | 91 | return nil | |
| 480 | 92 | } | |
| 481 | - | ||
| 482 | - | // invalidateSafeWidget invalidates and unregisters a widget safely | |
| 483 | - | func (slm *StackLifecycleManager) invalidateSafeWidget(widget **SafeWidget) { | |
| 484 | - | if widget == nil || *widget == nil { | |
| 485 | - | return | |
| 486 | - | } | |
| 487 | - | ||
| 488 | - | ptr := (*widget).Ptr() | |
| 489 | - | (*widget).Invalidate() | |
| 490 | - | ||
| 491 | - | if slm.wm != nil && slm.wm.widgetRegistry != nil && ptr != 0 { | |
| 492 | - | slm.wm.widgetRegistry.Unregister(ptr) | |
| 493 | - | } | |
| 494 | - | ||
| 495 | - | *widget = nil | |
| 496 | - | } | |
D internal/app/browser/workspace_state_tombstone.go +0 -705
| @@ -1,705 +0,0 @@ | |||
| 1 | - | // workspace_state_tombstone.go - State tombstones for rollback capability inspired by Zellij | |
| 2 | - | package browser | |
| 3 | - | ||
| 4 | - | import ( | |
| 5 | - | "bytes" | |
| 6 | - | "compress/gzip" | |
| 7 | - | "encoding/gob" | |
| 8 | - | "fmt" | |
| 9 | - | "log" | |
| 10 | - | "sync" | |
| 11 | - | "time" | |
| 12 | - | ||
| 13 | - | "github.com/bnema/dumber/pkg/webkit" | |
| 14 | - | ) | |
| 15 | - | ||
| 16 | - | // StateTombstone captures the complete state of the workspace at a point in time | |
| 17 | - | type StateTombstone struct { | |
| 18 | - | ID string | |
| 19 | - | Timestamp time.Time | |
| 20 | - | Operation string | |
| 21 | - | TreeState *TombstoneTreeSnapshot | |
| 22 | - | WidgetState *WidgetSnapshot | |
| 23 | - | FocusState *FocusSnapshot | |
| 24 | - | StackState *StackSnapshot | |
| 25 | - | Compressed bool | |
| 26 | - | Size int | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | // TombstoneTreeSnapshot captures the tree structure for state tombstones | |
| 30 | - | type TombstoneTreeSnapshot struct { | |
| 31 | - | RootID string | |
| 32 | - | MainPaneID string | |
| 33 | - | Nodes map[string]*NodeSnapshot | |
| 34 | - | ParentLinks map[string]string // child_id -> parent_id | |
| 35 | - | ChildLinks map[string][2]string // parent_id -> [left_id, right_id] | |
| 36 | - | NodeOrder []string // Traversal order for reconstruction | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | // NodeSnapshot captures the state of a single node | |
| 40 | - | type NodeSnapshot struct { | |
| 41 | - | ID string | |
| 42 | - | IsLeaf bool | |
| 43 | - | IsStacked bool | |
| 44 | - | IsPopup bool | |
| 45 | - | WindowType int // webkit.WindowType | |
| 46 | - | Orientation int // webkit.Orientation | |
| 47 | - | HasPane bool | |
| 48 | - | PaneID string | |
| 49 | - | StackInfo *StackInfo | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | // StackInfo captures stack-specific state | |
| 53 | - | type StackInfo struct { | |
| 54 | - | StackedPaneIDs []string | |
| 55 | - | ActiveStackIndex int | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | // WidgetSnapshot captures widget state information | |
| 59 | - | type WidgetSnapshot struct { | |
| 60 | - | Widgets map[string]*WidgetInfo | |
| 61 | - | Allocations map[string]*AllocationInfo | |
| 62 | - | CSSClasses map[string][]string | |
| 63 | - | Visibility map[string]bool | |
| 64 | - | ParentChild map[string]string // widget_id -> parent_id | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | // WidgetInfo captures widget information | |
| 68 | - | type WidgetInfo struct { | |
| 69 | - | ID string | |
| 70 | - | Type string | |
| 71 | - | Pointer uintptr | |
| 72 | - | IsValid bool | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | // AllocationInfo captures widget allocation | |
| 76 | - | type AllocationInfo struct { | |
| 77 | - | X int | |
| 78 | - | Y int | |
| 79 | - | Width int | |
| 80 | - | Height int | |
| 81 | - | } | |
| 82 | - | ||
| 83 | - | // FocusSnapshot captures focus state | |
| 84 | - | type FocusSnapshot struct { | |
| 85 | - | ActiveNodeID string | |
| 86 | - | LastFocusTime time.Time | |
| 87 | - | FocusSource int // FocusSource | |
| 88 | - | HoverTokens map[string]uintptr | |
| 89 | - | } | |
| 90 | - | ||
| 91 | - | // StackSnapshot captures all stack states | |
| 92 | - | type StackSnapshot struct { | |
| 93 | - | StackContainers map[string]*StackContainerInfo | |
| 94 | - | } | |
| 95 | - | ||
| 96 | - | // StackContainerInfo captures stack container state | |
| 97 | - | type StackContainerInfo struct { | |
| 98 | - | NodeID string | |
| 99 | - | StackedPaneIDs []string | |
| 100 | - | ActiveIndex int | |
| 101 | - | TitleBars map[string]string // pane_id -> title | |
| 102 | - | Visibility map[string]bool // pane_id -> visible | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | var gobRegisterOnce sync.Once | |
| 106 | - | ||
| 107 | - | func registerTombstoneGobTypes() { | |
| 108 | - | gobRegisterOnce.Do(func() { | |
| 109 | - | gob.Register(&TombstoneTreeSnapshot{}) | |
| 110 | - | gob.Register(&NodeSnapshot{}) | |
| 111 | - | gob.Register(&WidgetSnapshot{}) | |
| 112 | - | gob.Register(&FocusSnapshot{}) | |
| 113 | - | gob.Register(&StackSnapshot{}) | |
| 114 | - | gob.Register(&StackInfo{}) | |
| 115 | - | gob.Register(&WidgetInfo{}) | |
| 116 | - | gob.Register(&AllocationInfo{}) | |
| 117 | - | gob.Register(&StackContainerInfo{}) | |
| 118 | - | }) | |
| 119 | - | } | |
| 120 | - | ||
| 121 | - | // StateTombstoneManager manages state tombstones for rollback operations | |
| 122 | - | type StateTombstoneManager struct { | |
| 123 | - | tombstones map[string]*StateTombstone | |
| 124 | - | history []*StateTombstone | |
| 125 | - | maxTombstones int | |
| 126 | - | maxHistory int | |
| 127 | - | compress bool | |
| 128 | - | mu sync.RWMutex | |
| 129 | - | wm *WorkspaceManager | |
| 130 | - | } | |
| 131 | - | ||
| 132 | - | // NewStateTombstoneManager creates a new state tombstone manager | |
| 133 | - | func NewStateTombstoneManager(wm *WorkspaceManager) *StateTombstoneManager { | |
| 134 | - | // Ensure concrete tombstone types are available for gob encoding/decoding. | |
| 135 | - | registerTombstoneGobTypes() | |
| 136 | - | ||
| 137 | - | return &StateTombstoneManager{ | |
| 138 | - | tombstones: make(map[string]*StateTombstone), | |
| 139 | - | history: make([]*StateTombstone, 0, 50), | |
| 140 | - | maxTombstones: 20, | |
| 141 | - | maxHistory: 50, | |
| 142 | - | compress: true, | |
| 143 | - | wm: wm, | |
| 144 | - | } | |
| 145 | - | } | |
| 146 | - | ||
| 147 | - | // CaptureState captures the current workspace state as a tombstone | |
| 148 | - | func (stm *StateTombstoneManager) CaptureState(operation string) (*StateTombstone, error) { | |
| 149 | - | stm.mu.Lock() | |
| 150 | - | defer stm.mu.Unlock() | |
| 151 | - | ||
| 152 | - | tombstoneID := fmt.Sprintf("%s_%d", operation, time.Now().UnixNano()) | |
| 153 | - | ||
| 154 | - | log.Printf("[tombstone] Capturing state for operation: %s", operation) | |
| 155 | - | ||
| 156 | - | // Capture tree state | |
| 157 | - | treeState, err := stm.captureTreeState() | |
| 158 | - | if err != nil { | |
| 159 | - | return nil, fmt.Errorf("failed to capture tree state: %w", err) | |
| 160 | - | } | |
| 161 | - | ||
| 162 | - | // Capture widget state | |
| 163 | - | widgetState, err := stm.captureWidgetState() | |
| 164 | - | if err != nil { | |
| 165 | - | return nil, fmt.Errorf("failed to capture widget state: %w", err) | |
| 166 | - | } | |
| 167 | - | ||
| 168 | - | // Capture focus state | |
| 169 | - | focusState := stm.captureFocusState() | |
| 170 | - | ||
| 171 | - | // Capture stack state | |
| 172 | - | stackState := stm.captureStackState() | |
| 173 | - | ||
| 174 | - | tombstone := &StateTombstone{ | |
| 175 | - | ID: tombstoneID, | |
| 176 | - | Timestamp: time.Now(), | |
| 177 | - | Operation: operation, | |
| 178 | - | TreeState: treeState, | |
| 179 | - | WidgetState: widgetState, | |
| 180 | - | FocusState: focusState, | |
| 181 | - | StackState: stackState, | |
| 182 | - | Compressed: false, | |
| 183 | - | } | |
| 184 | - | ||
| 185 | - | // Compress if enabled | |
| 186 | - | if stm.compress { | |
| 187 | - | if err := stm.compressTombstone(tombstone); err != nil { | |
| 188 | - | log.Printf("[tombstone] Failed to compress tombstone: %v", err) | |
| 189 | - | } | |
| 190 | - | } | |
| 191 | - | ||
| 192 | - | // Calculate size | |
| 193 | - | tombstone.Size = stm.calculateTombstoneSize(tombstone) | |
| 194 | - | ||
| 195 | - | // Store tombstone | |
| 196 | - | stm.tombstones[tombstoneID] = tombstone | |
| 197 | - | ||
| 198 | - | // Add to history | |
| 199 | - | stm.history = append(stm.history, tombstone) | |
| 200 | - | ||
| 201 | - | // Cleanup old tombstones | |
| 202 | - | stm.cleanupOldTombstones() | |
| 203 | - | ||
| 204 | - | log.Printf("[tombstone] Captured state: id=%s, size=%d bytes", tombstoneID, tombstone.Size) | |
| 205 | - | ||
| 206 | - | return tombstone, nil | |
| 207 | - | } | |
| 208 | - | ||
| 209 | - | // captureTreeState captures the current tree structure | |
| 210 | - | func (stm *StateTombstoneManager) captureTreeState() (*TombstoneTreeSnapshot, error) { | |
| 211 | - | snapshot := &TombstoneTreeSnapshot{ | |
| 212 | - | Nodes: make(map[string]*NodeSnapshot), | |
| 213 | - | ParentLinks: make(map[string]string), | |
| 214 | - | ChildLinks: make(map[string][2]string), | |
| 215 | - | NodeOrder: make([]string, 0), | |
| 216 | - | } | |
| 217 | - | ||
| 218 | - | if stm.wm.root != nil { | |
| 219 | - | snapshot.RootID = stm.nodeID(stm.wm.root) | |
| 220 | - | } | |
| 221 | - | ||
| 222 | - | if stm.wm.mainPane != nil { | |
| 223 | - | snapshot.MainPaneID = stm.nodeID(stm.wm.mainPane) | |
| 224 | - | } | |
| 225 | - | ||
| 226 | - | // Traverse tree and capture all nodes | |
| 227 | - | if stm.wm.root != nil { | |
| 228 | - | stm.captureNodeRecursive(stm.wm.root, snapshot) | |
| 229 | - | } | |
| 230 | - | ||
| 231 | - | return snapshot, nil | |
| 232 | - | } | |
| 233 | - | ||
| 234 | - | // captureNodeRecursive recursively captures node information | |
| 235 | - | func (stm *StateTombstoneManager) captureNodeRecursive(node *paneNode, snapshot *TombstoneTreeSnapshot) { | |
| 236 | - | if node == nil { | |
| 237 | - | return | |
| 238 | - | } | |
| 239 | - | ||
| 240 | - | nodeID := stm.nodeID(node) | |
| 241 | - | snapshot.NodeOrder = append(snapshot.NodeOrder, nodeID) | |
| 242 | - | ||
| 243 | - | // Capture node information | |
| 244 | - | nodeSnap := &NodeSnapshot{ | |
| 245 | - | ID: nodeID, | |
| 246 | - | IsLeaf: node.isLeaf, | |
| 247 | - | IsStacked: node.isStacked, | |
| 248 | - | IsPopup: node.isPopup, | |
| 249 | - | WindowType: int(node.windowType), | |
| 250 | - | Orientation: int(node.orientation), | |
| 251 | - | } | |
| 252 | - | ||
| 253 | - | // Capture pane information | |
| 254 | - | if node.pane != nil { | |
| 255 | - | nodeSnap.HasPane = true | |
| 256 | - | nodeSnap.PaneID = stm.paneID(node.pane) | |
| 257 | - | } | |
| 258 | - | ||
| 259 | - | // Capture stack information | |
| 260 | - | if node.isStacked { | |
| 261 | - | nodeSnap.StackInfo = &StackInfo{ | |
| 262 | - | StackedPaneIDs: make([]string, len(node.stackedPanes)), | |
| 263 | - | ActiveStackIndex: node.activeStackIndex, | |
| 264 | - | } | |
| 265 | - | ||
| 266 | - | for i, stackedPane := range node.stackedPanes { | |
| 267 | - | if stackedPane != nil { | |
| 268 | - | nodeSnap.StackInfo.StackedPaneIDs[i] = stm.nodeID(stackedPane) | |
| 269 | - | } | |
| 270 | - | } | |
| 271 | - | } | |
| 272 | - | ||
| 273 | - | snapshot.Nodes[nodeID] = nodeSnap | |
| 274 | - | ||
| 275 | - | // Capture parent-child relationships | |
| 276 | - | if node.parent != nil { | |
| 277 | - | snapshot.ParentLinks[nodeID] = stm.nodeID(node.parent) | |
| 278 | - | } | |
| 279 | - | ||
| 280 | - | if node.left != nil || node.right != nil { | |
| 281 | - | var leftID, rightID string | |
| 282 | - | if node.left != nil { | |
| 283 | - | leftID = stm.nodeID(node.left) | |
| 284 | - | } | |
| 285 | - | if node.right != nil { | |
| 286 | - | rightID = stm.nodeID(node.right) | |
| 287 | - | } | |
| 288 | - | snapshot.ChildLinks[nodeID] = [2]string{leftID, rightID} | |
| 289 | - | } | |
| 290 | - | ||
| 291 | - | // Recurse for children | |
| 292 | - | stm.captureNodeRecursive(node.left, snapshot) | |
| 293 | - | stm.captureNodeRecursive(node.right, snapshot) | |
| 294 | - | ||
| 295 | - | // Recurse for stacked panes | |
| 296 | - | if node.isStacked { | |
| 297 | - | for _, stackedPane := range node.stackedPanes { | |
| 298 | - | stm.captureNodeRecursive(stackedPane, snapshot) | |
| 299 | - | } | |
| 300 | - | } | |
| 301 | - | } | |
| 302 | - | ||
| 303 | - | // captureWidgetState captures widget state information | |
| 304 | - | func (stm *StateTombstoneManager) captureWidgetState() (*WidgetSnapshot, error) { | |
| 305 | - | snapshot := &WidgetSnapshot{ | |
| 306 | - | Widgets: make(map[string]*WidgetInfo), | |
| 307 | - | Allocations: make(map[string]*AllocationInfo), | |
| 308 | - | CSSClasses: make(map[string][]string), | |
| 309 | - | Visibility: make(map[string]bool), | |
| 310 | - | ParentChild: make(map[string]string), | |
| 311 | - | } | |
| 312 | - | ||
| 313 | - | // Capture widget information from all nodes | |
| 314 | - | if stm.wm.root != nil { | |
| 315 | - | stm.captureWidgetRecursive(stm.wm.root, snapshot) | |
| 316 | - | } | |
| 317 | - | ||
| 318 | - | return snapshot, nil | |
| 319 | - | } | |
| 320 | - | ||
| 321 | - | // captureWidgetRecursive recursively captures widget information | |
| 322 | - | func (stm *StateTombstoneManager) captureWidgetRecursive(node *paneNode, snapshot *WidgetSnapshot) { | |
| 323 | - | if node == nil { | |
| 324 | - | return | |
| 325 | - | } | |
| 326 | - | ||
| 327 | - | nodeID := stm.nodeID(node) | |
| 328 | - | ||
| 329 | - | // Capture container widget | |
| 330 | - | if node.container != nil { | |
| 331 | - | widgetID := fmt.Sprintf("%s_container", nodeID) | |
| 332 | - | snapshot.Widgets[widgetID] = &WidgetInfo{ | |
| 333 | - | ID: widgetID, | |
| 334 | - | Type: "container", | |
| 335 | - | Pointer: node.container.Ptr(), | |
| 336 | - | IsValid: node.container.IsValid(), | |
| 337 | - | } | |
| 338 | - | ||
| 339 | - | // Capture allocation and visibility | |
| 340 | - | node.container.Execute(func(ptr uintptr) error { | |
| 341 | - | allocation := webkit.WidgetGetAllocation(ptr) | |
| 342 | - | snapshot.Allocations[widgetID] = &AllocationInfo{ | |
| 343 | - | X: allocation.X, | |
| 344 | - | Y: allocation.Y, | |
| 345 | - | Width: allocation.Width, | |
| 346 | - | Height: allocation.Height, | |
| 347 | - | } | |
| 348 | - | ||
| 349 | - | snapshot.Visibility[widgetID] = webkit.WidgetGetVisible(ptr) | |
| 350 | - | ||
| 351 | - | // Capture CSS classes | |
| 352 | - | cssClasses := stm.extractCSSClasses(ptr) | |
| 353 | - | if len(cssClasses) > 0 { | |
| 354 | - | snapshot.CSSClasses[widgetID] = cssClasses | |
| 355 | - | } | |
| 356 | - | ||
| 357 | - | return nil | |
| 358 | - | }) | |
| 359 | - | } | |
| 360 | - | ||
| 361 | - | // Capture title bar widget | |
| 362 | - | if node.titleBar != nil { | |
| 363 | - | widgetID := fmt.Sprintf("%s_titlebar", nodeID) | |
| 364 | - | snapshot.Widgets[widgetID] = &WidgetInfo{ | |
| 365 | - | ID: widgetID, | |
| 366 | - | Type: "titlebar", | |
| 367 | - | Pointer: node.titleBar.Ptr(), | |
| 368 | - | IsValid: node.titleBar.IsValid(), | |
| 369 | - | } | |
| 370 | - | ||
| 371 | - | node.titleBar.Execute(func(ptr uintptr) error { | |
| 372 | - | snapshot.Visibility[widgetID] = webkit.WidgetGetVisible(ptr) | |
| 373 | - | return nil | |
| 374 | - | }) | |
| 375 | - | } | |
| 376 | - | ||
| 377 | - | // Capture stack wrapper widget | |
| 378 | - | if node.stackWrapper != nil { | |
| 379 | - | widgetID := fmt.Sprintf("%s_stackwrapper", nodeID) | |
| 380 | - | snapshot.Widgets[widgetID] = &WidgetInfo{ | |
| 381 | - | ID: widgetID, | |
| 382 | - | Type: "stackwrapper", | |
| 383 | - | Pointer: node.stackWrapper.Ptr(), | |
| 384 | - | IsValid: node.stackWrapper.IsValid(), | |
| 385 | - | } | |
| 386 | - | } | |
| 387 | - | ||
| 388 | - | // Recurse for children | |
| 389 | - | stm.captureWidgetRecursive(node.left, snapshot) | |
| 390 | - | stm.captureWidgetRecursive(node.right, snapshot) | |
| 391 | - | ||
| 392 | - | // Recurse for stacked panes | |
| 393 | - | if node.isStacked { | |
| 394 | - | for _, stackedPane := range node.stackedPanes { | |
| 395 | - | stm.captureWidgetRecursive(stackedPane, snapshot) | |
| 396 | - | } | |
| 397 | - | } | |
| 398 | - | } | |
| 399 | - | ||
| 400 | - | // captureFocusState captures the current focus state | |
| 401 | - | func (stm *StateTombstoneManager) captureFocusState() *FocusSnapshot { | |
| 402 | - | snapshot := &FocusSnapshot{ | |
| 403 | - | HoverTokens: make(map[string]uintptr), | |
| 404 | - | } | |
| 405 | - | ||
| 406 | - | // Capture active node | |
| 407 | - | if activeNode := stm.wm.GetActiveNode(); activeNode != nil { | |
| 408 | - | snapshot.ActiveNodeID = stm.nodeID(activeNode) | |
| 409 | - | } | |
| 410 | - | ||
| 411 | - | // Capture focus timing information | |
| 412 | - | snapshot.LastFocusTime = stm.wm.lastFocusTime | |
| 413 | - | if stm.wm.lastFocusTarget != nil { | |
| 414 | - | // Additional focus target info could be captured here | |
| 415 | - | } | |
| 416 | - | ||
| 417 | - | // Capture hover tokens (simplified - in practice you'd need access to hover token map) | |
| 418 | - | // This would require extending the workspace manager to expose hover tokens | |
| 419 | - | ||
| 420 | - | return snapshot | |
| 421 | - | } | |
| 422 | - | ||
| 423 | - | // captureStackState captures all stack container states | |
| 424 | - | func (stm *StateTombstoneManager) captureStackState() *StackSnapshot { | |
| 425 | - | snapshot := &StackSnapshot{ | |
| 426 | - | StackContainers: make(map[string]*StackContainerInfo), | |
| 427 | - | } | |
| 428 | - | ||
| 429 | - | // Find all stacked nodes and capture their state | |
| 430 | - | if stm.wm.root != nil { | |
| 431 | - | stm.captureStackRecursive(stm.wm.root, snapshot) | |
| 432 | - | } | |
| 433 | - | ||
| 434 | - | return snapshot | |
| 435 | - | } | |
| 436 | - | ||
| 437 | - | // captureStackRecursive recursively captures stack states | |
| 438 | - | func (stm *StateTombstoneManager) captureStackRecursive(node *paneNode, snapshot *StackSnapshot) { | |
| 439 | - | if node == nil { | |
| 440 | - | return | |
| 441 | - | } | |
| 442 | - | ||
| 443 | - | if node.isStacked { | |
| 444 | - | nodeID := stm.nodeID(node) | |
| 445 | - | stackInfo := &StackContainerInfo{ | |
| 446 | - | NodeID: nodeID, | |
| 447 | - | StackedPaneIDs: make([]string, len(node.stackedPanes)), | |
| 448 | - | ActiveIndex: node.activeStackIndex, | |
| 449 | - | TitleBars: make(map[string]string), | |
| 450 | - | Visibility: make(map[string]bool), | |
| 451 | - | } | |
| 452 | - | ||
| 453 | - | // Capture stacked pane IDs | |
| 454 | - | for i, stackedPane := range node.stackedPanes { | |
| 455 | - | if stackedPane != nil { | |
| 456 | - | paneID := stm.nodeID(stackedPane) | |
| 457 | - | stackInfo.StackedPaneIDs[i] = paneID | |
| 458 | - | ||
| 459 | - | // Capture title and visibility | |
| 460 | - | if stackedPane.pane != nil && stackedPane.pane.webView != nil { | |
| 461 | - | title := stackedPane.pane.webView.GetTitle() | |
| 462 | - | stackInfo.TitleBars[paneID] = title | |
| 463 | - | } | |
| 464 | - | ||
| 465 | - | if stackedPane.container != nil { | |
| 466 | - | stackedPane.container.Execute(func(ptr uintptr) error { | |
| 467 | - | stackInfo.Visibility[paneID] = webkit.WidgetGetVisible(ptr) | |
| 468 | - | return nil | |
| 469 | - | }) | |
| 470 | - | } | |
| 471 | - | } | |
| 472 | - | } | |
| 473 | - | ||
| 474 | - | snapshot.StackContainers[nodeID] = stackInfo | |
| 475 | - | } | |
| 476 | - | ||
| 477 | - | // Recurse for children | |
| 478 | - | stm.captureStackRecursive(node.left, snapshot) | |
| 479 | - | stm.captureStackRecursive(node.right, snapshot) | |
| 480 | - | ||
| 481 | - | // Recurse for stacked panes | |
| 482 | - | if node.isStacked { | |
| 483 | - | for _, stackedPane := range node.stackedPanes { | |
| 484 | - | stm.captureStackRecursive(stackedPane, snapshot) | |
| 485 | - | } | |
| 486 | - | } | |
| 487 | - | } | |
| 488 | - | ||
| 489 | - | // RestoreState restores the workspace to a previously captured state | |
| 490 | - | func (stm *StateTombstoneManager) RestoreState(tombstoneID string) error { | |
| 491 | - | stm.mu.RLock() | |
| 492 | - | tombstone, exists := stm.tombstones[tombstoneID] | |
| 493 | - | stm.mu.RUnlock() | |
| 494 | - | ||
| 495 | - | if !exists { | |
| 496 | - | return fmt.Errorf("tombstone %s not found", tombstoneID) | |
| 497 | - | } | |
| 498 | - | ||
| 499 | - | log.Printf("[tombstone] Restoring state from tombstone: %s (operation: %s)", | |
| 500 | - | tombstoneID, tombstone.Operation) | |
| 501 | - | ||
| 502 | - | // Decompress if needed | |
| 503 | - | if tombstone.Compressed { | |
| 504 | - | if err := stm.decompressTombstone(tombstone); err != nil { | |
| 505 | - | return fmt.Errorf("failed to decompress tombstone: %w", err) | |
| 506 | - | } | |
| 507 | - | } | |
| 508 | - | ||
| 509 | - | // This is a complex operation that would require: | |
| 510 | - | // 1. Destroying current tree structure | |
| 511 | - | // 2. Recreating nodes from snapshot | |
| 512 | - | // 3. Restoring widget hierarchy | |
| 513 | - | // 4. Restoring focus state | |
| 514 | - | // 5. Validating the restored state | |
| 515 | - | ||
| 516 | - | // For now, we'll implement a basic restoration framework | |
| 517 | - | return stm.performRestore(tombstone) | |
| 518 | - | } | |
| 519 | - | ||
| 520 | - | // performRestore performs the actual state restoration | |
| 521 | - | func (stm *StateTombstoneManager) performRestore(tombstone *StateTombstone) error { | |
| 522 | - | // This is a placeholder for the complex restoration logic | |
| 523 | - | // In a full implementation, this would: | |
| 524 | - | ||
| 525 | - | // 1. Create a transaction for the entire restoration | |
| 526 | - | // 2. Clear current state while preserving essential references | |
| 527 | - | // 3. Recreate the tree structure from the snapshot | |
| 528 | - | // 4. Restore widget hierarchy and properties | |
| 529 | - | // 5. Restore focus and stack states | |
| 530 | - | // 6. Validate the restored state | |
| 531 | - | ||
| 532 | - | log.Printf("[tombstone] Restoration framework executed for tombstone %s", tombstone.ID) | |
| 533 | - | return fmt.Errorf("state restoration not yet fully implemented") | |
| 534 | - | } | |
| 535 | - | ||
| 536 | - | // Helper methods | |
| 537 | - | ||
| 538 | - | // nodeID generates a unique ID for a pane node | |
| 539 | - | func (stm *StateTombstoneManager) nodeID(node *paneNode) string { | |
| 540 | - | if node == nil { | |
| 541 | - | return "" | |
| 542 | - | } | |
| 543 | - | return fmt.Sprintf("node_%p", node) | |
| 544 | - | } | |
| 545 | - | ||
| 546 | - | // paneID generates a unique ID for a browser pane | |
| 547 | - | func (stm *StateTombstoneManager) paneID(pane *BrowserPane) string { | |
| 548 | - | if pane == nil { | |
| 549 | - | return "" | |
| 550 | - | } | |
| 551 | - | return fmt.Sprintf("pane_%p", pane) | |
| 552 | - | } | |
| 553 | - | ||
| 554 | - | // extractCSSClasses extracts CSS classes from a widget (simplified) | |
| 555 | - | func (stm *StateTombstoneManager) extractCSSClasses(ptr uintptr) []string { | |
| 556 | - | // This would need to be implemented using GTK introspection | |
| 557 | - | // to extract the actual CSS classes from the widget | |
| 558 | - | var classes []string | |
| 559 | - | ||
| 560 | - | // Check for known classes | |
| 561 | - | knownClasses := []string{ | |
| 562 | - | basePaneClass, | |
| 563 | - | multiPaneClass, | |
| 564 | - | activePaneClass, | |
| 565 | - | outlinePaneClass, | |
| 566 | - | stackContainerClass, | |
| 567 | - | } | |
| 568 | - | ||
| 569 | - | for _, class := range knownClasses { | |
| 570 | - | if webkit.WidgetHasCSSClass(ptr, class) { | |
| 571 | - | classes = append(classes, class) | |
| 572 | - | } | |
| 573 | - | } | |
| 574 | - | ||
| 575 | - | return classes | |
| 576 | - | } | |
| 577 | - | ||
| 578 | - | // compressTombstone compresses a tombstone to save memory | |
| 579 | - | func (stm *StateTombstoneManager) compressTombstone(tombstone *StateTombstone) error { | |
| 580 | - | if tombstone.Compressed { | |
| 581 | - | return nil | |
| 582 | - | } | |
| 583 | - | ||
| 584 | - | // Serialize the tombstone data | |
| 585 | - | var buf bytes.Buffer | |
| 586 | - | encoder := gob.NewEncoder(&buf) | |
| 587 | - | ||
| 588 | - | data := map[string]interface{}{ | |
| 589 | - | "tree": tombstone.TreeState, | |
| 590 | - | "widget": tombstone.WidgetState, | |
| 591 | - | "focus": tombstone.FocusState, | |
| 592 | - | "stack": tombstone.StackState, | |
| 593 | - | } | |
| 594 | - | ||
| 595 | - | if err := encoder.Encode(data); err != nil { | |
| 596 | - | return err | |
| 597 | - | } | |
| 598 | - | ||
| 599 | - | // Compress with gzip | |
| 600 | - | var compressed bytes.Buffer | |
| 601 | - | gzipWriter := gzip.NewWriter(&compressed) | |
| 602 | - | if _, err := gzipWriter.Write(buf.Bytes()); err != nil { | |
| 603 | - | return err | |
| 604 | - | } | |
| 605 | - | if err := gzipWriter.Close(); err != nil { | |
| 606 | - | return err | |
| 607 | - | } | |
| 608 | - | ||
| 609 | - | // Store compressed data (this is simplified - in practice you'd store the compressed data) | |
| 610 | - | tombstone.Compressed = true | |
| 611 | - | ||
| 612 | - | log.Printf("[tombstone] Compressed tombstone %s: %d -> %d bytes", | |
| 613 | - | tombstone.ID, buf.Len(), compressed.Len()) | |
| 614 | - | ||
| 615 | - | return nil | |
| 616 | - | } | |
| 617 | - | ||
| 618 | - | // decompressTombstone decompresses a tombstone | |
| 619 | - | func (stm *StateTombstoneManager) decompressTombstone(tombstone *StateTombstone) error { | |
| 620 | - | if !tombstone.Compressed { | |
| 621 | - | return nil | |
| 622 | - | } | |
| 623 | - | ||
| 624 | - | // This would decompress the data stored during compression | |
| 625 | - | tombstone.Compressed = false | |
| 626 | - | ||
| 627 | - | log.Printf("[tombstone] Decompressed tombstone %s", tombstone.ID) | |
| 628 | - | return nil | |
| 629 | - | } | |
| 630 | - | ||
| 631 | - | // calculateTombstoneSize estimates the size of a tombstone | |
| 632 | - | func (stm *StateTombstoneManager) calculateTombstoneSize(tombstone *StateTombstone) int { | |
| 633 | - | // This is a rough estimate - in practice you'd calculate more precisely | |
| 634 | - | size := 0 | |
| 635 | - | ||
| 636 | - | if tombstone.TreeState != nil { | |
| 637 | - | size += len(tombstone.TreeState.Nodes) * 100 // Rough estimate per node | |
| 638 | - | } | |
| 639 | - | ||
| 640 | - | if tombstone.WidgetState != nil { | |
| 641 | - | size += len(tombstone.WidgetState.Widgets) * 50 // Rough estimate per widget | |
| 642 | - | } | |
| 643 | - | ||
| 644 | - | return size | |
| 645 | - | } | |
| 646 | - | ||
| 647 | - | // cleanupOldTombstones removes old tombstones to manage memory | |
| 648 | - | func (stm *StateTombstoneManager) cleanupOldTombstones() { | |
| 649 | - | // Remove excess tombstones | |
| 650 | - | if len(stm.tombstones) > stm.maxTombstones { | |
| 651 | - | // Remove oldest tombstones first | |
| 652 | - | oldestTime := time.Now() | |
| 653 | - | var oldestID string | |
| 654 | - | ||
| 655 | - | for id, tombstone := range stm.tombstones { | |
| 656 | - | if tombstone.Timestamp.Before(oldestTime) { | |
| 657 | - | oldestTime = tombstone.Timestamp | |
| 658 | - | oldestID = id | |
| 659 | - | } | |
| 660 | - | } | |
| 661 | - | ||
| 662 | - | if oldestID != "" { | |
| 663 | - | delete(stm.tombstones, oldestID) | |
| 664 | - | log.Printf("[tombstone] Removed old tombstone: %s", oldestID) | |
| 665 | - | } | |
| 666 | - | } | |
| 667 | - | ||
| 668 | - | // Clean up history | |
| 669 | - | if len(stm.history) > stm.maxHistory { | |
| 670 | - | stm.history = stm.history[len(stm.history)-stm.maxHistory:] | |
| 671 | - | } | |
| 672 | - | } | |
| 673 | - | ||
| 674 | - | // GetTombstoneStats returns statistics about tombstones | |
| 675 | - | func (stm *StateTombstoneManager) GetTombstoneStats() map[string]interface{} { | |
| 676 | - | stm.mu.RLock() | |
| 677 | - | defer stm.mu.RUnlock() | |
| 678 | - | ||
| 679 | - | totalSize := 0 | |
| 680 | - | compressedCount := 0 | |
| 681 | - | ||
| 682 | - | for _, tombstone := range stm.tombstones { | |
| 683 | - | totalSize += tombstone.Size | |
| 684 | - | if tombstone.Compressed { | |
| 685 | - | compressedCount++ | |
| 686 | - | } | |
| 687 | - | } | |
| 688 | - | ||
| 689 | - | return map[string]interface{}{ | |
| 690 | - | "total_tombstones": len(stm.tombstones), | |
| 691 | - | "history_length": len(stm.history), | |
| 692 | - | "total_size_bytes": totalSize, | |
| 693 | - | "compressed_count": compressedCount, | |
| 694 | - | "compression_rate": float64(compressedCount) / float64(max(len(stm.tombstones), 1)), | |
| 695 | - | "max_tombstones": stm.maxTombstones, | |
| 696 | - | "compression_enabled": stm.compress, | |
| 697 | - | } | |
| 698 | - | } | |
| 699 | - | ||
| 700 | - | func max(a, b int) int { | |
| 701 | - | if a > b { | |
| 702 | - | return a | |
| 703 | - | } | |
| 704 | - | return b | |
| 705 | - | } | |
M internal/app/browser/workspace_tree_rebalancer.go +28 -791
| @@ -1,31 +1,24 @@ | |||
| 1 | - | // workspace_tree_rebalancer.go - Tree rebalancing for optimal split pane layout | |
| 1 | + | // workspace_tree_rebalancer.go - Simplified no-op tree rebalancer placeholder. | |
| 2 | + | // | |
| 3 | + | // The original TreeRebalancer relied on SafeWidget abstractions. Until a | |
| 4 | + | // pointer-based implementation is restored, we keep a lightweight stub so the | |
| 5 | + | // rest of the workspace code can continue to depend on the API without | |
| 6 | + | // carrying dead logic. | |
| 2 | 7 | package browser | |
| 3 | 8 | ||
| 4 | - | import ( | |
| 5 | - | "fmt" | |
| 6 | - | "log" | |
| 7 | - | "math" | |
| 8 | - | "time" | |
| 9 | - | ||
| 10 | - | "github.com/bnema/dumber/pkg/webkit" | |
| 11 | - | ) | |
| 12 | - | ||
| 13 | - | // TreeRebalancer provides algorithms for rebalancing the binary tree after operations | |
| 9 | + | // TreeRebalancer is currently a no-op shim kept for API compatibility. | |
| 14 | 10 | type TreeRebalancer struct { | |
| 15 | - | wm *WorkspaceManager | |
| 16 | - | treeValidator *TreeValidator | |
| 17 | - | maxImbalance int // Maximum allowed imbalance before rebalancing | |
| 18 | - | enabled bool // Whether rebalancing is enabled | |
| 11 | + | enabled bool | |
| 19 | 12 | } | |
| 20 | 13 | ||
| 21 | - | // RebalanceOperation represents a tree rebalancing operation | |
| 14 | + | // RebalanceOperation describes an attempted rebalance step (unused in shim). | |
| 22 | 15 | type RebalanceOperation struct { | |
| 23 | 16 | Type RebalanceType | |
| 24 | 17 | Node *paneNode | |
| 25 | 18 | Description string | |
| 26 | 19 | } | |
| 27 | 20 | ||
| 28 | - | // RebalanceType represents different types of rebalancing operations | |
| 21 | + | // RebalanceType enumerates rebalancer operations (kept for completeness). | |
| 29 | 22 | type RebalanceType int | |
| 30 | 23 | ||
| 31 | 24 | const ( | |
| @@ -35,792 +28,36 @@ const ( | |||
| 35 | 28 | RebalanceRestructure | |
| 36 | 29 | ) | |
| 37 | 30 | ||
| 38 | - | func (rt RebalanceType) String() string { | |
| 39 | - | switch rt { | |
| 40 | - | case RebalanceRotateLeft: | |
| 41 | - | return "rotate_left" | |
| 42 | - | case RebalanceRotateRight: | |
| 43 | - | return "rotate_right" | |
| 44 | - | case RebalancePromote: | |
| 45 | - | return "promote" | |
| 46 | - | case RebalanceRestructure: | |
| 47 | - | return "restructure" | |
| 48 | - | default: | |
| 49 | - | return "unknown" | |
| 50 | - | } | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | // TreeMetrics contains metrics about the tree structure | |
| 54 | - | type TreeMetrics struct { | |
| 55 | - | Height int | |
| 56 | - | NodeCount int | |
| 57 | - | LeafCount int | |
| 58 | - | BalanceFactor int | |
| 59 | - | MaxDepth int | |
| 60 | - | AvgDepth float64 | |
| 61 | - | ImbalanceScore int | |
| 62 | - | } | |
| 63 | - | ||
| 64 | - | // NewTreeRebalancer creates a new tree rebalancer | |
| 65 | - | func NewTreeRebalancer(wm *WorkspaceManager, treeValidator *TreeValidator) *TreeRebalancer { | |
| 66 | - | return &TreeRebalancer{ | |
| 67 | - | wm: wm, | |
| 68 | - | treeValidator: treeValidator, | |
| 69 | - | maxImbalance: 2, // Allow up to 2 levels of imbalance | |
| 70 | - | enabled: true, | |
| 71 | - | } | |
| 72 | - | } | |
| 73 | - | ||
| 74 | - | // SetMaxImbalance sets the maximum allowed imbalance | |
| 75 | - | func (tr *TreeRebalancer) SetMaxImbalance(maxImbalance int) { | |
| 76 | - | tr.maxImbalance = maxImbalance | |
| 77 | - | } | |
| 78 | - | ||
| 79 | - | // Enable enables tree rebalancing | |
| 80 | - | func (tr *TreeRebalancer) Enable() { | |
| 81 | - | tr.enabled = true | |
| 82 | - | log.Printf("[tree-rebalancer] Tree rebalancing enabled") | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | // Disable disables tree rebalancing | |
| 86 | - | func (tr *TreeRebalancer) Disable() { | |
| 87 | - | tr.enabled = false | |
| 88 | - | log.Printf("[tree-rebalancer] Tree rebalancing disabled") | |
| 89 | - | } | |
| 90 | - | ||
| 91 | - | // RebalanceAfterClose performs tree rebalancing after a pane close operation | |
| 92 | - | func (tr *TreeRebalancer) RebalanceAfterClose(closedNode *paneNode, promotedNode *paneNode) error { | |
| 93 | - | if !tr.enabled { | |
| 94 | - | return nil | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | log.Printf("[tree-rebalancer] Analyzing tree for rebalancing after close operation") | |
| 98 | - | ||
| 99 | - | if promotedNode != nil { | |
| 100 | - | if err := tr.executePromotion(promotedNode); err != nil { | |
| 101 | - | return fmt.Errorf("promotion execution failed: %w", err) | |
| 102 | - | } | |
| 103 | - | ||
| 104 | - | if tr.wm != nil && tr.wm.geometryValidator != nil { | |
| 105 | - | tr.logInitialPromotionGeometry(promotedNode) | |
| 106 | - | tr.schedulePromotionValidation(promotedNode) | |
| 107 | - | } | |
| 108 | - | } | |
| 109 | - | ||
| 110 | - | // Calculate tree metrics | |
| 111 | - | metrics := tr.CalculateTreeMetrics(tr.wm.root) | |
| 112 | - | log.Printf("[tree-rebalancer] Tree metrics: height=%d, nodes=%d, balance=%d, imbalance=%d", | |
| 113 | - | metrics.Height, metrics.NodeCount, metrics.BalanceFactor, metrics.ImbalanceScore) | |
| 114 | - | ||
| 115 | - | // Check if rebalancing is needed | |
| 116 | - | if metrics.ImbalanceScore <= tr.maxImbalance { | |
| 117 | - | log.Printf("[tree-rebalancer] Tree is balanced, no rebalancing needed") | |
| 118 | - | return nil | |
| 119 | - | } | |
| 120 | - | ||
| 121 | - | // Find rebalancing operations | |
| 122 | - | operations := tr.findRebalancingOperations(tr.wm.root, metrics) | |
| 123 | - | if len(operations) == 0 { | |
| 124 | - | log.Printf("[tree-rebalancer] No rebalancing operations found") | |
| 125 | - | return nil | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | log.Printf("[tree-rebalancer] Found %d rebalancing operations", len(operations)) | |
| 129 | - | ||
| 130 | - | // Execute rebalancing operations | |
| 131 | - | return tr.executeRebalancingOperations(operations) | |
| 132 | - | } | |
| 133 | - | ||
| 134 | - | // CalculateTreeMetrics calculates comprehensive metrics about the tree | |
| 135 | - | func (tr *TreeRebalancer) CalculateTreeMetrics(root *paneNode) TreeMetrics { | |
| 136 | - | if root == nil { | |
| 137 | - | return TreeMetrics{} | |
| 138 | - | } | |
| 139 | - | ||
| 140 | - | metrics := TreeMetrics{} | |
| 141 | - | ||
| 142 | - | // Calculate basic metrics | |
| 143 | - | metrics.Height = tr.calculateHeight(root) | |
| 144 | - | metrics.NodeCount = tr.countNodes(root) | |
| 145 | - | metrics.LeafCount = tr.countLeaves(root) | |
| 146 | - | metrics.MaxDepth = tr.calculateMaxDepth(root) | |
| 147 | - | metrics.AvgDepth = tr.calculateAverageDepth(root) | |
| 148 | - | ||
| 149 | - | // Calculate balance factor | |
| 150 | - | leftHeight := tr.calculateHeight(root.left) | |
| 151 | - | rightHeight := tr.calculateHeight(root.right) | |
| 152 | - | metrics.BalanceFactor = rightHeight - leftHeight | |
| 153 | - | ||
| 154 | - | // Calculate imbalance score (how far from balanced the tree is) | |
| 155 | - | metrics.ImbalanceScore = tr.calculateImbalanceScore(root) | |
| 156 | - | ||
| 157 | - | return metrics | |
| 158 | - | } | |
| 159 | - | ||
| 160 | - | // calculateHeight calculates the height of a subtree | |
| 161 | - | func (tr *TreeRebalancer) calculateHeight(node *paneNode) int { | |
| 162 | - | if node == nil { | |
| 163 | - | return 0 | |
| 164 | - | } | |
| 165 | - | ||
| 166 | - | // Skip stacked nodes for height calculation (they don't affect layout balance) | |
| 167 | - | if node.isStacked { | |
| 168 | - | // For stacked nodes, height is determined by the active pane | |
| 169 | - | if len(node.stackedPanes) > 0 && node.activeStackIndex >= 0 && node.activeStackIndex < len(node.stackedPanes) { | |
| 170 | - | return tr.calculateHeight(node.stackedPanes[node.activeStackIndex]) | |
| 171 | - | } | |
| 172 | - | return 1 | |
| 173 | - | } | |
| 174 | - | ||
| 175 | - | if node.isLeaf { | |
| 176 | - | return 1 | |
| 177 | - | } | |
| 178 | - | ||
| 179 | - | leftHeight := tr.calculateHeight(node.left) | |
| 180 | - | rightHeight := tr.calculateHeight(node.right) | |
| 181 | - | return 1 + max(leftHeight, rightHeight) | |
| 182 | - | } | |
| 183 | - | ||
| 184 | - | // countNodes counts the total number of nodes in the tree | |
| 185 | - | func (tr *TreeRebalancer) countNodes(node *paneNode) int { | |
| 186 | - | if node == nil { | |
| 187 | - | return 0 | |
| 188 | - | } | |
| 189 | - | ||
| 190 | - | count := 1 | |
| 191 | - | if node.isStacked { | |
| 192 | - | // Count stacked panes | |
| 193 | - | count += len(node.stackedPanes) | |
| 194 | - | } else { | |
| 195 | - | count += tr.countNodes(node.left) | |
| 196 | - | count += tr.countNodes(node.right) | |
| 197 | - | } | |
| 198 | - | ||
| 199 | - | return count | |
| 200 | - | } | |
| 201 | - | ||
| 202 | - | // countLeaves counts the number of leaf nodes | |
| 203 | - | func (tr *TreeRebalancer) countLeaves(node *paneNode) int { | |
| 204 | - | if node == nil { | |
| 205 | - | return 0 | |
| 206 | - | } | |
| 207 | - | ||
| 208 | - | if node.isLeaf { | |
| 209 | - | return 1 | |
| 210 | - | } | |
| 211 | - | ||
| 212 | - | if node.isStacked { | |
| 213 | - | return len(node.stackedPanes) | |
| 214 | - | } | |
| 215 | - | ||
| 216 | - | return tr.countLeaves(node.left) + tr.countLeaves(node.right) | |
| 217 | - | } | |
| 218 | - | ||
| 219 | - | // calculateMaxDepth calculates the maximum depth of any leaf | |
| 220 | - | func (tr *TreeRebalancer) calculateMaxDepth(node *paneNode) int { | |
| 221 | - | return tr.calculateMaxDepthHelper(node, 0) | |
| 222 | - | } | |
| 223 | - | ||
| 224 | - | func (tr *TreeRebalancer) calculateMaxDepthHelper(node *paneNode, currentDepth int) int { | |
| 225 | - | if node == nil { | |
| 226 | - | return currentDepth | |
| 227 | - | } | |
| 31 | + | // TreeMetrics represents the structural metrics of the workspace tree. | |
| 32 | + | type TreeMetrics struct{} | |
| 228 | 33 | ||
| 229 | - | if node.isLeaf || node.isStacked { | |
| 230 | - | return currentDepth + 1 | |
| 231 | - | } | |
| 232 | - | ||
| 233 | - | leftDepth := tr.calculateMaxDepthHelper(node.left, currentDepth+1) | |
| 234 | - | rightDepth := tr.calculateMaxDepthHelper(node.right, currentDepth+1) | |
| 235 | - | return max(leftDepth, rightDepth) | |
| 34 | + | // NewTreeRebalancer returns a disabled stub implementation. | |
| 35 | + | func NewTreeRebalancer(_ *WorkspaceManager, _ *TreeValidator) *TreeRebalancer { | |
| 36 | + | return &TreeRebalancer{} | |
| 236 | 37 | } | |
| 237 | 38 | ||
| 238 | - | // calculateAverageDepth calculates the average depth of all leaves | |
| 239 | - | func (tr *TreeRebalancer) calculateAverageDepth(node *paneNode) float64 { | |
| 240 | - | if node == nil { | |
| 241 | - | return 0 | |
| 242 | - | } | |
| 243 | - | ||
| 244 | - | totalDepth, leafCount := tr.calculateTotalDepthAndCount(node, 0) | |
| 245 | - | if leafCount == 0 { | |
| 246 | - | return 0 | |
| 247 | - | } | |
| 248 | - | ||
| 249 | - | return float64(totalDepth) / float64(leafCount) | |
| 250 | - | } | |
| 251 | - | ||
| 252 | - | func (tr *TreeRebalancer) calculateTotalDepthAndCount(node *paneNode, currentDepth int) (int, int) { | |
| 253 | - | if node == nil { | |
| 254 | - | return 0, 0 | |
| 255 | - | } | |
| 256 | - | ||
| 257 | - | if node.isLeaf { | |
| 258 | - | return currentDepth + 1, 1 | |
| 259 | - | } | |
| 260 | - | ||
| 261 | - | if node.isStacked { | |
| 262 | - | // All stacked panes have the same depth | |
| 263 | - | return (currentDepth + 1) * len(node.stackedPanes), len(node.stackedPanes) | |
| 264 | - | } | |
| 39 | + | // SetMaxImbalance is preserved for API compatibility. | |
| 40 | + | func (tr *TreeRebalancer) SetMaxImbalance(_ int) {} | |
| 265 | 41 | ||
| 266 | - | leftDepth, leftCount := tr.calculateTotalDepthAndCount(node.left, currentDepth+1) | |
| 267 | - | rightDepth, rightCount := tr.calculateTotalDepthAndCount(node.right, currentDepth+1) | |
| 268 | - | ||
| 269 | - | return leftDepth + rightDepth, leftCount + rightCount | |
| 270 | - | } | |
| 271 | - | ||
| 272 | - | // calculateImbalanceScore calculates how imbalanced the tree is | |
| 273 | - | func (tr *TreeRebalancer) calculateImbalanceScore(node *paneNode) int { | |
| 274 | - | if node == nil { | |
| 275 | - | return 0 | |
| 276 | - | } | |
| 277 | - | ||
| 278 | - | leftHeight := tr.calculateHeight(node.left) | |
| 279 | - | rightHeight := tr.calculateHeight(node.right) | |
| 280 | - | currentImbalance := int(math.Abs(float64(leftHeight - rightHeight))) | |
| 281 | - | ||
| 282 | - | // Recursively calculate imbalance for subtrees | |
| 283 | - | leftImbalance := tr.calculateImbalanceScore(node.left) | |
| 284 | - | rightImbalance := tr.calculateImbalanceScore(node.right) | |
| 285 | - | ||
| 286 | - | return max(currentImbalance, max(leftImbalance, rightImbalance)) | |
| 287 | - | } | |
| 288 | - | ||
| 289 | - | // findRebalancingOperations identifies operations needed to rebalance the tree | |
| 290 | - | func (tr *TreeRebalancer) findRebalancingOperations(root *paneNode, metrics TreeMetrics) []RebalanceOperation { | |
| 291 | - | var operations []RebalanceOperation | |
| 292 | - | ||
| 293 | - | // Find all imbalanced nodes | |
| 294 | - | imbalancedNodes := tr.findImbalancedNodes(root) | |
| 295 | - | ||
| 296 | - | for _, node := range imbalancedNodes { | |
| 297 | - | // Determine the best rebalancing operation for this node | |
| 298 | - | operation := tr.determineRebalancingOperation(node) | |
| 299 | - | if operation != nil { | |
| 300 | - | operations = append(operations, *operation) | |
| 301 | - | } | |
| 302 | - | } | |
| 303 | - | ||
| 304 | - | return operations | |
| 305 | - | } | |
| 306 | - | ||
| 307 | - | // findImbalancedNodes finds all nodes that are significantly imbalanced | |
| 308 | - | func (tr *TreeRebalancer) findImbalancedNodes(node *paneNode) []*paneNode { | |
| 309 | - | if node == nil { | |
| 310 | - | return nil | |
| 311 | - | } | |
| 312 | - | ||
| 313 | - | var imbalanced []*paneNode | |
| 314 | - | ||
| 315 | - | leftHeight := tr.calculateHeight(node.left) | |
| 316 | - | rightHeight := tr.calculateHeight(node.right) | |
| 317 | - | balance := int(math.Abs(float64(leftHeight - rightHeight))) | |
| 318 | - | ||
| 319 | - | if balance > tr.maxImbalance { | |
| 320 | - | imbalanced = append(imbalanced, node) | |
| 321 | - | } | |
| 322 | - | ||
| 323 | - | // Recursively check children | |
| 324 | - | imbalanced = append(imbalanced, tr.findImbalancedNodes(node.left)...) | |
| 325 | - | imbalanced = append(imbalanced, tr.findImbalancedNodes(node.right)...) | |
| 326 | - | ||
| 327 | - | return imbalanced | |
| 328 | - | } | |
| 329 | - | ||
| 330 | - | // determineRebalancingOperation determines the best rebalancing operation for a node | |
| 331 | - | func (tr *TreeRebalancer) determineRebalancingOperation(node *paneNode) *RebalanceOperation { | |
| 332 | - | if node == nil || node.isLeaf { | |
| 333 | - | return nil | |
| 334 | - | } | |
| 42 | + | // Enable toggles the stub on (no functional impact). | |
| 43 | + | func (tr *TreeRebalancer) Enable() { tr.enabled = true } | |
| 335 | 44 | ||
| 336 | - | leftHeight := tr.calculateHeight(node.left) | |
| 337 | - | rightHeight := tr.calculateHeight(node.right) | |
| 338 | - | ||
| 339 | - | if leftHeight > rightHeight+tr.maxImbalance { | |
| 340 | - | // Left-heavy, need right rotation | |
| 341 | - | return &RebalanceOperation{ | |
| 342 | - | Type: RebalanceRotateRight, | |
| 343 | - | Node: node, | |
| 344 | - | Description: fmt.Sprintf("Right rotation on node %p (left=%d, right=%d)", node, leftHeight, rightHeight), | |
| 345 | - | } | |
| 346 | - | } else if rightHeight > leftHeight+tr.maxImbalance { | |
| 347 | - | // Right-heavy, need left rotation | |
| 348 | - | return &RebalanceOperation{ | |
| 349 | - | Type: RebalanceRotateLeft, | |
| 350 | - | Node: node, | |
| 351 | - | Description: fmt.Sprintf("Left rotation on node %p (left=%d, right=%d)", node, leftHeight, rightHeight), | |
| 352 | - | } | |
| 353 | - | } | |
| 354 | - | ||
| 355 | - | return nil | |
| 356 | - | } | |
| 357 | - | ||
| 358 | - | // executeRebalancingOperations executes a series of rebalancing operations | |
| 359 | - | func (tr *TreeRebalancer) executeRebalancingOperations(operations []RebalanceOperation) error { | |
| 360 | - | if len(operations) == 0 { | |
| 361 | - | return nil | |
| 362 | - | } | |
| 363 | - | ||
| 364 | - | log.Printf("[tree-rebalancer] Executing %d rebalancing operations", len(operations)) | |
| 365 | - | ||
| 366 | - | // Execute each operation | |
| 367 | - | for i, operation := range operations { | |
| 368 | - | log.Printf("[tree-rebalancer] Executing operation %d/%d: %s", i+1, len(operations), operation.Description) | |
| 369 | - | ||
| 370 | - | var err error | |
| 371 | - | switch operation.Type { | |
| 372 | - | case RebalanceRotateLeft: | |
| 373 | - | err = tr.executeLeftRotation(operation.Node) | |
| 374 | - | case RebalanceRotateRight: | |
| 375 | - | err = tr.executeRightRotation(operation.Node) | |
| 376 | - | case RebalancePromote: | |
| 377 | - | err = tr.executePromotion(operation.Node) | |
| 378 | - | case RebalanceRestructure: | |
| 379 | - | err = tr.executeRestructure(operation.Node) | |
| 380 | - | default: | |
| 381 | - | err = fmt.Errorf("unknown rebalance operation type: %s", operation.Type) | |
| 382 | - | } | |
| 383 | - | ||
| 384 | - | if err != nil { | |
| 385 | - | log.Printf("[tree-rebalancer] Operation failed: %v", err) | |
| 386 | - | return fmt.Errorf("rebalancing operation failed: %w", err) | |
| 387 | - | } | |
| 388 | - | } | |
| 389 | - | ||
| 390 | - | // Validate tree after rebalancing | |
| 391 | - | if tr.treeValidator != nil { | |
| 392 | - | if err := tr.treeValidator.ValidateTree(tr.wm.root, "rebalance"); err != nil { | |
| 393 | - | log.Printf("[tree-rebalancer] Tree validation failed after rebalancing: %v", err) | |
| 394 | - | } | |
| 395 | - | } | |
| 396 | - | ||
| 397 | - | // Calculate new metrics | |
| 398 | - | newMetrics := tr.CalculateTreeMetrics(tr.wm.root) | |
| 399 | - | log.Printf("[tree-rebalancer] Rebalancing completed: new imbalance=%d", | |
| 400 | - | newMetrics.ImbalanceScore) | |
| 45 | + | // Disable toggles the stub off. | |
| 46 | + | func (tr *TreeRebalancer) Disable() { tr.enabled = false } | |
| 401 | 47 | ||
| 48 | + | // RebalanceAfterClose is a no-op in the stub implementation. | |
| 49 | + | func (tr *TreeRebalancer) RebalanceAfterClose(_ *paneNode, _ *paneNode) error { | |
| 402 | 50 | return nil | |
| 403 | 51 | } | |
| 404 | 52 | ||
| 405 | - | // executeLeftRotation performs a left rotation on the given node | |
| 406 | - | func (tr *TreeRebalancer) executeLeftRotation(node *paneNode) error { | |
| 407 | - | if node == nil || node.right == nil { | |
| 408 | - | return fmt.Errorf("cannot perform left rotation: invalid node structure") | |
| 409 | - | } | |
| 410 | - | ||
| 411 | - | log.Printf("[tree-rebalancer] Performing left rotation on node %p", node) | |
| 412 | - | ||
| 413 | - | // Store original structure | |
| 414 | - | rightChild := node.right | |
| 415 | - | rightLeftChild := rightChild.left | |
| 416 | - | parent := node.parent | |
| 417 | - | ||
| 418 | - | // Update tree structure | |
| 419 | - | rightChild.left = node | |
| 420 | - | node.right = rightLeftChild | |
| 421 | - | rightChild.parent = parent | |
| 422 | - | node.parent = rightChild | |
| 423 | - | ||
| 424 | - | if rightLeftChild != nil { | |
| 425 | - | rightLeftChild.parent = node | |
| 426 | - | } | |
| 427 | - | ||
| 428 | - | // Update parent's child pointer or root | |
| 429 | - | if parent == nil { | |
| 430 | - | tr.wm.root = rightChild | |
| 431 | - | } else if parent.left == node { | |
| 432 | - | parent.left = rightChild | |
| 433 | - | } else { | |
| 434 | - | parent.right = rightChild | |
| 435 | - | } | |
| 436 | - | ||
| 437 | - | // Update widget hierarchy for the rotation | |
| 438 | - | return tr.updateRotationWidgets(node, rightChild, "left") | |
| 53 | + | // CalculateTreeMetrics returns empty metrics in the stub. | |
| 54 | + | func (tr *TreeRebalancer) CalculateTreeMetrics(_ *paneNode) TreeMetrics { | |
| 55 | + | return TreeMetrics{} | |
| 439 | 56 | } | |
| 440 | 57 | ||
| 441 | - | // executeRightRotation performs a right rotation on the given node | |
| 442 | - | func (tr *TreeRebalancer) executeRightRotation(node *paneNode) error { | |
| 443 | - | if node == nil || node.left == nil { | |
| 444 | - | return fmt.Errorf("cannot perform right rotation: invalid node structure") | |
| 445 | - | } | |
| 446 | - | ||
| 447 | - | log.Printf("[tree-rebalancer] Performing right rotation on node %p", node) | |
| 448 | - | ||
| 449 | - | // Store original structure | |
| 450 | - | leftChild := node.left | |
| 451 | - | leftRightChild := leftChild.right | |
| 452 | - | parent := node.parent | |
| 453 | - | ||
| 454 | - | // Update tree structure | |
| 455 | - | leftChild.right = node | |
| 456 | - | node.left = leftRightChild | |
| 457 | - | leftChild.parent = parent | |
| 458 | - | node.parent = leftChild | |
| 459 | - | ||
| 460 | - | if leftRightChild != nil { | |
| 461 | - | leftRightChild.parent = node | |
| 462 | - | } | |
| 463 | - | ||
| 464 | - | // Update parent's child pointer or root | |
| 465 | - | if parent == nil { | |
| 466 | - | tr.wm.root = leftChild | |
| 467 | - | } else if parent.left == node { | |
| 468 | - | parent.left = leftChild | |
| 469 | - | } else { | |
| 470 | - | parent.right = leftChild | |
| 471 | - | } | |
| 472 | - | ||
| 473 | - | // Update widget hierarchy for the rotation | |
| 474 | - | return tr.updateRotationWidgets(node, leftChild, "right") | |
| 475 | - | } | |
| 476 | - | ||
| 477 | - | // updateRotationWidgets updates widget hierarchy after tree rotation | |
| 478 | - | func (tr *TreeRebalancer) updateRotationWidgets(oldRoot, newRoot *paneNode, rotationType string) error { | |
| 479 | - | // The widget hierarchy needs to be updated to match the new tree structure | |
| 480 | - | // This is complex because GTK widgets need to be reparented correctly | |
| 481 | - | ||
| 482 | - | if oldRoot.container == nil || newRoot.container == nil { | |
| 483 | - | return fmt.Errorf("rotation nodes missing containers") | |
| 484 | - | } | |
| 485 | - | ||
| 486 | - | log.Printf("[tree-rebalancer] Updating widget hierarchy for %s rotation", rotationType) | |
| 487 | - | ||
| 488 | - | // Force a layout update | |
| 489 | - | if newRoot.container != nil { | |
| 490 | - | return newRoot.container.Execute(func(ptr uintptr) error { | |
| 491 | - | if ptr == 0 || !webkit.WidgetIsValid(ptr) { | |
| 492 | - | return fmt.Errorf("rotation widget update: invalid widget pointer") | |
| 493 | - | } | |
| 494 | - | webkit.WidgetQueueAllocate(ptr) | |
| 495 | - | return nil | |
| 496 | - | }) | |
| 497 | - | } | |
| 498 | - | return nil | |
| 499 | - | } | |
| 500 | - | ||
| 501 | - | // executePromotion promotes a child node to replace its parent | |
| 502 | - | func (tr *TreeRebalancer) executePromotion(node *paneNode) error { | |
| 503 | - | if node == nil { | |
| 504 | - | return fmt.Errorf("promotion target is nil") | |
| 505 | - | } | |
| 506 | - | if node.container == nil { | |
| 507 | - | return fmt.Errorf("promotion target missing container") | |
| 508 | - | } | |
| 509 | - | if !node.container.IsValid() { | |
| 510 | - | return fmt.Errorf("promotion target container %s invalid", node.container.String()) | |
| 511 | - | } | |
| 512 | - | ||
| 513 | - | log.Printf("[tree-rebalancer] Promoting node %p (parent=%p)", node, node.parent) | |
| 514 | - | promotionStart := time.Now() | |
| 515 | - | ||
| 516 | - | // Ensure the promoted widget can expand to occupy available space | |
| 517 | - | log.Printf("[tree-rebalancer] promotion expand for %p executing at +%s", node, time.Since(promotionStart).Round(time.Millisecond)) | |
| 518 | - | if err := node.container.Execute(func(ptr uintptr) error { | |
| 519 | - | if ptr == 0 || !webkit.WidgetIsValid(ptr) { | |
| 520 | - | return fmt.Errorf("promotion expand: invalid widget pointer") | |
| 521 | - | } | |
| 522 | - | webkit.WidgetResetSizeRequest(ptr) | |
| 523 | - | webkit.WidgetSetHExpand(ptr, true) | |
| 524 | - | webkit.WidgetSetVExpand(ptr, true) | |
| 525 | - | webkit.WidgetQueueAllocate(ptr) | |
| 526 | - | return nil | |
| 527 | - | }); err != nil { | |
| 528 | - | return fmt.Errorf("failed to expand promoted widget: %w", err) | |
| 529 | - | } | |
| 530 | - | ||
| 531 | - | parent := node.parent | |
| 532 | - | if parent == nil { | |
| 533 | - | // Attach to window root | |
| 534 | - | if tr.wm != nil && tr.wm.window != nil { | |
| 535 | - | log.Printf("[tree-rebalancer] promotion window attach for %p executing at +%s", node, time.Since(promotionStart).Round(time.Millisecond)) | |
| 536 | - | if err := node.container.Execute(func(ptr uintptr) error { | |
| 537 | - | if ptr == 0 || !webkit.WidgetIsValid(ptr) { | |
| 538 | - | return fmt.Errorf("promotion attach window: invalid widget pointer") | |
| 539 | - | } | |
| 540 | - | ||
| 541 | - | currentParent := webkit.WidgetGetParent(ptr) | |
| 542 | - | if currentParent != 0 { | |
| 543 | - | // Check if parent is a container widget (paned or box) that we need to unparent from | |
| 544 | - | if webkit.IsPaned(currentParent) { | |
| 545 | - | log.Printf("[tree-rebalancer] unparenting widget from paned %#x", currentParent) | |
| 546 | - | webkit.WidgetUnparent(ptr) | |
| 547 | - | } else if webkit.IsBox(currentParent) { | |
| 548 | - | log.Printf("[tree-rebalancer] unparenting widget from box (stack) %#x", currentParent) | |
| 549 | - | webkit.WidgetUnparent(ptr) | |
| 550 | - | } else { | |
| 551 | - | // Parent is likely a window or other non-container widget | |
| 552 | - | // For safety, check if it's a toplevel window by attempting unparent | |
| 553 | - | log.Printf("[tree-rebalancer] widget %#x has non-container parent %#x, checking if unparent needed", ptr, currentParent) | |
| 554 | - | if webkit.WidgetIsValid(currentParent) { | |
| 555 | - | // If parent is valid but not a container, try unparent for safety | |
| 556 | - | webkit.WidgetUnparent(ptr) | |
| 557 | - | log.Printf("[tree-rebalancer] unparented widget %#x from non-container parent %#x", ptr, currentParent) | |
| 558 | - | } | |
| 559 | - | } | |
| 560 | - | } | |
| 561 | - | ||
| 562 | - | // Ensure widget is configured for window child | |
| 563 | - | webkit.WidgetSetHExpand(ptr, true) | |
| 564 | - | webkit.WidgetSetVExpand(ptr, true) | |
| 565 | - | ||
| 566 | - | // Set as window child | |
| 567 | - | tr.wm.window.SetChild(ptr) | |
| 568 | - | webkit.WidgetQueueAllocate(ptr) | |
| 569 | - | webkit.WidgetShow(ptr) | |
| 570 | - | ||
| 571 | - | // Verify the attachment worked | |
| 572 | - | finalParent := webkit.WidgetGetParent(ptr) | |
| 573 | - | if finalParent == 0 { | |
| 574 | - | log.Printf("[tree-rebalancer] WARNING: SetChild failed, widget still has no parent") | |
| 575 | - | // Try one more time | |
| 576 | - | tr.wm.window.SetChild(ptr) | |
| 577 | - | webkit.WidgetQueueAllocate(ptr) | |
| 578 | - | finalParent = webkit.WidgetGetParent(ptr) | |
| 579 | - | if finalParent == 0 { | |
| 580 | - | return fmt.Errorf("failed to attach widget %#x to window after multiple attempts", ptr) | |
| 581 | - | } | |
| 582 | - | } | |
| 583 | - | ||
| 584 | - | log.Printf("[tree-rebalancer] promotion window attach successful: widget %#x now has parent %#x", ptr, finalParent) | |
| 585 | - | return nil | |
| 586 | - | }); err != nil { | |
| 587 | - | return fmt.Errorf("failed to attach widget to window: %w", err) | |
| 588 | - | } | |
| 589 | - | } | |
| 590 | - | } else { | |
| 591 | - | // Attach to parent container | |
| 592 | - | if parent.container == nil { | |
| 593 | - | return fmt.Errorf("promotion parent missing container") | |
| 594 | - | } | |
| 595 | - | if !parent.container.IsValid() { | |
| 596 | - | return fmt.Errorf("promotion parent container %s invalid", parent.container.String()) | |
| 597 | - | } | |
| 598 | - | ||
| 599 | - | log.Printf("[tree-rebalancer] promotion reparent for %p executing at +%s", node, time.Since(promotionStart).Round(time.Millisecond)) | |
| 600 | - | if err := parent.container.Execute(func(parentPtr uintptr) error { | |
| 601 | - | if parentPtr == 0 || !webkit.WidgetIsValid(parentPtr) { | |
| 602 | - | return fmt.Errorf("promotion reparent: invalid parent widget") | |
| 603 | - | } | |
| 604 | - | return node.container.Execute(func(childPtr uintptr) error { | |
| 605 | - | if childPtr == 0 || !webkit.WidgetIsValid(childPtr) { | |
| 606 | - | return fmt.Errorf("promotion reparent: invalid child widget") | |
| 607 | - | } | |
| 608 | - | ||
| 609 | - | // Check if widget is already correctly parented (closePane.swapContainers already did the work) | |
| 610 | - | currentParent := webkit.WidgetGetParent(childPtr) | |
| 611 | - | if currentParent == parentPtr { | |
| 612 | - | log.Printf("[tree-rebalancer] widget %#x already correctly parented to %#x, skipping reparent", childPtr, parentPtr) | |
| 613 | - | // Just ensure properties are set correctly | |
| 614 | - | webkit.WidgetResetSizeRequest(childPtr) | |
| 615 | - | webkit.WidgetSetHExpand(childPtr, true) | |
| 616 | - | webkit.WidgetSetVExpand(childPtr, true) | |
| 617 | - | webkit.WidgetQueueAllocate(childPtr) | |
| 618 | - | webkit.WidgetQueueAllocate(parentPtr) | |
| 619 | - | return nil | |
| 620 | - | } | |
| 621 | - | ||
| 622 | - | // Widget needs reparenting - unparent from wrong parent if needed | |
| 623 | - | if currentParent != 0 { | |
| 624 | - | log.Printf("[tree-rebalancer] unparenting widget %#x from incorrect parent %#x", childPtr, currentParent) | |
| 625 | - | webkit.WidgetUnparent(childPtr) | |
| 626 | - | } | |
| 627 | - | ||
| 628 | - | // Reattach promoted widget to the GtkPaned | |
| 629 | - | if parent.left == node { | |
| 630 | - | webkit.PanedSetStartChild(parentPtr, childPtr) | |
| 631 | - | } else { | |
| 632 | - | webkit.PanedSetEndChild(parentPtr, childPtr) | |
| 633 | - | } | |
| 634 | - | ||
| 635 | - | // Verify attachment succeeded before setting properties | |
| 636 | - | finalParent := webkit.WidgetGetParent(childPtr) | |
| 637 | - | if finalParent != parentPtr { | |
| 638 | - | return fmt.Errorf("failed to attach widget %#x to parent %#x (current parent: %#x)", childPtr, parentPtr, finalParent) | |
| 639 | - | } | |
| 640 | - | ||
| 641 | - | webkit.WidgetResetSizeRequest(childPtr) | |
| 642 | - | webkit.WidgetSetHExpand(childPtr, true) | |
| 643 | - | webkit.WidgetSetVExpand(childPtr, true) | |
| 644 | - | webkit.WidgetQueueAllocate(childPtr) | |
| 645 | - | webkit.WidgetQueueAllocate(parentPtr) | |
| 646 | - | return nil | |
| 647 | - | }) | |
| 648 | - | }); err != nil { | |
| 649 | - | return fmt.Errorf("failed to reparent promoted widget: %w", err) | |
| 650 | - | } | |
| 651 | - | } | |
| 652 | - | ||
| 653 | - | // Propagate allocation updates up the ancestor chain so GTK recalculates sizes | |
| 654 | - | ancestorPtrs := tr.collectAncestorContainers(node) | |
| 655 | - | if len(ancestorPtrs) > 0 { | |
| 656 | - | log.Printf("[tree-rebalancer] promotion allocation queue for %p executing at +%s (ancestors=%d)", node, time.Since(promotionStart).Round(time.Millisecond), len(ancestorPtrs)) | |
| 657 | - | for _, ancestorPtr := range ancestorPtrs { | |
| 658 | - | if ancestorPtr == 0 || !webkit.WidgetIsValid(ancestorPtr) { | |
| 659 | - | continue | |
| 660 | - | } | |
| 661 | - | webkit.WidgetQueueAllocate(ancestorPtr) | |
| 662 | - | } | |
| 663 | - | } | |
| 664 | - | ||
| 665 | - | // Validate allocation after promotion for debugging purposes | |
| 666 | - | log.Printf("[tree-rebalancer] promotion immediate validation for %p executing at +%s", node, time.Since(promotionStart).Round(time.Millisecond)) | |
| 667 | - | _ = node.container.Execute(func(ptr uintptr) error { | |
| 668 | - | if ptr == 0 || !webkit.WidgetIsValid(ptr) { | |
| 669 | - | log.Printf("[tree-rebalancer] promotion validate: invalid widget pointer") | |
| 670 | - | return nil | |
| 671 | - | } | |
| 672 | - | bounds, ok := webkit.WidgetGetBounds(ptr) | |
| 673 | - | if !ok { | |
| 674 | - | log.Printf("[tree-rebalancer] promotion validation: failed to read bounds for %#x", ptr) | |
| 675 | - | return nil | |
| 676 | - | } | |
| 677 | - | if bounds.Width <= 1 || bounds.Height <= 1 { | |
| 678 | - | log.Printf("[tree-rebalancer] WARNING: promoted pane %#x has tiny allocation %.1fx%.1f", ptr, bounds.Width, bounds.Height) | |
| 679 | - | } else { | |
| 680 | - | log.Printf("[tree-rebalancer] Promotion allocation verified for %#x: %.1fx%.1f", ptr, bounds.Width, bounds.Height) | |
| 681 | - | } | |
| 682 | - | return nil | |
| 683 | - | }) | |
| 684 | - | ||
| 685 | - | return nil | |
| 686 | - | } | |
| 687 | - | ||
| 688 | - | // executeRestructure performs a complete restructure of a subtree | |
| 689 | - | func (tr *TreeRebalancer) executeRestructure(node *paneNode) error { | |
| 690 | - | // This would be used for more complex rebalancing scenarios | |
| 691 | - | log.Printf("[tree-rebalancer] Restructuring subtree at node %p", node) | |
| 692 | - | ||
| 693 | - | // TODO: Implement restructure logic | |
| 694 | - | return fmt.Errorf("restructure operation not yet implemented") | |
| 695 | - | } | |
| 696 | - | ||
| 697 | - | func (tr *TreeRebalancer) logInitialPromotionGeometry(node *paneNode) { | |
| 698 | - | if tr == nil || tr.wm == nil || tr.wm.geometryValidator == nil || node == nil { | |
| 699 | - | return | |
| 700 | - | } | |
| 701 | - | if node.container == nil || !node.container.IsValid() { | |
| 702 | - | log.Printf("[tree-rebalancer] Promotion geometry skipped: container invalid for %p", node) | |
| 703 | - | return | |
| 704 | - | } | |
| 705 | - | tr.ensureRootAttachment(node) | |
| 706 | - | geom := tr.wm.geometryValidator.GetPaneGeometry(node) | |
| 707 | - | if !geom.IsValid || geom.Width <= 0 || geom.Height <= 0 { | |
| 708 | - | log.Printf("[tree-rebalancer] Promotion geometry pending after commit: valid=%v size=%dx%d", geom.IsValid, geom.Width, geom.Height) | |
| 709 | - | return | |
| 710 | - | } | |
| 711 | - | log.Printf("[tree-rebalancer] Promotion geometry validated immediately: %dx%d", geom.Width, geom.Height) | |
| 712 | - | } | |
| 713 | - | ||
| 714 | - | func (tr *TreeRebalancer) schedulePromotionValidation(node *paneNode) { | |
| 715 | - | if tr == nil || tr.wm == nil || tr.wm.geometryValidator == nil || node == nil { | |
| 716 | - | return | |
| 717 | - | } | |
| 718 | - | ||
| 719 | - | const maxAttempts = 5 | |
| 720 | - | start := time.Now() | |
| 721 | - | attempt := 0 | |
| 722 | - | ||
| 723 | - | var retry func() bool | |
| 724 | - | retry = func() bool { | |
| 725 | - | attempt++ | |
| 726 | - | elapsed := time.Since(start).Round(time.Millisecond) | |
| 727 | - | if node.container == nil || !node.container.IsValid() { | |
| 728 | - | log.Printf("[tree-rebalancer] Deferred promotion geometry aborted after %s (attempt %d): container invalid", elapsed, attempt) | |
| 729 | - | return false | |
| 730 | - | } | |
| 731 | - | tr.ensureRootAttachment(node) | |
| 732 | - | geom := tr.wm.geometryValidator.GetPaneGeometry(node) | |
| 733 | - | if geom.IsValid && geom.Width > 0 && geom.Height > 0 { | |
| 734 | - | log.Printf("[tree-rebalancer] Deferred promotion geometry validated after %s (attempt %d): %dx%d", elapsed, attempt, geom.Width, geom.Height) | |
| 735 | - | return false | |
| 736 | - | } | |
| 737 | - | ||
| 738 | - | if attempt >= maxAttempts { | |
| 739 | - | log.Printf("[tree-rebalancer] Deferred promotion geometry still invalid after %s (attempt %d): valid=%v size=%dx%d", elapsed, attempt, geom.IsValid, geom.Width, geom.Height) | |
| 740 | - | return false | |
| 741 | - | } | |
| 742 | - | ||
| 743 | - | log.Printf("[tree-rebalancer] Promotion geometry still pending after %s (attempt %d); rescheduling", elapsed, attempt) | |
| 744 | - | webkit.IdleAdd(retry) | |
| 745 | - | return false | |
| 746 | - | } | |
| 747 | - | ||
| 748 | - | webkit.IdleAdd(retry) | |
| 749 | - | } | |
| 750 | - | ||
| 751 | - | func (tr *TreeRebalancer) ensureRootAttachment(node *paneNode) { | |
| 752 | - | if tr == nil || tr.wm == nil || node == nil || node.parent != nil { | |
| 753 | - | return | |
| 754 | - | } | |
| 755 | - | if node.container == nil || !node.container.IsValid() { | |
| 756 | - | return | |
| 757 | - | } | |
| 758 | - | ||
| 759 | - | _ = node.container.Execute(func(ptr uintptr) error { | |
| 760 | - | parent := webkit.WidgetGetParent(ptr) | |
| 761 | - | if parent != 0 { | |
| 762 | - | // Widget is properly attached, no action needed | |
| 763 | - | return nil | |
| 764 | - | } | |
| 765 | - | if tr.wm.window == nil { | |
| 766 | - | log.Printf("[tree-rebalancer] Root pane %#x missing parent but window unavailable", ptr) | |
| 767 | - | return nil | |
| 768 | - | } | |
| 769 | - | ||
| 770 | - | // This should not happen if promotion worked correctly - log as error | |
| 771 | - | log.Printf("[tree-rebalancer] ERROR: Root pane %#x lost window attachment (this indicates a promotion bug); attempting recovery", ptr) | |
| 772 | - | ||
| 773 | - | // Recovery attempt | |
| 774 | - | tr.wm.window.SetChild(ptr) | |
| 775 | - | webkit.WidgetQueueAllocate(ptr) | |
| 776 | - | webkit.WidgetShow(ptr) | |
| 777 | - | ||
| 778 | - | // Verify recovery | |
| 779 | - | recoveredParent := webkit.WidgetGetParent(ptr) | |
| 780 | - | if recoveredParent == 0 { | |
| 781 | - | log.Printf("[tree-rebalancer] CRITICAL: Failed to recover window attachment for %#x", ptr) | |
| 782 | - | } else { | |
| 783 | - | log.Printf("[tree-rebalancer] Recovery successful: widget %#x now has parent %#x", ptr, recoveredParent) | |
| 784 | - | } | |
| 785 | - | return nil | |
| 786 | - | }) | |
| 787 | - | } | |
| 788 | - | ||
| 789 | - | // collectAncestorContainers returns the widget pointers for the promoted node and all ancestors up to the root. | |
| 790 | - | func (tr *TreeRebalancer) collectAncestorContainers(node *paneNode) []uintptr { | |
| 791 | - | var containers []uintptr | |
| 792 | - | visited := make(map[uintptr]bool) | |
| 793 | - | ||
| 794 | - | current := node | |
| 795 | - | for current != nil { | |
| 796 | - | if current.container != nil && current.container.IsValid() { | |
| 797 | - | ptr := current.container.Ptr() | |
| 798 | - | if ptr != 0 && !visited[ptr] && webkit.WidgetIsValid(ptr) { | |
| 799 | - | containers = append(containers, ptr) | |
| 800 | - | visited[ptr] = true | |
| 801 | - | } | |
| 802 | - | } | |
| 803 | - | current = current.parent | |
| 804 | - | } | |
| 805 | - | ||
| 806 | - | return containers | |
| 807 | - | } | |
| 808 | - | ||
| 809 | - | // GetRebalancingStats returns statistics about tree rebalancing | |
| 58 | + | // GetRebalancingStats exposes minimal instrumentation for diagnostics. | |
| 810 | 59 | func (tr *TreeRebalancer) GetRebalancingStats() map[string]interface{} { | |
| 811 | - | metrics := tr.CalculateTreeMetrics(tr.wm.root) | |
| 812 | - | ||
| 813 | 60 | return map[string]interface{}{ | |
| 814 | - | "enabled": tr.enabled, | |
| 815 | - | "max_imbalance": tr.maxImbalance, | |
| 816 | - | "current_height": metrics.Height, | |
| 817 | - | "node_count": metrics.NodeCount, | |
| 818 | - | "leaf_count": metrics.LeafCount, | |
| 819 | - | "balance_factor": metrics.BalanceFactor, | |
| 820 | - | "imbalance_score": metrics.ImbalanceScore, | |
| 821 | - | "avg_depth": metrics.AvgDepth, | |
| 822 | - | "needs_rebalance": metrics.ImbalanceScore > tr.maxImbalance, | |
| 61 | + | "enabled": tr != nil && tr.enabled, | |
| 823 | 62 | } | |
| 824 | 63 | } | |
| 825 | - | ||
| 826 | - | // Helper functions | |
M internal/app/browser/workspace_tree_validator.go +28 -539
| @@ -1,564 +1,53 @@ | |||
| 1 | - | // workspace_tree_validator.go - Tree invariant validation for bulletproof binary tree operations | |
| 1 | + | // workspace_tree_validator.go - Simplified validator stub to keep API surface. | |
| 2 | 2 | package browser | |
| 3 | 3 | ||
| 4 | - | import ( | |
| 5 | - | "fmt" | |
| 6 | - | "log" | |
| 7 | - | "sync" | |
| 8 | - | "time" | |
| 9 | - | "unsafe" | |
| 10 | - | ) | |
| 4 | + | import "fmt" | |
| 11 | 5 | ||
| 12 | - | // TreeValidationError represents errors found during tree validation | |
| 13 | - | type TreeValidationError struct { | |
| 14 | - | ErrorType string | |
| 15 | - | NodePointer uintptr | |
| 16 | - | Message string | |
| 17 | - | StackTrace []string | |
| 18 | - | } | |
| 19 | - | ||
| 20 | - | func (e TreeValidationError) Error() string { | |
| 21 | - | return fmt.Sprintf("Tree validation error [%s]: %s (node: %p)", e.ErrorType, e.Message, e.NodePointer) | |
| 22 | - | } | |
| 23 | - | ||
| 24 | - | // TreeValidator provides comprehensive validation of binary tree invariants | |
| 6 | + | // TreeValidator previously performed extensive tree checks. The SafeWidget | |
| 7 | + | // removal rendered the implementation obsolete, so we keep a lightweight shim | |
| 8 | + | // until a new validator is written. | |
| 25 | 9 | type TreeValidator struct { | |
| 26 | - | enabled bool | |
| 27 | - | debugMode bool | |
| 28 | - | maxDepth int | |
| 29 | - | validationHistory []TreeValidationResult | |
| 30 | - | mu sync.RWMutex | |
| 10 | + | enabled bool | |
| 31 | 11 | } | |
| 32 | 12 | ||
| 33 | - | // TreeValidationResult captures the result of a tree validation | |
| 34 | - | type TreeValidationResult struct { | |
| 35 | - | Timestamp time.Time | |
| 36 | - | Success bool | |
| 37 | - | ErrorCount int | |
| 38 | - | Errors []TreeValidationError | |
| 39 | - | NodeCount int | |
| 40 | - | MaxDepth int | |
| 41 | - | BalanceFactor int | |
| 42 | - | Duration time.Duration | |
| 13 | + | // NewTreeValidator returns a stub validator instance. | |
| 14 | + | func NewTreeValidator(enabled bool, _ bool) *TreeValidator { | |
| 15 | + | return &TreeValidator{enabled: enabled} | |
| 43 | 16 | } | |
| 44 | 17 | ||
| 45 | - | // NewTreeValidator creates a new tree validator | |
| 46 | - | func NewTreeValidator(enabled, debugMode bool) *TreeValidator { | |
| 47 | - | return &TreeValidator{ | |
| 48 | - | enabled: enabled, | |
| 49 | - | debugMode: debugMode, | |
| 50 | - | maxDepth: 50, // Prevent infinite recursion | |
| 51 | - | validationHistory: make([]TreeValidationResult, 0, 100), | |
| 52 | - | } | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | // Enable turns on tree validation | |
| 56 | - | func (tv *TreeValidator) Enable() { | |
| 57 | - | tv.mu.Lock() | |
| 58 | - | defer tv.mu.Unlock() | |
| 59 | - | tv.enabled = true | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | // Disable turns off tree validation | |
| 63 | - | func (tv *TreeValidator) Disable() { | |
| 64 | - | tv.mu.Lock() | |
| 65 | - | defer tv.mu.Unlock() | |
| 66 | - | tv.enabled = false | |
| 67 | - | } | |
| 68 | - | ||
| 69 | - | // SetDebugMode enables/disables debug mode | |
| 70 | - | func (tv *TreeValidator) SetDebugMode(debug bool) { | |
| 71 | - | tv.mu.Lock() | |
| 72 | - | defer tv.mu.Unlock() | |
| 73 | - | tv.debugMode = debug | |
| 74 | - | } | |
| 75 | - | ||
| 76 | - | // ValidateTree performs comprehensive validation of the binary tree | |
| 77 | - | func (tv *TreeValidator) ValidateTree(root *paneNode, operation string) error { | |
| 78 | - | tv.mu.Lock() | |
| 79 | - | defer tv.mu.Unlock() | |
| 80 | - | ||
| 81 | - | if !tv.enabled { | |
| 18 | + | // ValidateTree is a no-op that reports success unless validation is explicitly enabled. | |
| 19 | + | func (tv *TreeValidator) ValidateTree(_ *paneNode, _ string) error { | |
| 20 | + | if tv == nil || !tv.enabled { | |
| 82 | 21 | return nil | |
| 83 | 22 | } | |
| 84 | - | ||
| 85 | - | startTime := time.Now() | |
| 86 | - | result := TreeValidationResult{ | |
| 87 | - | Timestamp: startTime, | |
| 88 | - | Errors: make([]TreeValidationError, 0), | |
| 89 | - | } | |
| 90 | - | ||
| 91 | - | if tv.debugMode { | |
| 92 | - | log.Printf("[tree-validator] Starting validation after operation: %s", operation) | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | // Perform all validation checks | |
| 96 | - | visited := make(map[*paneNode]bool) | |
| 97 | - | nodeCount := 0 | |
| 98 | - | maxDepth := 0 | |
| 99 | - | ||
| 100 | - | // Check 1: Root validation | |
| 101 | - | if err := tv.validateRoot(root, &result); err.ErrorType != "" { | |
| 102 | - | result.Errors = append(result.Errors, err) | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | // Check 2: Tree structure validation | |
| 106 | - | if root != nil { | |
| 107 | - | if err := tv.validateTreeStructure(root, nil, visited, 0, &nodeCount, &maxDepth, &result); err != nil { | |
| 108 | - | result.Errors = append(result.Errors, err...) | |
| 109 | - | } | |
| 110 | - | } | |
| 111 | - | ||
| 112 | - | // Check 3: Cycle detection | |
| 113 | - | if err := tv.detectCycles(root, &result); err != nil { | |
| 114 | - | result.Errors = append(result.Errors, err...) | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | // Check 4: Parent-child consistency | |
| 118 | - | if err := tv.validateParentChildConsistency(root, &result); err != nil { | |
| 119 | - | result.Errors = append(result.Errors, err...) | |
| 120 | - | } | |
| 121 | - | ||
| 122 | - | // Check 5: Widget consistency | |
| 123 | - | if err := tv.validateWidgetConsistency(root, &result); err != nil { | |
| 124 | - | result.Errors = append(result.Errors, err...) | |
| 125 | - | } | |
| 126 | - | ||
| 127 | - | // Finalize result | |
| 128 | - | result.Duration = time.Since(startTime) | |
| 129 | - | result.NodeCount = nodeCount | |
| 130 | - | result.MaxDepth = maxDepth | |
| 131 | - | result.ErrorCount = len(result.Errors) | |
| 132 | - | result.Success = result.ErrorCount == 0 | |
| 133 | - | ||
| 134 | - | // Calculate balance factor | |
| 135 | - | if root != nil { | |
| 136 | - | leftDepth := tv.calculateDepth(root.left) | |
| 137 | - | rightDepth := tv.calculateDepth(root.right) | |
| 138 | - | result.BalanceFactor = abs(leftDepth - rightDepth) | |
| 139 | - | } | |
| 140 | - | ||
| 141 | - | // Store result in history | |
| 142 | - | tv.addToHistory(result) | |
| 143 | - | ||
| 144 | - | if tv.debugMode { | |
| 145 | - | log.Printf("[tree-validator] Validation completed: success=%v, errors=%d, nodes=%d, depth=%d, balance=%d, duration=%v", | |
| 146 | - | result.Success, result.ErrorCount, result.NodeCount, result.MaxDepth, result.BalanceFactor, result.Duration) | |
| 147 | - | } | |
| 148 | - | ||
| 149 | - | if !result.Success { | |
| 150 | - | // Return first error as representative | |
| 151 | - | return result.Errors[0] | |
| 152 | - | } | |
| 153 | - | ||
| 154 | - | return nil | |
| 23 | + | return fmt.Errorf("tree validation not implemented") | |
| 155 | 24 | } | |
| 156 | 25 | ||
| 157 | - | // validateRoot checks root node constraints | |
| 158 | - | func (tv *TreeValidator) validateRoot(root *paneNode, result *TreeValidationResult) TreeValidationError { | |
| 159 | - | if root == nil { | |
| 160 | - | return TreeValidationError{} // Empty tree is valid | |
| 161 | - | } | |
| 162 | - | ||
| 163 | - | if root.parent != nil { | |
| 164 | - | return TreeValidationError{ | |
| 165 | - | ErrorType: "ROOT_HAS_PARENT", | |
| 166 | - | NodePointer: uintptr(getNodePointer(root)), | |
| 167 | - | Message: fmt.Sprintf("root node has parent: %p", root.parent), | |
| 168 | - | } | |
| 169 | - | } | |
| 170 | - | ||
| 171 | - | return TreeValidationError{} // No error | |
| 172 | - | } | |
| 173 | - | ||
| 174 | - | // validateTreeStructure performs recursive validation of tree structure | |
| 175 | - | func (tv *TreeValidator) validateTreeStructure(node *paneNode, expectedParent *paneNode, visited map[*paneNode]bool, depth int, nodeCount *int, maxDepth *int, result *TreeValidationResult) []TreeValidationError { | |
| 176 | - | if node == nil { | |
| 177 | - | return nil | |
| 178 | - | } | |
| 179 | - | ||
| 180 | - | var errors []TreeValidationError | |
| 181 | - | ||
| 182 | - | // Check max depth to prevent infinite recursion | |
| 183 | - | if depth > tv.maxDepth { | |
| 184 | - | errors = append(errors, TreeValidationError{ | |
| 185 | - | ErrorType: "MAX_DEPTH_EXCEEDED", | |
| 186 | - | NodePointer: uintptr(getNodePointer(node)), | |
| 187 | - | Message: fmt.Sprintf("tree depth exceeds maximum of %d", tv.maxDepth), | |
| 188 | - | }) | |
| 189 | - | return errors | |
| 190 | - | } | |
| 191 | - | ||
| 192 | - | // Update counters | |
| 193 | - | *nodeCount++ | |
| 194 | - | if depth > *maxDepth { | |
| 195 | - | *maxDepth = depth | |
| 196 | - | } | |
| 197 | - | ||
| 198 | - | // Check if already visited (cycle detection) | |
| 199 | - | if visited[node] { | |
| 200 | - | errors = append(errors, TreeValidationError{ | |
| 201 | - | ErrorType: "CYCLE_DETECTED", | |
| 202 | - | NodePointer: uintptr(getNodePointer(node)), | |
| 203 | - | Message: "node already visited in traversal", | |
| 204 | - | }) | |
| 205 | - | return errors | |
| 206 | - | } | |
| 207 | - | visited[node] = true | |
| 208 | - | ||
| 209 | - | // Check parent pointer consistency | |
| 210 | - | if node.parent != expectedParent { | |
| 211 | - | errors = append(errors, TreeValidationError{ | |
| 212 | - | ErrorType: "PARENT_MISMATCH", | |
| 213 | - | NodePointer: uintptr(getNodePointer(node)), | |
| 214 | - | Message: fmt.Sprintf("parent mismatch: expected %p, got %p", expectedParent, node.parent), | |
| 215 | - | }) | |
| 216 | - | } | |
| 217 | - | ||
| 218 | - | // Validate node type constraints | |
| 219 | - | if node.isLeaf { | |
| 220 | - | // Leaf nodes must have no children | |
| 221 | - | if node.left != nil { | |
| 222 | - | errors = append(errors, TreeValidationError{ | |
| 223 | - | ErrorType: "LEAF_HAS_LEFT_CHILD", | |
| 224 | - | NodePointer: uintptr(getNodePointer(node)), | |
| 225 | - | Message: fmt.Sprintf("leaf node has left child: %p", node.left), | |
| 226 | - | }) | |
| 227 | - | } | |
| 228 | - | if node.right != nil { | |
| 229 | - | errors = append(errors, TreeValidationError{ | |
| 230 | - | ErrorType: "LEAF_HAS_RIGHT_CHILD", | |
| 231 | - | NodePointer: uintptr(getNodePointer(node)), | |
| 232 | - | Message: fmt.Sprintf("leaf node has right child: %p", node.right), | |
| 233 | - | }) | |
| 234 | - | } | |
| 235 | - | ||
| 236 | - | // Leaf nodes must have a pane (unless it's a stack container) | |
| 237 | - | if !node.isStacked && node.pane == nil { | |
| 238 | - | errors = append(errors, TreeValidationError{ | |
| 239 | - | ErrorType: "LEAF_MISSING_PANE", | |
| 240 | - | NodePointer: uintptr(getNodePointer(node)), | |
| 241 | - | Message: "leaf node missing pane", | |
| 242 | - | }) | |
| 243 | - | } | |
| 244 | - | } else if node.isStacked { | |
| 245 | - | // Stacked container nodes live in the binary tree but do not use left/right children. | |
| 246 | - | if node.left != nil { | |
| 247 | - | errors = append(errors, TreeValidationError{ | |
| 248 | - | ErrorType: "STACK_CONTAINER_HAS_LEFT_CHILD", | |
| 249 | - | NodePointer: uintptr(getNodePointer(node)), | |
| 250 | - | Message: "stack container should not have left child", | |
| 251 | - | }) | |
| 252 | - | } | |
| 253 | - | if node.right != nil { | |
| 254 | - | errors = append(errors, TreeValidationError{ | |
| 255 | - | ErrorType: "STACK_CONTAINER_HAS_RIGHT_CHILD", | |
| 256 | - | NodePointer: uintptr(getNodePointer(node)), | |
| 257 | - | Message: "stack container should not have right child", | |
| 258 | - | }) | |
| 259 | - | } | |
| 260 | - | } else { | |
| 261 | - | // Branch nodes must have exactly 2 children | |
| 262 | - | if node.left == nil { | |
| 263 | - | errors = append(errors, TreeValidationError{ | |
| 264 | - | ErrorType: "BRANCH_MISSING_LEFT_CHILD", | |
| 265 | - | NodePointer: uintptr(getNodePointer(node)), | |
| 266 | - | Message: "branch node missing left child", | |
| 267 | - | }) | |
| 268 | - | } | |
| 269 | - | if node.right == nil { | |
| 270 | - | errors = append(errors, TreeValidationError{ | |
| 271 | - | ErrorType: "BRANCH_MISSING_RIGHT_CHILD", | |
| 272 | - | NodePointer: uintptr(getNodePointer(node)), | |
| 273 | - | Message: "branch node missing right child", | |
| 274 | - | }) | |
| 275 | - | } | |
| 276 | - | ||
| 277 | - | // Branch nodes should not have panes (except for stack containers) | |
| 278 | - | if !node.isStacked && node.pane != nil { | |
| 279 | - | errors = append(errors, TreeValidationError{ | |
| 280 | - | ErrorType: "BRANCH_HAS_PANE", | |
| 281 | - | NodePointer: uintptr(getNodePointer(node)), | |
| 282 | - | Message: "branch node should not have pane", | |
| 283 | - | }) | |
| 284 | - | } | |
| 285 | - | } | |
| 286 | - | ||
| 287 | - | // Validate stack-specific constraints | |
| 288 | - | if node.isStacked { | |
| 289 | - | if err := tv.validateStackConstraints(node); err.ErrorType != "" { | |
| 290 | - | errors = append(errors, err) | |
| 291 | - | } | |
| 292 | - | } | |
| 293 | - | ||
| 294 | - | // Recursively validate children | |
| 295 | - | if node.left != nil { | |
| 296 | - | leftErrors := tv.validateTreeStructure(node.left, node, visited, depth+1, nodeCount, maxDepth, result) | |
| 297 | - | errors = append(errors, leftErrors...) | |
| 298 | - | } | |
| 299 | - | if node.right != nil { | |
| 300 | - | rightErrors := tv.validateTreeStructure(node.right, node, visited, depth+1, nodeCount, maxDepth, result) | |
| 301 | - | errors = append(errors, rightErrors...) | |
| 302 | - | } | |
| 26 | + | // SetDebugMode preserved for compatibility. | |
| 27 | + | func (tv *TreeValidator) SetDebugMode(_ bool) {} | |
| 303 | 28 | ||
| 304 | - | return errors | |
| 305 | - | } | |
| 306 | - | ||
| 307 | - | // validateStackConstraints validates stack-specific invariants | |
| 308 | - | func (tv *TreeValidator) validateStackConstraints(node *paneNode) TreeValidationError { | |
| 309 | - | if !node.isStacked { | |
| 310 | - | return TreeValidationError{} // Not a stack | |
| 311 | - | } | |
| 312 | - | ||
| 313 | - | // Stack nodes should have stackedPanes | |
| 314 | - | if len(node.stackedPanes) == 0 { | |
| 315 | - | return TreeValidationError{ | |
| 316 | - | ErrorType: "EMPTY_STACK", | |
| 317 | - | NodePointer: uintptr(getNodePointer(node)), | |
| 318 | - | Message: "stacked node has no panes", | |
| 319 | - | } | |
| 320 | - | } | |
| 321 | - | ||
| 322 | - | // Active index should be valid | |
| 323 | - | if node.activeStackIndex < 0 || node.activeStackIndex >= len(node.stackedPanes) { | |
| 324 | - | return TreeValidationError{ | |
| 325 | - | ErrorType: "INVALID_STACK_INDEX", | |
| 326 | - | NodePointer: uintptr(getNodePointer(node)), | |
| 327 | - | Message: fmt.Sprintf("active stack index %d out of range [0, %d)", node.activeStackIndex, len(node.stackedPanes)), | |
| 328 | - | } | |
| 329 | - | } | |
| 330 | - | ||
| 331 | - | // All panes in stack should have this node as parent | |
| 332 | - | for i, stackedPane := range node.stackedPanes { | |
| 333 | - | if stackedPane == nil { | |
| 334 | - | return TreeValidationError{ | |
| 335 | - | ErrorType: "NULL_STACK_PANE", | |
| 336 | - | NodePointer: uintptr(getNodePointer(node)), | |
| 337 | - | Message: fmt.Sprintf("stacked pane at index %d is nil", i), | |
| 338 | - | } | |
| 339 | - | } | |
| 340 | - | if stackedPane.parent != node { | |
| 341 | - | return TreeValidationError{ | |
| 342 | - | ErrorType: "STACK_PANE_PARENT_MISMATCH", | |
| 343 | - | NodePointer: uintptr(getNodePointer(stackedPane)), | |
| 344 | - | Message: fmt.Sprintf("stacked pane parent mismatch: expected %p, got %p", node, stackedPane.parent), | |
| 345 | - | } | |
| 346 | - | } | |
| 347 | - | } | |
| 348 | - | ||
| 349 | - | return TreeValidationError{} // No error | |
| 350 | - | } | |
| 351 | - | ||
| 352 | - | // detectCycles performs cycle detection using Floyd's algorithm | |
| 353 | - | func (tv *TreeValidator) detectCycles(root *paneNode, result *TreeValidationResult) []TreeValidationError { | |
| 354 | - | if root == nil { | |
| 355 | - | return nil | |
| 356 | - | } | |
| 357 | - | ||
| 358 | - | var errors []TreeValidationError | |
| 359 | - | visited := make(map[*paneNode]bool) | |
| 360 | - | ||
| 361 | - | var detectCycleHelper func(*paneNode, map[*paneNode]bool) bool | |
| 362 | - | detectCycleHelper = func(node *paneNode, path map[*paneNode]bool) bool { | |
| 363 | - | if node == nil { | |
| 364 | - | return false | |
| 365 | - | } | |
| 366 | - | ||
| 367 | - | if path[node] { | |
| 368 | - | errors = append(errors, TreeValidationError{ | |
| 369 | - | ErrorType: "CYCLE_IN_PATH", | |
| 370 | - | NodePointer: uintptr(getNodePointer(node)), | |
| 371 | - | Message: "cycle detected in tree path", | |
| 372 | - | }) | |
| 373 | - | return true | |
| 374 | - | } | |
| 375 | - | ||
| 376 | - | if visited[node] { | |
| 377 | - | return false // Already checked this subtree | |
| 378 | - | } | |
| 379 | - | ||
| 380 | - | path[node] = true | |
| 381 | - | visited[node] = true | |
| 382 | - | ||
| 383 | - | hasCycle := detectCycleHelper(node.left, path) || detectCycleHelper(node.right, path) | |
| 384 | - | ||
| 385 | - | delete(path, node) | |
| 386 | - | return hasCycle | |
| 387 | - | } | |
| 388 | - | ||
| 389 | - | detectCycleHelper(root, make(map[*paneNode]bool)) | |
| 390 | - | return errors | |
| 391 | - | } | |
| 392 | - | ||
| 393 | - | // validateParentChildConsistency checks bidirectional parent-child relationships | |
| 394 | - | func (tv *TreeValidator) validateParentChildConsistency(root *paneNode, result *TreeValidationResult) []TreeValidationError { | |
| 395 | - | if root == nil { | |
| 396 | - | return nil | |
| 397 | - | } | |
| 398 | - | ||
| 399 | - | var errors []TreeValidationError | |
| 400 | - | ||
| 401 | - | var validate func(*paneNode) | |
| 402 | - | validate = func(node *paneNode) { | |
| 403 | - | if node == nil { | |
| 404 | - | return | |
| 405 | - | } | |
| 406 | - | ||
| 407 | - | // Check left child consistency | |
| 408 | - | if node.left != nil { | |
| 409 | - | if node.left.parent != node { | |
| 410 | - | errors = append(errors, TreeValidationError{ | |
| 411 | - | ErrorType: "LEFT_CHILD_PARENT_MISMATCH", | |
| 412 | - | NodePointer: uintptr(getNodePointer(node.left)), | |
| 413 | - | Message: fmt.Sprintf("left child parent points to %p instead of %p", node.left.parent, node), | |
| 414 | - | }) | |
| 415 | - | } | |
| 416 | - | } | |
| 417 | - | ||
| 418 | - | // Check right child consistency | |
| 419 | - | if node.right != nil { | |
| 420 | - | if node.right.parent != node { | |
| 421 | - | errors = append(errors, TreeValidationError{ | |
| 422 | - | ErrorType: "RIGHT_CHILD_PARENT_MISMATCH", | |
| 423 | - | NodePointer: uintptr(getNodePointer(node.right)), | |
| 424 | - | Message: fmt.Sprintf("right child parent points to %p instead of %p", node.right.parent, node), | |
| 425 | - | }) | |
| 426 | - | } | |
| 427 | - | } | |
| 428 | - | ||
| 429 | - | validate(node.left) | |
| 430 | - | validate(node.right) | |
| 431 | - | } | |
| 432 | - | ||
| 433 | - | validate(root) | |
| 434 | - | return errors | |
| 435 | - | } | |
| 436 | - | ||
| 437 | - | // validateWidgetConsistency checks widget-related invariants | |
| 438 | - | func (tv *TreeValidator) validateWidgetConsistency(root *paneNode, result *TreeValidationResult) []TreeValidationError { | |
| 439 | - | if root == nil { | |
| 440 | - | return nil | |
| 441 | - | } | |
| 442 | - | ||
| 443 | - | var errors []TreeValidationError | |
| 444 | - | ||
| 445 | - | var validate func(*paneNode) | |
| 446 | - | validate = func(node *paneNode) { | |
| 447 | - | if node == nil { | |
| 448 | - | return | |
| 449 | - | } | |
| 450 | - | ||
| 451 | - | // Every node should have a container | |
| 452 | - | if node.container == nil { | |
| 453 | - | errors = append(errors, TreeValidationError{ | |
| 454 | - | ErrorType: "MISSING_CONTAINER", | |
| 455 | - | NodePointer: uintptr(getNodePointer(node)), | |
| 456 | - | Message: "node missing container widget", | |
| 457 | - | }) | |
| 458 | - | } else if !node.container.IsValid() { | |
| 459 | - | errors = append(errors, TreeValidationError{ | |
| 460 | - | ErrorType: "INVALID_CONTAINER", | |
| 461 | - | NodePointer: uintptr(getNodePointer(node)), | |
| 462 | - | Message: fmt.Sprintf("node has invalid container: %s", node.container.String()), | |
| 463 | - | }) | |
| 464 | - | } | |
| 465 | - | ||
| 466 | - | // Stack containers should have stackWrapper | |
| 467 | - | if node.isStacked && node.stackWrapper == nil { | |
| 468 | - | errors = append(errors, TreeValidationError{ | |
| 469 | - | ErrorType: "STACK_MISSING_WRAPPER", | |
| 470 | - | NodePointer: uintptr(getNodePointer(node)), | |
| 471 | - | Message: "stacked node missing stackWrapper", | |
| 472 | - | }) | |
| 473 | - | } | |
| 474 | - | ||
| 475 | - | validate(node.left) | |
| 476 | - | validate(node.right) | |
| 477 | - | } | |
| 478 | - | ||
| 479 | - | validate(root) | |
| 480 | - | return errors | |
| 481 | - | } | |
| 482 | - | ||
| 483 | - | // calculateDepth calculates the depth of a subtree | |
| 484 | - | func (tv *TreeValidator) calculateDepth(node *paneNode) int { | |
| 485 | - | if node == nil { | |
| 486 | - | return 0 | |
| 29 | + | // Enable turns validation on (no functional impact in the stub). | |
| 30 | + | func (tv *TreeValidator) Enable() { | |
| 31 | + | if tv != nil { | |
| 32 | + | tv.enabled = true | |
| 487 | 33 | } | |
| 488 | - | leftDepth := tv.calculateDepth(node.left) | |
| 489 | - | rightDepth := tv.calculateDepth(node.right) | |
| 490 | - | return 1 + max(leftDepth, rightDepth) | |
| 491 | 34 | } | |
| 492 | 35 | ||
| 493 | - | // addToHistory adds a validation result to the history | |
| 494 | - | func (tv *TreeValidator) addToHistory(result TreeValidationResult) { | |
| 495 | - | // Keep only the last 100 results | |
| 496 | - | if len(tv.validationHistory) >= 100 { | |
| 497 | - | tv.validationHistory = tv.validationHistory[1:] | |
| 36 | + | // Disable turns validation off. | |
| 37 | + | func (tv *TreeValidator) Disable() { | |
| 38 | + | if tv != nil { | |
| 39 | + | tv.enabled = false | |
| 498 | 40 | } | |
| 499 | - | tv.validationHistory = append(tv.validationHistory, result) | |
| 500 | 41 | } | |
| 501 | 42 | ||
| 502 | - | // GetValidationHistory returns the validation history | |
| 503 | - | func (tv *TreeValidator) GetValidationHistory() []TreeValidationResult { | |
| 504 | - | tv.mu.RLock() | |
| 505 | - | defer tv.mu.RUnlock() | |
| 506 | - | ||
| 507 | - | // Return a copy to prevent race conditions | |
| 508 | - | history := make([]TreeValidationResult, len(tv.validationHistory)) | |
| 509 | - | copy(history, tv.validationHistory) | |
| 510 | - | return history | |
| 511 | - | } | |
| 512 | - | ||
| 513 | - | // GetValidationStats returns summary statistics | |
| 43 | + | // GetValidationStats mimics the old diagnostic output. | |
| 514 | 44 | 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 | 45 | return map[string]interface{}{ | |
| 540 | - | "total_validations": totalValidations, | |
| 541 | - | "successful_validations": successfulValidations, | |
| 542 | - | "success_rate": float64(successfulValidations) / float64(totalValidations), | |
| 543 | - | "total_errors": totalErrors, | |
| 544 | - | "avg_duration_ms": avgDuration.Milliseconds(), | |
| 545 | - | "enabled": tv.enabled, | |
| 546 | - | "debug_mode": tv.debugMode, | |
| 547 | - | } | |
| 548 | - | } | |
| 549 | - | ||
| 550 | - | // Helper functions | |
| 551 | - | ||
| 552 | - | func getNodePointer(node *paneNode) unsafe.Pointer { | |
| 553 | - | if node == nil { | |
| 554 | - | return nil | |
| 46 | + | "enabled": tv != nil && tv.enabled, | |
| 555 | 47 | } | |
| 556 | - | return unsafe.Pointer(node) | |
| 557 | 48 | } | |
| 558 | 49 | ||
| 559 | - | func abs(x int) int { | |
| 560 | - | if x < 0 { | |
| 561 | - | return -x | |
| 562 | - | } | |
| 563 | - | return x | |
| 50 | + | // Enabled reports current state (used by tests/instrumentation). | |
| 51 | + | func (tv *TreeValidator) Enabled() bool { | |
| 52 | + | return tv != nil && tv.enabled | |
| 564 | 53 | } | |
M internal/app/browser/workspace_types.go +44 -8
| @@ -2,20 +2,55 @@ | |||
| 2 | 2 | package browser | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | + | "os" | |
| 6 | + | ||
| 5 | 7 | "github.com/bnema/dumber/pkg/webkit" | |
| 6 | 8 | ) | |
| 7 | 9 | ||
| 10 | + | // DebugLevel controls validation and safety checks | |
| 11 | + | type DebugLevel int | |
| 12 | + | ||
| 13 | + | const ( | |
| 14 | + | // DebugOff disables all validation (production mode) | |
| 15 | + | DebugOff DebugLevel = iota | |
| 16 | + | // DebugBasic enables basic validation only (development default) | |
| 17 | + | DebugBasic | |
| 18 | + | // DebugFull enables all validation and detailed logging (testing) | |
| 19 | + | DebugFull | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // getDebugLevel reads debug level from environment variable | |
| 23 | + | func getDebugLevel() DebugLevel { | |
| 24 | + | switch os.Getenv("DUMBER_DEBUG_WORKSPACE") { | |
| 25 | + | case "off", "0": | |
| 26 | + | return DebugOff | |
| 27 | + | case "basic", "1": | |
| 28 | + | return DebugBasic | |
| 29 | + | case "full", "2": | |
| 30 | + | return DebugFull | |
| 31 | + | default: | |
| 32 | + | // Default to basic for development | |
| 33 | + | return DebugBasic | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 8 | 37 | // paneNode represents a node in the workspace pane tree structure. | |
| 9 | 38 | // It can be either a leaf node (containing a browser pane) or a branch node | |
| 10 | 39 | // (containing child nodes for split panes or stacked panes). | |
| 40 | + | // | |
| 41 | + | // Node Types: | |
| 42 | + | // 1. Regular Leaf (isLeaf=true, windowType=Tab): Normal browsing pane | |
| 43 | + | // 2. Popup Leaf (isLeaf=true, windowType=Popup): OAuth/feature-restricted popup | |
| 44 | + | // 3. Stacked Container (isStacked=true, no left/right): Terminal branch with stackedPanes[] | |
| 45 | + | // 4. Split Branch (isLeaf=false, has left+right): Pure layout node with GtkPaned | |
| 11 | 46 | type paneNode struct { | |
| 12 | 47 | pane *BrowserPane | |
| 13 | 48 | parent *paneNode | |
| 14 | 49 | left *paneNode | |
| 15 | 50 | right *paneNode | |
| 16 | 51 | ||
| 17 | - | // Widget management with proper lifecycle tracking | |
| 18 | - | container *SafeWidget // Main container (GtkPaned for branch nodes, wrapper GtkBox for stacked nodes, WebView container for leaves) | |
| 52 | + | // Widget management - direct GTK pointers (all ops on main thread) | |
| 53 | + | container uintptr // Main container: GtkPaned (branch), GtkBox (stack), or WebView container (leaf) | |
| 19 | 54 | orientation webkit.Orientation | |
| 20 | 55 | isLeaf bool | |
| 21 | 56 | isPopup bool // Deprecated: use windowType instead | |
| @@ -31,16 +66,17 @@ type paneNode struct { | |||
| 31 | 66 | pendingHoverReattach bool | |
| 32 | 67 | pendingFocusReattach bool | |
| 33 | 68 | ||
| 34 | - | // Stacked panes support with proper widget management | |
| 69 | + | // Stacked panes support - terminal branch nodes | |
| 35 | 70 | isStacked bool // Whether this node contains stacked panes | |
| 36 | 71 | stackedPanes []*paneNode // List of stacked panes (if isStacked) | |
| 37 | 72 | activeStackIndex int // Index of currently visible pane in stack | |
| 38 | - | titleBar *SafeWidget // GtkBox for title bar (when collapsed) | |
| 39 | - | stackWrapper *SafeWidget // Internal GtkBox containing the actual stacked widgets (titles + webviews) | |
| 73 | + | titleBar uintptr // GtkBox for title bar (when collapsed) | |
| 74 | + | stackWrapper uintptr // Internal GtkBox containing the actual stacked widgets (titles + webviews) | |
| 40 | 75 | ||
| 41 | - | // Enhanced pane close refactoring fields | |
| 42 | - | widgetValid bool // Guard flagged before GTK destruction | |
| 43 | - | cleanupGeneration uint // Helps assert that asynchronous callbacks skip stale nodes | |
| 76 | + | // Cleanup tracking | |
| 77 | + | widgetValid bool // Guard flagged before GTK destruction | |
| 78 | + | cleanupGeneration uint // Helps assert that asynchronous callbacks skip stale nodes | |
| 79 | + | pendingIdleHandles map[uintptr]struct{} // Idle callbacks touching this node | |
| 44 | 80 | } | |
| 45 | 81 | ||
| 46 | 82 | // Workspace CSS class constants | |
M internal/app/browser/workspace_utils.go +196 -146
| @@ -22,15 +22,12 @@ func (wm *WorkspaceManager) UpdateTitleBar(webView *webkit.WebView, title string | |||
| 22 | 22 | } | |
| 23 | 23 | ||
| 24 | 24 | // ensureGUIInPane lazily loads GUI components into a pane when it gains focus | |
| 25 | - | // ensureGUIInPane is now a no-op since GUI is injected globally by WebKit | |
| 26 | - | // This prevents duplicate GUI injection that was causing duplicate log messages | |
| 27 | 25 | func (wm *WorkspaceManager) ensureGUIInPane(pane *BrowserPane) { | |
| 28 | 26 | if pane == nil { | |
| 29 | 27 | return | |
| 30 | 28 | } | |
| 31 | 29 | ||
| 32 | 30 | // GUI is already injected globally via WebKit's enableUserContentManager | |
| 33 | - | // Just mark the pane as having GUI to prevent unnecessary calls | |
| 34 | 31 | if !pane.HasGUI() { | |
| 35 | 32 | pane.SetHasGUI(true) | |
| 36 | 33 | pane.SetGUIComponent("manager", true) | |
| @@ -54,75 +51,38 @@ func mapDirection(direction string) (webkit.Orientation, bool) { | |||
| 54 | 51 | ||
| 55 | 52 | // Widget management utilities | |
| 56 | 53 | ||
| 57 | - | // registerWidget registers a widget with the registry and returns a SafeWidget | |
| 58 | - | func (wm *WorkspaceManager) registerWidget(ptr uintptr, typeInfo string) *SafeWidget { | |
| 59 | - | if ptr == 0 { | |
| 60 | - | return nil | |
| 61 | - | } | |
| 62 | - | return wm.widgetRegistry.Register(ptr, typeInfo) | |
| 63 | - | } | |
| 64 | - | ||
| 65 | - | // initializePaneWidgets sets up SafeWidget wrappers for a paneNode | |
| 54 | + | // initializePaneWidgets sets up widgets for a paneNode | |
| 66 | 55 | func (wm *WorkspaceManager) initializePaneWidgets(node *paneNode, containerPtr uintptr) { | |
| 67 | - | // Register the main container | |
| 68 | - | node.container = wm.registerWidget(containerPtr, "pane-container") | |
| 56 | + | node.container = containerPtr | |
| 69 | 57 | ||
| 70 | 58 | // Add base CSS class to the container | |
| 71 | - | if node.container != nil { | |
| 72 | - | node.container.Execute(func(ptr uintptr) error { | |
| 73 | - | webkit.WidgetAddCSSClass(ptr, basePaneClass) | |
| 74 | - | return nil | |
| 75 | - | }) | |
| 59 | + | if node.container != 0 && webkit.WidgetIsValid(node.container) { | |
| 60 | + | webkit.WidgetAddCSSClass(node.container, basePaneClass) | |
| 76 | 61 | } | |
| 77 | 62 | ||
| 78 | - | // Initialize other widget fields as nil (will be set when needed) | |
| 79 | - | node.titleBar = nil | |
| 80 | - | node.stackWrapper = nil | |
| 63 | + | // Initialize other widget fields as zero (will be set when needed) | |
| 64 | + | node.titleBar = 0 | |
| 65 | + | node.stackWrapper = 0 | |
| 81 | 66 | ||
| 82 | - | // Initialize enhanced close refactoring fields | |
| 67 | + | // Initialize cleanup tracking fields | |
| 83 | 68 | node.widgetValid = true | |
| 84 | 69 | node.cleanupGeneration = 0 | |
| 85 | 70 | } | |
| 86 | 71 | ||
| 87 | - | // Helper functions for safe widget operations | |
| 88 | - | ||
| 89 | - | // getContainerPtr returns the container widget pointer for a pane node | |
| 90 | - | func (node *paneNode) getContainerPtr() uintptr { | |
| 91 | - | if node.container != nil { | |
| 92 | - | return node.container.Ptr() | |
| 93 | - | } | |
| 94 | - | return 0 | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | // getTitleBarPtr returns the title bar widget pointer for a pane node | |
| 98 | - | func (node *paneNode) getTitleBarPtr() uintptr { | |
| 99 | - | if node.titleBar != nil { | |
| 100 | - | return node.titleBar.Ptr() | |
| 101 | - | } | |
| 102 | - | return 0 | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | // getStackWrapperPtr returns the stack wrapper widget pointer for a pane node | |
| 106 | - | func (node *paneNode) getStackWrapperPtr() uintptr { | |
| 107 | - | if node.stackWrapper != nil { | |
| 108 | - | return node.stackWrapper.Ptr() | |
| 109 | - | } | |
| 110 | - | return 0 | |
| 111 | - | } | |
| 112 | - | ||
| 113 | - | // setContainer sets the SafeWidget container | |
| 72 | + | // setContainer sets the container widget pointer | |
| 114 | 73 | func (wm *WorkspaceManager) setContainer(node *paneNode, ptr uintptr, typeInfo string) { | |
| 115 | - | node.container = wm.registerWidget(ptr, typeInfo) | |
| 74 | + | _ = typeInfo // Type info kept for API compatibility but not used | |
| 75 | + | node.container = ptr | |
| 116 | 76 | } | |
| 117 | 77 | ||
| 118 | - | // setTitleBar sets the SafeWidget titleBar | |
| 78 | + | // setTitleBar sets the titleBar widget pointer | |
| 119 | 79 | func (wm *WorkspaceManager) setTitleBar(node *paneNode, ptr uintptr) { | |
| 120 | - | node.titleBar = wm.registerWidget(ptr, "title-bar") | |
| 80 | + | node.titleBar = ptr | |
| 121 | 81 | } | |
| 122 | 82 | ||
| 123 | - | // setStackWrapper sets the SafeWidget stackWrapper | |
| 83 | + | // setStackWrapper sets the stackWrapper widget pointer | |
| 124 | 84 | func (wm *WorkspaceManager) setStackWrapper(node *paneNode, ptr uintptr) { | |
| 125 | - | node.stackWrapper = wm.registerWidget(ptr, "stack-wrapper") | |
| 85 | + | node.stackWrapper = ptr | |
| 126 | 86 | } | |
| 127 | 87 | ||
| 128 | 88 | // Centralized Active Pane Border Management System | |
| @@ -130,10 +90,10 @@ func (wm *WorkspaceManager) setStackWrapper(node *paneNode, ptr uintptr) { | |||
| 130 | 90 | ||
| 131 | 91 | // PaneBorderContext holds the context for applying borders to any pane type | |
| 132 | 92 | type PaneBorderContext struct { | |
| 133 | - | webViewWidget uintptr // The WebView's native widget (for margin) | |
| 134 | - | borderContainer *SafeWidget // The container that gets background color | |
| 135 | - | cssClasses []string // Additional CSS classes to apply | |
| 136 | - | paneType string // Description for debugging | |
| 93 | + | webViewWidget uintptr // The WebView's native widget (for margin) | |
| 94 | + | borderContainer uintptr // The container that gets background color | |
| 95 | + | cssClasses []string // Additional CSS classes to apply | |
| 96 | + | paneType string // Description for debugging | |
| 137 | 97 | } | |
| 138 | 98 | ||
| 139 | 99 | // determineBorderContext analyzes a pane node and determines the correct border context | |
| @@ -149,137 +109,227 @@ func (wm *WorkspaceManager) determineBorderContext(node *paneNode) *PaneBorderCo | |||
| 149 | 109 | ctx.webViewWidget = node.pane.webView.RootWidget() | |
| 150 | 110 | } | |
| 151 | 111 | ||
| 152 | - | // Step 2: Determine border container and CSS classes based on pane hierarchy and type | |
| 112 | + | // Step 2: Determine the border container based on pane type | |
| 153 | 113 | switch { | |
| 154 | - | case node.parent != nil && node.parent.isStacked: | |
| 155 | - | // STACKED PANE: Border goes on the stack container | |
| 156 | - | ctx.borderContainer = node.parent.container | |
| 157 | - | ctx.cssClasses = []string{stackContainerClass, activePaneClass} | |
| 158 | - | ctx.paneType = "stacked" | |
| 159 | - | ||
| 160 | 114 | case node.windowType == webkit.WindowTypePopup: | |
| 161 | - | // POPUP PANE: Border goes on the popup's own container | |
| 115 | + | // Popup panes: Border on popup container | |
| 162 | 116 | ctx.borderContainer = node.container | |
| 163 | - | ctx.cssClasses = []string{basePaneClass, activePaneClass} | |
| 117 | + | ctx.cssClasses = []string{activePaneClass} | |
| 164 | 118 | ctx.paneType = "popup" | |
| 165 | 119 | ||
| 166 | - | case node.isLeaf && !node.isStacked: | |
| 167 | - | // REGULAR LEAF PANE: Border goes on the pane's container | |
| 168 | - | ctx.borderContainer = node.container | |
| 169 | - | ctx.cssClasses = []string{basePaneClass, activePaneClass} | |
| 170 | - | ctx.paneType = "regular-leaf" | |
| 171 | - | ||
| 172 | - | case !node.isLeaf: | |
| 173 | - | // SPLIT PANE (branch node): Border goes on the split container | |
| 174 | - | ctx.borderContainer = node.container | |
| 175 | - | ctx.cssClasses = []string{basePaneClass, multiPaneClass, activePaneClass} | |
| 176 | - | ctx.paneType = "split-branch" | |
| 120 | + | case node.parent != nil && node.parent.isStacked: | |
| 121 | + | // Pane in stack: Border on stack wrapper container | |
| 122 | + | ctx.borderContainer = node.parent.stackWrapper | |
| 123 | + | ctx.cssClasses = []string{activePaneClass} | |
| 124 | + | ctx.paneType = "stacked" | |
| 177 | 125 | ||
| 178 | 126 | default: | |
| 179 | - | // FALLBACK: Use the node's own container | |
| 127 | + | // Regular pane: Border on pane container | |
| 180 | 128 | ctx.borderContainer = node.container | |
| 181 | - | ctx.cssClasses = []string{basePaneClass, activePaneClass} | |
| 182 | - | ctx.paneType = "fallback" | |
| 129 | + | ctx.cssClasses = []string{activePaneClass} | |
| 130 | + | ctx.paneType = "regular" | |
| 183 | 131 | } | |
| 184 | 132 | ||
| 185 | 133 | return ctx | |
| 186 | 134 | } | |
| 187 | 135 | ||
| 188 | - | // applyActivePaneBorder adds the active pane visual border using the centralized system | |
| 189 | - | func (wm *WorkspaceManager) applyActivePaneBorder(node *paneNode) { | |
| 190 | - | ctx := wm.determineBorderContext(node) | |
| 136 | + | // applyActivePaneBorder applies the active pane border using the border context | |
| 137 | + | func (wm *WorkspaceManager) applyActivePaneBorder(ctx *PaneBorderContext) { | |
| 191 | 138 | if ctx == nil { | |
| 139 | + | log.Printf("[workspace] applyActivePaneBorder: nil context") | |
| 192 | 140 | return | |
| 193 | 141 | } | |
| 194 | 142 | ||
| 195 | - | // Apply CSS classes to border container (provides border styling) | |
| 196 | - | if ctx.borderContainer != nil { | |
| 197 | - | var applied bool | |
| 198 | - | if err := ctx.borderContainer.Execute(func(containerPtr uintptr) error { | |
| 199 | - | // Safety check: verify container pointer is valid and widget still exists | |
| 200 | - | if containerPtr == 0 || !webkit.WidgetIsValid(containerPtr) { | |
| 201 | - | return fmt.Errorf("invalid container pointer or destroyed widget") | |
| 202 | - | } | |
| 203 | - | if webkit.WidgetHasCSSClass(containerPtr, activePaneClass) { | |
| 204 | - | return nil | |
| 205 | - | } | |
| 206 | - | webkit.WidgetAddCSSClass(containerPtr, activePaneClass) | |
| 207 | - | applied = true | |
| 208 | - | return nil | |
| 209 | - | }); err != nil { | |
| 210 | - | log.Printf("[workspace] Failed to apply border to %s pane: %v", ctx.paneType, err) | |
| 211 | - | } else if applied { | |
| 212 | - | log.Printf("[workspace] Applied border to %s pane: %p", ctx.paneType, node) | |
| 143 | + | // Apply margin to WebView widget (creates space for border) | |
| 144 | + | if ctx.webViewWidget != 0 && webkit.WidgetIsValid(ctx.webViewWidget) { | |
| 145 | + | webkit.WidgetSetMargin(ctx.webViewWidget, 2) | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // Apply CSS classes to border container | |
| 149 | + | if ctx.borderContainer != 0 && webkit.WidgetIsValid(ctx.borderContainer) { | |
| 150 | + | for _, class := range ctx.cssClasses { | |
| 151 | + | webkit.WidgetAddCSSClass(ctx.borderContainer, class) | |
| 213 | 152 | } | |
| 214 | 153 | } | |
| 154 | + | ||
| 155 | + | log.Printf("[workspace] Applied active border: type=%s webView=%#x container=%#x", | |
| 156 | + | ctx.paneType, ctx.webViewWidget, ctx.borderContainer) | |
| 215 | 157 | } | |
| 216 | 158 | ||
| 217 | - | // removeActivePaneBorder removes the active pane visual border using the centralized system | |
| 159 | + | // removeActivePaneBorder removes the active pane border from a node | |
| 218 | 160 | func (wm *WorkspaceManager) removeActivePaneBorder(node *paneNode) { | |
| 161 | + | if node == nil { | |
| 162 | + | return | |
| 163 | + | } | |
| 164 | + | ||
| 219 | 165 | ctx := wm.determineBorderContext(node) | |
| 220 | 166 | if ctx == nil { | |
| 221 | 167 | return | |
| 222 | 168 | } | |
| 223 | 169 | ||
| 170 | + | // Remove margin from WebView widget | |
| 171 | + | if ctx.webViewWidget != 0 && webkit.WidgetIsValid(ctx.webViewWidget) { | |
| 172 | + | webkit.WidgetSetMargin(ctx.webViewWidget, 0) | |
| 173 | + | } | |
| 174 | + | ||
| 224 | 175 | // Remove CSS classes from border container | |
| 225 | - | if ctx.borderContainer != nil { | |
| 226 | - | var removed bool | |
| 227 | - | if err := ctx.borderContainer.Execute(func(containerPtr uintptr) error { | |
| 228 | - | // Safety check: verify container pointer is valid and widget still exists | |
| 229 | - | if containerPtr == 0 || !webkit.WidgetIsValid(containerPtr) { | |
| 230 | - | return fmt.Errorf("invalid container pointer or destroyed widget") | |
| 231 | - | } | |
| 232 | - | if !webkit.WidgetHasCSSClass(containerPtr, activePaneClass) { | |
| 233 | - | return nil | |
| 234 | - | } | |
| 235 | - | webkit.WidgetRemoveCSSClass(containerPtr, activePaneClass) | |
| 236 | - | removed = true | |
| 237 | - | return nil | |
| 238 | - | }); err != nil { | |
| 239 | - | log.Printf("[workspace] Failed to remove border from %s pane: %v", ctx.paneType, err) | |
| 240 | - | } else if removed { | |
| 241 | - | log.Printf("[workspace] Removed border from %s pane: %p", ctx.paneType, node) | |
| 176 | + | if ctx.borderContainer != 0 && webkit.WidgetIsValid(ctx.borderContainer) { | |
| 177 | + | for _, class := range ctx.cssClasses { | |
| 178 | + | webkit.WidgetRemoveCSSClass(ctx.borderContainer, class) | |
| 242 | 179 | } | |
| 243 | 180 | } | |
| 181 | + | ||
| 182 | + | log.Printf("[workspace] Removed active border: type=%s webView=%#x container=%#x", | |
| 183 | + | ctx.paneType, ctx.webViewWidget, ctx.borderContainer) | |
| 244 | 184 | } | |
| 245 | 185 | ||
| 246 | - | // removeActivePaneBorderFromAll removes active pane borders from all containers using centralized system | |
| 247 | - | func (wm *WorkspaceManager) removeActivePaneBorderFromAll() { | |
| 248 | - | if wm == nil { | |
| 186 | + | // Widget cleanup utilities | |
| 187 | + | ||
| 188 | + | // cleanupWidget safely invalidates and cleans up a widget | |
| 189 | + | func (wm *WorkspaceManager) cleanupWidget(ptr uintptr) { | |
| 190 | + | if ptr == 0 { | |
| 249 | 191 | return | |
| 250 | 192 | } | |
| 251 | 193 | ||
| 252 | - | log.Printf("[workspace] Removing active borders from all panes") | |
| 194 | + | // Widget will be destroyed by GTK when unparented/removed | |
| 195 | + | // No manual cleanup needed - GTK4 handles reference counting | |
| 196 | + | } | |
| 253 | 197 | ||
| 254 | - | // Use the centralized system for each pane | |
| 255 | - | for _, node := range wm.viewToNode { | |
| 256 | - | if node != nil { | |
| 257 | - | wm.removeActivePaneBorder(node) | |
| 198 | + | // scheduleIdleGuarded queues a callback to run on the GTK main loop and tracks it against | |
| 199 | + | // the provided pane nodes so it can be cancelled safely if the nodes are destroyed. | |
| 200 | + | func (wm *WorkspaceManager) scheduleIdleGuarded(fn func() bool, nodes ...*paneNode) uintptr { | |
| 201 | + | if wm == nil || fn == nil { | |
| 202 | + | return 0 | |
| 203 | + | } | |
| 204 | + | guards := uniquePaneNodes(nodes...) | |
| 205 | + | var handle uintptr | |
| 206 | + | handle = webkit.IdleAdd(func() bool { | |
| 207 | + | defer wm.releaseIdleHandle(handle) | |
| 208 | + | for _, node := range guards { | |
| 209 | + | if node == nil { | |
| 210 | + | continue | |
| 211 | + | } | |
| 212 | + | if !node.widgetValid { | |
| 213 | + | return false | |
| 214 | + | } | |
| 258 | 215 | } | |
| 216 | + | return fn() | |
| 217 | + | }) | |
| 218 | + | if handle != 0 { | |
| 219 | + | wm.registerIdleHandle(handle, guards...) | |
| 259 | 220 | } | |
| 221 | + | return handle | |
| 260 | 222 | } | |
| 261 | 223 | ||
| 262 | - | // isActivePaneBorderApplied checks if the active pane border is currently applied to a node using centralized system | |
| 263 | - | func (wm *WorkspaceManager) isActivePaneBorderApplied(node *paneNode) bool { | |
| 264 | - | ctx := wm.determineBorderContext(node) | |
| 265 | - | if ctx == nil || ctx.borderContainer == nil { | |
| 266 | - | return false | |
| 224 | + | func uniquePaneNodes(nodes ...*paneNode) []*paneNode { | |
| 225 | + | if len(nodes) == 0 { | |
| 226 | + | return nil | |
| 227 | + | } | |
| 228 | + | seen := make(map[*paneNode]struct{}, len(nodes)) | |
| 229 | + | unique := make([]*paneNode, 0, len(nodes)) | |
| 230 | + | for _, node := range nodes { | |
| 231 | + | if node == nil { | |
| 232 | + | continue | |
| 233 | + | } | |
| 234 | + | if _, ok := seen[node]; ok { | |
| 235 | + | continue | |
| 236 | + | } | |
| 237 | + | seen[node] = struct{}{} | |
| 238 | + | unique = append(unique, node) | |
| 267 | 239 | } | |
| 240 | + | return unique | |
| 241 | + | } | |
| 268 | 242 | ||
| 269 | - | var hasClass bool | |
| 270 | - | ctx.borderContainer.Execute(func(containerPtr uintptr) error { | |
| 271 | - | hasClass = webkit.WidgetHasCSSClass(containerPtr, activePaneClass) | |
| 272 | - | return nil | |
| 273 | - | }) | |
| 243 | + | func (wm *WorkspaceManager) registerIdleHandle(handle uintptr, nodes ...*paneNode) { | |
| 244 | + | if wm == nil || handle == 0 { | |
| 245 | + | return | |
| 246 | + | } | |
| 247 | + | unique := uniquePaneNodes(nodes...) | |
| 248 | + | if len(unique) == 0 { | |
| 249 | + | return | |
| 250 | + | } | |
| 251 | + | if wm.pendingIdle == nil { | |
| 252 | + | wm.pendingIdle = make(map[uintptr][]*paneNode) | |
| 253 | + | } | |
| 254 | + | wm.pendingIdle[handle] = unique | |
| 255 | + | for _, node := range unique { | |
| 256 | + | if node.pendingIdleHandles == nil { | |
| 257 | + | node.pendingIdleHandles = make(map[uintptr]struct{}) | |
| 258 | + | } | |
| 259 | + | node.pendingIdleHandles[handle] = struct{}{} | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | func (wm *WorkspaceManager) releaseIdleHandle(handle uintptr) { | |
| 264 | + | if wm == nil || handle == 0 { | |
| 265 | + | return | |
| 266 | + | } | |
| 267 | + | if wm.pendingIdle == nil { | |
| 268 | + | return | |
| 269 | + | } | |
| 270 | + | nodes, ok := wm.pendingIdle[handle] | |
| 271 | + | if !ok { | |
| 272 | + | return | |
| 273 | + | } | |
| 274 | + | delete(wm.pendingIdle, handle) | |
| 275 | + | for _, node := range nodes { | |
| 276 | + | if node == nil || node.pendingIdleHandles == nil { | |
| 277 | + | continue | |
| 278 | + | } | |
| 279 | + | delete(node.pendingIdleHandles, handle) | |
| 280 | + | if len(node.pendingIdleHandles) == 0 { | |
| 281 | + | node.pendingIdleHandles = nil | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | } | |
| 274 | 285 | ||
| 275 | - | return hasClass | |
| 286 | + | func (wm *WorkspaceManager) cancelIdleHandle(handle uintptr) { | |
| 287 | + | if handle == 0 { | |
| 288 | + | return | |
| 289 | + | } | |
| 290 | + | wm.releaseIdleHandle(handle) | |
| 291 | + | webkit.IdleRemove(handle) | |
| 276 | 292 | } | |
| 277 | 293 | ||
| 278 | - | // getBorderContextInfo returns debugging information about a pane's border context | |
| 279 | - | func (wm *WorkspaceManager) getBorderContextInfo(node *paneNode) string { | |
| 280 | - | ctx := wm.determineBorderContext(node) | |
| 281 | - | if ctx == nil { | |
| 282 | - | return "no-context" | |
| 294 | + | func (wm *WorkspaceManager) cancelIdleHandles(node *paneNode) { | |
| 295 | + | if wm == nil || node == nil || len(node.pendingIdleHandles) == 0 { | |
| 296 | + | return | |
| 297 | + | } | |
| 298 | + | handles := make([]uintptr, 0, len(node.pendingIdleHandles)) | |
| 299 | + | for handle := range node.pendingIdleHandles { | |
| 300 | + | handles = append(handles, handle) | |
| 283 | 301 | } | |
| 284 | - | return ctx.paneType | |
| 302 | + | for _, handle := range handles { | |
| 303 | + | wm.cancelIdleHandle(handle) | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | // Note: nextCleanupGeneration, updateMainPane, dumpTreeState, paneCloseLogf | |
| 308 | + | // are defined in workspace_pane_ops.go and workspace_debug.go | |
| 309 | + | ||
| 310 | + | // formatPaneInfo returns a debug string for a pane node | |
| 311 | + | func formatPaneInfo(node *paneNode) string { | |
| 312 | + | if node == nil { | |
| 313 | + | return "nil" | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | var info string | |
| 317 | + | if node.isLeaf { | |
| 318 | + | info = fmt.Sprintf("leaf(container=%#x", node.container) | |
| 319 | + | if node.pane != nil && node.pane.webView != nil { | |
| 320 | + | info += fmt.Sprintf(" webview=%s", node.pane.webView.ID()) | |
| 321 | + | } | |
| 322 | + | if node.windowType == webkit.WindowTypePopup { | |
| 323 | + | info += " popup" | |
| 324 | + | } | |
| 325 | + | info += ")" | |
| 326 | + | } else if node.isStacked { | |
| 327 | + | info = fmt.Sprintf("stack(panes=%d active=%d container=%#x)", | |
| 328 | + | len(node.stackedPanes), node.activeStackIndex, node.container) | |
| 329 | + | } else { | |
| 330 | + | info = fmt.Sprintf("branch(container=%#x orientation=%v)", | |
| 331 | + | node.container, node.orientation) | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | return info | |
| 285 | 335 | } | |
M pkg/webkit/build_flags_stub.go +15 -0
| @@ -5,3 +5,18 @@ package webkit | |||
| 5 | 5 | // IsNativeAvailable reports whether the native WebKit2GTK backend is compiled in. | |
| 6 | 6 | // In non-CGO builds, this returns false and WebView methods are logical no-ops. | |
| 7 | 7 | func IsNativeAvailable() bool { return false } | |
| 8 | + | ||
| 9 | + | // InitMainThread is a no-op in non-CGO builds. | |
| 10 | + | func InitMainThread() {} | |
| 11 | + | ||
| 12 | + | // IsMainThread always returns true in non-CGO builds since there's no GTK main thread. | |
| 13 | + | func IsMainThread() bool { return true } | |
| 14 | + | ||
| 15 | + | // IterateMainLoop is a no-op in non-CGO builds and always returns false. | |
| 16 | + | func IterateMainLoop() bool { return false } | |
| 17 | + | ||
| 18 | + | // PanedGetStartChild is a no-op in non-CGO builds and returns 0. | |
| 19 | + | func PanedGetStartChild(paned uintptr) uintptr { return 0 } | |
| 20 | + | ||
| 21 | + | // PanedGetEndChild is a no-op in non-CGO builds and returns 0. | |
| 22 | + | func PanedGetEndChild(paned uintptr) uintptr { return 0 } | |
M pkg/webkit/thread_helpers.c +2 -0
| @@ -1,3 +1,5 @@ | |||
| 1 | + | // +build webkit_cgo | |
| 2 | + | ||
| 1 | 3 | #include <glib.h> | |
| 2 | 4 | #include <pthread.h> | |
| 3 | 5 | ||
M pkg/webkit/webview_cgo.go +10 -0
| @@ -1630,21 +1630,26 @@ func (w *WebView) Destroy() error { | |||
| 1630 | 1630 | return ErrNotImplemented | |
| 1631 | 1631 | } | |
| 1632 | 1632 | ||
| 1633 | + | log.Printf("[webkit-destroy] begin destroy: id=%d container=%p win=%p view=%p", w.id, w.native.container, w.native.win, w.native.view) | |
| 1633 | 1634 | // Stop periodic GC if running | |
| 1634 | 1635 | w.stopPeriodicGC() | |
| 1636 | + | log.Printf("[webkit-destroy] periodic GC stopped: id=%d", w.id) | |
| 1635 | 1637 | ||
| 1636 | 1638 | // Clean up memory pressure settings | |
| 1637 | 1639 | if w.memStats != nil && w.memStats.memoryPressureSettings != nil { | |
| 1638 | 1640 | // WebKit will clean up the memory pressure settings when context is destroyed | |
| 1639 | 1641 | w.memStats.memoryPressureSettings = nil | |
| 1642 | + | log.Printf("[webkit-destroy] cleared memory pressure settings: id=%d", w.id) | |
| 1640 | 1643 | } | |
| 1641 | 1644 | ||
| 1642 | 1645 | // Unregister from memory manager | |
| 1643 | 1646 | if globalMemoryManager != nil { | |
| 1644 | 1647 | globalMemoryManager.UnregisterWebView(w.id) | |
| 1648 | + | log.Printf("[webkit-destroy] unregistered from memory manager: id=%d", w.id) | |
| 1645 | 1649 | } | |
| 1646 | 1650 | ||
| 1647 | 1651 | if w.native != nil && w.native.ucm != nil && w.domBridgeScript != nil { | |
| 1652 | + | log.Printf("[webkit-destroy] removing DOM bridge script: id=%d", w.id) | |
| 1648 | 1653 | C.webkit_user_content_manager_remove_script(w.native.ucm, w.domBridgeScript) | |
| 1649 | 1654 | C.webkit_user_script_unref(w.domBridgeScript) | |
| 1650 | 1655 | w.domBridgeScript = nil | |
| @@ -1652,6 +1657,7 @@ func (w *WebView) Destroy() error { | |||
| 1652 | 1657 | ||
| 1653 | 1658 | // GTK4: destroy windows via gtk_window_destroy if we created one | |
| 1654 | 1659 | if w.native.win != nil && w.config != nil && w.config.CreateWindow { | |
| 1660 | + | log.Printf("[webkit-destroy] destroying GTK window: id=%d win=%p", w.id, w.native.win) | |
| 1655 | 1661 | C.gtk_window_destroy((*C.GtkWindow)(unsafe.Pointer(w.native.win))) | |
| 1656 | 1662 | w.native.win = nil | |
| 1657 | 1663 | if w.window != nil { | |
| @@ -1660,13 +1666,17 @@ func (w *WebView) Destroy() error { | |||
| 1660 | 1666 | log.Printf("[webkit] Destroy window") | |
| 1661 | 1667 | } | |
| 1662 | 1668 | ||
| 1669 | + | log.Printf("[webkit-destroy] releasing native widgets: id=%d", w.id) | |
| 1663 | 1670 | w.releaseNativeWidgets() | |
| 1671 | + | log.Printf("[webkit-destroy] native widgets released: id=%d", w.id) | |
| 1664 | 1672 | w.destroyed = true | |
| 1665 | 1673 | ||
| 1666 | 1674 | // Unregister from dynamic protection updates | |
| 1667 | 1675 | UnregisterActiveWebView(w) | |
| 1676 | + | log.Printf("[webkit-destroy] unregistered active view: id=%d", w.id) | |
| 1668 | 1677 | ||
| 1669 | 1678 | unregisterView(w.id) | |
| 1679 | + | log.Printf("[webkit-destroy] destroy complete: id=%d", w.id) | |
| 1670 | 1680 | return nil | |
| 1671 | 1681 | } | |
| 1672 | 1682 | ||
M pkg/webkit/workspace_widgets_cgo.go +24 -4
| @@ -43,8 +43,12 @@ static gboolean idle_callback_wrapper(gpointer data) { | |||
| 43 | 43 | return goIdleCallback((uintptr_t)data); | |
| 44 | 44 | } | |
| 45 | 45 | ||
| 46 | - | static void add_idle_callback(uintptr_t handle) { | |
| 47 | - | g_idle_add_full(G_PRIORITY_DEFAULT_IDLE, idle_callback_wrapper, (gpointer)handle, NULL); | |
| 46 | + | static guint add_idle_callback(uintptr_t handle) { | |
| 47 | + | return g_idle_add_full(G_PRIORITY_DEFAULT_IDLE, idle_callback_wrapper, (gpointer)handle, NULL); | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | static gboolean remove_idle_callback(uintptr_t handle) { | |
| 51 | + | return g_idle_remove_by_data((gpointer)handle); | |
| 48 | 52 | } | |
| 49 | 53 | ||
| 50 | 54 | static void motion_enter_cb(GtkEventControllerMotion* controller, gdouble x, gdouble y, gpointer user_data) { | |
| @@ -817,11 +821,27 @@ var ( | |||
| 817 | 821 | ) | |
| 818 | 822 | ||
| 819 | 823 | // IdleAdd schedules a function to be called when the main loop is idle. | |
| 820 | - | func IdleAdd(fn func() bool) { | |
| 824 | + | func IdleAdd(fn func() bool) uintptr { | |
| 825 | + | if fn == nil { | |
| 826 | + | return 0 | |
| 827 | + | } | |
| 821 | 828 | id := nextIdleID | |
| 822 | 829 | nextIdleID++ | |
| 823 | 830 | idleCallbacks[id] = fn | |
| 824 | - | C.add_idle_callback(C.uintptr_t(id)) | |
| 831 | + | if C.add_idle_callback(C.uintptr_t(id)) == 0 { | |
| 832 | + | delete(idleCallbacks, id) | |
| 833 | + | return 0 | |
| 834 | + | } | |
| 835 | + | return id | |
| 836 | + | } | |
| 837 | + | ||
| 838 | + | // IdleRemove cancels a previously scheduled idle callback. | |
| 839 | + | func IdleRemove(handle uintptr) { | |
| 840 | + | if handle == 0 { | |
| 841 | + | return | |
| 842 | + | } | |
| 843 | + | C.remove_idle_callback(C.uintptr_t(handle)) | |
| 844 | + | delete(idleCallbacks, handle) | |
| 825 | 845 | } | |
| 826 | 846 | ||
| 827 | 847 | // IterateMainLoop processes a single GTK main loop iteration and reports if work was handled. | |
M pkg/webkit/workspace_widgets_stub.go +7 -1
| @@ -395,10 +395,16 @@ func WidgetWaitForDraw(widget uintptr) { | |||
| 395 | 395 | } | |
| 396 | 396 | ||
| 397 | 397 | // IdleAdd simulates GTK's idle callback system - DEPRECATED, use WidgetWaitForDraw instead | |
| 398 | - | func IdleAdd(fn func() bool) { | |
| 398 | + | func IdleAdd(fn func() bool) uintptr { | |
| 399 | 399 | if fn != nil { | |
| 400 | 400 | fn() | |
| 401 | 401 | } | |
| 402 | + | return 0 | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | // IdleRemove cancels a simulated idle callback (no-op in stub builds). | |
| 406 | + | func IdleRemove(handle uintptr) { | |
| 407 | + | _ = handle | |
| 402 | 408 | } | |
| 403 | 409 | ||
| 404 | 410 | func AddCSSProvider(css string) {} | |