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 | log.Printf("Warning: failed to initialize WebKit log capture: %v", err) | 110 | log.Printf("Warning: failed to initialize WebKit log capture: %v", err) | ||
| 111 | } else { | 111 | } else { | ||
| 112 | defer webkit.StopWebKitLogCapture() | 112 | defer webkit.StopWebKitLogCapture() | ||
| 113 | + | webkit.StartWebKitOutputCapture() | |||
| 113 | } | 114 | } | ||
| 114 | } | 115 | } | ||
| 115 | 116 | ||||
| @@ -220,6 +221,13 @@ func (app *BrowserApp) cleanup() { | |||||
| 220 | // setupOutputCapture initializes stdout/stderr capture if configured | 221 | // setupOutputCapture initializes stdout/stderr capture if configured | ||
| 221 | func (app *BrowserApp) setupOutputCapture() error { | 222 | func (app *BrowserApp) setupOutputCapture() error { | ||
| 222 | if app.config.Logging.CaptureStdout || app.config.Logging.CaptureStderr { | 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 | log.Printf("Warning: stdout/stderr capture is experimental and may interfere with normal operations") | 231 | log.Printf("Warning: stdout/stderr capture is experimental and may interfere with normal operations") | ||
| 224 | if logger := logging.GetLogger(); logger != nil { | 232 | if logger := logging.GetLogger(); logger != nil { | ||
| 225 | outputCapture := logging.NewOutputCapture(logger) | 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 | var bestScore float64 = 1e9 | 529 | var bestScore float64 = 1e9 | ||
| 530 | 530 | ||||
| 531 | for _, leaf := range leaves { | 531 | for _, leaf := range leaves { | ||
| 532 | - | if leaf.container == nil { | 532 | + | if leaf.container == 0 || !webkit.WidgetIsValid(leaf.container) { |
| 533 | continue | 533 | continue | ||
| 534 | } | 534 | } | ||
| 535 | 535 | ||||
| 536 | - | var bounds webkit.WidgetBounds | 536 | + | bounds, ok := webkit.WidgetGetBounds(leaf.container) |
| 537 | - | var ok bool | |||
| 538 | - | leaf.container.Execute(func(containerPtr uintptr) error { | |||
| 539 | - | bounds, ok = webkit.WidgetGetBounds(containerPtr) | |||
| 540 | - | return nil | |||
| 541 | - | }) | |||
| 542 | - | ||||
| 543 | if !ok { | 537 | if !ok { | ||
| 544 | continue | 538 | continue | ||
| 545 | } | 539 | } | ||
| @@ -570,7 +564,8 @@ func (fsm *FocusStateMachine) applyInitialFocus(node *paneNode) error { | |||||
| 570 | 564 | ||||
| 571 | // Apply initial visual border | 565 | // Apply initial visual border | ||
| 572 | if fsm.wm != nil { | 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 | // Update state | 571 | // Update state | ||
| @@ -736,7 +731,8 @@ func (fsm *FocusStateMachine) executeFocusChange(request FocusRequest) error { | |||||
| 736 | fsm.wm.removeActivePaneBorder(oldPane) | 731 | fsm.wm.removeActivePaneBorder(oldPane) | ||
| 737 | } | 732 | } | ||
| 738 | // Add border to new pane | 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 | // Update state | 738 | // Update state | ||
M internal/app/browser/stacked_panes.go +190 -201
| @@ -130,13 +130,10 @@ func (spm *StackedPaneManager) addPaneToExistingStack(target, newLeaf *paneNode) | |||||
| 130 | stackNode.stackedPanes[insertIndex] = newLeaf // Insert new pane | 130 | stackNode.stackedPanes[insertIndex] = newLeaf // Insert new pane | ||
| 131 | 131 | ||||
| 132 | // Unparent the new container before adding it to the stack (only if it has a parent) | 132 | // Unparent the new container before adding it to the stack (only if it has a parent) | ||
| 133 | - | if newLeaf.container != nil { | 133 | + | if newLeaf.container != 0 && webkit.WidgetIsValid(newLeaf.container) { |
| 134 | - | newLeaf.container.Execute(func(containerPtr uintptr) error { | 134 | + | if webkit.WidgetGetParent(newLeaf.container) != 0 { |
| 135 | - | if webkit.WidgetGetParent(containerPtr) != 0 { | 135 | + | webkit.WidgetUnparent(newLeaf.container) |
| 136 | - | webkit.WidgetUnparent(containerPtr) | 136 | + | } |
| 137 | - | } | |||
| 138 | - | return nil | |||
| 139 | - | }) | |||
| 140 | } | 137 | } | ||
| 141 | 138 | ||||
| 142 | // Insert widgets at correct position for Zellij-style layout | 139 | // Insert widgets at correct position for Zellij-style layout | ||
| @@ -147,28 +144,28 @@ func (spm *StackedPaneManager) addPaneToExistingStack(target, newLeaf *paneNode) | |||||
| 147 | if insertIndex > 0 { | 144 | if insertIndex > 0 { | ||
| 148 | // Insert after the previous pane's container | 145 | // Insert after the previous pane's container | ||
| 149 | prevPane := stackNode.stackedPanes[insertIndex-1] | 146 | prevPane := stackNode.stackedPanes[insertIndex-1] | ||
| 150 | - | if prevPane.container != nil { | 147 | + | if prevPane.container != 0 { |
| 151 | - | insertAfterWidget = prevPane.container.Ptr() | 148 | + | insertAfterWidget = prevPane.container |
| 152 | } | 149 | } | ||
| 153 | } | 150 | } | ||
| 154 | 151 | ||||
| 155 | if insertAfterWidget != 0 { | 152 | if insertAfterWidget != 0 { | ||
| 156 | // Insert titleBar after the previous pane's container | 153 | // Insert titleBar after the previous pane's container | ||
| 157 | - | if stackNode.stackWrapper != nil && newLeaf.titleBar != nil { | 154 | + | if stackNode.stackWrapper != 0 && newLeaf.titleBar != 0 { |
| 158 | - | webkit.BoxInsertChildAfter(stackNode.stackWrapper.Ptr(), newLeaf.titleBar.Ptr(), insertAfterWidget) | 155 | + | webkit.BoxInsertChildAfter(stackNode.stackWrapper, newLeaf.titleBar, insertAfterWidget) |
| 159 | } | 156 | } | ||
| 160 | // Insert container after the newly inserted titleBar | 157 | // Insert container after the newly inserted titleBar | ||
| 161 | - | if stackNode.stackWrapper != nil && newLeaf.container != nil && newLeaf.titleBar != nil { | 158 | + | if stackNode.stackWrapper != 0 && newLeaf.container != 0 && newLeaf.titleBar != 0 { |
| 162 | - | webkit.BoxInsertChildAfter(stackNode.stackWrapper.Ptr(), newLeaf.container.Ptr(), newLeaf.titleBar.Ptr()) | 159 | + | webkit.BoxInsertChildAfter(stackNode.stackWrapper, newLeaf.container, newLeaf.titleBar) |
| 163 | } | 160 | } | ||
| 164 | log.Printf("[workspace] inserted widgets at position %d (after widget %#x)", insertIndex, insertAfterWidget) | 161 | log.Printf("[workspace] inserted widgets at position %d (after widget %#x)", insertIndex, insertAfterWidget) | ||
| 165 | } else { | 162 | } else { | ||
| 166 | // Insert at the beginning (insertIndex = 0) | 163 | // Insert at the beginning (insertIndex = 0) | ||
| 167 | - | if stackNode.stackWrapper != nil && newLeaf.container != nil { | 164 | + | if stackNode.stackWrapper != 0 && newLeaf.container != 0 { |
| 168 | - | webkit.BoxPrepend(stackNode.stackWrapper.Ptr(), newLeaf.container.Ptr()) | 165 | + | webkit.BoxPrepend(stackNode.stackWrapper, newLeaf.container) |
| 169 | } | 166 | } | ||
| 170 | - | if stackNode.stackWrapper != nil && newLeaf.titleBar != nil { | 167 | + | if stackNode.stackWrapper != 0 && newLeaf.titleBar != 0 { |
| 171 | - | webkit.BoxPrepend(stackNode.stackWrapper.Ptr(), newLeaf.titleBar.Ptr()) | 168 | + | webkit.BoxPrepend(stackNode.stackWrapper, newLeaf.titleBar) |
| 172 | } | 169 | } | ||
| 173 | log.Printf("[workspace] prepended widgets at position 0") | 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 | // Build the complete stack structure with hidden widgets | 216 | // Build the complete stack structure with hidden widgets | ||
| 220 | webkit.BoxAppend(stackInternalBox, titleBar) | 217 | webkit.BoxAppend(stackInternalBox, titleBar) | ||
| 221 | - | if existingContainer != nil { | 218 | + | if existingContainer != 0 { |
| 222 | - | webkit.BoxAppend(stackInternalBox, existingContainer.Ptr()) | 219 | + | webkit.BoxAppend(stackInternalBox, existingContainer) |
| 223 | } | 220 | } | ||
| 224 | 221 | ||||
| 225 | // Immediately reattach the stack wrapper to minimize visibility gap | 222 | // Immediately reattach the stack wrapper to minimize visibility gap | ||
| @@ -267,27 +264,24 @@ func (spm *StackedPaneManager) convertToStackedContainer(target, newLeaf *paneNo | |||||
| 267 | 264 | ||||
| 268 | // Unparent the new container before adding it to the stack | 265 | // Unparent the new container before adding it to the stack | ||
| 269 | // This is critical: even freshly created WebViews might have internal parent refs | 266 | // This is critical: even freshly created WebViews might have internal parent refs | ||
| 270 | - | if newLeaf.container != nil { | 267 | + | if newLeaf.container != 0 && webkit.WidgetIsValid(newLeaf.container) { |
| 271 | - | newLeaf.container.Execute(func(containerPtr uintptr) error { | 268 | + | parent := webkit.WidgetGetParent(newLeaf.container) |
| 272 | - | parent := webkit.WidgetGetParent(containerPtr) | 269 | + | if parent != 0 { |
| 273 | - | if parent != 0 { | 270 | + | log.Printf("[workspace] unparenting new pane container %#x from parent %#x before stack append", newLeaf.container, parent) |
| 274 | - | log.Printf("[workspace] unparenting new pane container %#x from parent %#x before stack append", containerPtr, parent) | 271 | + | webkit.WidgetUnparent(newLeaf.container) |
| 275 | - | webkit.WidgetUnparent(containerPtr) | 272 | + | // Verify unparent succeeded |
| 276 | - | // Verify unparent succeeded | 273 | + | if finalParent := webkit.WidgetGetParent(newLeaf.container); finalParent != 0 { |
| 277 | - | if finalParent := webkit.WidgetGetParent(containerPtr); finalParent != 0 { | 274 | + | log.Printf("[workspace] WARNING: container %#x still has parent %#x after unparent", newLeaf.container, finalParent) |
| 278 | - | log.Printf("[workspace] WARNING: container %#x still has parent %#x after unparent", containerPtr, finalParent) | |||
| 279 | - | } | |||
| 280 | } | 275 | } | ||
| 281 | - | return nil | 276 | + | } |
| 282 | - | }) | |||
| 283 | } | 277 | } | ||
| 284 | 278 | ||||
| 285 | // Add the new widgets to the internal stack box | 279 | // Add the new widgets to the internal stack box | ||
| 286 | - | if stackNode.stackWrapper != nil && newLeaf.titleBar != nil { | 280 | + | if stackNode.stackWrapper != 0 && newLeaf.titleBar != 0 { |
| 287 | - | webkit.BoxAppend(stackNode.stackWrapper.Ptr(), newLeaf.titleBar.Ptr()) | 281 | + | webkit.BoxAppend(stackNode.stackWrapper, newLeaf.titleBar) |
| 288 | } | 282 | } | ||
| 289 | - | if stackNode.stackWrapper != nil && newLeaf.container != nil { | 283 | + | if stackNode.stackWrapper != 0 && newLeaf.container != 0 { |
| 290 | - | webkit.BoxAppend(stackNode.stackWrapper.Ptr(), newLeaf.container.Ptr()) | 284 | + | webkit.BoxAppend(stackNode.stackWrapper, newLeaf.container) |
| 291 | } | 285 | } | ||
| 292 | 286 | ||||
| 293 | return stackNode, insertIndex, nil | 287 | return stackNode, insertIndex, nil | ||
| @@ -301,24 +295,18 @@ func (spm *StackedPaneManager) detachFromParent(target *paneNode, parent *paneNo | |||||
| 301 | spm.wm.window.SetChild(0) | 295 | spm.wm.window.SetChild(0) | ||
| 302 | } | 296 | } | ||
| 303 | // Unparent if it has a GTK parent | 297 | // Unparent if it has a GTK parent | ||
| 304 | - | if target.container != nil { | 298 | + | if target.container != 0 && webkit.WidgetIsValid(target.container) { |
| 305 | - | target.container.Execute(func(containerPtr uintptr) error { | 299 | + | if webkit.WidgetGetParent(target.container) != 0 { |
| 306 | - | if webkit.WidgetGetParent(containerPtr) != 0 { | 300 | + | webkit.WidgetUnparent(target.container) |
| 307 | - | webkit.WidgetUnparent(containerPtr) | 301 | + | } |
| 308 | - | } | |||
| 309 | - | return nil | |||
| 310 | - | }) | |||
| 311 | } | 302 | } | ||
| 312 | - | } else if parent.container != nil { | 303 | + | } else if parent.container != 0 && webkit.WidgetIsValid(parent.container) { |
| 313 | // Target has a parent paned - remove it (automatically unparents in GTK4) | 304 | // Target has a parent paned - remove it (automatically unparents in GTK4) | ||
| 314 | - | parent.container.Execute(func(panedPtr uintptr) error { | 305 | + | if parent.left == target { |
| 315 | - | if parent.left == target { | 306 | + | webkit.PanedSetStartChild(parent.container, 0) |
| 316 | - | webkit.PanedSetStartChild(panedPtr, 0) | 307 | + | } else if parent.right == target { |
| 317 | - | } else if parent.right == target { | 308 | + | webkit.PanedSetEndChild(parent.container, 0) |
| 318 | - | webkit.PanedSetEndChild(panedPtr, 0) | 309 | + | } |
| 319 | - | } | |||
| 320 | - | return nil | |||
| 321 | - | }) | |||
| 322 | } | 310 | } | ||
| 323 | } | 311 | } | ||
| 324 | 312 | ||||
| @@ -328,15 +316,12 @@ func (spm *StackedPaneManager) reattachToParent(container uintptr, target *paneN | |||||
| 328 | if spm.wm.window != nil { | 316 | if spm.wm.window != nil { | ||
| 329 | spm.wm.window.SetChild(container) | 317 | spm.wm.window.SetChild(container) | ||
| 330 | } | 318 | } | ||
| 331 | - | } else if parent.container != nil { | 319 | + | } else if parent.container != 0 && webkit.WidgetIsValid(parent.container) { |
| 332 | - | parent.container.Execute(func(panedPtr uintptr) error { | 320 | + | if parent.left == target { |
| 333 | - | if parent.left == target { | 321 | + | webkit.PanedSetStartChild(parent.container, container) |
| 334 | - | webkit.PanedSetStartChild(panedPtr, container) | 322 | + | } else if parent.right == target { |
| 335 | - | } else if parent.right == target { | 323 | + | webkit.PanedSetEndChild(parent.container, container) |
| 336 | - | webkit.PanedSetEndChild(panedPtr, container) | 324 | + | } |
| 337 | - | } | |||
| 338 | - | return nil | |||
| 339 | - | }) | |||
| 340 | } | 325 | } | ||
| 341 | } | 326 | } | ||
| 342 | 327 | ||||
| @@ -357,7 +342,7 @@ func (spm *StackedPaneManager) finalizeStackCreation(stackNode, newLeaf *paneNod | |||||
| 357 | currentActiveIndex := stackNode.activeStackIndex | 342 | currentActiveIndex := stackNode.activeStackIndex | ||
| 358 | if currentActiveIndex >= 0 && currentActiveIndex < len(stackNode.stackedPanes) && currentActiveIndex != insertIndex { | 343 | if currentActiveIndex >= 0 && currentActiveIndex < len(stackNode.stackedPanes) && currentActiveIndex != insertIndex { | ||
| 359 | currentlyActivePane := stackNode.stackedPanes[currentActiveIndex] | 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 | currentTitle := currentlyActivePane.pane.webView.GetTitle() | 346 | currentTitle := currentlyActivePane.pane.webView.GetTitle() | ||
| 362 | if currentTitle != "" { | 347 | if currentTitle != "" { | ||
| 363 | spm.updateTitleBarLabel(currentlyActivePane, currentTitle) | 348 | spm.updateTitleBarLabel(currentlyActivePane, currentTitle) | ||
| @@ -410,20 +395,20 @@ func (spm *StackedPaneManager) UpdateStackVisibility(stackNode *paneNode) { | |||||
| 410 | for i, pane := range stackNode.stackedPanes { | 395 | for i, pane := range stackNode.stackedPanes { | ||
| 411 | if i == activeIndex { | 396 | if i == activeIndex { | ||
| 412 | // Active pane: show container, NEVER show title bar | 397 | // Active pane: show container, NEVER show title bar | ||
| 413 | - | if pane.container != nil { | 398 | + | if pane.container != 0 { |
| 414 | - | webkit.WidgetSetVisible(pane.container.Ptr(), true) | 399 | + | webkit.WidgetSetVisible(pane.container, true) |
| 415 | } | 400 | } | ||
| 416 | - | if pane.titleBar != nil { | 401 | + | if pane.titleBar != 0 { |
| 417 | - | webkit.WidgetSetVisible(pane.titleBar.Ptr(), false) // ABSOLUTE RULE: never visible for active pane | 402 | + | webkit.WidgetSetVisible(pane.titleBar, false) // ABSOLUTE RULE: never visible for active pane |
| 418 | } | 403 | } | ||
| 419 | log.Printf("[workspace] active pane %d: container=visible, titleBar=HIDDEN", i) | 404 | log.Printf("[workspace] active pane %d: container=visible, titleBar=HIDDEN", i) | ||
| 420 | } else { | 405 | } else { | ||
| 421 | // Inactive panes: hide container, show title bar | 406 | // Inactive panes: hide container, show title bar | ||
| 422 | - | if pane.container != nil { | 407 | + | if pane.container != 0 { |
| 423 | - | webkit.WidgetSetVisible(pane.container.Ptr(), false) | 408 | + | webkit.WidgetSetVisible(pane.container, false) |
| 424 | } | 409 | } | ||
| 425 | - | if pane.titleBar != nil { | 410 | + | if pane.titleBar != 0 { |
| 426 | - | webkit.WidgetSetVisible(pane.titleBar.Ptr(), true) | 411 | + | webkit.WidgetSetVisible(pane.titleBar, true) |
| 427 | } | 412 | } | ||
| 428 | } | 413 | } | ||
| 429 | } | 414 | } | ||
| @@ -486,7 +471,7 @@ func (spm *StackedPaneManager) NavigateStack(direction string) bool { | |||||
| 486 | // Update title bar for the pane transitioning from ACTIVE to INACTIVE | 471 | // Update title bar for the pane transitioning from ACTIVE to INACTIVE | ||
| 487 | // This ensures the collapsed pane shows its current page title (Zellij-style layout) | 472 | // This ensures the collapsed pane shows its current page title (Zellij-style layout) | ||
| 488 | currentActivePane := stackNode.stackedPanes[currentIndex] | 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 | currentTitle := currentActivePane.pane.webView.GetTitle() | 475 | currentTitle := currentActivePane.pane.webView.GetTitle() | ||
| 491 | if currentTitle != "" { | 476 | if currentTitle != "" { | ||
| 492 | spm.updateTitleBarLabel(currentActivePane, currentTitle) | 477 | spm.updateTitleBarLabel(currentActivePane, currentTitle) | ||
| @@ -561,7 +546,7 @@ func (spm *StackedPaneManager) UpdateTitleBar(webView *webkit.WebView, title str | |||||
| 561 | } | 546 | } | ||
| 562 | 547 | ||||
| 563 | // Check if this pane is in a stack and has a title bar | 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 | // CRITICAL: Only update title bar for INACTIVE panes | 550 | // CRITICAL: Only update title bar for INACTIVE panes | ||
| 566 | // Active panes should never show their title bar | 551 | // Active panes should never show their title bar | ||
| 567 | stackNode := node.parent | 552 | stackNode := node.parent | ||
| @@ -589,7 +574,7 @@ func (spm *StackedPaneManager) UpdateTitleBar(webView *webkit.WebView, title str | |||||
| 589 | 574 | ||||
| 590 | // updateTitleBarLabel updates the label widget within a title bar by recreating it | 575 | // updateTitleBarLabel updates the label widget within a title bar by recreating it | ||
| 591 | func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string) { | 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 | return | 578 | return | ||
| 594 | } | 579 | } | ||
| 595 | 580 | ||||
| @@ -607,7 +592,7 @@ func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string) | |||||
| 607 | } | 592 | } | ||
| 608 | 593 | ||||
| 609 | // Replace the old title bar in the stack | 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 | // Find the correct insertion position based on the pane's index in the stack | 596 | // Find the correct insertion position based on the pane's index in the stack | ||
| 612 | paneIndex := -1 | 597 | paneIndex := -1 | ||
| 613 | for i, stackedPane := range node.parent.stackedPanes { | 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 | // Remove old title bar | 605 | // Remove old title bar | ||
| 621 | - | if node.titleBar != nil { | 606 | + | if node.titleBar != 0 && webkit.WidgetIsValid(node.titleBar) { |
| 622 | - | node.titleBar.Execute(func(titleBarPtr uintptr) error { | 607 | + | webkit.WidgetUnparent(node.titleBar) |
| 623 | - | webkit.WidgetUnparent(titleBarPtr) | |||
| 624 | - | return nil | |||
| 625 | - | }) | |||
| 626 | } | 608 | } | ||
| 627 | 609 | ||||
| 628 | // Update the node's title bar reference | 610 | // Update the node's title bar reference | ||
| @@ -631,14 +613,14 @@ func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string) | |||||
| 631 | // Insert the new title bar at the correct position | 613 | // Insert the new title bar at the correct position | ||
| 632 | if paneIndex == 0 { | 614 | if paneIndex == 0 { | ||
| 633 | // First pane - insert at the beginning | 615 | // First pane - insert at the beginning | ||
| 634 | - | if node.parent.stackWrapper != nil { | 616 | + | if node.parent.stackWrapper != 0 { |
| 635 | - | webkit.BoxPrepend(node.parent.stackWrapper.Ptr(), newTitleBar) | 617 | + | webkit.BoxPrepend(node.parent.stackWrapper, newTitleBar) |
| 636 | } | 618 | } | ||
| 637 | } else { | 619 | } else { | ||
| 638 | // Insert after the previous pane's container | 620 | // Insert after the previous pane's container | ||
| 639 | prevPane := node.parent.stackedPanes[paneIndex-1] | 621 | prevPane := node.parent.stackedPanes[paneIndex-1] | ||
| 640 | - | if node.parent.stackWrapper != nil && prevPane.container != nil { | 622 | + | if node.parent.stackWrapper != 0 && prevPane.container != 0 { |
| 641 | - | webkit.BoxInsertChildAfter(node.parent.stackWrapper.Ptr(), newTitleBar, prevPane.container.Ptr()) | 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 | return titleBox | 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 | func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error { | 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 | return errors.New("node is not part of a stacked pane") | 665 | return errors.New("node is not part of a stacked pane") | ||
| 681 | } | 666 | } | ||
| 682 | 667 | ||||
| 683 | stackNode := node.parent | 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 | nodeIndex := -1 | 671 | nodeIndex := -1 | ||
| 687 | for i, stackedPane := range stackNode.stackedPanes { | 672 | for i, stackedPane := range stackNode.stackedPanes { | ||
| 688 | if stackedPane == node { | 673 | if stackedPane == node { | ||
| @@ -690,155 +675,162 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error { | |||||
| 690 | break | 675 | break | ||
| 691 | } | 676 | } | ||
| 692 | } | 677 | } | ||
| 693 | - | ||||
| 694 | if nodeIndex == -1 { | 678 | if nodeIndex == -1 { | ||
| 695 | return errors.New("node not found in stack") | 679 | return errors.New("node not found in stack") | ||
| 696 | } | 680 | } | ||
| 697 | 681 | ||||
| 698 | log.Printf("[workspace] closing stacked pane: index=%d stackSize=%d", nodeIndex, len(stackNode.stackedPanes)) | 682 | log.Printf("[workspace] closing stacked pane: index=%d stackSize=%d", nodeIndex, len(stackNode.stackedPanes)) | ||
| 699 | 683 | ||||
| 700 | - | // Clean up the pane | 684 | + | // Detach hover/focus controllers before tearing down widgets to avoid GTK |
| 701 | - | spm.wm.detachHover(node) | 685 | + | // callbacks referencing freed memory during destruction. |
| 702 | - | node.pane.CleanupFromWorkspace(spm.wm) | 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 | 691 | + | // Remove the pane widgets from the stack container before mutating the tree. |
| 705 | - | var stackBox *SafeWidget = stackNode.stackWrapper | 692 | + | stackBox := stackNode.stackWrapper |
| 706 | - | if stackBox == nil { | 693 | + | if stackBox == 0 { |
| 707 | stackBox = stackNode.container | 694 | stackBox = stackNode.container | ||
| 708 | } | 695 | } | ||
| 709 | - | 696 | + | if stackBox != 0 { | |
| 710 | - | if stackBox != nil { | 697 | + | if node.titleBar != 0 { |
| 711 | - | if node.titleBar != nil { | 698 | + | webkit.BoxRemove(stackBox, node.titleBar) |
| 712 | - | webkit.BoxRemove(stackBox.Ptr(), node.titleBar.Ptr()) | |||
| 713 | } | 699 | } | ||
| 714 | - | if node.container != nil { | 700 | + | if node.container != 0 { |
| 715 | - | webkit.BoxRemove(stackBox.Ptr(), node.container.Ptr()) | 701 | + | webkit.BoxRemove(stackBox, node.container) |
| 716 | } | 702 | } | ||
| 717 | } | 703 | } | ||
| 718 | 704 | ||||
| 719 | - | // Clean up the pane | 705 | + | // Remove the pane from the stack slice. |
| 720 | - | node.pane.Cleanup() | |||
| 721 | - | ||||
| 722 | - | // Remove from the stack | |||
| 723 | stackNode.stackedPanes = append(stackNode.stackedPanes[:nodeIndex], stackNode.stackedPanes[nodeIndex+1:]...) | 706 | stackNode.stackedPanes = append(stackNode.stackedPanes[:nodeIndex], stackNode.stackedPanes[nodeIndex+1:]...) | ||
| 724 | 707 | ||||
| 725 | - | // Handle the remaining panes in the stack | 708 | + | switch len(stackNode.stackedPanes) { |
| 726 | - | if len(stackNode.stackedPanes) == 0 { | 709 | + | case 0: |
| 727 | - | // No panes left in stack - this should not happen normally | 710 | + | // Stack container is now empty: remove it from the layout entirely. |
| 728 | - | log.Printf("[workspace] warning: empty stack after closing pane") | 711 | + | parent := stackNode.parent |
| 729 | - | return errors.New("stack became empty") | 712 | + | if parent == nil { |
| 730 | - | } else if len(stackNode.stackedPanes) == 1 { | 713 | + | // Stack was the root of the workspace — closing this pane exits the app. |
| 731 | - | // Only one pane left - convert back to a regular pane | 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 | lastPane := stackNode.stackedPanes[0] | 766 | lastPane := stackNode.stackedPanes[0] | ||
| 733 | parent := stackNode.parent | 767 | parent := stackNode.parent | ||
| 734 | lastPaneContainer := lastPane.container | 768 | lastPaneContainer := lastPane.container | ||
| 735 | 769 | ||||
| 736 | - | // Remove the title bar since we're going back to single pane | 770 | + | if lastPane.titleBar != 0 && stackBox != 0 { |
| 737 | - | if lastPane.titleBar != nil && stackBox != nil { | 771 | + | webkit.BoxRemove(stackBox, lastPane.titleBar) |
| 738 | - | webkit.BoxRemove(stackBox.Ptr(), lastPane.titleBar.Ptr()) | |||
| 739 | } | 772 | } | ||
| 740 | - | 773 | + | if stackBox != 0 && lastPaneContainer != 0 { | |
| 741 | - | // Unparent the pane container from stack wrapper | 774 | + | webkit.BoxRemove(stackBox, lastPaneContainer) |
| 742 | - | if stackBox != nil && lastPaneContainer != nil { | 775 | + | } |
| 743 | - | webkit.BoxRemove(stackBox.Ptr(), lastPaneContainer.Ptr()) | 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 | if parent == nil { | 782 | if parent == nil { | ||
| 749 | spm.wm.root = lastPane | 783 | spm.wm.root = lastPane | ||
| 750 | - | if spm.wm.window != nil && lastPaneContainer != nil { | 784 | + | lastPane.parent = nil |
| 751 | - | spm.wm.window.SetChild(lastPaneContainer.Ptr()) | 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 | } else { | 790 | } else { | ||
| 754 | if parent.left == stackNode { | 791 | if parent.left == stackNode { | ||
| 755 | parent.left = lastPane | 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 | } else if parent.right == stackNode { | 793 | } else if parent.right == stackNode { | ||
| 763 | parent.right = lastPane | 794 | parent.right = lastPane | ||
| 764 | - | if parent.container != nil && lastPaneContainer != nil { | 795 | + | } |
| 765 | - | parent.container.Execute(func(panedPtr uintptr) error { | 796 | + | lastPane.parent = parent |
| 766 | - | webkit.PanedSetEndChild(panedPtr, lastPaneContainer.Ptr()) | 797 | + | if parent.container != 0 && webkit.WidgetIsValid(parent.container) && lastPaneContainer != 0 { |
| 767 | - | return nil | 798 | + | if parent.left == lastPane { |
| 768 | - | }) | 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 | lastPane.isStacked = false | 810 | lastPane.isStacked = false | ||
| 776 | lastPane.stackedPanes = nil | 811 | lastPane.stackedPanes = nil | ||
| 777 | - | lastPane.titleBar = nil | 812 | + | lastPane.titleBar = 0 |
| 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 | |||
| 786 | spm.wm.viewToNode[lastPane.pane.webView] = lastPane | 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 | 815 | + | generation := spm.wm.nextCleanupGeneration() |
| 789 | - | // Check if currentlyFocused is part of this stack (including the pane being closed) | 816 | + | spm.wm.cleanupPane(node, generation) |
| 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") | |||
| 808 | 817 | ||||
| 809 | - | // Log widget state before focus operations | 818 | + | spm.wm.SetActivePane(lastPane, SourceClose) |
| 810 | - | if lastPaneWidget := lastPane.pane.webView.Widget(); lastPaneWidget != 0 { | 819 | + | if lastPaneWidget := lastPane.pane.webView.Widget(); lastPaneWidget != 0 { |
| 811 | - | visible := webkit.WidgetGetVisible(lastPaneWidget) | 820 | + | webkit.WidgetShow(lastPaneWidget) |
| 812 | - | log.Printf("[workspace] Widget state before focus: widget=%#x visible=%v", | 821 | + | webkit.WidgetGrabFocus(lastPaneWidget) |
| 813 | - | lastPaneWidget, visible) | 822 | + | } |
| 814 | - | } | |||
| 815 | 823 | ||||
| 816 | - | // Mirror the exact logic from finalizeStackCreation that works perfectly: | 824 | + | stackNode.parent = nil |
| 817 | - | // 1. First call SetActivePane (this does all the focus work) | 825 | + | stackNode.container = 0 |
| 818 | - | // 2. Then set currentlyFocused | 826 | + | stackNode.stackWrapper = 0 |
| 819 | - | spm.wm.SetActivePane(lastPane, SourceClose) | 827 | + | stackNode.stackedPanes = nil |
| 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 | - | } | |||
| 833 | 828 | ||||
| 834 | - | log.Printf("[workspace] DEBUG: applied finalizeStackCreation focus logic in reverse") | 829 | + | log.Printf("[workspace] converted stack back to regular pane") |
| 835 | - | } else { | 830 | + | return nil |
| 836 | - | log.Printf("[workspace] DEBUG: not focusing remaining pane (shouldFocus=false)") | |||
| 837 | - | } | |||
| 838 | 831 | ||||
| 839 | - | log.Printf("[workspace] converted single-pane stack back to regular pane") | 832 | + | default: |
| 840 | - | } else { | 833 | + | // Still multiple panes in stack — adjust active index and visibility. |
| 841 | - | // Multiple panes remain in stack - adjust active index | |||
| 842 | if stackNode.activeStackIndex >= nodeIndex && stackNode.activeStackIndex > 0 { | 834 | if stackNode.activeStackIndex >= nodeIndex && stackNode.activeStackIndex > 0 { | ||
| 843 | stackNode.activeStackIndex-- | 835 | stackNode.activeStackIndex-- | ||
| 844 | } | 836 | } | ||
| @@ -846,20 +838,17 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error { | |||||
| 846 | stackNode.activeStackIndex = len(stackNode.stackedPanes) - 1 | 838 | stackNode.activeStackIndex = len(stackNode.stackedPanes) - 1 | ||
| 847 | } | 839 | } | ||
| 848 | 840 | ||||
| 849 | - | // Update visibility for remaining panes | |||
| 850 | spm.UpdateStackVisibility(stackNode) | 841 | spm.UpdateStackVisibility(stackNode) | ||
| 851 | - | ||||
| 852 | - | // Focus the new active pane if we closed the currently active one | |||
| 853 | if spm.wm.GetActiveNode() == node { | 842 | if spm.wm.GetActiveNode() == node { | ||
| 854 | - | newActivePaneInStack := stackNode.stackedPanes[stackNode.activeStackIndex] | 843 | + | newActivePane := stackNode.stackedPanes[stackNode.activeStackIndex] |
| 855 | - | spm.wm.SetActivePane(newActivePaneInStack, SourceClose) | 844 | + | spm.wm.SetActivePane(newActivePane, SourceClose) |
| 856 | } | 845 | } | ||
| 857 | 846 | ||||
| 847 | + | generation := spm.wm.nextCleanupGeneration() | |||
| 848 | + | spm.wm.cleanupPane(node, generation) | |||
| 849 | + | ||||
| 858 | log.Printf("[workspace] closed pane from stack: remaining=%d activeIndex=%d", | 850 | log.Printf("[workspace] closed pane from stack: remaining=%d activeIndex=%d", | ||
| 859 | len(stackNode.stackedPanes), stackNode.activeStackIndex) | 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 | parts = append(parts, fmt.Sprintf("split orientation=%d", node.orientation)) | 134 | parts = append(parts, fmt.Sprintf("split orientation=%d", node.orientation)) | ||
| 135 | } | 135 | } | ||
| 136 | 136 | ||||
| 137 | - | if node.container != nil { | 137 | + | if node.container != 0 { |
| 138 | - | parts = append(parts, fmt.Sprintf("widget=%#x", node.container.Ptr())) | 138 | + | parts = append(parts, fmt.Sprintf("widget=%#x", node.container)) |
| 139 | } | 139 | } | ||
| 140 | 140 | ||||
| 141 | if node.parent != nil { | 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 | package browser | 2 | package browser | ||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "log" | 6 | "log" | ||
| 7 | - | "time" | |||
| 8 | 7 | ||||
| 9 | "github.com/bnema/dumber/pkg/webkit" | 8 | "github.com/bnema/dumber/pkg/webkit" | ||
| 10 | ) | 9 | ) | ||
| 11 | 10 | ||||
| 12 | // GeometryValidator validates pane geometry constraints for splits and operations | 11 | // GeometryValidator validates pane geometry constraints for splits and operations | ||
| 13 | type GeometryValidator struct { | 12 | type GeometryValidator struct { | ||
| 14 | - | minPaneWidth int // Minimum width for a pane in pixels | 13 | + | minPaneWidth int |
| 15 | - | minPaneHeight int // Minimum height for a pane in pixels | 14 | + | minPaneHeight int |
| 16 | - | debugMode bool // Enable debug logging | 15 | + | debugMode bool |
| 17 | } | 16 | } | ||
| 18 | 17 | ||||
| 19 | // PaneGeometry represents the geometry of a pane | 18 | // PaneGeometry represents the geometry of a pane | ||
| @@ -25,285 +24,145 @@ type PaneGeometry struct { | |||||
| 25 | IsValid bool | 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 | // ValidationResult represents the result of a geometry validation | 27 | // ValidationResult represents the result of a geometry validation | ||
| 37 | type ValidationResult struct { | 28 | type ValidationResult struct { | ||
| 38 | IsValid bool | 29 | IsValid bool | ||
| 39 | Reason string | 30 | Reason string | ||
| 40 | - | Constraints SplitConstraints | |||
| 41 | Geometry PaneGeometry | 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 | func NewGeometryValidator() *GeometryValidator { | 36 | func NewGeometryValidator() *GeometryValidator { | ||
| 47 | return &GeometryValidator{ | 37 | return &GeometryValidator{ | ||
| 48 | - | minPaneWidth: 300, // Inspired by Zellij's MIN_TERMINAL_WIDTH * character_width | 38 | + | minPaneWidth: 300, // Minimum width for usable pane |
| 49 | - | minPaneHeight: 200, // Inspired by Zellij's MIN_TERMINAL_HEIGHT * character_height | 39 | + | minPaneHeight: 200, // Minimum height for usable pane |
| 50 | debugMode: false, | 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 | // SetDebugMode enables or disables debug logging | 44 | // SetDebugMode enables or disables debug logging | ||
| 62 | func (gv *GeometryValidator) SetDebugMode(debug bool) { | 45 | func (gv *GeometryValidator) SetDebugMode(debug bool) { | ||
| 63 | gv.debugMode = debug | 46 | gv.debugMode = debug | ||
| 64 | } | 47 | } | ||
| 65 | 48 | ||||
| 66 | // GetPaneGeometry extracts geometry information from a pane node | 49 | // GetPaneGeometry extracts geometry information from a pane node | ||
| 67 | - | // CRITICAL FIX: Use timeout to prevent indefinite blocking on GTK calls | |||
| 68 | func (gv *GeometryValidator) GetPaneGeometry(node *paneNode) PaneGeometry { | 50 | func (gv *GeometryValidator) GetPaneGeometry(node *paneNode) PaneGeometry { | ||
| 69 | geometry := PaneGeometry{IsValid: false} | 51 | geometry := PaneGeometry{IsValid: false} | ||
| 70 | 52 | ||||
| 71 | - | if node == nil || node.container == nil { | 53 | + | if node == nil || node.container == 0 { |
| 72 | if gv.debugMode { | 54 | if gv.debugMode { | ||
| 73 | log.Printf("[geometry] Invalid node or missing container") | 55 | log.Printf("[geometry] Invalid node or missing container") | ||
| 74 | } | 56 | } | ||
| 75 | return geometry | 57 | return geometry | ||
| 76 | } | 58 | } | ||
| 77 | 59 | ||||
| 78 | - | // CRITICAL FIX: If not on main thread, marshal GTK calls to prevent deadlock | 60 | + | // Get widget allocation directly - GTK operations are always on main thread |
| 79 | - | if !webkit.IsMainThread() { | 61 | + | allocation := webkit.WidgetGetAllocation(node.container) |
| 80 | - | // Use IdleAdd to marshal geometry query to main thread | |||
| 81 | - | resultChan := make(chan PaneGeometry, 1) | |||
| 82 | - | ||||
| 83 | - | webkit.IdleAdd(func() bool { | |||
| 84 | - | // Execute on main thread | |||
| 85 | - | mainThreadGeometry := gv.getGeometryOnMainThread(node) | |||
| 86 | - | resultChan <- mainThreadGeometry | |||
| 87 | - | return false // Remove idle callback | |||
| 88 | - | }) | |||
| 89 | - | ||||
| 90 | - | // Wait for result with timeout | |||
| 91 | - | select { | |||
| 92 | - | case result := <-resultChan: | |||
| 93 | - | return result | |||
| 94 | - | case <-time.After(100 * time.Millisecond): | |||
| 95 | - | log.Printf("[geometry] Geometry query timed out (100ms), assuming invalid") | |||
| 96 | - | return geometry | |||
| 97 | - | } | |||
| 98 | - | } | |||
| 99 | - | ||||
| 100 | - | // We're on main thread, execute directly but with timeout protection | |||
| 101 | - | return gv.getGeometryWithTimeout(node, 50*time.Millisecond) | |||
| 102 | - | } | |||
| 103 | - | ||||
| 104 | - | // getGeometryOnMainThread executes geometry query on main thread (must be called from main thread) | |||
| 105 | - | func (gv *GeometryValidator) getGeometryOnMainThread(node *paneNode) PaneGeometry { | |||
| 106 | - | geometry := PaneGeometry{IsValid: false} | |||
| 107 | - | ||||
| 108 | - | // Get geometry from GTK widget (now safe since we're on main thread) | |||
| 109 | - | err := node.container.Execute(func(ptr uintptr) error { | |||
| 110 | - | // Get widget allocation | |||
| 111 | - | allocation := webkit.WidgetGetAllocation(ptr) | |||
| 112 | - | ||||
| 113 | - | geometry.X = allocation.X | |||
| 114 | - | geometry.Y = allocation.Y | |||
| 115 | - | geometry.Width = allocation.Width | |||
| 116 | - | geometry.Height = allocation.Height | |||
| 117 | - | geometry.IsValid = true | |||
| 118 | - | ||||
| 119 | - | if gv.debugMode { | |||
| 120 | - | log.Printf("[geometry] Pane geometry: %dx%d at (%d,%d)", | |||
| 121 | - | geometry.Width, geometry.Height, geometry.X, geometry.Y) | |||
| 122 | - | } | |||
| 123 | - | ||||
| 124 | - | return nil | |||
| 125 | - | }) | |||
| 126 | - | ||||
| 127 | - | if err != nil { | |||
| 128 | - | log.Printf("[geometry] Failed to get pane geometry: %v", err) | |||
| 129 | - | geometry.IsValid = false | |||
| 130 | - | } | |||
| 131 | - | ||||
| 132 | - | return geometry | |||
| 133 | - | } | |||
| 134 | - | ||||
| 135 | - | // getGeometryWithTimeout gets geometry with timeout protection | |||
| 136 | - | func (gv *GeometryValidator) getGeometryWithTimeout(node *paneNode, timeout time.Duration) PaneGeometry { | |||
| 137 | - | geometry := PaneGeometry{IsValid: false} | |||
| 138 | - | ||||
| 139 | - | // Use ExecuteWithTimeout to prevent indefinite blocking | |||
| 140 | - | err := node.container.ExecuteWithTimeout(func(ptr uintptr) error { | |||
| 141 | - | // Get widget allocation | |||
| 142 | - | allocation := webkit.WidgetGetAllocation(ptr) | |||
| 143 | - | ||||
| 144 | - | geometry.X = allocation.X | |||
| 145 | - | geometry.Y = allocation.Y | |||
| 146 | - | geometry.Width = allocation.Width | |||
| 147 | - | geometry.Height = allocation.Height | |||
| 148 | - | geometry.IsValid = true | |||
| 149 | 62 | ||||
| 150 | - | if gv.debugMode { | 63 | + | geometry.X = allocation.X |
| 151 | - | log.Printf("[geometry] Pane geometry: %dx%d at (%d,%d)", | 64 | + | geometry.Y = allocation.Y |
| 152 | - | geometry.Width, geometry.Height, geometry.X, geometry.Y) | 65 | + | geometry.Width = allocation.Width |
| 153 | - | } | 66 | + | geometry.Height = allocation.Height |
| 154 | - | 67 | + | geometry.IsValid = true | |
| 155 | - | return nil | |||
| 156 | - | }, timeout) | |||
| 157 | 68 | ||||
| 158 | - | if err != nil { | 69 | + | if gv.debugMode { |
| 159 | - | log.Printf("[geometry] Failed to get pane geometry: %v", err) | 70 | + | log.Printf("[geometry] Pane geometry: %dx%d at (%d,%d)", |
| 160 | - | geometry.IsValid = false | 71 | + | geometry.Width, geometry.Height, geometry.X, geometry.Y) |
| 161 | } | 72 | } | ||
| 162 | 73 | ||||
| 163 | return geometry | 74 | return geometry | ||
| 164 | } | 75 | } | ||
| 165 | 76 | ||||
| 166 | - | // ValidateHorizontalSplit validates if a pane can be split horizontally | 77 | + | // prepareGeometryResult centralizes the common geometry validation logic used by |
| 167 | - | func (gv *GeometryValidator) ValidateHorizontalSplit(node *paneNode) ValidationResult { | 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 | geometry := gv.GetPaneGeometry(node) | 82 | geometry := gv.GetPaneGeometry(node) | ||
| 169 | - | 83 | + | result := ValidationResult{Geometry: geometry} | |
| 170 | - | result := ValidationResult{ | |||
| 171 | - | Geometry: geometry, | |||
| 172 | - | Constraints: SplitConstraints{ | |||
| 173 | - | MinResultingWidth: gv.minPaneWidth, | |||
| 174 | - | MinResultingHeight: gv.minPaneHeight, | |||
| 175 | - | MaxSplitRatio: 0.8, | |||
| 176 | - | MinSplitRatio: 0.2, | |||
| 177 | - | }, | |||
| 178 | - | } | |||
| 179 | 84 | ||||
| 180 | if !geometry.IsValid { | 85 | if !geometry.IsValid { | ||
| 181 | - | // Check if this is a zero allocation during startup | |||
| 182 | if geometry.Width == 0 && geometry.Height == 0 { | 86 | if geometry.Width == 0 && geometry.Height == 0 { | ||
| 183 | - | // Allow operation but flag for re-validation | |||
| 184 | result.IsValid = true | 87 | result.IsValid = true | ||
| 185 | - | result.Reason = "geometry pending allocation, allowing horizontal split" | 88 | + | result.Reason = "geometry pending allocation" |
| 186 | result.RequiresRevalidation = true | 89 | result.RequiresRevalidation = true | ||
| 187 | - | log.Printf("[geometry] Allowing horizontal split with zero allocation - will revalidate after first allocation") | |||
| 188 | - | return result | |||
| 189 | } else { | 90 | } else { | ||
| 190 | result.IsValid = false | 91 | result.IsValid = false | ||
| 191 | result.Reason = "could not determine pane geometry" | 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 | 97 | + | return geometry, result, true |
| 197 | - | // Each resulting pane must be at least minPaneWidth wide | 98 | + | } |
| 198 | - | requiredWidth := gv.minPaneWidth * 2 | 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 | if geometry.Width < requiredWidth { | 108 | if geometry.Width < requiredWidth { | ||
| 201 | result.IsValid = false | 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 | geometry.Width, requiredWidth) | 111 | geometry.Width, requiredWidth) | ||
| 204 | return result | 112 | return result | ||
| 205 | } | 113 | } | ||
| 206 | 114 | ||||
| 207 | - | // Check height constraints | |||
| 208 | if geometry.Height < gv.minPaneHeight { | 115 | if geometry.Height < gv.minPaneHeight { | ||
| 209 | result.IsValid = false | 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 | geometry.Height, gv.minPaneHeight) | 118 | geometry.Height, gv.minPaneHeight) | ||
| 212 | return result | 119 | return result | ||
| 213 | } | 120 | } | ||
| 214 | 121 | ||||
| 215 | result.IsValid = true | 122 | result.IsValid = true | ||
| 216 | result.Reason = "horizontal split is valid" | 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 | return result | 124 | return result | ||
| 224 | } | 125 | } | ||
| 225 | 126 | ||||
| 226 | // ValidateVerticalSplit validates if a pane can be split vertically | 127 | // ValidateVerticalSplit validates if a pane can be split vertically | ||
| 227 | func (gv *GeometryValidator) ValidateVerticalSplit(node *paneNode) ValidationResult { | 128 | func (gv *GeometryValidator) ValidateVerticalSplit(node *paneNode) ValidationResult { | ||
| 228 | - | geometry := gv.GetPaneGeometry(node) | 129 | + | geometry, result, ok := gv.prepareGeometryResult(node) |
| 229 | - | 130 | + | if !ok { | |
| 230 | - | result := ValidationResult{ | 131 | + | return result |
| 231 | - | Geometry: geometry, | |||
| 232 | - | Constraints: SplitConstraints{ | |||
| 233 | - | MinResultingWidth: gv.minPaneWidth, | |||
| 234 | - | MinResultingHeight: gv.minPaneHeight, | |||
| 235 | - | MaxSplitRatio: 0.8, | |||
| 236 | - | MinSplitRatio: 0.2, | |||
| 237 | - | }, | |||
| 238 | - | } | |||
| 239 | - | ||||
| 240 | - | if !geometry.IsValid { | |||
| 241 | - | // Check if this is a zero allocation during startup | |||
| 242 | - | if geometry.Width == 0 && geometry.Height == 0 { | |||
| 243 | - | // Allow operation but flag for re-validation | |||
| 244 | - | result.IsValid = true | |||
| 245 | - | result.Reason = "geometry pending allocation, allowing vertical split" | |||
| 246 | - | result.RequiresRevalidation = true | |||
| 247 | - | log.Printf("[geometry] Allowing vertical split with zero allocation - will revalidate after first allocation") | |||
| 248 | - | return result | |||
| 249 | - | } else { | |||
| 250 | - | result.IsValid = false | |||
| 251 | - | result.Reason = "could not determine pane geometry" | |||
| 252 | - | return result | |||
| 253 | - | } | |||
| 254 | } | 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 | requiredHeight := gv.minPaneHeight * 2 | 134 | requiredHeight := gv.minPaneHeight * 2 | ||
| 259 | - | ||||
| 260 | if geometry.Height < requiredHeight { | 135 | if geometry.Height < requiredHeight { | ||
| 261 | result.IsValid = false | 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 | geometry.Height, requiredHeight) | 138 | geometry.Height, requiredHeight) | ||
| 264 | return result | 139 | return result | ||
| 265 | } | 140 | } | ||
| 266 | 141 | ||||
| 267 | - | // Check width constraints | |||
| 268 | if geometry.Width < gv.minPaneWidth { | 142 | if geometry.Width < gv.minPaneWidth { | ||
| 269 | result.IsValid = false | 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 | geometry.Width, gv.minPaneWidth) | 145 | geometry.Width, gv.minPaneWidth) | ||
| 272 | return result | 146 | return result | ||
| 273 | } | 147 | } | ||
| 274 | 148 | ||||
| 275 | result.IsValid = true | 149 | result.IsValid = true | ||
| 276 | result.Reason = "vertical split is valid" | 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 | return result | 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 | func (gv *GeometryValidator) ValidateSplit(node *paneNode, direction string) ValidationResult { | 155 | func (gv *GeometryValidator) ValidateSplit(node *paneNode, direction string) ValidationResult { | ||
| 288 | if node == nil { | 156 | if node == nil { | ||
| 289 | - | return ValidationResult{ | 157 | + | return ValidationResult{IsValid: false, Reason: "node is nil"} |
| 290 | - | IsValid: false, | |||
| 291 | - | Reason: "node is nil", | |||
| 292 | - | } | |||
| 293 | } | 158 | } | ||
| 294 | 159 | ||||
| 295 | if node.isStacked { | 160 | if node.isStacked { | ||
| 296 | - | return ValidationResult{ | 161 | + | return ValidationResult{IsValid: false, Reason: "cannot split stacked pane container"} |
| 297 | - | IsValid: false, | |||
| 298 | - | Reason: "cannot split stacked pane container directly", | |||
| 299 | - | } | |||
| 300 | } | 162 | } | ||
| 301 | 163 | ||||
| 302 | if !node.isLeaf { | 164 | if !node.isLeaf { | ||
| 303 | - | return ValidationResult{ | 165 | + | return ValidationResult{IsValid: false, Reason: "can only split leaf panes"} |
| 304 | - | IsValid: false, | |||
| 305 | - | Reason: "can only split leaf panes", | |||
| 306 | - | } | |||
| 307 | } | 166 | } | ||
| 308 | 167 | ||||
| 309 | switch direction { | 168 | switch direction { | ||
| @@ -312,285 +171,39 @@ func (gv *GeometryValidator) ValidateSplit(node *paneNode, direction string) Val | |||||
| 312 | case "up", "down": | 171 | case "up", "down": | ||
| 313 | return gv.ValidateVerticalSplit(node) | 172 | return gv.ValidateVerticalSplit(node) | ||
| 314 | default: | 173 | default: | ||
| 315 | - | return ValidationResult{ | 174 | + | return ValidationResult{IsValid: false, Reason: fmt.Sprintf("unknown split direction: %s", direction)} |
| 316 | - | IsValid: false, | |||
| 317 | - | Reason: fmt.Sprintf("unknown split direction: %s", direction), | |||
| 318 | - | } | |||
| 319 | - | } | |||
| 320 | - | } | |||
| 321 | - | ||||
| 322 | - | // DetermineBestSplitDirection determines the best split direction for a pane (like Zellij's algorithm) | |||
| 323 | - | func (gv *GeometryValidator) DetermineBestSplitDirection(node *paneNode) (string, ValidationResult) { | |||
| 324 | - | geometry := gv.GetPaneGeometry(node) | |||
| 325 | - | ||||
| 326 | - | if !geometry.IsValid { | |||
| 327 | - | return "", ValidationResult{ | |||
| 328 | - | IsValid: false, | |||
| 329 | - | Reason: "could not determine pane geometry", | |||
| 330 | - | } | |||
| 331 | - | } | |||
| 332 | - | ||||
| 333 | - | // Calculate aspect ratio and available space | |||
| 334 | - | aspectRatio := float64(geometry.Width) / float64(geometry.Height) | |||
| 335 | - | ||||
| 336 | - | // Check which splits are possible | |||
| 337 | - | horizontalValid := gv.ValidateHorizontalSplit(node) | |||
| 338 | - | verticalValid := gv.ValidateVerticalSplit(node) | |||
| 339 | - | ||||
| 340 | - | if gv.debugMode { | |||
| 341 | - | log.Printf("[geometry] Determining best split: aspect=%.2f, horizontal=%v, vertical=%v", | |||
| 342 | - | aspectRatio, horizontalValid.IsValid, verticalValid.IsValid) | |||
| 343 | - | } | |||
| 344 | - | ||||
| 345 | - | // If only one direction is valid, use it | |||
| 346 | - | if horizontalValid.IsValid && !verticalValid.IsValid { | |||
| 347 | - | return "right", horizontalValid | |||
| 348 | - | } | |||
| 349 | - | if verticalValid.IsValid && !horizontalValid.IsValid { | |||
| 350 | - | return "down", verticalValid | |||
| 351 | - | } | |||
| 352 | - | ||||
| 353 | - | // If neither is valid, return error | |||
| 354 | - | if !horizontalValid.IsValid && !verticalValid.IsValid { | |||
| 355 | - | return "", ValidationResult{ | |||
| 356 | - | IsValid: false, | |||
| 357 | - | Reason: "pane is too small for any split operation", | |||
| 358 | - | } | |||
| 359 | - | } | |||
| 360 | - | ||||
| 361 | - | // Both directions are valid - choose based on aspect ratio and space | |||
| 362 | - | // Prefer horizontal split for wide panes, vertical for tall panes | |||
| 363 | - | const aspectThreshold = 1.5 | |||
| 364 | - | ||||
| 365 | - | if aspectRatio > aspectThreshold { | |||
| 366 | - | // Wide pane - prefer horizontal split | |||
| 367 | - | return "right", horizontalValid | |||
| 368 | - | } else if aspectRatio < (1.0 / aspectThreshold) { | |||
| 369 | - | // Tall pane - prefer vertical split | |||
| 370 | - | return "down", verticalValid | |||
| 371 | - | } else { | |||
| 372 | - | // Nearly square - prefer the direction with more available space | |||
| 373 | - | horizontalSpace := geometry.Width - (gv.minPaneWidth * 2) | |||
| 374 | - | verticalSpace := geometry.Height - (gv.minPaneHeight * 2) | |||
| 375 | - | ||||
| 376 | - | if horizontalSpace > verticalSpace { | |||
| 377 | - | return "right", horizontalValid | |||
| 378 | - | } else { | |||
| 379 | - | return "down", verticalValid | |||
| 380 | - | } | |||
| 381 | } | 175 | } | ||
| 382 | } | 176 | } | ||
| 383 | 177 | ||||
| 384 | // ValidateStackOperation validates if a pane can be converted to a stack | 178 | // ValidateStackOperation validates if a pane can be converted to a stack | ||
| 385 | func (gv *GeometryValidator) ValidateStackOperation(node *paneNode) ValidationResult { | 179 | func (gv *GeometryValidator) ValidateStackOperation(node *paneNode) ValidationResult { | ||
| 386 | - | geometry := gv.GetPaneGeometry(node) | 180 | + | geometry, result, ok := gv.prepareGeometryResult(node) |
| 387 | - | 181 | + | if !ok { | |
| 388 | - | result := ValidationResult{ | 182 | + | return result |
| 389 | - | Geometry: geometry, | |||
| 390 | - | Constraints: SplitConstraints{ | |||
| 391 | - | MinResultingWidth: gv.minPaneWidth, | |||
| 392 | - | MinResultingHeight: gv.minPaneHeight + 30, // Extra space for title bar | |||
| 393 | - | }, | |||
| 394 | - | } | |||
| 395 | - | ||||
| 396 | - | if !geometry.IsValid { | |||
| 397 | - | // Check if this is a zero allocation during startup | |||
| 398 | - | if geometry.Width == 0 && geometry.Height == 0 { | |||
| 399 | - | // Allow operation but flag for re-validation | |||
| 400 | - | result.IsValid = true | |||
| 401 | - | result.Reason = "geometry pending allocation, allowing stack operation" | |||
| 402 | - | result.RequiresRevalidation = true | |||
| 403 | - | log.Printf("[geometry] Allowing stack operation with zero allocation - will revalidate after first allocation") | |||
| 404 | - | return result | |||
| 405 | - | } else { | |||
| 406 | - | result.IsValid = false | |||
| 407 | - | result.Reason = "could not determine pane geometry" | |||
| 408 | - | return result | |||
| 409 | - | } | |||
| 410 | } | 183 | } | ||
| 411 | 184 | ||||
| 412 | - | // Check if pane is already stacked | |||
| 413 | if node.isStacked { | 185 | if node.isStacked { | ||
| 414 | result.IsValid = false | 186 | result.IsValid = false | ||
| 415 | result.Reason = "pane is already stacked" | 187 | result.Reason = "pane is already stacked" | ||
| 416 | return result | 188 | return result | ||
| 417 | } | 189 | } | ||
| 418 | 190 | ||||
| 419 | - | // Check if pane is large enough for stacking (needs space for title bars) | |||
| 420 | minStackHeight := gv.minPaneHeight + 30 // Extra space for title bar | 191 | minStackHeight := gv.minPaneHeight + 30 // Extra space for title bar | ||
| 421 | - | ||||
| 422 | if geometry.Height < minStackHeight { | 192 | if geometry.Height < minStackHeight { | ||
| 423 | result.IsValid = false | 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 | geometry.Height, minStackHeight) | 195 | geometry.Height, minStackHeight) | ||
| 426 | return result | 196 | return result | ||
| 427 | } | 197 | } | ||
| 428 | 198 | ||||
| 429 | if geometry.Width < gv.minPaneWidth { | 199 | if geometry.Width < gv.minPaneWidth { | ||
| 430 | result.IsValid = false | 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 | geometry.Width, gv.minPaneWidth) | 202 | geometry.Width, gv.minPaneWidth) | ||
| 433 | return result | 203 | return result | ||
| 434 | } | 204 | } | ||
| 435 | 205 | ||||
| 436 | result.IsValid = true | 206 | result.IsValid = true | ||
| 437 | result.Reason = "stack operation is valid" | 207 | result.Reason = "stack operation is valid" | ||
| 438 | - | ||||
| 439 | - | if gv.debugMode { | |||
| 440 | - | log.Printf("[geometry] Stack validation: %s", result.Reason) | |||
| 441 | - | } | |||
| 442 | - | ||||
| 443 | return result | 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 | stackedPaneManager *StackedPaneManager | 48 | stackedPaneManager *StackedPaneManager | ||
| 49 | focusStateMachine *FocusStateMachine | 49 | focusStateMachine *FocusStateMachine | ||
| 50 | 50 | ||||
| 51 | - | // Widget operation synchronization to prevent GTK race conditions | |||
| 52 | - | widgetMutex sync.Mutex | |||
| 53 | - | widgetRegistry *WidgetRegistry | |||
| 54 | - | ||||
| 55 | // Focus debouncing to prevent rapid oscillation from any source | 51 | // Focus debouncing to prevent rapid oscillation from any source | ||
| 56 | lastFocusTime time.Time | 52 | lastFocusTime time.Time | ||
| 57 | lastFocusTarget *paneNode | 53 | lastFocusTarget *paneNode | ||
| 58 | focusDebounce time.Duration | 54 | focusDebounce time.Duration | ||
| 59 | 55 | ||||
| 60 | - | // ENHANCED COMPONENTS: Validation and safety systems (opt-in for debugging) | 56 | + | // Validation and safety systems (debug-only, controlled by DUMBER_DEBUG_WORKSPACE env) |
| 61 | - | treeValidator *TreeValidator | 57 | + | treeValidator *TreeValidator // Debug-only tree validation |
| 62 | - | treeRebalancer *TreeRebalancer | 58 | + | treeRebalancer *TreeRebalancer // Rebalance after close operations |
| 63 | - | geometryValidator *GeometryValidator | 59 | + | geometryValidator *GeometryValidator // Validate split constraints |
| 64 | - | stackLifecycleManager *StackLifecycleManager | 60 | + | stackLifecycleManager *StackLifecycleManager // Stack pane lifecycle |
| 65 | - | stateTombstoneManager *StateTombstoneManager | |||
| 66 | 61 | ||||
| 67 | // Debug instrumentation helpers | 62 | // Debug instrumentation helpers | ||
| 63 | + | debugLevel DebugLevel | |||
| 68 | debugPaneClose bool | 64 | debugPaneClose bool | ||
| 69 | diagnostics *WorkspaceDiagnostics | 65 | diagnostics *WorkspaceDiagnostics | ||
| 70 | 66 | ||||
| 71 | - | // Enhanced pane close refactoring fields | 67 | + | // Cleanup tracking |
| 72 | cleanupCounter uint | 68 | cleanupCounter uint | ||
| 69 | + | pendingIdle map[uintptr][]*paneNode | |||
| 73 | } | 70 | } | ||
| 74 | 71 | ||||
| 75 | // Workspace navigation shortcuts are now handled globally by WindowShortcutHandler | 72 | // Workspace navigation shortcuts are now handled globally by WindowShortcutHandler | ||
| 76 | 73 | ||||
| 77 | // NewWorkspaceManager builds a workspace manager rooted at the provided pane. | 74 | // NewWorkspaceManager builds a workspace manager rooted at the provided pane. | ||
| 78 | func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManager { | 75 | func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManager { | ||
| 76 | + | debugLevel := getDebugLevel() | |||
| 77 | + | ||||
| 79 | manager := &WorkspaceManager{ | 78 | manager := &WorkspaceManager{ | ||
| 80 | app: app, | 79 | app: app, | ||
| 81 | window: rootPane.webView.Window(), | 80 | window: rootPane.webView.Window(), | ||
| 82 | viewToNode: make(map[*webkit.WebView]*paneNode), | 81 | viewToNode: make(map[*webkit.WebView]*paneNode), | ||
| 83 | lastSplitMsg: make(map[*webkit.WebView]time.Time), | 82 | lastSplitMsg: make(map[*webkit.WebView]time.Time), | ||
| 84 | lastExitMsg: make(map[*webkit.WebView]time.Time), | 83 | lastExitMsg: make(map[*webkit.WebView]time.Time), | ||
| 85 | - | paneDeduplicator: messaging.NewPaneRequestDeduplicator(), // NEW: Initialize deduplicator | 84 | + | paneDeduplicator: messaging.NewPaneRequestDeduplicator(), |
| 86 | - | widgetRegistry: NewWidgetRegistry(), // Initialize widget registry | 85 | + | focusDebounce: 150 * time.Millisecond, |
| 87 | - | focusDebounce: 150 * time.Millisecond, // 150ms focus debouncing for all sources | 86 | + | debugLevel: debugLevel, |
| 88 | } | 87 | } | ||
| 89 | 88 | ||||
| 90 | - | // Initialize enhanced components first | 89 | + | // Initialize validation components (opt-in based on debug level) |
| 91 | - | manager.treeValidator = NewTreeValidator(true, false) // enabled, debug off | 90 | + | if debugLevel >= DebugBasic { |
| 92 | - | manager.geometryValidator = NewGeometryValidator() | 91 | + | manager.treeValidator = NewTreeValidator(true, debugLevel == DebugFull) |
| 93 | - | manager.stateTombstoneManager = NewStateTombstoneManager(manager) | 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 | manager.treeRebalancer = NewTreeRebalancer(manager, manager.treeValidator) | 101 | manager.treeRebalancer = NewTreeRebalancer(manager, manager.treeValidator) | ||
| 97 | 102 | ||||
| 98 | - | // Initialize stack lifecycle manager with enhanced components | 103 | + | // Initialize stack lifecycle manager |
| 99 | manager.stackLifecycleManager = NewStackLifecycleManager(manager, manager.treeValidator) | 104 | manager.stackLifecycleManager = NewStackLifecycleManager(manager, manager.treeValidator) | ||
| 100 | 105 | ||||
| 101 | // Initialize existing specialized managers (now enhanced with bulletproof components) | 106 | // Initialize existing specialized managers (now enhanced with bulletproof components) | ||
| @@ -109,9 +114,11 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag | |||||
| 109 | if err != nil { | 114 | if err != nil { | ||
| 110 | return nil, err | 115 | return nil, err | ||
| 111 | } | 116 | } | ||
| 112 | - | // Keep CreateWindow = true for popup WebViews to ensure proper window features initialization | 117 | + | // Pane WebViews are embedded inside the main application window, so they do not |
| 113 | - | // This prevents WindowFeatures optional crashes when WebKit accesses popup properties | 118 | + | // need their own toplevel GtkWindow. Avoid creating per-pane windows to keep GTK |
| 114 | - | cfg.CreateWindow = true | 119 | + | // ownership simple (Epiphany follows the same pattern) and to prevent premature |
| 120 | + | // swapchain destruction when panes are closed. | |||
| 121 | + | cfg.CreateWindow = false | |||
| 115 | return webkit.NewWebView(cfg) | 122 | return webkit.NewWebView(cfg) | ||
| 116 | } | 123 | } | ||
| 117 | manager.createPaneFn = func(view *webkit.WebView) (*BrowserPane, error) { | 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 | if wm == nil || node == nil || !node.isLeaf { | 89 | if wm == nil || node == nil || !node.isLeaf { | ||
| 90 | return | 90 | return | ||
| 91 | } | 91 | } | ||
| 92 | - | if node.container == nil || node.hoverToken != 0 { | 92 | + | if node.container == 0 || node.hoverToken != 0 { |
| 93 | return | 93 | return | ||
| 94 | } | 94 | } | ||
| 95 | 95 | ||||
| 96 | - | var token uintptr | 96 | + | token := webkit.WidgetAddHoverHandler(node.container, func() { |
| 97 | - | node.container.Execute(func(containerPtr uintptr) error { | 97 | + | if wm == nil { |
| 98 | - | token = webkit.WidgetAddHoverHandler(containerPtr, func() { | 98 | + | return |
| 99 | - | if wm == nil { | 99 | + | } |
| 100 | - | return | 100 | + | wm.SetActivePane(node, SourceMouse) |
| 101 | - | } | |||
| 102 | - | wm.SetActivePane(node, SourceMouse) | |||
| 103 | - | }) | |||
| 104 | - | return nil | |||
| 105 | }) | 101 | }) | ||
| 106 | node.hoverToken = token | 102 | node.hoverToken = token | ||
| 107 | if token == 0 { | 103 | if token == 0 { | ||
| @@ -115,7 +111,7 @@ func (wm *WorkspaceManager) detachHover(node *paneNode) { | |||||
| 115 | return | 111 | return | ||
| 116 | } | 112 | } | ||
| 117 | 113 | ||||
| 118 | - | if node.container == nil || !node.container.IsValid() { | 114 | + | if node.container == 0 || !webkit.WidgetIsValid(node.container) { |
| 119 | node.hoverToken = 0 | 115 | node.hoverToken = 0 | ||
| 120 | return | 116 | return | ||
| 121 | } | 117 | } | ||
| @@ -123,12 +119,22 @@ func (wm *WorkspaceManager) detachHover(node *paneNode) { | |||||
| 123 | token := node.hoverToken | 119 | token := node.hoverToken | ||
| 124 | node.hoverToken = 0 | 120 | node.hoverToken = 0 | ||
| 125 | 121 | ||||
| 126 | - | node.container.Execute(func(containerPtr uintptr) error { | 122 | + | if webkit.WidgetIsValid(node.container) { |
| 127 | - | if containerPtr != 0 && webkit.WidgetIsValid(containerPtr) { | 123 | + | webkit.WidgetRemoveHoverHandler(node.container, token) |
| 128 | - | webkit.WidgetRemoveHoverHandler(containerPtr, token) | 124 | + | } |
| 129 | - | } | 125 | + | } |
| 130 | - | return nil | 126 | + | |
| 131 | - | }) | 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 | // FocusNeighbor moves focus to the nearest pane in the requested direction using the | 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 | // focusAdjacent uses geometry-based calculations to find and focus adjacent panes | 235 | // focusAdjacent uses geometry-based calculations to find and focus adjacent panes | ||
| 230 | func (wm *WorkspaceManager) focusAdjacent(direction string) bool { | 236 | func (wm *WorkspaceManager) focusAdjacent(direction string) bool { | ||
| 231 | currentFocused := wm.GetActiveNode() | 237 | currentFocused := wm.GetActiveNode() | ||
| 232 | - | if currentFocused == nil || !currentFocused.isLeaf || currentFocused.container == nil { | 238 | + | if currentFocused == nil || !currentFocused.isLeaf || currentFocused.container == 0 { |
| 233 | return false | 239 | return false | ||
| 234 | } | 240 | } | ||
| 235 | 241 | ||||
| @@ -238,12 +244,7 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool { | |||||
| 238 | return true | 244 | return true | ||
| 239 | } | 245 | } | ||
| 240 | 246 | ||||
| 241 | - | var currentBounds webkit.WidgetBounds | 247 | + | currentBounds, ok := webkit.WidgetGetBounds(currentFocused.container) |
| 242 | - | var ok bool | |||
| 243 | - | currentFocused.container.Execute(func(containerPtr uintptr) error { | |||
| 244 | - | currentBounds, ok = webkit.WidgetGetBounds(containerPtr) | |||
| 245 | - | return nil | |||
| 246 | - | }) | |||
| 247 | if !ok { | 248 | if !ok { | ||
| 248 | log.Printf("[workspace] unable to compute bounds for active pane") | 249 | log.Printf("[workspace] unable to compute bounds for active pane") | ||
| 249 | return false | 250 | return false | ||
| @@ -258,16 +259,11 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool { | |||||
| 258 | var debugCandidates []string | 259 | var debugCandidates []string | ||
| 259 | 260 | ||||
| 260 | for _, candidate := range leaves { | 261 | for _, candidate := range leaves { | ||
| 261 | - | if candidate == nil || candidate == currentFocused || candidate.container == nil { | 262 | + | if candidate == nil || candidate == currentFocused || candidate.container == 0 { |
| 262 | continue | 263 | continue | ||
| 263 | } | 264 | } | ||
| 264 | - | var bounds webkit.WidgetBounds | 265 | + | |
| 265 | - | var boundsOk bool | 266 | + | bounds, ok := webkit.WidgetGetBounds(candidate.container) |
| 266 | - | candidate.container.Execute(func(containerPtr uintptr) error { | |||
| 267 | - | bounds, boundsOk = webkit.WidgetGetBounds(containerPtr) | |||
| 268 | - | return nil | |||
| 269 | - | }) | |||
| 270 | - | ok := boundsOk | |||
| 271 | if !ok { | 267 | if !ok { | ||
| 272 | continue | 268 | continue | ||
| 273 | } | 269 | } | ||
| @@ -324,16 +320,11 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool { | |||||
| 324 | 320 | ||||
| 325 | // structuralNeighbor finds neighbors based on the tree structure rather than geometry | 321 | // structuralNeighbor finds neighbors based on the tree structure rather than geometry | ||
| 326 | func (wm *WorkspaceManager) structuralNeighbor(node *paneNode, direction string) *paneNode { | 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 | return nil | 324 | return nil | ||
| 329 | } | 325 | } | ||
| 330 | 326 | ||||
| 331 | - | var refBounds webkit.WidgetBounds | 327 | + | refBounds, ok := webkit.WidgetGetBounds(node.container) |
| 332 | - | var ok bool | |||
| 333 | - | node.container.Execute(func(containerPtr uintptr) error { | |||
| 334 | - | refBounds, ok = webkit.WidgetGetBounds(containerPtr) | |||
| 335 | - | return nil | |||
| 336 | - | }) | |||
| 337 | if !ok { | 328 | if !ok { | ||
| 338 | return nil | 329 | return nil | ||
| 339 | } | 330 | } | ||
| @@ -379,16 +370,11 @@ func (wm *WorkspaceManager) closestLeafFromSubtree(node *paneNode, cx, cy float6 | |||||
| 379 | bestScore := math.MaxFloat64 | 370 | bestScore := math.MaxFloat64 | ||
| 380 | var best *paneNode | 371 | var best *paneNode | ||
| 381 | for _, leaf := range leaves { | 372 | for _, leaf := range leaves { | ||
| 382 | - | if leaf == nil || leaf.container == nil { | 373 | + | if leaf == nil || leaf.container == 0 { |
| 383 | continue | 374 | continue | ||
| 384 | } | 375 | } | ||
| 385 | - | var bounds webkit.WidgetBounds | 376 | + | |
| 386 | - | var boundsOk bool | 377 | + | bounds, ok := webkit.WidgetGetBounds(leaf.container) |
| 387 | - | leaf.container.Execute(func(containerPtr uintptr) error { | |||
| 388 | - | bounds, boundsOk = webkit.WidgetGetBounds(containerPtr) | |||
| 389 | - | return nil | |||
| 390 | - | }) | |||
| 391 | - | ok := boundsOk | |||
| 392 | if !ok { | 378 | if !ok { | ||
| 393 | continue | 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 | log.Printf("[workspace] Starting split operation: target=%p direction=%s", target, direction) | 17 | log.Printf("[workspace] Starting split operation: target=%p direction=%s", target, direction) | ||
| 18 | 18 | ||||
| 19 | - | // Step 1: Capture state tombstone for rollback | 19 | + | // Step 1: Validate geometry constraints (only in DebugBasic+) |
| 20 | - | tombstone, err := wm.stateTombstoneManager.CaptureState("split") | 20 | + | if wm.debugLevel >= DebugBasic { |
| 21 | - | if err != nil { | 21 | + | validation := wm.geometryValidator.ValidateSplit(target, direction) |
| 22 | - | log.Printf("[workspace] Failed to capture state tombstone: %v", err) | 22 | + | if !validation.IsValid { |
| 23 | - | // Continue anyway - tombstone is for rollback safety | 23 | + | if wm.debugLevel == DebugFull { |
| 24 | - | } | 24 | + | return nil, fmt.Errorf("split validation failed: %s", validation.Reason) |
| 25 | - | 25 | + | } | |
| 26 | - | // Step 2: Validate geometry constraints | 26 | + | log.Printf("[workspace] WARNING: Split validation failed but allowing operation: %s", validation.Reason) |
| 27 | - | validation := wm.geometryValidator.ValidateSplit(target, direction) | 27 | + | } |
| 28 | - | if !validation.IsValid { | |||
| 29 | - | return nil, fmt.Errorf("split validation failed: %s", validation.Reason) | |||
| 30 | - | } | |||
| 31 | 28 | ||||
| 32 | - | // Log if re-validation will be needed due to pending widget allocation | 29 | + | // Log if re-validation will be needed due to pending widget allocation |
| 33 | - | if validation.RequiresRevalidation { | 30 | + | if validation.RequiresRevalidation { |
| 34 | - | log.Printf("[workspace] Split validation passed with pending allocation - operation will proceed") | 31 | + | log.Printf("[workspace] Split validation passed with pending allocation - operation will proceed") |
| 35 | - | } | 32 | + | } |
| 36 | 33 | ||||
| 37 | - | // Step 3: Validate tree invariants before operation | 34 | + | // Step 3: Validate tree invariants before operation |
| 38 | - | if err := wm.treeValidator.ValidateTree(wm.root, "before_split"); err != nil { | 35 | + | if err := wm.treeValidator.ValidateTree(wm.root, "before_split"); err != nil { |
| 39 | - | return nil, fmt.Errorf("tree validation failed before split: %w", err) | 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 | if webkit.IsMainThread() { | 44 | if webkit.IsMainThread() { | ||
| 44 | log.Printf("[workspace] Already on main thread, executing split directly") | 45 | log.Printf("[workspace] Already on main thread, executing split directly") | ||
| 45 | newNode, err := wm.splitNode(target, direction) | 46 | newNode, err := wm.splitNode(target, direction) | ||
| 46 | if err != nil { | 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 | return nil, err | 48 | return nil, err | ||
| 53 | } | 49 | } | ||
| 54 | 50 | ||||
| 55 | - | if err := wm.treeValidator.ValidateTree(wm.root, "after_split"); err != nil { | 51 | + | if wm.debugLevel >= DebugBasic { |
| 56 | - | log.Printf("[workspace] Tree validation failed after split: %v", err) | 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 | // Tree rebalancing is only needed after close promotions. Splits have correct allocation from GTK. | 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 | return newNode, nil | 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 | log.Printf("[workspace] Not on main thread, marshalling split via IdleAdd") | 64 | log.Printf("[workspace] Not on main thread, marshalling split via IdleAdd") | ||
| 67 | var newNode *paneNode | 65 | var newNode *paneNode | ||
| 68 | var splitErr error | 66 | var splitErr error | ||
| 69 | done := make(chan struct{}) | 67 | done := make(chan struct{}) | ||
| 70 | 68 | ||||
| 71 | - | webkit.IdleAdd(func() bool { | 69 | + | _ = webkit.IdleAdd(func() bool { |
| 72 | newNode, splitErr = wm.splitNode(target, direction) | 70 | newNode, splitErr = wm.splitNode(target, direction) | ||
| 73 | - | if splitErr != nil { | 71 | + | if splitErr == nil { |
| 74 | - | if tombstone != nil { | 72 | + | if wm.debugLevel >= DebugBasic { |
| 75 | - | if rollbackErr := wm.stateTombstoneManager.RestoreState(tombstone.ID); rollbackErr != nil { | 73 | + | if verr := wm.treeValidator.ValidateTree(wm.root, "after_split"); verr != nil { |
| 76 | - | log.Printf("[workspace] Rollback failed after split failure: %v", rollbackErr) | 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 | close(done) | 78 | close(done) | ||
| 85 | return false | 79 | return false | ||
| @@ -105,15 +99,14 @@ func (wm *WorkspaceManager) ClosePane(node *paneNode) error { | |||||
| 105 | wm.paneCloseLogf("start bulletproof close node=%p", node) | 99 | wm.paneCloseLogf("start bulletproof close node=%p", node) | ||
| 106 | wm.dumpTreeState("before_close") | 100 | wm.dumpTreeState("before_close") | ||
| 107 | 101 | ||||
| 108 | - | // Step 1: Capture state tombstone for rollback | 102 | + | // Step 1: Validate tree invariants before operation (only in DebugBasic+) |
| 109 | - | tombstone, err := wm.stateTombstoneManager.CaptureState("close") | 103 | + | if wm.debugLevel >= DebugBasic { |
| 110 | - | if err != nil { | 104 | + | if err := wm.treeValidator.ValidateTree(wm.root, "before_close"); err != nil { |
| 111 | - | log.Printf("[workspace] Failed to capture state tombstone: %v", err) | 105 | + | if wm.debugLevel == DebugFull { |
| 112 | - | } | 106 | + | return fmt.Errorf("tree validation failed before close: %w", err) |
| 113 | - | 107 | + | } | |
| 114 | - | // Step 2: Validate tree invariants before operation | 108 | + | log.Printf("[workspace] WARNING: Tree validation failed before close but allowing operation: %v", err) |
| 115 | - | if err := wm.treeValidator.ValidateTree(wm.root, "before_close"); err != nil { | 109 | + | } |
| 116 | - | return fmt.Errorf("tree validation failed before close: %w", err) | |||
| 117 | } | 110 | } | ||
| 118 | 111 | ||||
| 119 | // Step 3: Check if this is a stacked pane and use enhanced lifecycle management | 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 | if err != nil { | 124 | if err != nil { | ||
| 132 | wm.paneCloseLogf("closePane failed node=%p err=%v", node, err) | 125 | wm.paneCloseLogf("closePane failed node=%p err=%v", node, err) | ||
| 133 | wm.dumpTreeState("after_close_error") | 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 | return err | 127 | return err | ||
| 140 | } | 128 | } | ||
| 141 | 129 | ||||
| 142 | - | if err := wm.treeValidator.ValidateTree(wm.root, "after_close"); err != nil { | 130 | + | if wm.debugLevel >= DebugBasic { |
| 143 | - | log.Printf("[workspace] Tree validation failed after close: %v", err) | 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 | wm.paneCloseLogf("closePane succeeded node=%p promoted=%p root=%p", node, promoted, wm.root) | 135 | wm.paneCloseLogf("closePane succeeded node=%p promoted=%p root=%p", node, promoted, wm.root) | ||
| 146 | wm.dumpTreeState("after_close_success") | 136 | wm.dumpTreeState("after_close_success") | ||
| @@ -161,20 +151,17 @@ func (wm *WorkspaceManager) ClosePane(node *paneNode) error { | |||||
| 161 | var closeErr error | 151 | var closeErr error | ||
| 162 | done := make(chan struct{}) | 152 | done := make(chan struct{}) | ||
| 163 | 153 | ||||
| 164 | - | webkit.IdleAdd(func() bool { | 154 | + | _ = webkit.IdleAdd(func() bool { |
| 165 | wm.paneCloseLogf("invoking closePane node=%p", node) | 155 | wm.paneCloseLogf("invoking closePane node=%p", node) | ||
| 166 | promoted, closeErr = wm.closePane(node) | 156 | promoted, closeErr = wm.closePane(node) | ||
| 167 | if closeErr != nil { | 157 | if closeErr != nil { | ||
| 168 | wm.paneCloseLogf("closePane failed node=%p err=%v", node, closeErr) | 158 | wm.paneCloseLogf("closePane failed node=%p err=%v", node, closeErr) | ||
| 169 | wm.dumpTreeState("after_close_error") | 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 | } else { | 160 | } else { | ||
| 176 | - | if verr := wm.treeValidator.ValidateTree(wm.root, "after_close"); verr != nil { | 161 | + | if wm.debugLevel >= DebugBasic { |
| 177 | - | log.Printf("[workspace] Tree validation failed after close: %v", verr) | 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 | wm.paneCloseLogf("closePane succeeded node=%p promoted=%p root=%p", node, promoted, wm.root) | 166 | wm.paneCloseLogf("closePane succeeded node=%p promoted=%p root=%p", node, promoted, wm.root) | ||
| 180 | wm.dumpTreeState("after_close_success") | 167 | wm.dumpTreeState("after_close_success") | ||
| @@ -207,67 +194,64 @@ func (wm *WorkspaceManager) StackPane(target *paneNode) (*paneNode, error) { | |||||
| 207 | 194 | ||||
| 208 | log.Printf("[workspace] Starting stack operation: target=%p", target) | 195 | log.Printf("[workspace] Starting stack operation: target=%p", target) | ||
| 209 | 196 | ||||
| 210 | - | // Step 1: Validate stack operation constraints | 197 | + | // Step 1: Validate stack operation constraints (only in DebugBasic+) |
| 211 | - | validation := wm.geometryValidator.ValidateStackOperation(target) | 198 | + | if wm.debugLevel >= DebugBasic { |
| 212 | - | if !validation.IsValid { | 199 | + | validation := wm.geometryValidator.ValidateStackOperation(target) |
| 213 | - | return nil, fmt.Errorf("stack validation failed: %s", validation.Reason) | 200 | + | if !validation.IsValid { |
| 214 | - | } | 201 | + | if wm.debugLevel == DebugFull { |
| 215 | - | 202 | + | return nil, fmt.Errorf("stack validation failed: %s", validation.Reason) | |
| 216 | - | // Log if re-validation will be needed due to pending widget allocation | 203 | + | } |
| 217 | - | if validation.RequiresRevalidation { | 204 | + | log.Printf("[workspace] WARNING: Stack validation failed but allowing operation: %s", validation.Reason) |
| 218 | - | log.Printf("[workspace] Stack validation passed with pending allocation - operation will proceed") | 205 | + | } |
| 219 | - | } | |||
| 220 | 206 | ||||
| 221 | - | // Step 2: Capture state tombstone for rollback | 207 | + | // Log if re-validation will be needed due to pending widget allocation |
| 222 | - | tombstone, err := wm.stateTombstoneManager.CaptureState("stack") | 208 | + | if validation.RequiresRevalidation { |
| 223 | - | if err != nil { | 209 | + | log.Printf("[workspace] Stack validation passed with pending allocation - operation will proceed") |
| 224 | - | log.Printf("[workspace] Failed to capture state tombstone: %v", err) | 210 | + | } |
| 225 | } | 211 | } | ||
| 226 | 212 | ||||
| 227 | - | // Step 3: Validate tree invariants before operation | 213 | + | // Step 2: Validate tree invariants before operation (only in DebugBasic+) |
| 228 | - | if err := wm.treeValidator.ValidateTree(wm.root, "before_stack"); err != nil { | 214 | + | if wm.debugLevel >= DebugBasic { |
| 229 | - | return nil, fmt.Errorf("tree validation failed before stack: %w", err) | 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 | if webkit.IsMainThread() { | 224 | if webkit.IsMainThread() { | ||
| 234 | log.Printf("[workspace] Already on main thread, executing stack directly") | 225 | log.Printf("[workspace] Already on main thread, executing stack directly") | ||
| 235 | newNode, err := wm.stackedPaneManager.StackPane(target) | 226 | newNode, err := wm.stackedPaneManager.StackPane(target) | ||
| 236 | if err != nil { | 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 | return nil, err | 228 | return nil, err | ||
| 243 | } | 229 | } | ||
| 244 | 230 | ||||
| 245 | - | if err := wm.treeValidator.ValidateTree(wm.root, "after_stack"); err != nil { | 231 | + | if wm.debugLevel >= DebugBasic { |
| 246 | - | log.Printf("[workspace] Tree validation failed after stack: %v", err) | 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 | log.Printf("[workspace] Stack operation completed successfully (direct execution): newNode=%p", newNode) | 237 | log.Printf("[workspace] Stack operation completed successfully (direct execution): newNode=%p", newNode) | ||
| 250 | return newNode, nil | 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 | log.Printf("[workspace] Not on main thread, marshalling stack via IdleAdd") | 242 | log.Printf("[workspace] Not on main thread, marshalling stack via IdleAdd") | ||
| 255 | var newNode *paneNode | 243 | var newNode *paneNode | ||
| 256 | var stackErr error | 244 | var stackErr error | ||
| 257 | done := make(chan struct{}) | 245 | done := make(chan struct{}) | ||
| 258 | 246 | ||||
| 259 | - | webkit.IdleAdd(func() bool { | 247 | + | _ = webkit.IdleAdd(func() bool { |
| 260 | newNode, stackErr = wm.stackedPaneManager.StackPane(target) | 248 | newNode, stackErr = wm.stackedPaneManager.StackPane(target) | ||
| 261 | - | if stackErr != nil { | 249 | + | if stackErr == nil { |
| 262 | - | if tombstone != nil { | 250 | + | if wm.debugLevel >= DebugBasic { |
| 263 | - | if rollbackErr := wm.stateTombstoneManager.RestoreState(tombstone.ID); rollbackErr != nil { | 251 | + | if verr := wm.treeValidator.ValidateTree(wm.root, "after_stack"); verr != nil { |
| 264 | - | log.Printf("[workspace] Rollback failed after stack failure: %v", rollbackErr) | 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 | close(done) | 256 | close(done) | ||
| 273 | return false | 257 | return false | ||
| @@ -324,19 +308,11 @@ func (wm *WorkspaceManager) GetEnhancedStats() map[string]interface{} { | |||||
| 324 | stats["tree_validation"] = wm.treeValidator.GetValidationStats() | 308 | stats["tree_validation"] = wm.treeValidator.GetValidationStats() | ||
| 325 | } | 309 | } | ||
| 326 | 310 | ||||
| 327 | - | ||||
| 328 | - | ||||
| 329 | if wm.treeRebalancer != nil { | 311 | if wm.treeRebalancer != nil { | ||
| 330 | stats["tree_rebalancing"] = wm.treeRebalancer.GetRebalancingStats() | 312 | stats["tree_rebalancing"] = wm.treeRebalancer.GetRebalancingStats() | ||
| 331 | } | 313 | } | ||
| 332 | 314 | ||||
| 333 | - | if wm.geometryValidator != nil { | 315 | + | // Geometry validator no longer tracks stats - simplified |
| 334 | - | stats["geometry"] = wm.geometryValidator.GetGeometryStats(wm.root) | |||
| 335 | - | } | |||
| 336 | - | ||||
| 337 | - | if wm.stateTombstoneManager != nil { | |||
| 338 | - | stats["tombstones"] = wm.stateTombstoneManager.GetTombstoneStats() | |||
| 339 | - | } | |||
| 340 | 316 | ||||
| 341 | return stats | 317 | return stats | ||
| 342 | } | 318 | } | ||
| @@ -352,16 +328,7 @@ func (wm *WorkspaceManager) ValidateWorkspaceIntegrity() error { | |||||
| 352 | } | 328 | } | ||
| 353 | } | 329 | } | ||
| 354 | 330 | ||||
| 355 | - | // Geometry validation | 331 | + | // Geometry validation simplified - ValidateWorkspaceLayout removed |
| 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 | - | } | |||
| 365 | 332 | ||||
| 366 | log.Printf("[workspace] Workspace integrity check completed successfully") | 333 | log.Printf("[workspace] Workspace integrity check completed successfully") | ||
| 367 | return nil | 334 | return nil | ||
| @@ -371,7 +338,6 @@ func (wm *WorkspaceManager) ValidateWorkspaceIntegrity() error { | |||||
| 371 | func (wm *WorkspaceManager) ShutdownEnhancedComponents() { | 338 | func (wm *WorkspaceManager) ShutdownEnhancedComponents() { | ||
| 372 | log.Printf("[workspace] Shutting down enhanced validation components") | 339 | log.Printf("[workspace] Shutting down enhanced validation components") | ||
| 373 | 340 | ||||
| 374 | - | ||||
| 375 | // Other components don't require explicit shutdown currently | 341 | // Other components don't require explicit shutdown currently | ||
| 376 | log.Printf("[workspace] Enhanced validation components shutdown complete") | 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 | target.pane.webView.RegisterURIChangedHandler(func(uri string) { | 128 | target.pane.webView.RegisterURIChangedHandler(func(uri string) { | ||
| 129 | if uri == blankURL { | 129 | if uri == blankURL { | ||
| 130 | // Defer omnibox opening to allow page to fully initialize | 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 | if injErr := target.pane.webView.InjectScript("window.__dumber_omnibox?.open('omnibox');"); injErr != nil { | 135 | if injErr := target.pane.webView.InjectScript("window.__dumber_omnibox?.open('omnibox');"); injErr != nil { | ||
| 133 | log.Printf("[workspace] failed to open omnibox: %v", injErr) | 136 | log.Printf("[workspace] failed to open omnibox: %v", injErr) | ||
| 134 | } | 137 | } | ||
| 135 | return false // Remove idle callback | 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 | webkit.WidgetRealizeInContainer(webViewWidget) | 328 | webkit.WidgetRealizeInContainer(webViewWidget) | ||
| 326 | } | 329 | } | ||
| 327 | 330 | ||||
| 328 | - | if target.container == nil { | 331 | + | if target.container == 0 { |
| 329 | return errors.New("existing pane missing container") | 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 | wm.safelyDetachControllersBeforeReparent(target) | 363 | wm.safelyDetachControllersBeforeReparent(target) | ||
| 361 | 364 | ||||
| 362 | // Detach existing container from its current GTK parent before inserting into new paned. | 365 | // Detach existing container from its current GTK parent before inserting into new paned. | ||
| 363 | - | existingContainer := target.getContainerPtr() | 366 | + | existingContainer := target.container |
| 364 | if parent == nil { | 367 | if parent == nil { | ||
| 365 | // Target is the root - remove it from the window | 368 | // Target is the root - remove it from the window | ||
| 366 | log.Printf("[workspace] removing existing container=%#x from window", existingContainer) | 369 | log.Printf("[workspace] removing existing container=%#x from window", existingContainer) | ||
| 367 | if wm.window != nil { | 370 | if wm.window != nil { | ||
| 368 | wm.window.SetChild(0) | 371 | wm.window.SetChild(0) | ||
| 369 | } | 372 | } | ||
| 370 | - | } else if parent.container != nil { | 373 | + | } else if parent.container != 0 { |
| 371 | // Target has a parent paned - unparent it from there | 374 | // Target has a parent paned - unparent it from there | ||
| 372 | log.Printf("[workspace] unparenting existing container from parent paned") | 375 | log.Printf("[workspace] unparenting existing container from parent paned") | ||
| 373 | - | err := parent.container.Execute(func(parentPtr uintptr) error { | 376 | + | if webkit.WidgetIsValid(parent.container) { |
| 374 | - | log.Printf("[workspace] executing unparent: parentPtr=%#x", parentPtr) | 377 | + | log.Printf("[workspace] executing unparent: parentPtr=%#x", parent.container) |
| 375 | if parent.left == target { | 378 | if parent.left == target { | ||
| 376 | - | webkit.PanedSetStartChild(parentPtr, 0) | 379 | + | webkit.PanedSetStartChild(parent.container, 0) |
| 377 | } else if parent.right == target { | 380 | } else if parent.right == target { | ||
| 378 | - | webkit.PanedSetEndChild(parentPtr, 0) | 381 | + | webkit.PanedSetEndChild(parent.container, 0) |
| 379 | } | 382 | } | ||
| 380 | - | webkit.WidgetQueueAllocate(parentPtr) | 383 | + | webkit.WidgetQueueAllocate(parent.container) |
| 381 | - | return nil | |||
| 382 | - | }) | |||
| 383 | - | if err != nil { | |||
| 384 | - | log.Printf("[workspace] error during parent unparent: %v", err) | |||
| 385 | } | 384 | } | ||
| 386 | } | 385 | } | ||
| 387 | 386 | ||||
| @@ -411,22 +410,18 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa | |||||
| 411 | log.Printf("[workspace] paned set as window child: paned=%#x", paned) | 410 | log.Printf("[workspace] paned set as window child: paned=%#x", paned) | ||
| 412 | } | 411 | } | ||
| 413 | } else { | 412 | } else { | ||
| 414 | - | err := parent.container.Execute(func(parentPtr uintptr) error { | 413 | + | if webkit.WidgetIsValid(parent.container) { |
| 415 | - | log.Printf("[workspace] executing parent reparent: parentPtr=%#x, paned=%#x", parentPtr, paned) | 414 | + | log.Printf("[workspace] executing parent reparent: parentPtr=%#x, paned=%#x", parent.container, paned) |
| 416 | if parent.left == target { | 415 | if parent.left == target { | ||
| 417 | parent.left = split | 416 | parent.left = split | ||
| 418 | - | webkit.PanedSetStartChild(parentPtr, paned) | 417 | + | webkit.PanedSetStartChild(parent.container, paned) |
| 419 | } else if parent.right == target { | 418 | } else if parent.right == target { | ||
| 420 | parent.right = split | 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 | webkit.WidgetQueueAllocate(paned) | 423 | webkit.WidgetQueueAllocate(paned) | ||
| 425 | log.Printf("[workspace] paned inserted into parent successfully") | 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 | if target != nil && target.isStacked { | 441 | if target != nil && target.isStacked { | ||
| 447 | reattachTargets = append(reattachTargets, target.stackedPanes...) | 442 | reattachTargets = append(reattachTargets, target.stackedPanes...) | ||
| 448 | } | 443 | } | ||
| 449 | - | 444 | + | guardNodes := append([]*paneNode{newLeaf}, reattachTargets...) | |
| 450 | - | webkit.IdleAdd(func() bool { | 445 | + | wm.scheduleIdleGuarded(func() bool { |
| 451 | - | if newContainer != 0 { | 446 | + | if newLeaf == nil || !newLeaf.widgetValid { |
| 447 | + | return false | |||
| 448 | + | } | |||
| 449 | + | if newContainer != 0 && webkit.WidgetIsValid(newContainer) { | |||
| 452 | webkit.WidgetShow(newContainer) | 450 | webkit.WidgetShow(newContainer) | ||
| 453 | } | 451 | } | ||
| 454 | // Attach GTK focus controller to new pane | 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 | for _, candidate := range reattachTargets { | 457 | for _, candidate := range reattachTargets { | ||
| 460 | - | if candidate == nil { | 458 | + | if candidate == nil || !candidate.widgetValid { |
| 461 | continue | 459 | continue | ||
| 462 | } | 460 | } | ||
| 463 | if candidate.pendingHoverReattach { | 461 | if candidate.pendingHoverReattach { | ||
| @@ -476,7 +474,7 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa | |||||
| 476 | 474 | ||||
| 477 | wm.SetActivePane(newLeaf, SourceSplit) | 475 | wm.SetActivePane(newLeaf, SourceSplit) | ||
| 478 | return false | 476 | return false | ||
| 479 | - | }) | 477 | + | }, guardNodes...) |
| 480 | 478 | ||||
| 481 | return nil | 479 | return nil | ||
| 482 | } | 480 | } | ||
| @@ -499,7 +497,7 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane | |||||
| 499 | 497 | ||||
| 500 | // Determine what we're actually splitting | 498 | // Determine what we're actually splitting | ||
| 501 | var splitTarget *paneNode | 499 | var splitTarget *paneNode | ||
| 502 | - | var splitTargetContainer *SafeWidget | 500 | + | var splitTargetContainer uintptr |
| 503 | 501 | ||||
| 504 | if stackContainer != nil { | 502 | if stackContainer != nil { | ||
| 505 | // Case: splitting from inside a stack - we split around the entire stack | 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 | webkit.WidgetRealizeInContainer(newContainer) | 534 | webkit.WidgetRealizeInContainer(newContainer) | ||
| 537 | 535 | ||||
| 538 | // Use the determined split target container | 536 | // Use the determined split target container | ||
| 539 | - | if splitTargetContainer == nil { | 537 | + | if splitTargetContainer == 0 { |
| 540 | return nil, errors.New("split target missing container") | 538 | return nil, errors.New("split target missing container") | ||
| 541 | } | 539 | } | ||
| 542 | 540 | ||||
| 543 | // Only apply GTK operations if this is a simple widget (leaf pane) | 541 | // Only apply GTK operations if this is a simple widget (leaf pane) | ||
| 544 | // For stacked containers, we need to be more careful | 542 | // For stacked containers, we need to be more careful | ||
| 545 | if splitTarget.isLeaf { | 543 | if splitTarget.isLeaf { | ||
| 546 | - | splitTargetContainer.Execute(func(containerPtr uintptr) error { | 544 | + | if webkit.WidgetIsValid(splitTargetContainer) { |
| 547 | - | webkit.WidgetSetHExpand(containerPtr, true) | 545 | + | webkit.WidgetSetHExpand(splitTargetContainer, true) |
| 548 | - | webkit.WidgetSetVExpand(containerPtr, true) | 546 | + | webkit.WidgetSetVExpand(splitTargetContainer, true) |
| 549 | - | webkit.WidgetRealizeInContainer(containerPtr) | 547 | + | webkit.WidgetRealizeInContainer(splitTargetContainer) |
| 550 | - | return nil | 548 | + | } |
| 551 | - | }) | |||
| 552 | } else if splitTarget.isStacked { | 549 | } else if splitTarget.isStacked { | ||
| 553 | // Stack containers need the same setup as regular panes for proper splitting | 550 | // Stack containers need the same setup as regular panes for proper splitting | ||
| 554 | - | splitTargetContainer.Execute(func(containerPtr uintptr) error { | 551 | + | if webkit.WidgetIsValid(splitTargetContainer) { |
| 555 | - | webkit.WidgetSetHExpand(containerPtr, true) | 552 | + | webkit.WidgetSetHExpand(splitTargetContainer, true) |
| 556 | - | webkit.WidgetSetVExpand(containerPtr, true) | 553 | + | webkit.WidgetSetVExpand(splitTargetContainer, true) |
| 557 | - | webkit.WidgetRealizeInContainer(containerPtr) | 554 | + | webkit.WidgetRealizeInContainer(splitTargetContainer) |
| 558 | - | log.Printf("[workspace] configured stack wrapper for split: %#x", containerPtr) | 555 | + | log.Printf("[workspace] configured stack wrapper for split: %#x", splitTargetContainer) |
| 559 | - | return nil | 556 | + | } |
| 560 | - | }) | |||
| 561 | } | 557 | } | ||
| 562 | 558 | ||||
| 563 | orientation, existingFirst := mapDirection(direction) | 559 | orientation, existingFirst := mapDirection(direction) | ||
| @@ -588,6 +584,17 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane | |||||
| 588 | 584 | ||||
| 589 | parent := split.parent | 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 | // Detach split target container from its current GTK parent before inserting into new paned. | 598 | // Detach split target container from its current GTK parent before inserting into new paned. | ||
| 592 | if parent == nil { | 599 | if parent == nil { | ||
| 593 | // Split target is the root - remove it from the window | 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 | wm.window.SetChild(0) | 603 | wm.window.SetChild(0) | ||
| 597 | } | 604 | } | ||
| 598 | // For root widgets, only unparent if they actually have a GTK parent | 605 | // For root widgets, only unparent if they actually have a GTK parent | ||
| 599 | - | splitTargetContainer.Execute(func(containerPtr uintptr) error { | 606 | + | if webkit.WidgetIsValid(splitTargetContainer) { |
| 600 | - | if webkit.WidgetGetParent(containerPtr) != 0 { | 607 | + | if webkit.WidgetGetParent(splitTargetContainer) != 0 { |
| 601 | - | webkit.WidgetUnparent(containerPtr) | 608 | + | webkit.WidgetUnparent(splitTargetContainer) |
| 602 | } | 609 | } | ||
| 603 | - | return nil | 610 | + | } |
| 604 | - | }) | 611 | + | } else if parent.container != 0 { |
| 605 | - | } else if parent.container != nil { | |||
| 606 | // Split target has a parent paned - remove it (automatically unparents in GTK4) | 612 | // Split target has a parent paned - remove it (automatically unparents in GTK4) | ||
| 607 | log.Printf("[workspace] unparenting split target container=%#x from parent paned=%#x", splitTargetContainer, parent.container) | 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 | if parent.left == splitTarget { | 615 | if parent.left == splitTarget { | ||
| 610 | - | webkit.PanedSetStartChild(panedPtr, 0) | 616 | + | webkit.PanedSetStartChild(parent.container, 0) |
| 611 | } else if parent.right == splitTarget { | 617 | } else if parent.right == splitTarget { | ||
| 612 | - | webkit.PanedSetEndChild(panedPtr, 0) | 618 | + | webkit.PanedSetEndChild(parent.container, 0) |
| 613 | } | 619 | } | ||
| 614 | // GTK4 automatically unparents when we set paned child to 0 - no manual unparent needed | 620 | // GTK4 automatically unparents when we set paned child to 0 - no manual unparent needed | ||
| 615 | - | webkit.WidgetQueueAllocate(panedPtr) | 621 | + | webkit.WidgetQueueAllocate(parent.container) |
| 616 | - | return nil | 622 | + | } |
| 617 | - | }) | |||
| 618 | } | 623 | } | ||
| 619 | 624 | ||||
| 620 | // Set up the tree structure first | 625 | // Set up the tree structure first | ||
| @@ -644,19 +649,17 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane | |||||
| 644 | 649 | ||||
| 645 | // Add both containers to the new paned | 650 | // Add both containers to the new paned | ||
| 646 | if existingFirst { | 651 | if existingFirst { | ||
| 647 | - | splitTargetContainer.Execute(func(splitPtr uintptr) error { | 652 | + | if webkit.WidgetIsValid(splitTargetContainer) { |
| 648 | - | webkit.PanedSetStartChild(paned, splitPtr) | 653 | + | webkit.PanedSetStartChild(paned, splitTargetContainer) |
| 649 | webkit.PanedSetEndChild(paned, newContainer) | 654 | webkit.PanedSetEndChild(paned, newContainer) | ||
| 650 | - | log.Printf("[workspace] added splitTarget=%#x as start child, new=%#x as end child", splitPtr, newContainer) | 655 | + | log.Printf("[workspace] added splitTarget=%#x as start child, new=%#x as end child", splitTargetContainer, newContainer) |
| 651 | - | return nil | 656 | + | } |
| 652 | - | }) | |||
| 653 | } else { | 657 | } else { | ||
| 654 | - | splitTargetContainer.Execute(func(splitPtr uintptr) error { | 658 | + | if webkit.WidgetIsValid(splitTargetContainer) { |
| 655 | webkit.PanedSetStartChild(paned, newContainer) | 659 | webkit.PanedSetStartChild(paned, newContainer) | ||
| 656 | - | webkit.PanedSetEndChild(paned, splitPtr) | 660 | + | webkit.PanedSetEndChild(paned, splitTargetContainer) |
| 657 | - | log.Printf("[workspace] added new=%#x as start child, splitTarget=%#x as end child", newContainer, splitPtr) | 661 | + | log.Printf("[workspace] added new=%#x as start child, splitTarget=%#x as end child", newContainer, splitTargetContainer) |
| 658 | - | return nil | 662 | + | } |
| 659 | - | }) | |||
| 660 | } | 663 | } | ||
| 661 | 664 | ||||
| 662 | // Attach the new paned to its parent | 665 | // Attach the new paned to its parent | ||
| @@ -667,17 +670,16 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane | |||||
| 667 | log.Printf("[workspace] paned set as window child: paned=%#x", paned) | 670 | log.Printf("[workspace] paned set as window child: paned=%#x", paned) | ||
| 668 | } | 671 | } | ||
| 669 | } else { | 672 | } else { | ||
| 670 | - | parent.container.Execute(func(parentPtr uintptr) error { | 673 | + | if webkit.WidgetIsValid(parent.container) { |
| 671 | if parent.left == split { | 674 | if parent.left == split { | ||
| 672 | - | webkit.PanedSetStartChild(parentPtr, paned) | 675 | + | webkit.PanedSetStartChild(parent.container, paned) |
| 673 | } else if parent.right == split { | 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 | webkit.WidgetQueueAllocate(paned) | 680 | webkit.WidgetQueueAllocate(paned) | ||
| 678 | - | log.Printf("[workspace] paned inserted into parent=%#x", parentPtr) | 681 | + | log.Printf("[workspace] paned inserted into parent=%#x", parent.container) |
| 679 | - | return nil | 682 | + | } |
| 680 | - | }) | |||
| 681 | } | 683 | } | ||
| 682 | 684 | ||||
| 683 | webkit.WidgetShow(paned) | 685 | webkit.WidgetShow(paned) | ||
| @@ -705,9 +707,12 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane | |||||
| 705 | if splitTarget != nil && splitTarget.isStacked { | 707 | if splitTarget != nil && splitTarget.isStacked { | ||
| 706 | reattachTargets = append(reattachTargets, splitTarget.stackedPanes...) | 708 | reattachTargets = append(reattachTargets, splitTarget.stackedPanes...) | ||
| 707 | } | 709 | } | ||
| 708 | - | 710 | + | guardNodes := append([]*paneNode{newLeaf}, reattachTargets...) | |
| 709 | - | webkit.IdleAdd(func() bool { | 711 | + | wm.scheduleIdleGuarded(func() bool { |
| 710 | - | if newContainer != 0 { | 712 | + | if newLeaf == nil || !newLeaf.widgetValid { |
| 713 | + | return false | |||
| 714 | + | } | |||
| 715 | + | if newContainer != 0 && webkit.WidgetIsValid(newContainer) { | |||
| 711 | webkit.WidgetShow(newContainer) | 716 | webkit.WidgetShow(newContainer) | ||
| 712 | } | 717 | } | ||
| 713 | // Attach GTK focus controller to new pane | 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 | for _, candidate := range reattachTargets { | 723 | for _, candidate := range reattachTargets { | ||
| 719 | - | if candidate == nil { | 724 | + | if candidate == nil || !candidate.widgetValid { |
| 720 | continue | 725 | continue | ||
| 721 | } | 726 | } | ||
| 722 | if candidate.pendingHoverReattach { | 727 | if candidate.pendingHoverReattach { | ||
| @@ -734,7 +739,7 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane | |||||
| 734 | } | 739 | } | ||
| 735 | wm.SetActivePane(newLeaf, SourceSplit) | 740 | wm.SetActivePane(newLeaf, SourceSplit) | ||
| 736 | return false | 741 | return false | ||
| 737 | - | }) | 742 | + | }, guardNodes...) |
| 738 | 743 | ||||
| 739 | return newLeaf, nil | 744 | return newLeaf, nil | ||
| 740 | } | 745 | } | ||
| @@ -758,21 +763,6 @@ func (wm *WorkspaceManager) nextCleanupGeneration() uint { | |||||
| 758 | return wm.cleanupCounter | 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 | // promoteSibling promotes a sibling node to replace its parent in the tree | 766 | // promoteSibling promotes a sibling node to replace its parent in the tree | ||
| 777 | func (wm *WorkspaceManager) promoteSibling(grand *paneNode, parent *paneNode, sibling *paneNode) { | 767 | func (wm *WorkspaceManager) promoteSibling(grand *paneNode, parent *paneNode, sibling *paneNode) { | ||
| 778 | if grand == nil { | 768 | if grand == nil { | ||
| @@ -798,53 +788,141 @@ func (wm *WorkspaceManager) swapContainers(grand *paneNode, sibling *paneNode) { | |||||
| 798 | } | 788 | } | ||
| 799 | return | 789 | return | ||
| 800 | } | 790 | } | ||
| 801 | - | grand.container.Execute(func(gPtr uintptr) error { | 791 | + | // Clear CSS classes before reparenting to avoid GTK bloom filter corruption |
| 802 | - | sibling.container.Execute(func(sPtr uintptr) error { | 792 | + | if sibling.container != 0 && webkit.WidgetIsValid(sibling.container) { |
| 803 | - | // GTK4 auto-unparents when setting paned child | 793 | + | if webkit.WidgetHasCSSClass(sibling.container, activePaneClass) { |
| 804 | - | // But we should verify the widget is ready for reparenting | 794 | + | webkit.WidgetRemoveCSSClass(sibling.container, activePaneClass) |
| 805 | - | if parent := webkit.WidgetGetParent(sPtr); parent != 0 && parent != gPtr { | 795 | + | } |
| 806 | - | log.Printf("[workspace] widget %#x has unexpected parent %#x, expected %#x or 0", sPtr, parent, gPtr) | 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 { | 852 | + | wm.window.SetChild(onlyChild.container) |
| 810 | - | webkit.PanedSetStartChild(gPtr, sPtr) | 853 | + | webkit.WidgetSetHExpand(onlyChild.container, true) |
| 811 | - | log.Printf("[workspace] swapContainers: set widget %#x as start child of paned %#x", sPtr, gPtr) | 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 | } else { | 881 | } else { | ||
| 813 | - | webkit.PanedSetEndChild(gPtr, sPtr) | 882 | + | webkit.PanedSetEndChild(greatGrandparent.container, onlyChild.container) |
| 814 | - | log.Printf("[workspace] swapContainers: set widget %#x as end child of paned %#x", sPtr, gPtr) | |||
| 815 | } | 883 | } | ||
| 816 | - | webkit.WidgetQueueAllocate(gPtr) | 884 | + | |
| 817 | - | return nil | 885 | + | webkit.WidgetSetHExpand(onlyChild.container, true) |
| 818 | - | }) | 886 | + | webkit.WidgetSetVExpand(onlyChild.container, true) |
| 819 | - | return nil | 887 | + | webkit.WidgetQueueAllocate(onlyChild.container) |
| 820 | - | }) | 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 | // attachRoot attaches a node as the new window root | 902 | // attachRoot attaches a node as the new window root | ||
| 824 | func (wm *WorkspaceManager) attachRoot(root *paneNode) { | 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 | return | 905 | return | ||
| 827 | } | 906 | } | ||
| 828 | - | root.container.Execute(func(ptr uintptr) error { | 907 | + | if webkit.WidgetIsValid(root.container) { |
| 829 | // Check if widget has a parent and unparent it first | 908 | // Check if widget has a parent and unparent it first | ||
| 830 | - | if parent := webkit.WidgetGetParent(ptr); parent != 0 { | 909 | + | if parent := webkit.WidgetGetParent(root.container); parent != 0 { |
| 831 | - | log.Printf("[workspace] unparenting widget %#x from parent %#x before window attach", ptr, parent) | 910 | + | log.Printf("[workspace] unparenting widget %#x from parent %#x before window attach", root.container, parent) |
| 832 | - | webkit.WidgetUnparent(ptr) | 911 | + | webkit.WidgetUnparent(root.container) |
| 833 | } | 912 | } | ||
| 834 | 913 | ||||
| 835 | // Now safe to set as window child | 914 | // Now safe to set as window child | ||
| 836 | - | wm.window.SetChild(ptr) | 915 | + | wm.window.SetChild(root.container) |
| 837 | - | webkit.WidgetQueueAllocate(ptr) | 916 | + | webkit.WidgetQueueAllocate(root.container) |
| 838 | - | webkit.WidgetShow(ptr) | 917 | + | webkit.WidgetShow(root.container) |
| 839 | 918 | ||||
| 840 | // Verify attachment succeeded | 919 | // Verify attachment succeeded | ||
| 841 | - | if finalParent := webkit.WidgetGetParent(ptr); finalParent == 0 { | 920 | + | if finalParent := webkit.WidgetGetParent(root.container); finalParent == 0 { |
| 842 | - | log.Printf("[workspace] WARNING: widget %#x has no parent after SetChild", ptr) | 921 | + | log.Printf("[workspace] WARNING: widget %#x has no parent after SetChild", root.container) |
| 843 | } else { | 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 | 925 | + | } |
| 847 | - | }) | |||
| 848 | } | 926 | } | ||
| 849 | 927 | ||||
| 850 | // cleanupPane safely cleans up a pane node with generation tracking | 928 | // cleanupPane safely cleans up a pane node with generation tracking | ||
| @@ -856,21 +934,28 @@ func (wm *WorkspaceManager) cleanupPane(node *paneNode, generation uint) { | |||||
| 856 | return | 934 | return | ||
| 857 | } | 935 | } | ||
| 858 | 936 | ||||
| 937 | + | wm.cancelIdleHandles(node) | |||
| 938 | + | ||||
| 859 | node.widgetValid = false | 939 | node.widgetValid = false | ||
| 860 | node.cleanupGeneration = generation | 940 | node.cleanupGeneration = generation | ||
| 861 | 941 | ||||
| 862 | if node.pane != nil { | 942 | if node.pane != nil { | ||
| 863 | - | node.pane.CleanupFromWorkspace(wm) | 943 | + | pane := node.pane |
| 864 | - | if node.pane.webView != nil { | 944 | + | pane.Cleanup() |
| 865 | - | node.pane.webView.Destroy() | 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 { | 953 | + | if node.container != 0 { |
| 870 | - | node.container.Invalidate() | 954 | + | // Container widget will be destroyed by GTK when unparented |
| 871 | - | node.container = nil | 955 | + | node.container = 0 |
| 872 | } | 956 | } | ||
| 873 | 957 | ||||
| 958 | + | node.pendingIdleHandles = nil | |||
| 874 | node.parent = nil | 959 | node.parent = nil | ||
| 875 | node.left = nil | 960 | node.left = nil | ||
| 876 | node.right = nil | 961 | node.right = nil | ||
| @@ -882,30 +967,11 @@ func (wm *WorkspaceManager) decommissionParent(parent *paneNode, generation uint | |||||
| 882 | return | 967 | return | ||
| 883 | } | 968 | } | ||
| 884 | 969 | ||||
| 885 | - | // For branch nodes (paneds), ensure the GTK widget is destroyed properly | 970 | + | // For branch nodes (paneds), destroy the widget |
| 886 | - | if !parent.isLeaf && parent.container != nil { | 971 | + | // Children have already been unparented during promotion/close, no need to clear them |
| 887 | - | parent.container.Execute(func(ptr uintptr) error { | 972 | + | if !parent.isLeaf && parent.container != 0 && webkit.WidgetIsValid(parent.container) { |
| 888 | - | // Clear any remaining children first to ensure clean destruction | 973 | + | webkit.WidgetHookDestroy(parent.container) |
| 889 | - | // Note: Children may already be reparented during promotion, so verify parent relationship | 974 | + | log.Printf("[workspace] destroyed paned widget: %#x", parent.container) |
| 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 | - | }) | |||
| 909 | } | 975 | } | ||
| 910 | 976 | ||||
| 911 | wm.cleanupPane(parent, generation) | 977 | wm.cleanupPane(parent, generation) | ||
| @@ -937,9 +1003,6 @@ func (wm *WorkspaceManager) cleanupAndExit(node *paneNode) (*paneNode, error) { | |||||
| 937 | log.Printf("[workspace] closing final pane; exiting browser") | 1003 | log.Printf("[workspace] closing final pane; exiting browser") | ||
| 938 | wm.cleanupPane(node, wm.nextCleanupGeneration()) | 1004 | wm.cleanupPane(node, wm.nextCleanupGeneration()) | ||
| 939 | wm.detachHover(node) | 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 | webkit.QuitMainLoop() | 1006 | webkit.QuitMainLoop() | ||
| 944 | return nil, nil | 1007 | return nil, nil | ||
| 945 | } | 1008 | } | ||
| @@ -967,6 +1030,10 @@ func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) { | |||||
| 967 | return wm.closeStackedPaneCompat(node) | 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 | // STEP 3: Handle trivial exit cases | 1037 | // STEP 3: Handle trivial exit cases | ||
| 971 | if ctx.remaining == 1 { | 1038 | if ctx.remaining == 1 { | ||
| 972 | return wm.cleanupAndExit(node) | 1039 | return wm.cleanupAndExit(node) | ||
| @@ -975,21 +1042,49 @@ func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) { | |||||
| 975 | return wm.promoteNewRoot(ctx, node) | 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 | parent := node.parent | 1046 | parent := node.parent | ||
| 980 | sibling := wm.getSibling(node) | 1047 | sibling := wm.getSibling(node) | ||
| 981 | grandparent := parent.parent | 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 | wm.promoteSibling(grandparent, parent, sibling) | 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 | wm.swapContainers(grandparent, sibling) | 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 | wm.cleanupPane(node, ctx.Generation()) | 1077 | wm.cleanupPane(node, ctx.Generation()) | ||
| 991 | wm.decommissionParent(parent, ctx.Generation()) | 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 | return sibling, nil | 1089 | return sibling, nil | ||
| 995 | } | 1090 | } | ||
| @@ -1031,24 +1126,19 @@ func (wm *WorkspaceManager) closeStackedPaneCompat(node *paneNode) (*paneNode, e | |||||
| 1031 | } | 1126 | } | ||
| 1032 | 1127 | ||||
| 1033 | // Update GTK widgets | 1128 | // Update GTK widgets | ||
| 1034 | - | stack.parent.container.Execute(func(pPtr uintptr) error { | 1129 | + | if webkit.WidgetIsValid(stack.parent.container) && webkit.WidgetIsValid(remaining.container) { |
| 1035 | - | remaining.container.Execute(func(rPtr uintptr) error { | 1130 | + | if stack.parent.left == remaining { |
| 1036 | - | if stack.parent.left == remaining { | 1131 | + | webkit.PanedSetStartChild(stack.parent.container, remaining.container) |
| 1037 | - | webkit.PanedSetStartChild(pPtr, rPtr) | 1132 | + | } else { |
| 1038 | - | } else { | 1133 | + | webkit.PanedSetEndChild(stack.parent.container, remaining.container) |
| 1039 | - | webkit.PanedSetEndChild(pPtr, rPtr) | 1134 | + | } |
| 1040 | - | } | 1135 | + | } |
| 1041 | - | return nil | |||
| 1042 | - | }) | |||
| 1043 | - | return nil | |||
| 1044 | - | }) | |||
| 1045 | } else { | 1136 | } else { | ||
| 1046 | // Stack was root | 1137 | // Stack was root | ||
| 1047 | wm.root = remaining | 1138 | wm.root = remaining | ||
| 1048 | - | remaining.container.Execute(func(ptr uintptr) error { | 1139 | + | if webkit.WidgetIsValid(remaining.container) { |
| 1049 | - | wm.window.SetChild(ptr) | 1140 | + | wm.window.SetChild(remaining.container) |
| 1050 | - | return nil | 1141 | + | } |
| 1051 | - | }) | |||
| 1052 | } | 1142 | } | ||
| 1053 | 1143 | ||||
| 1054 | // Cleanup stack container | 1144 | // Cleanup stack container | ||
| @@ -1063,21 +1153,16 @@ func (wm *WorkspaceManager) closeStackedPaneCompat(node *paneNode) (*paneNode, e | |||||
| 1063 | } | 1153 | } | ||
| 1064 | 1154 | ||||
| 1065 | // Remove widget from GTK box | 1155 | // Remove widget from GTK box | ||
| 1066 | - | stack.container.Execute(func(boxPtr uintptr) error { | 1156 | + | if webkit.WidgetIsValid(stack.container) && webkit.WidgetIsValid(node.container) { |
| 1067 | - | node.container.Execute(func(nodePtr uintptr) error { | 1157 | + | webkit.BoxRemove(stack.container, node.container) |
| 1068 | - | webkit.BoxRemove(boxPtr, nodePtr) | 1158 | + | } |
| 1069 | - | return nil | |||
| 1070 | - | }) | |||
| 1071 | - | return nil | |||
| 1072 | - | }) | |||
| 1073 | 1159 | ||||
| 1074 | // Show new active pane | 1160 | // Show new active pane | ||
| 1075 | if stack.activeStackIndex >= 0 { | 1161 | if stack.activeStackIndex >= 0 { | ||
| 1076 | active := stack.stackedPanes[stack.activeStackIndex] | 1162 | active := stack.stackedPanes[stack.activeStackIndex] | ||
| 1077 | - | active.container.Execute(func(ptr uintptr) error { | 1163 | + | if webkit.WidgetIsValid(active.container) { |
| 1078 | - | webkit.WidgetShow(ptr) | 1164 | + | webkit.WidgetShow(active.container) |
| 1079 | - | return nil | 1165 | + | } |
| 1080 | - | }) | |||
| 1081 | } | 1166 | } | ||
| 1082 | 1167 | ||||
| 1083 | // Cleanup closed pane | 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 | log.Printf("[workspace] Closing popup pane") | 154 | log.Printf("[workspace] Closing popup pane") | ||
| 155 | // Brief delay to allow any final redirects to complete | 155 | // Brief delay to allow any final redirects to complete | ||
| 156 | time.AfterFunc(200*time.Millisecond, func() { | 156 | time.AfterFunc(200*time.Millisecond, func() { | ||
| 157 | - | webkit.IdleAdd(func() bool { | 157 | + | wm.scheduleIdleGuarded(func() bool { |
| 158 | + | if node == nil || !node.widgetValid { | |||
| 159 | + | return false | |||
| 160 | + | } | |||
| 158 | if err := wm.ClosePane(node); err != nil { | 161 | if err := wm.ClosePane(node); err != nil { | ||
| 159 | log.Printf("[workspace] Failed to close popup pane: %v", err) | 162 | log.Printf("[workspace] Failed to close popup pane: %v", err) | ||
| 160 | } | 163 | } | ||
| 161 | return false | 164 | return false | ||
| 162 | - | }) | 165 | + | }, node) |
| 163 | }) | 166 | }) | ||
| 164 | } else { | 167 | } else { | ||
| 165 | log.Printf("[workspace] Could not find popup node for close handler") | 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 | if n := wm.viewToNode[newView]; n != nil && n.isPopup { | 412 | if n := wm.viewToNode[newView]; n != nil && n.isPopup { | ||
| 410 | time.AfterFunc(200*time.Millisecond, func() { | 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 | if err := wm.ClosePane(n); err != nil { | 418 | if err := wm.ClosePane(n); err != nil { | ||
| 413 | log.Printf("[workspace] Failed to close popup pane: %v", err) | 419 | log.Printf("[workspace] Failed to close popup pane: %v", err) | ||
| 414 | } | 420 | } | ||
| 415 | return false | 421 | return false | ||
| 416 | - | }) | 422 | + | }, n) |
| 417 | }) | 423 | }) | ||
| 418 | } | 424 | } | ||
| 419 | }) | 425 | }) | ||
| @@ -494,12 +500,15 @@ func (wm *WorkspaceManager) configureRelatedPopup(sourceNode *paneNode, webView | |||||
| 494 | log.Printf("[workspace] Popup requesting close via window.close()") | 500 | log.Printf("[workspace] Popup requesting close via window.close()") | ||
| 495 | if n := wm.viewToNode[related]; n != nil && n.isPopup { | 501 | if n := wm.viewToNode[related]; n != nil && n.isPopup { | ||
| 496 | time.AfterFunc(200*time.Millisecond, func() { | 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 | if err := wm.ClosePane(n); err != nil { | 507 | if err := wm.ClosePane(n); err != nil { | ||
| 499 | log.Printf("[workspace] Failed to close popup pane: %v", err) | 508 | log.Printf("[workspace] Failed to close popup pane: %v", err) | ||
| 500 | } | 509 | } | ||
| 501 | return false | 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 | package browser | 2 | package browser | ||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "log" | 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 | type StackLifecycleManager struct { | 11 | type StackLifecycleManager struct { | ||
| 14 | wm *WorkspaceManager | 12 | wm *WorkspaceManager | ||
| 15 | treeValidator *TreeValidator | 13 | treeValidator *TreeValidator | ||
| 16 | } | 14 | } | ||
| 17 | 15 | ||||
| 18 | - | // NewStackLifecycleManager creates a new stack lifecycle manager | 16 | + | // NewStackLifecycleManager creates a new stack lifecycle manager. |
| 19 | func NewStackLifecycleManager(wm *WorkspaceManager, treeValidator *TreeValidator) *StackLifecycleManager { | 17 | func NewStackLifecycleManager(wm *WorkspaceManager, treeValidator *TreeValidator) *StackLifecycleManager { | ||
| 20 | return &StackLifecycleManager{ | 18 | return &StackLifecycleManager{ | ||
| 21 | wm: wm, | 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 | func (slm *StackLifecycleManager) CloseStackedPaneWithLifecycle(node *paneNode) error { | 27 | func (slm *StackLifecycleManager) CloseStackedPaneWithLifecycle(node *paneNode) error { | ||
| 28 | - | if node.parent == nil || !node.parent.isStacked { | 28 | + | if node == nil { |
| 29 | - | return errors.New("node is not part of a stacked pane") | 29 | + | return errors.New("node is nil") |
| 30 | } | 30 | } | ||
| 31 | 31 | ||||
| 32 | stackNode := node.parent | 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 | log.Printf("[stack-lifecycle] Closing stacked pane: node=%p stack=%p stackSize=%d", | 37 | log.Printf("[stack-lifecycle] Closing stacked pane: node=%p stack=%p stackSize=%d", | ||
| 35 | node, stackNode, len(stackNode.stackedPanes)) | 38 | node, stackNode, len(stackNode.stackedPanes)) | ||
| 36 | 39 | ||||
| 37 | - | // Validate initial state | |||
| 38 | if err := slm.validateStackState(stackNode, "before close"); err != nil { | 40 | if err := slm.validateStackState(stackNode, "before close"); err != nil { | ||
| 39 | return fmt.Errorf("invalid stack state before close: %w", err) | 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 | 44 | + | if slm.wm == nil || slm.wm.stackedPaneManager == nil { |
| 43 | - | nodeIndex := -1 | 45 | + | return errors.New("stacked pane manager not available") |
| 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) | |||
| 140 | } | 46 | } | ||
| 141 | 47 | ||||
| 142 | - | // Update stack data structures | 48 | + | if err := slm.wm.stackedPaneManager.CloseStackedPane(node); err != nil { |
| 143 | - | slm.updateStackAfterRemoval(stackNode, closingIndex) | 49 | + | return err |
| 144 | - | ||||
| 145 | - | // Update stack visibility after removal | |||
| 146 | - | if slm.wm.stackedPaneManager != nil { | |||
| 147 | - | slm.wm.stackedPaneManager.UpdateStackVisibility(stackNode) | |||
| 148 | } | 50 | } | ||
| 149 | 51 | ||||
| 150 | - | // Validate final state | |||
| 151 | if slm.treeValidator != nil { | 52 | if slm.treeValidator != nil { | ||
| 152 | - | if err := slm.treeValidator.ValidateTree(slm.wm.root, "remove_from_stack"); err != nil { | 53 | + | if err := slm.treeValidator.ValidateTree(slm.wm.root, "after stack close"); err != nil { |
| 153 | - | log.Printf("[stack-lifecycle] Tree validation failed after removing from stack: %v", err) | 54 | + | log.Printf("[stack-lifecycle] Tree validation failed after stack close: %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 | - | } | |||
| 218 | } | 55 | } | ||
| 219 | } | 56 | } | ||
| 220 | 57 | ||||
| 221 | return nil | 58 | return nil | ||
| 222 | } | 59 | } | ||
| 223 | 60 | ||||
| 224 | - | // convertStackToRegularPane converts a stack container to a regular pane | 61 | + | // validateStackState validates the current state of a stack before we perform operations. |
| 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 | |||
| 449 | func (slm *StackLifecycleManager) validateStackState(stackNode *paneNode, operation string) error { | 62 | func (slm *StackLifecycleManager) validateStackState(stackNode *paneNode, operation string) error { | ||
| 450 | if stackNode == nil { | 63 | if stackNode == nil { | ||
| 451 | return errors.New("stack node is nil") | 64 | return errors.New("stack node is nil") | ||
| @@ -464,7 +77,6 @@ func (slm *StackLifecycleManager) validateStackState(stackNode *paneNode, operat | |||||
| 464 | stackNode.activeStackIndex, len(stackNode.stackedPanes)) | 77 | stackNode.activeStackIndex, len(stackNode.stackedPanes)) | ||
| 465 | } | 78 | } | ||
| 466 | 79 | ||||
| 467 | - | // Check that all stacked panes have this node as parent | |||
| 468 | for i, pane := range stackNode.stackedPanes { | 80 | for i, pane := range stackNode.stackedPanes { | ||
| 469 | if pane == nil { | 81 | if pane == nil { | ||
| 470 | return fmt.Errorf("stacked pane at index %d is nil", i) | 82 | return fmt.Errorf("stacked pane at index %d is nil", i) | ||
| @@ -478,19 +90,3 @@ func (slm *StackLifecycleManager) validateStackState(stackNode *paneNode, operat | |||||
| 478 | operation, len(stackNode.stackedPanes), stackNode.activeStackIndex) | 90 | operation, len(stackNode.stackedPanes), stackNode.activeStackIndex) | ||
| 479 | return nil | 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 | package browser | 7 | package browser | ||
| 3 | 8 | ||||
| 4 | - | import ( | 9 | + | // TreeRebalancer is currently a no-op shim kept for API compatibility. |
| 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 | |||
| 14 | type TreeRebalancer struct { | 10 | type TreeRebalancer struct { | ||
| 15 | - | wm *WorkspaceManager | 11 | + | enabled bool |
| 16 | - | treeValidator *TreeValidator | |||
| 17 | - | maxImbalance int // Maximum allowed imbalance before rebalancing | |||
| 18 | - | enabled bool // Whether rebalancing is enabled | |||
| 19 | } | 12 | } | ||
| 20 | 13 | ||||
| 21 | - | // RebalanceOperation represents a tree rebalancing operation | 14 | + | // RebalanceOperation describes an attempted rebalance step (unused in shim). |
| 22 | type RebalanceOperation struct { | 15 | type RebalanceOperation struct { | ||
| 23 | Type RebalanceType | 16 | Type RebalanceType | ||
| 24 | Node *paneNode | 17 | Node *paneNode | ||
| 25 | Description string | 18 | Description string | ||
| 26 | } | 19 | } | ||
| 27 | 20 | ||||
| 28 | - | // RebalanceType represents different types of rebalancing operations | 21 | + | // RebalanceType enumerates rebalancer operations (kept for completeness). |
| 29 | type RebalanceType int | 22 | type RebalanceType int | ||
| 30 | 23 | ||||
| 31 | const ( | 24 | const ( | ||
| @@ -35,792 +28,36 @@ const ( | |||||
| 35 | RebalanceRestructure | 28 | RebalanceRestructure | ||
| 36 | ) | 29 | ) | ||
| 37 | 30 | ||||
| 38 | - | func (rt RebalanceType) String() string { | 31 | + | // TreeMetrics represents the structural metrics of the workspace tree. |
| 39 | - | switch rt { | 32 | + | type TreeMetrics struct{} |
| 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 | - | } | |||
| 228 | 33 | ||||
| 229 | - | if node.isLeaf || node.isStacked { | 34 | + | // NewTreeRebalancer returns a disabled stub implementation. |
| 230 | - | return currentDepth + 1 | 35 | + | func NewTreeRebalancer(_ *WorkspaceManager, _ *TreeValidator) *TreeRebalancer { |
| 231 | - | } | 36 | + | return &TreeRebalancer{} |
| 232 | - | ||||
| 233 | - | leftDepth := tr.calculateMaxDepthHelper(node.left, currentDepth+1) | |||
| 234 | - | rightDepth := tr.calculateMaxDepthHelper(node.right, currentDepth+1) | |||
| 235 | - | return max(leftDepth, rightDepth) | |||
| 236 | } | 37 | } | ||
| 237 | 38 | ||||
| 238 | - | // calculateAverageDepth calculates the average depth of all leaves | 39 | + | // SetMaxImbalance is preserved for API compatibility. |
| 239 | - | func (tr *TreeRebalancer) calculateAverageDepth(node *paneNode) float64 { | 40 | + | func (tr *TreeRebalancer) SetMaxImbalance(_ int) {} |
| 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 | - | } | |||
| 265 | 41 | ||||
| 266 | - | leftDepth, leftCount := tr.calculateTotalDepthAndCount(node.left, currentDepth+1) | 42 | + | // Enable toggles the stub on (no functional impact). |
| 267 | - | rightDepth, rightCount := tr.calculateTotalDepthAndCount(node.right, currentDepth+1) | 43 | + | func (tr *TreeRebalancer) Enable() { tr.enabled = true } |
| 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 | - | } | |||
| 335 | 44 | ||||
| 336 | - | leftHeight := tr.calculateHeight(node.left) | 45 | + | // Disable toggles the stub off. |
| 337 | - | rightHeight := tr.calculateHeight(node.right) | 46 | + | func (tr *TreeRebalancer) Disable() { tr.enabled = false } |
| 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) | |||
| 401 | 47 | ||||
| 48 | + | // RebalanceAfterClose is a no-op in the stub implementation. | |||
| 49 | + | func (tr *TreeRebalancer) RebalanceAfterClose(_ *paneNode, _ *paneNode) error { | |||
| 402 | return nil | 50 | return nil | ||
| 403 | } | 51 | } | ||
| 404 | 52 | ||||
| 405 | - | // executeLeftRotation performs a left rotation on the given node | 53 | + | // CalculateTreeMetrics returns empty metrics in the stub. |
| 406 | - | func (tr *TreeRebalancer) executeLeftRotation(node *paneNode) error { | 54 | + | func (tr *TreeRebalancer) CalculateTreeMetrics(_ *paneNode) TreeMetrics { |
| 407 | - | if node == nil || node.right == nil { | 55 | + | return TreeMetrics{} |
| 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") | |||
| 439 | } | 56 | } | ||
| 440 | 57 | ||||
| 441 | - | // executeRightRotation performs a right rotation on the given node | 58 | + | // GetRebalancingStats exposes minimal instrumentation for diagnostics. |
| 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 | |||
| 810 | func (tr *TreeRebalancer) GetRebalancingStats() map[string]interface{} { | 59 | func (tr *TreeRebalancer) GetRebalancingStats() map[string]interface{} { | ||
| 811 | - | metrics := tr.CalculateTreeMetrics(tr.wm.root) | |||
| 812 | - | ||||
| 813 | return map[string]interface{}{ | 60 | return map[string]interface{}{ | ||
| 814 | - | "enabled": tr.enabled, | 61 | + | "enabled": tr != nil && 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, | |||
| 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 | package browser | 2 | package browser | ||
| 3 | 3 | ||||
| 4 | - | import ( | 4 | + | import "fmt" |
| 5 | - | "fmt" | |||
| 6 | - | "log" | |||
| 7 | - | "sync" | |||
| 8 | - | "time" | |||
| 9 | - | "unsafe" | |||
| 10 | - | ) | |||
| 11 | 5 | ||||
| 12 | - | // TreeValidationError represents errors found during tree validation | 6 | + | // TreeValidator previously performed extensive tree checks. The SafeWidget |
| 13 | - | type TreeValidationError struct { | 7 | + | // removal rendered the implementation obsolete, so we keep a lightweight shim |
| 14 | - | ErrorType string | 8 | + | // until a new validator is written. |
| 15 | - | NodePointer uintptr | |||
| 16 | - | Message string | |||
| 17 | - | StackTrace []string | |||
| 18 | - | } | |||
| 19 | - | ||||
| 20 | - | func (e TreeValidationError) Error() string { | |||
| 21 | - | return fmt.Sprintf("Tree validation error [%s]: %s (node: %p)", e.ErrorType, e.Message, e.NodePointer) | |||
| 22 | - | } | |||
| 23 | - | ||||
| 24 | - | // TreeValidator provides comprehensive validation of binary tree invariants | |||
| 25 | type TreeValidator struct { | 9 | type TreeValidator struct { | ||
| 26 | - | enabled bool | 10 | + | enabled bool |
| 27 | - | debugMode bool | |||
| 28 | - | maxDepth int | |||
| 29 | - | validationHistory []TreeValidationResult | |||
| 30 | - | mu sync.RWMutex | |||
| 31 | } | 11 | } | ||
| 32 | 12 | ||||
| 33 | - | // TreeValidationResult captures the result of a tree validation | 13 | + | // NewTreeValidator returns a stub validator instance. |
| 34 | - | type TreeValidationResult struct { | 14 | + | func NewTreeValidator(enabled bool, _ bool) *TreeValidator { |
| 35 | - | Timestamp time.Time | 15 | + | return &TreeValidator{enabled: enabled} |
| 36 | - | Success bool | |||
| 37 | - | ErrorCount int | |||
| 38 | - | Errors []TreeValidationError | |||
| 39 | - | NodeCount int | |||
| 40 | - | MaxDepth int | |||
| 41 | - | BalanceFactor int | |||
| 42 | - | Duration time.Duration | |||
| 43 | } | 16 | } | ||
| 44 | 17 | ||||
| 45 | - | // NewTreeValidator creates a new tree validator | 18 | + | // ValidateTree is a no-op that reports success unless validation is explicitly enabled. |
| 46 | - | func NewTreeValidator(enabled, debugMode bool) *TreeValidator { | 19 | + | func (tv *TreeValidator) ValidateTree(_ *paneNode, _ string) error { |
| 47 | - | return &TreeValidator{ | 20 | + | if tv == nil || !tv.enabled { |
| 48 | - | enabled: enabled, | |||
| 49 | - | debugMode: debugMode, | |||
| 50 | - | maxDepth: 50, // Prevent infinite recursion | |||
| 51 | - | validationHistory: make([]TreeValidationResult, 0, 100), | |||
| 52 | - | } | |||
| 53 | - | } | |||
| 54 | - | ||||
| 55 | - | // Enable turns on tree validation | |||
| 56 | - | func (tv *TreeValidator) Enable() { | |||
| 57 | - | tv.mu.Lock() | |||
| 58 | - | defer tv.mu.Unlock() | |||
| 59 | - | tv.enabled = true | |||
| 60 | - | } | |||
| 61 | - | ||||
| 62 | - | // Disable turns off tree validation | |||
| 63 | - | func (tv *TreeValidator) Disable() { | |||
| 64 | - | tv.mu.Lock() | |||
| 65 | - | defer tv.mu.Unlock() | |||
| 66 | - | tv.enabled = false | |||
| 67 | - | } | |||
| 68 | - | ||||
| 69 | - | // SetDebugMode enables/disables debug mode | |||
| 70 | - | func (tv *TreeValidator) SetDebugMode(debug bool) { | |||
| 71 | - | tv.mu.Lock() | |||
| 72 | - | defer tv.mu.Unlock() | |||
| 73 | - | tv.debugMode = debug | |||
| 74 | - | } | |||
| 75 | - | ||||
| 76 | - | // ValidateTree performs comprehensive validation of the binary tree | |||
| 77 | - | func (tv *TreeValidator) ValidateTree(root *paneNode, operation string) error { | |||
| 78 | - | tv.mu.Lock() | |||
| 79 | - | defer tv.mu.Unlock() | |||
| 80 | - | ||||
| 81 | - | if !tv.enabled { | |||
| 82 | return nil | 21 | return nil | ||
| 83 | } | 22 | } | ||
| 84 | - | 23 | + | return fmt.Errorf("tree validation not implemented") | |
| 85 | - | startTime := time.Now() | |||
| 86 | - | result := TreeValidationResult{ | |||
| 87 | - | Timestamp: startTime, | |||
| 88 | - | Errors: make([]TreeValidationError, 0), | |||
| 89 | - | } | |||
| 90 | - | ||||
| 91 | - | if tv.debugMode { | |||
| 92 | - | log.Printf("[tree-validator] Starting validation after operation: %s", operation) | |||
| 93 | - | } | |||
| 94 | - | ||||
| 95 | - | // Perform all validation checks | |||
| 96 | - | visited := make(map[*paneNode]bool) | |||
| 97 | - | nodeCount := 0 | |||
| 98 | - | maxDepth := 0 | |||
| 99 | - | ||||
| 100 | - | // Check 1: Root validation | |||
| 101 | - | if err := tv.validateRoot(root, &result); err.ErrorType != "" { | |||
| 102 | - | result.Errors = append(result.Errors, err) | |||
| 103 | - | } | |||
| 104 | - | ||||
| 105 | - | // Check 2: Tree structure validation | |||
| 106 | - | if root != nil { | |||
| 107 | - | if err := tv.validateTreeStructure(root, nil, visited, 0, &nodeCount, &maxDepth, &result); err != nil { | |||
| 108 | - | result.Errors = append(result.Errors, err...) | |||
| 109 | - | } | |||
| 110 | - | } | |||
| 111 | - | ||||
| 112 | - | // Check 3: Cycle detection | |||
| 113 | - | if err := tv.detectCycles(root, &result); err != nil { | |||
| 114 | - | result.Errors = append(result.Errors, err...) | |||
| 115 | - | } | |||
| 116 | - | ||||
| 117 | - | // Check 4: Parent-child consistency | |||
| 118 | - | if err := tv.validateParentChildConsistency(root, &result); err != nil { | |||
| 119 | - | result.Errors = append(result.Errors, err...) | |||
| 120 | - | } | |||
| 121 | - | ||||
| 122 | - | // Check 5: Widget consistency | |||
| 123 | - | if err := tv.validateWidgetConsistency(root, &result); err != nil { | |||
| 124 | - | result.Errors = append(result.Errors, err...) | |||
| 125 | - | } | |||
| 126 | - | ||||
| 127 | - | // Finalize result | |||
| 128 | - | result.Duration = time.Since(startTime) | |||
| 129 | - | result.NodeCount = nodeCount | |||
| 130 | - | result.MaxDepth = maxDepth | |||
| 131 | - | result.ErrorCount = len(result.Errors) | |||
| 132 | - | result.Success = result.ErrorCount == 0 | |||
| 133 | - | ||||
| 134 | - | // Calculate balance factor | |||
| 135 | - | if root != nil { | |||
| 136 | - | leftDepth := tv.calculateDepth(root.left) | |||
| 137 | - | rightDepth := tv.calculateDepth(root.right) | |||
| 138 | - | result.BalanceFactor = abs(leftDepth - rightDepth) | |||
| 139 | - | } | |||
| 140 | - | ||||
| 141 | - | // Store result in history | |||
| 142 | - | tv.addToHistory(result) | |||
| 143 | - | ||||
| 144 | - | if tv.debugMode { | |||
| 145 | - | log.Printf("[tree-validator] Validation completed: success=%v, errors=%d, nodes=%d, depth=%d, balance=%d, duration=%v", | |||
| 146 | - | result.Success, result.ErrorCount, result.NodeCount, result.MaxDepth, result.BalanceFactor, result.Duration) | |||
| 147 | - | } | |||
| 148 | - | ||||
| 149 | - | if !result.Success { | |||
| 150 | - | // Return first error as representative | |||
| 151 | - | return result.Errors[0] | |||
| 152 | - | } | |||
| 153 | - | ||||
| 154 | - | return nil | |||
| 155 | } | 24 | } | ||
| 156 | 25 | ||||
| 157 | - | // validateRoot checks root node constraints | 26 | + | // SetDebugMode preserved for compatibility. |
| 158 | - | func (tv *TreeValidator) validateRoot(root *paneNode, result *TreeValidationResult) TreeValidationError { | 27 | + | func (tv *TreeValidator) SetDebugMode(_ bool) {} |
| 159 | - | if root == nil { | |||
| 160 | - | return TreeValidationError{} // Empty tree is valid | |||
| 161 | - | } | |||
| 162 | - | ||||
| 163 | - | if root.parent != nil { | |||
| 164 | - | return TreeValidationError{ | |||
| 165 | - | ErrorType: "ROOT_HAS_PARENT", | |||
| 166 | - | NodePointer: uintptr(getNodePointer(root)), | |||
| 167 | - | Message: fmt.Sprintf("root node has parent: %p", root.parent), | |||
| 168 | - | } | |||
| 169 | - | } | |||
| 170 | - | ||||
| 171 | - | return TreeValidationError{} // No error | |||
| 172 | - | } | |||
| 173 | - | ||||
| 174 | - | // validateTreeStructure performs recursive validation of tree structure | |||
| 175 | - | func (tv *TreeValidator) validateTreeStructure(node *paneNode, expectedParent *paneNode, visited map[*paneNode]bool, depth int, nodeCount *int, maxDepth *int, result *TreeValidationResult) []TreeValidationError { | |||
| 176 | - | if node == nil { | |||
| 177 | - | return nil | |||
| 178 | - | } | |||
| 179 | - | ||||
| 180 | - | var errors []TreeValidationError | |||
| 181 | - | ||||
| 182 | - | // Check max depth to prevent infinite recursion | |||
| 183 | - | if depth > tv.maxDepth { | |||
| 184 | - | errors = append(errors, TreeValidationError{ | |||
| 185 | - | ErrorType: "MAX_DEPTH_EXCEEDED", | |||
| 186 | - | NodePointer: uintptr(getNodePointer(node)), | |||
| 187 | - | Message: fmt.Sprintf("tree depth exceeds maximum of %d", tv.maxDepth), | |||
| 188 | - | }) | |||
| 189 | - | return errors | |||
| 190 | - | } | |||
| 191 | - | ||||
| 192 | - | // Update counters | |||
| 193 | - | *nodeCount++ | |||
| 194 | - | if depth > *maxDepth { | |||
| 195 | - | *maxDepth = depth | |||
| 196 | - | } | |||
| 197 | - | ||||
| 198 | - | // Check if already visited (cycle detection) | |||
| 199 | - | if visited[node] { | |||
| 200 | - | errors = append(errors, TreeValidationError{ | |||
| 201 | - | ErrorType: "CYCLE_DETECTED", | |||
| 202 | - | NodePointer: uintptr(getNodePointer(node)), | |||
| 203 | - | Message: "node already visited in traversal", | |||
| 204 | - | }) | |||
| 205 | - | return errors | |||
| 206 | - | } | |||
| 207 | - | visited[node] = true | |||
| 208 | - | ||||
| 209 | - | // Check parent pointer consistency | |||
| 210 | - | if node.parent != expectedParent { | |||
| 211 | - | errors = append(errors, TreeValidationError{ | |||
| 212 | - | ErrorType: "PARENT_MISMATCH", | |||
| 213 | - | NodePointer: uintptr(getNodePointer(node)), | |||
| 214 | - | Message: fmt.Sprintf("parent mismatch: expected %p, got %p", expectedParent, node.parent), | |||
| 215 | - | }) | |||
| 216 | - | } | |||
| 217 | - | ||||
| 218 | - | // Validate node type constraints | |||
| 219 | - | if node.isLeaf { | |||
| 220 | - | // Leaf nodes must have no children | |||
| 221 | - | if node.left != nil { | |||
| 222 | - | errors = append(errors, TreeValidationError{ | |||
| 223 | - | ErrorType: "LEAF_HAS_LEFT_CHILD", | |||
| 224 | - | NodePointer: uintptr(getNodePointer(node)), | |||
| 225 | - | Message: fmt.Sprintf("leaf node has left child: %p", node.left), | |||
| 226 | - | }) | |||
| 227 | - | } | |||
| 228 | - | if node.right != nil { | |||
| 229 | - | errors = append(errors, TreeValidationError{ | |||
| 230 | - | ErrorType: "LEAF_HAS_RIGHT_CHILD", | |||
| 231 | - | NodePointer: uintptr(getNodePointer(node)), | |||
| 232 | - | Message: fmt.Sprintf("leaf node has right child: %p", node.right), | |||
| 233 | - | }) | |||
| 234 | - | } | |||
| 235 | - | ||||
| 236 | - | // Leaf nodes must have a pane (unless it's a stack container) | |||
| 237 | - | if !node.isStacked && node.pane == nil { | |||
| 238 | - | errors = append(errors, TreeValidationError{ | |||
| 239 | - | ErrorType: "LEAF_MISSING_PANE", | |||
| 240 | - | NodePointer: uintptr(getNodePointer(node)), | |||
| 241 | - | Message: "leaf node missing pane", | |||
| 242 | - | }) | |||
| 243 | - | } | |||
| 244 | - | } else if node.isStacked { | |||
| 245 | - | // Stacked container nodes live in the binary tree but do not use left/right children. | |||
| 246 | - | if node.left != nil { | |||
| 247 | - | errors = append(errors, TreeValidationError{ | |||
| 248 | - | ErrorType: "STACK_CONTAINER_HAS_LEFT_CHILD", | |||
| 249 | - | NodePointer: uintptr(getNodePointer(node)), | |||
| 250 | - | Message: "stack container should not have left child", | |||
| 251 | - | }) | |||
| 252 | - | } | |||
| 253 | - | if node.right != nil { | |||
| 254 | - | errors = append(errors, TreeValidationError{ | |||
| 255 | - | ErrorType: "STACK_CONTAINER_HAS_RIGHT_CHILD", | |||
| 256 | - | NodePointer: uintptr(getNodePointer(node)), | |||
| 257 | - | Message: "stack container should not have right child", | |||
| 258 | - | }) | |||
| 259 | - | } | |||
| 260 | - | } else { | |||
| 261 | - | // Branch nodes must have exactly 2 children | |||
| 262 | - | if node.left == nil { | |||
| 263 | - | errors = append(errors, TreeValidationError{ | |||
| 264 | - | ErrorType: "BRANCH_MISSING_LEFT_CHILD", | |||
| 265 | - | NodePointer: uintptr(getNodePointer(node)), | |||
| 266 | - | Message: "branch node missing left child", | |||
| 267 | - | }) | |||
| 268 | - | } | |||
| 269 | - | if node.right == nil { | |||
| 270 | - | errors = append(errors, TreeValidationError{ | |||
| 271 | - | ErrorType: "BRANCH_MISSING_RIGHT_CHILD", | |||
| 272 | - | NodePointer: uintptr(getNodePointer(node)), | |||
| 273 | - | Message: "branch node missing right child", | |||
| 274 | - | }) | |||
| 275 | - | } | |||
| 276 | - | ||||
| 277 | - | // Branch nodes should not have panes (except for stack containers) | |||
| 278 | - | if !node.isStacked && node.pane != nil { | |||
| 279 | - | errors = append(errors, TreeValidationError{ | |||
| 280 | - | ErrorType: "BRANCH_HAS_PANE", | |||
| 281 | - | NodePointer: uintptr(getNodePointer(node)), | |||
| 282 | - | Message: "branch node should not have pane", | |||
| 283 | - | }) | |||
| 284 | - | } | |||
| 285 | - | } | |||
| 286 | - | ||||
| 287 | - | // Validate stack-specific constraints | |||
| 288 | - | if node.isStacked { | |||
| 289 | - | if err := tv.validateStackConstraints(node); err.ErrorType != "" { | |||
| 290 | - | errors = append(errors, err) | |||
| 291 | - | } | |||
| 292 | - | } | |||
| 293 | - | ||||
| 294 | - | // Recursively validate children | |||
| 295 | - | if node.left != nil { | |||
| 296 | - | leftErrors := tv.validateTreeStructure(node.left, node, visited, depth+1, nodeCount, maxDepth, result) | |||
| 297 | - | errors = append(errors, leftErrors...) | |||
| 298 | - | } | |||
| 299 | - | if node.right != nil { | |||
| 300 | - | rightErrors := tv.validateTreeStructure(node.right, node, visited, depth+1, nodeCount, maxDepth, result) | |||
| 301 | - | errors = append(errors, rightErrors...) | |||
| 302 | - | } | |||
| 303 | 28 | ||||
| 304 | - | return errors | 29 | + | // Enable turns validation on (no functional impact in the stub). |
| 305 | - | } | 30 | + | func (tv *TreeValidator) Enable() { |
| 306 | - | 31 | + | if tv != nil { | |
| 307 | - | // validateStackConstraints validates stack-specific invariants | 32 | + | tv.enabled = true |
| 308 | - | func (tv *TreeValidator) validateStackConstraints(node *paneNode) TreeValidationError { | |||
| 309 | - | if !node.isStacked { | |||
| 310 | - | return TreeValidationError{} // Not a stack | |||
| 311 | - | } | |||
| 312 | - | ||||
| 313 | - | // Stack nodes should have stackedPanes | |||
| 314 | - | if len(node.stackedPanes) == 0 { | |||
| 315 | - | return TreeValidationError{ | |||
| 316 | - | ErrorType: "EMPTY_STACK", | |||
| 317 | - | NodePointer: uintptr(getNodePointer(node)), | |||
| 318 | - | Message: "stacked node has no panes", | |||
| 319 | - | } | |||
| 320 | - | } | |||
| 321 | - | ||||
| 322 | - | // Active index should be valid | |||
| 323 | - | if node.activeStackIndex < 0 || node.activeStackIndex >= len(node.stackedPanes) { | |||
| 324 | - | return TreeValidationError{ | |||
| 325 | - | ErrorType: "INVALID_STACK_INDEX", | |||
| 326 | - | NodePointer: uintptr(getNodePointer(node)), | |||
| 327 | - | Message: fmt.Sprintf("active stack index %d out of range [0, %d)", node.activeStackIndex, len(node.stackedPanes)), | |||
| 328 | - | } | |||
| 329 | - | } | |||
| 330 | - | ||||
| 331 | - | // All panes in stack should have this node as parent | |||
| 332 | - | for i, stackedPane := range node.stackedPanes { | |||
| 333 | - | if stackedPane == nil { | |||
| 334 | - | return TreeValidationError{ | |||
| 335 | - | ErrorType: "NULL_STACK_PANE", | |||
| 336 | - | NodePointer: uintptr(getNodePointer(node)), | |||
| 337 | - | Message: fmt.Sprintf("stacked pane at index %d is nil", i), | |||
| 338 | - | } | |||
| 339 | - | } | |||
| 340 | - | if stackedPane.parent != node { | |||
| 341 | - | return TreeValidationError{ | |||
| 342 | - | ErrorType: "STACK_PANE_PARENT_MISMATCH", | |||
| 343 | - | NodePointer: uintptr(getNodePointer(stackedPane)), | |||
| 344 | - | Message: fmt.Sprintf("stacked pane parent mismatch: expected %p, got %p", node, stackedPane.parent), | |||
| 345 | - | } | |||
| 346 | - | } | |||
| 347 | - | } | |||
| 348 | - | ||||
| 349 | - | return TreeValidationError{} // No error | |||
| 350 | - | } | |||
| 351 | - | ||||
| 352 | - | // detectCycles performs cycle detection using Floyd's algorithm | |||
| 353 | - | func (tv *TreeValidator) detectCycles(root *paneNode, result *TreeValidationResult) []TreeValidationError { | |||
| 354 | - | if root == nil { | |||
| 355 | - | return nil | |||
| 356 | - | } | |||
| 357 | - | ||||
| 358 | - | var errors []TreeValidationError | |||
| 359 | - | visited := make(map[*paneNode]bool) | |||
| 360 | - | ||||
| 361 | - | var detectCycleHelper func(*paneNode, map[*paneNode]bool) bool | |||
| 362 | - | detectCycleHelper = func(node *paneNode, path map[*paneNode]bool) bool { | |||
| 363 | - | if node == nil { | |||
| 364 | - | return false | |||
| 365 | - | } | |||
| 366 | - | ||||
| 367 | - | if path[node] { | |||
| 368 | - | errors = append(errors, TreeValidationError{ | |||
| 369 | - | ErrorType: "CYCLE_IN_PATH", | |||
| 370 | - | NodePointer: uintptr(getNodePointer(node)), | |||
| 371 | - | Message: "cycle detected in tree path", | |||
| 372 | - | }) | |||
| 373 | - | return true | |||
| 374 | - | } | |||
| 375 | - | ||||
| 376 | - | if visited[node] { | |||
| 377 | - | return false // Already checked this subtree | |||
| 378 | - | } | |||
| 379 | - | ||||
| 380 | - | path[node] = true | |||
| 381 | - | visited[node] = true | |||
| 382 | - | ||||
| 383 | - | hasCycle := detectCycleHelper(node.left, path) || detectCycleHelper(node.right, path) | |||
| 384 | - | ||||
| 385 | - | delete(path, node) | |||
| 386 | - | return hasCycle | |||
| 387 | - | } | |||
| 388 | - | ||||
| 389 | - | detectCycleHelper(root, make(map[*paneNode]bool)) | |||
| 390 | - | return errors | |||
| 391 | - | } | |||
| 392 | - | ||||
| 393 | - | // validateParentChildConsistency checks bidirectional parent-child relationships | |||
| 394 | - | func (tv *TreeValidator) validateParentChildConsistency(root *paneNode, result *TreeValidationResult) []TreeValidationError { | |||
| 395 | - | if root == nil { | |||
| 396 | - | return nil | |||
| 397 | - | } | |||
| 398 | - | ||||
| 399 | - | var errors []TreeValidationError | |||
| 400 | - | ||||
| 401 | - | var validate func(*paneNode) | |||
| 402 | - | validate = func(node *paneNode) { | |||
| 403 | - | if node == nil { | |||
| 404 | - | return | |||
| 405 | - | } | |||
| 406 | - | ||||
| 407 | - | // Check left child consistency | |||
| 408 | - | if node.left != nil { | |||
| 409 | - | if node.left.parent != node { | |||
| 410 | - | errors = append(errors, TreeValidationError{ | |||
| 411 | - | ErrorType: "LEFT_CHILD_PARENT_MISMATCH", | |||
| 412 | - | NodePointer: uintptr(getNodePointer(node.left)), | |||
| 413 | - | Message: fmt.Sprintf("left child parent points to %p instead of %p", node.left.parent, node), | |||
| 414 | - | }) | |||
| 415 | - | } | |||
| 416 | - | } | |||
| 417 | - | ||||
| 418 | - | // Check right child consistency | |||
| 419 | - | if node.right != nil { | |||
| 420 | - | if node.right.parent != node { | |||
| 421 | - | errors = append(errors, TreeValidationError{ | |||
| 422 | - | ErrorType: "RIGHT_CHILD_PARENT_MISMATCH", | |||
| 423 | - | NodePointer: uintptr(getNodePointer(node.right)), | |||
| 424 | - | Message: fmt.Sprintf("right child parent points to %p instead of %p", node.right.parent, node), | |||
| 425 | - | }) | |||
| 426 | - | } | |||
| 427 | - | } | |||
| 428 | - | ||||
| 429 | - | validate(node.left) | |||
| 430 | - | validate(node.right) | |||
| 431 | - | } | |||
| 432 | - | ||||
| 433 | - | validate(root) | |||
| 434 | - | return errors | |||
| 435 | - | } | |||
| 436 | - | ||||
| 437 | - | // validateWidgetConsistency checks widget-related invariants | |||
| 438 | - | func (tv *TreeValidator) validateWidgetConsistency(root *paneNode, result *TreeValidationResult) []TreeValidationError { | |||
| 439 | - | if root == nil { | |||
| 440 | - | return nil | |||
| 441 | - | } | |||
| 442 | - | ||||
| 443 | - | var errors []TreeValidationError | |||
| 444 | - | ||||
| 445 | - | var validate func(*paneNode) | |||
| 446 | - | validate = func(node *paneNode) { | |||
| 447 | - | if node == nil { | |||
| 448 | - | return | |||
| 449 | - | } | |||
| 450 | - | ||||
| 451 | - | // Every node should have a container | |||
| 452 | - | if node.container == nil { | |||
| 453 | - | errors = append(errors, TreeValidationError{ | |||
| 454 | - | ErrorType: "MISSING_CONTAINER", | |||
| 455 | - | NodePointer: uintptr(getNodePointer(node)), | |||
| 456 | - | Message: "node missing container widget", | |||
| 457 | - | }) | |||
| 458 | - | } else if !node.container.IsValid() { | |||
| 459 | - | errors = append(errors, TreeValidationError{ | |||
| 460 | - | ErrorType: "INVALID_CONTAINER", | |||
| 461 | - | NodePointer: uintptr(getNodePointer(node)), | |||
| 462 | - | Message: fmt.Sprintf("node has invalid container: %s", node.container.String()), | |||
| 463 | - | }) | |||
| 464 | - | } | |||
| 465 | - | ||||
| 466 | - | // Stack containers should have stackWrapper | |||
| 467 | - | if node.isStacked && node.stackWrapper == nil { | |||
| 468 | - | errors = append(errors, TreeValidationError{ | |||
| 469 | - | ErrorType: "STACK_MISSING_WRAPPER", | |||
| 470 | - | NodePointer: uintptr(getNodePointer(node)), | |||
| 471 | - | Message: "stacked node missing stackWrapper", | |||
| 472 | - | }) | |||
| 473 | - | } | |||
| 474 | - | ||||
| 475 | - | validate(node.left) | |||
| 476 | - | validate(node.right) | |||
| 477 | - | } | |||
| 478 | - | ||||
| 479 | - | validate(root) | |||
| 480 | - | return errors | |||
| 481 | - | } | |||
| 482 | - | ||||
| 483 | - | // calculateDepth calculates the depth of a subtree | |||
| 484 | - | func (tv *TreeValidator) calculateDepth(node *paneNode) int { | |||
| 485 | - | if node == nil { | |||
| 486 | - | return 0 | |||
| 487 | } | 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 | 36 | + | // Disable turns validation off. |
| 494 | - | func (tv *TreeValidator) addToHistory(result TreeValidationResult) { | 37 | + | func (tv *TreeValidator) Disable() { |
| 495 | - | // Keep only the last 100 results | 38 | + | if tv != nil { |
| 496 | - | if len(tv.validationHistory) >= 100 { | 39 | + | tv.enabled = false |
| 497 | - | tv.validationHistory = tv.validationHistory[1:] | |||
| 498 | } | 40 | } | ||
| 499 | - | tv.validationHistory = append(tv.validationHistory, result) | |||
| 500 | } | 41 | } | ||
| 501 | 42 | ||||
| 502 | - | // GetValidationHistory returns the validation history | 43 | + | // GetValidationStats mimics the old diagnostic output. |
| 503 | - | func (tv *TreeValidator) GetValidationHistory() []TreeValidationResult { | |||
| 504 | - | tv.mu.RLock() | |||
| 505 | - | defer tv.mu.RUnlock() | |||
| 506 | - | ||||
| 507 | - | // Return a copy to prevent race conditions | |||
| 508 | - | history := make([]TreeValidationResult, len(tv.validationHistory)) | |||
| 509 | - | copy(history, tv.validationHistory) | |||
| 510 | - | return history | |||
| 511 | - | } | |||
| 512 | - | ||||
| 513 | - | // GetValidationStats returns summary statistics | |||
| 514 | func (tv *TreeValidator) GetValidationStats() map[string]interface{} { | 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 | return map[string]interface{}{ | 45 | return map[string]interface{}{ | ||
| 540 | - | "total_validations": totalValidations, | 46 | + | "enabled": tv != nil && tv.enabled, |
| 541 | - | "successful_validations": successfulValidations, | |||
| 542 | - | "success_rate": float64(successfulValidations) / float64(totalValidations), | |||
| 543 | - | "total_errors": totalErrors, | |||
| 544 | - | "avg_duration_ms": avgDuration.Milliseconds(), | |||
| 545 | - | "enabled": tv.enabled, | |||
| 546 | - | "debug_mode": tv.debugMode, | |||
| 547 | - | } | |||
| 548 | - | } | |||
| 549 | - | ||||
| 550 | - | // Helper functions | |||
| 551 | - | ||||
| 552 | - | func getNodePointer(node *paneNode) unsafe.Pointer { | |||
| 553 | - | if node == nil { | |||
| 554 | - | return nil | |||
| 555 | } | 47 | } | ||
| 556 | - | return unsafe.Pointer(node) | |||
| 557 | } | 48 | } | ||
| 558 | 49 | ||||
| 559 | - | func abs(x int) int { | 50 | + | // Enabled reports current state (used by tests/instrumentation). |
| 560 | - | if x < 0 { | 51 | + | func (tv *TreeValidator) Enabled() bool { |
| 561 | - | return -x | 52 | + | return tv != nil && tv.enabled |
| 562 | - | } | |||
| 563 | - | return x | |||
| 564 | } | 53 | } | ||
M internal/app/browser/workspace_types.go +44 -8
| @@ -2,20 +2,55 @@ | |||||
| 2 | package browser | 2 | package browser | ||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | + | "os" | |||
| 6 | + | ||||
| 5 | "github.com/bnema/dumber/pkg/webkit" | 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 | // paneNode represents a node in the workspace pane tree structure. | 37 | // paneNode represents a node in the workspace pane tree structure. | ||
| 9 | // It can be either a leaf node (containing a browser pane) or a branch node | 38 | // It can be either a leaf node (containing a browser pane) or a branch node | ||
| 10 | // (containing child nodes for split panes or stacked panes). | 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 | type paneNode struct { | 46 | type paneNode struct { | ||
| 12 | pane *BrowserPane | 47 | pane *BrowserPane | ||
| 13 | parent *paneNode | 48 | parent *paneNode | ||
| 14 | left *paneNode | 49 | left *paneNode | ||
| 15 | right *paneNode | 50 | right *paneNode | ||
| 16 | 51 | ||||
| 17 | - | // Widget management with proper lifecycle tracking | 52 | + | // Widget management - direct GTK pointers (all ops on main thread) |
| 18 | - | container *SafeWidget // Main container (GtkPaned for branch nodes, wrapper GtkBox for stacked nodes, WebView container for leaves) | 53 | + | container uintptr // Main container: GtkPaned (branch), GtkBox (stack), or WebView container (leaf) |
| 19 | orientation webkit.Orientation | 54 | orientation webkit.Orientation | ||
| 20 | isLeaf bool | 55 | isLeaf bool | ||
| 21 | isPopup bool // Deprecated: use windowType instead | 56 | isPopup bool // Deprecated: use windowType instead | ||
| @@ -31,16 +66,17 @@ type paneNode struct { | |||||
| 31 | pendingHoverReattach bool | 66 | pendingHoverReattach bool | ||
| 32 | pendingFocusReattach bool | 67 | pendingFocusReattach bool | ||
| 33 | 68 | ||||
| 34 | - | // Stacked panes support with proper widget management | 69 | + | // Stacked panes support - terminal branch nodes |
| 35 | isStacked bool // Whether this node contains stacked panes | 70 | isStacked bool // Whether this node contains stacked panes | ||
| 36 | stackedPanes []*paneNode // List of stacked panes (if isStacked) | 71 | stackedPanes []*paneNode // List of stacked panes (if isStacked) | ||
| 37 | activeStackIndex int // Index of currently visible pane in stack | 72 | activeStackIndex int // Index of currently visible pane in stack | ||
| 38 | - | titleBar *SafeWidget // GtkBox for title bar (when collapsed) | 73 | + | titleBar uintptr // GtkBox for title bar (when collapsed) |
| 39 | - | stackWrapper *SafeWidget // Internal GtkBox containing the actual stacked widgets (titles + webviews) | 74 | + | stackWrapper uintptr // Internal GtkBox containing the actual stacked widgets (titles + webviews) |
| 40 | 75 | ||||
| 41 | - | // Enhanced pane close refactoring fields | 76 | + | // Cleanup tracking |
| 42 | - | widgetValid bool // Guard flagged before GTK destruction | 77 | + | widgetValid bool // Guard flagged before GTK destruction |
| 43 | - | cleanupGeneration uint // Helps assert that asynchronous callbacks skip stale nodes | 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 | // Workspace CSS class constants | 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 | // ensureGUIInPane lazily loads GUI components into a pane when it gains focus | 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 | func (wm *WorkspaceManager) ensureGUIInPane(pane *BrowserPane) { | 25 | func (wm *WorkspaceManager) ensureGUIInPane(pane *BrowserPane) { | ||
| 28 | if pane == nil { | 26 | if pane == nil { | ||
| 29 | return | 27 | return | ||
| 30 | } | 28 | } | ||
| 31 | 29 | ||||
| 32 | // GUI is already injected globally via WebKit's enableUserContentManager | 30 | // GUI is already injected globally via WebKit's enableUserContentManager | ||
| 33 | - | // Just mark the pane as having GUI to prevent unnecessary calls | |||
| 34 | if !pane.HasGUI() { | 31 | if !pane.HasGUI() { | ||
| 35 | pane.SetHasGUI(true) | 32 | pane.SetHasGUI(true) | ||
| 36 | pane.SetGUIComponent("manager", true) | 33 | pane.SetGUIComponent("manager", true) | ||
| @@ -54,75 +51,38 @@ func mapDirection(direction string) (webkit.Orientation, bool) { | |||||
| 54 | 51 | ||||
| 55 | // Widget management utilities | 52 | // Widget management utilities | ||
| 56 | 53 | ||||
| 57 | - | // registerWidget registers a widget with the registry and returns a SafeWidget | 54 | + | // initializePaneWidgets sets up widgets for a paneNode |
| 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 | |||
| 66 | func (wm *WorkspaceManager) initializePaneWidgets(node *paneNode, containerPtr uintptr) { | 55 | func (wm *WorkspaceManager) initializePaneWidgets(node *paneNode, containerPtr uintptr) { | ||
| 67 | - | // Register the main container | 56 | + | node.container = containerPtr |
| 68 | - | node.container = wm.registerWidget(containerPtr, "pane-container") | |||
| 69 | 57 | ||||
| 70 | // Add base CSS class to the container | 58 | // Add base CSS class to the container | ||
| 71 | - | if node.container != nil { | 59 | + | if node.container != 0 && webkit.WidgetIsValid(node.container) { |
| 72 | - | node.container.Execute(func(ptr uintptr) error { | 60 | + | webkit.WidgetAddCSSClass(node.container, basePaneClass) |
| 73 | - | webkit.WidgetAddCSSClass(ptr, basePaneClass) | |||
| 74 | - | return nil | |||
| 75 | - | }) | |||
| 76 | } | 61 | } | ||
| 77 | 62 | ||||
| 78 | - | // Initialize other widget fields as nil (will be set when needed) | 63 | + | // Initialize other widget fields as zero (will be set when needed) |
| 79 | - | node.titleBar = nil | 64 | + | node.titleBar = 0 |
| 80 | - | node.stackWrapper = nil | 65 | + | node.stackWrapper = 0 |
| 81 | 66 | ||||
| 82 | - | // Initialize enhanced close refactoring fields | 67 | + | // Initialize cleanup tracking fields |
| 83 | node.widgetValid = true | 68 | node.widgetValid = true | ||
| 84 | node.cleanupGeneration = 0 | 69 | node.cleanupGeneration = 0 | ||
| 85 | } | 70 | } | ||
| 86 | 71 | ||||
| 87 | - | // Helper functions for safe widget operations | 72 | + | // setContainer sets the container widget pointer |
| 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 | |||
| 114 | func (wm *WorkspaceManager) setContainer(node *paneNode, ptr uintptr, typeInfo string) { | 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 | func (wm *WorkspaceManager) setTitleBar(node *paneNode, ptr uintptr) { | 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 | func (wm *WorkspaceManager) setStackWrapper(node *paneNode, ptr uintptr) { | 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 | // Centralized Active Pane Border Management System | 88 | // Centralized Active Pane Border Management System | ||
| @@ -130,10 +90,10 @@ func (wm *WorkspaceManager) setStackWrapper(node *paneNode, ptr uintptr) { | |||||
| 130 | 90 | ||||
| 131 | // PaneBorderContext holds the context for applying borders to any pane type | 91 | // PaneBorderContext holds the context for applying borders to any pane type | ||
| 132 | type PaneBorderContext struct { | 92 | type PaneBorderContext struct { | ||
| 133 | - | webViewWidget uintptr // The WebView's native widget (for margin) | 93 | + | webViewWidget uintptr // The WebView's native widget (for margin) |
| 134 | - | borderContainer *SafeWidget // The container that gets background color | 94 | + | borderContainer uintptr // The container that gets background color |
| 135 | - | cssClasses []string // Additional CSS classes to apply | 95 | + | cssClasses []string // Additional CSS classes to apply |
| 136 | - | paneType string // Description for debugging | 96 | + | paneType string // Description for debugging |
| 137 | } | 97 | } | ||
| 138 | 98 | ||||
| 139 | // determineBorderContext analyzes a pane node and determines the correct border context | 99 | // determineBorderContext analyzes a pane node and determines the correct border context | ||
| @@ -149,137 +109,227 @@ func (wm *WorkspaceManager) determineBorderContext(node *paneNode) *PaneBorderCo | |||||
| 149 | ctx.webViewWidget = node.pane.webView.RootWidget() | 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 | switch { | 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 | case node.windowType == webkit.WindowTypePopup: | 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 | ctx.borderContainer = node.container | 116 | ctx.borderContainer = node.container | ||
| 163 | - | ctx.cssClasses = []string{basePaneClass, activePaneClass} | 117 | + | ctx.cssClasses = []string{activePaneClass} |
| 164 | ctx.paneType = "popup" | 118 | ctx.paneType = "popup" | ||
| 165 | 119 | ||||
| 166 | - | case node.isLeaf && !node.isStacked: | 120 | + | case node.parent != nil && node.parent.isStacked: |
| 167 | - | // REGULAR LEAF PANE: Border goes on the pane's container | 121 | + | // Pane in stack: Border on stack wrapper container |
| 168 | - | ctx.borderContainer = node.container | 122 | + | ctx.borderContainer = node.parent.stackWrapper |
| 169 | - | ctx.cssClasses = []string{basePaneClass, activePaneClass} | 123 | + | ctx.cssClasses = []string{activePaneClass} |
| 170 | - | ctx.paneType = "regular-leaf" | 124 | + | ctx.paneType = "stacked" |
| 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" | |||
| 177 | 125 | ||||
| 178 | default: | 126 | default: | ||
| 179 | - | // FALLBACK: Use the node's own container | 127 | + | // Regular pane: Border on pane container |
| 180 | ctx.borderContainer = node.container | 128 | ctx.borderContainer = node.container | ||
| 181 | - | ctx.cssClasses = []string{basePaneClass, activePaneClass} | 129 | + | ctx.cssClasses = []string{activePaneClass} |
| 182 | - | ctx.paneType = "fallback" | 130 | + | ctx.paneType = "regular" |
| 183 | } | 131 | } | ||
| 184 | 132 | ||||
| 185 | return ctx | 133 | return ctx | ||
| 186 | } | 134 | } | ||
| 187 | 135 | ||||
| 188 | - | // applyActivePaneBorder adds the active pane visual border using the centralized system | 136 | + | // applyActivePaneBorder applies the active pane border using the border context |
| 189 | - | func (wm *WorkspaceManager) applyActivePaneBorder(node *paneNode) { | 137 | + | func (wm *WorkspaceManager) applyActivePaneBorder(ctx *PaneBorderContext) { |
| 190 | - | ctx := wm.determineBorderContext(node) | |||
| 191 | if ctx == nil { | 138 | if ctx == nil { | ||
| 139 | + | log.Printf("[workspace] applyActivePaneBorder: nil context") | |||
| 192 | return | 140 | return | ||
| 193 | } | 141 | } | ||
| 194 | 142 | ||||
| 195 | - | // Apply CSS classes to border container (provides border styling) | 143 | + | // Apply margin to WebView widget (creates space for border) |
| 196 | - | if ctx.borderContainer != nil { | 144 | + | if ctx.webViewWidget != 0 && webkit.WidgetIsValid(ctx.webViewWidget) { |
| 197 | - | var applied bool | 145 | + | webkit.WidgetSetMargin(ctx.webViewWidget, 2) |
| 198 | - | if err := ctx.borderContainer.Execute(func(containerPtr uintptr) error { | 146 | + | } |
| 199 | - | // Safety check: verify container pointer is valid and widget still exists | 147 | + | |
| 200 | - | if containerPtr == 0 || !webkit.WidgetIsValid(containerPtr) { | 148 | + | // Apply CSS classes to border container |
| 201 | - | return fmt.Errorf("invalid container pointer or destroyed widget") | 149 | + | if ctx.borderContainer != 0 && webkit.WidgetIsValid(ctx.borderContainer) { |
| 202 | - | } | 150 | + | for _, class := range ctx.cssClasses { |
| 203 | - | if webkit.WidgetHasCSSClass(containerPtr, activePaneClass) { | 151 | + | webkit.WidgetAddCSSClass(ctx.borderContainer, class) |
| 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) | |||
| 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 | func (wm *WorkspaceManager) removeActivePaneBorder(node *paneNode) { | 160 | func (wm *WorkspaceManager) removeActivePaneBorder(node *paneNode) { | ||
| 161 | + | if node == nil { | |||
| 162 | + | return | |||
| 163 | + | } | |||
| 164 | + | ||||
| 219 | ctx := wm.determineBorderContext(node) | 165 | ctx := wm.determineBorderContext(node) | ||
| 220 | if ctx == nil { | 166 | if ctx == nil { | ||
| 221 | return | 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 | // Remove CSS classes from border container | 175 | // Remove CSS classes from border container | ||
| 225 | - | if ctx.borderContainer != nil { | 176 | + | if ctx.borderContainer != 0 && webkit.WidgetIsValid(ctx.borderContainer) { |
| 226 | - | var removed bool | 177 | + | for _, class := range ctx.cssClasses { |
| 227 | - | if err := ctx.borderContainer.Execute(func(containerPtr uintptr) error { | 178 | + | webkit.WidgetRemoveCSSClass(ctx.borderContainer, class) |
| 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) | |||
| 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 | 186 | + | // Widget cleanup utilities |
| 247 | - | func (wm *WorkspaceManager) removeActivePaneBorderFromAll() { | 187 | + | |
| 248 | - | if wm == nil { | 188 | + | // cleanupWidget safely invalidates and cleans up a widget |
| 189 | + | func (wm *WorkspaceManager) cleanupWidget(ptr uintptr) { | |||
| 190 | + | if ptr == 0 { | |||
| 249 | return | 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 | 198 | + | // scheduleIdleGuarded queues a callback to run on the GTK main loop and tracks it against |
| 255 | - | for _, node := range wm.viewToNode { | 199 | + | // the provided pane nodes so it can be cancelled safely if the nodes are destroyed. |
| 256 | - | if node != nil { | 200 | + | func (wm *WorkspaceManager) scheduleIdleGuarded(fn func() bool, nodes ...*paneNode) uintptr { |
| 257 | - | wm.removeActivePaneBorder(node) | 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 | 224 | + | func uniquePaneNodes(nodes ...*paneNode) []*paneNode { |
| 263 | - | func (wm *WorkspaceManager) isActivePaneBorderApplied(node *paneNode) bool { | 225 | + | if len(nodes) == 0 { |
| 264 | - | ctx := wm.determineBorderContext(node) | 226 | + | return nil |
| 265 | - | if ctx == nil || ctx.borderContainer == nil { | 227 | + | } |
| 266 | - | return false | 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 | 243 | + | func (wm *WorkspaceManager) registerIdleHandle(handle uintptr, nodes ...*paneNode) { |
| 270 | - | ctx.borderContainer.Execute(func(containerPtr uintptr) error { | 244 | + | if wm == nil || handle == 0 { |
| 271 | - | hasClass = webkit.WidgetHasCSSClass(containerPtr, activePaneClass) | 245 | + | return |
| 272 | - | return nil | 246 | + | } |
| 273 | - | }) | 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 | 294 | + | func (wm *WorkspaceManager) cancelIdleHandles(node *paneNode) { |
| 279 | - | func (wm *WorkspaceManager) getBorderContextInfo(node *paneNode) string { | 295 | + | if wm == nil || node == nil || len(node.pendingIdleHandles) == 0 { |
| 280 | - | ctx := wm.determineBorderContext(node) | 296 | + | return |
| 281 | - | if ctx == nil { | 297 | + | } |
| 282 | - | return "no-context" | 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 | // IsNativeAvailable reports whether the native WebKit2GTK backend is compiled in. | 5 | // IsNativeAvailable reports whether the native WebKit2GTK backend is compiled in. | ||
| 6 | // In non-CGO builds, this returns false and WebView methods are logical no-ops. | 6 | // In non-CGO builds, this returns false and WebView methods are logical no-ops. | ||
| 7 | func IsNativeAvailable() bool { return false } | 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 | #include <glib.h> | 3 | #include <glib.h> | ||
| 2 | #include <pthread.h> | 4 | #include <pthread.h> | ||
| 3 | 5 | ||||
M pkg/webkit/webview_cgo.go +10 -0
| @@ -1630,21 +1630,26 @@ func (w *WebView) Destroy() error { | |||||
| 1630 | return ErrNotImplemented | 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 | // Stop periodic GC if running | 1634 | // Stop periodic GC if running | ||
| 1634 | w.stopPeriodicGC() | 1635 | w.stopPeriodicGC() | ||
| 1636 | + | log.Printf("[webkit-destroy] periodic GC stopped: id=%d", w.id) | |||
| 1635 | 1637 | ||||
| 1636 | // Clean up memory pressure settings | 1638 | // Clean up memory pressure settings | ||
| 1637 | if w.memStats != nil && w.memStats.memoryPressureSettings != nil { | 1639 | if w.memStats != nil && w.memStats.memoryPressureSettings != nil { | ||
| 1638 | // WebKit will clean up the memory pressure settings when context is destroyed | 1640 | // WebKit will clean up the memory pressure settings when context is destroyed | ||
| 1639 | w.memStats.memoryPressureSettings = nil | 1641 | w.memStats.memoryPressureSettings = nil | ||
| 1642 | + | log.Printf("[webkit-destroy] cleared memory pressure settings: id=%d", w.id) | |||
| 1640 | } | 1643 | } | ||
| 1641 | 1644 | ||||
| 1642 | // Unregister from memory manager | 1645 | // Unregister from memory manager | ||
| 1643 | if globalMemoryManager != nil { | 1646 | if globalMemoryManager != nil { | ||
| 1644 | globalMemoryManager.UnregisterWebView(w.id) | 1647 | globalMemoryManager.UnregisterWebView(w.id) | ||
| 1648 | + | log.Printf("[webkit-destroy] unregistered from memory manager: id=%d", w.id) | |||
| 1645 | } | 1649 | } | ||
| 1646 | 1650 | ||||
| 1647 | if w.native != nil && w.native.ucm != nil && w.domBridgeScript != nil { | 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 | C.webkit_user_content_manager_remove_script(w.native.ucm, w.domBridgeScript) | 1653 | C.webkit_user_content_manager_remove_script(w.native.ucm, w.domBridgeScript) | ||
| 1649 | C.webkit_user_script_unref(w.domBridgeScript) | 1654 | C.webkit_user_script_unref(w.domBridgeScript) | ||
| 1650 | w.domBridgeScript = nil | 1655 | w.domBridgeScript = nil | ||
| @@ -1652,6 +1657,7 @@ func (w *WebView) Destroy() error { | |||||
| 1652 | 1657 | ||||
| 1653 | // GTK4: destroy windows via gtk_window_destroy if we created one | 1658 | // GTK4: destroy windows via gtk_window_destroy if we created one | ||
| 1654 | if w.native.win != nil && w.config != nil && w.config.CreateWindow { | 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 | C.gtk_window_destroy((*C.GtkWindow)(unsafe.Pointer(w.native.win))) | 1661 | C.gtk_window_destroy((*C.GtkWindow)(unsafe.Pointer(w.native.win))) | ||
| 1656 | w.native.win = nil | 1662 | w.native.win = nil | ||
| 1657 | if w.window != nil { | 1663 | if w.window != nil { | ||
| @@ -1660,13 +1666,17 @@ func (w *WebView) Destroy() error { | |||||
| 1660 | log.Printf("[webkit] Destroy window") | 1666 | log.Printf("[webkit] Destroy window") | ||
| 1661 | } | 1667 | } | ||
| 1662 | 1668 | ||||
| 1669 | + | log.Printf("[webkit-destroy] releasing native widgets: id=%d", w.id) | |||
| 1663 | w.releaseNativeWidgets() | 1670 | w.releaseNativeWidgets() | ||
| 1671 | + | log.Printf("[webkit-destroy] native widgets released: id=%d", w.id) | |||
| 1664 | w.destroyed = true | 1672 | w.destroyed = true | ||
| 1665 | 1673 | ||||
| 1666 | // Unregister from dynamic protection updates | 1674 | // Unregister from dynamic protection updates | ||
| 1667 | UnregisterActiveWebView(w) | 1675 | UnregisterActiveWebView(w) | ||
| 1676 | + | log.Printf("[webkit-destroy] unregistered active view: id=%d", w.id) | |||
| 1668 | 1677 | ||||
| 1669 | unregisterView(w.id) | 1678 | unregisterView(w.id) | ||
| 1679 | + | log.Printf("[webkit-destroy] destroy complete: id=%d", w.id) | |||
| 1670 | return nil | 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 | return goIdleCallback((uintptr_t)data); | 43 | return goIdleCallback((uintptr_t)data); | ||
| 44 | } | 44 | } | ||
| 45 | 45 | ||||
| 46 | - | static void add_idle_callback(uintptr_t handle) { | 46 | + | static guint add_idle_callback(uintptr_t handle) { |
| 47 | - | g_idle_add_full(G_PRIORITY_DEFAULT_IDLE, idle_callback_wrapper, (gpointer)handle, NULL); | 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 | static void motion_enter_cb(GtkEventControllerMotion* controller, gdouble x, gdouble y, gpointer user_data) { | 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 | // IdleAdd schedules a function to be called when the main loop is idle. | 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 | id := nextIdleID | 828 | id := nextIdleID | ||
| 822 | nextIdleID++ | 829 | nextIdleID++ | ||
| 823 | idleCallbacks[id] = fn | 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 | // IterateMainLoop processes a single GTK main loop iteration and reports if work was handled. | 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 | // IdleAdd simulates GTK's idle callback system - DEPRECATED, use WidgetWaitForDraw instead | 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 | if fn != nil { | 399 | if fn != nil { | ||
| 400 | fn() | 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 | func AddCSSProvider(css string) {} | 410 | func AddCSSProvider(css string) {} | ||