feat(workspace): implement backend-driven pane mode with keyboard handling
Complete rewrite of pane mode to run entirely in the Go backend instead of JavaScript. Provides more reliable keyboard handling and eliminates race conditions between multiple webviews. New workspace_pane_mode.go: - EnterPaneMode/ExitPaneMode: Backend state management with visual border - HandlePaneAction: Process close/split actions with proper mutex locking - Visual feedback via CSS class on workspace root container - JS notification events for toast messages only Keyboard bridge refactoring (keyboard_bridge.go): - Unified action mapping for pane mode and navigation shortcuts - Ctrl+P enters pane mode, Escape exits - Pane mode blocks ALL keys except registered actions (x/l/r/d/u/arrows) - Alt+Arrow navigation with proper direction handling - Removed complex routing table in favor of simple action map WebView integration (webview.go): - RegisterPaneModeHandler: Register action handler and active state checker - Backend directly processes keyboard events instead of forwarding to JS Workspace manager integration (workspace_manager.go): - RegisterNavigationHandler now also registers pane mode callbacks - Clean integration with existing focus and navigation systems This eliminates ~450 lines of complex JS state machine in favor of simple, reliable backend control.
- commit
- 9a95d24e12e50771d92ad2d98f0f9704a0f0355f
- author
- bamen · · 2025-10-13 20:02 UTC
- committer
- bamen · · 2025-10-13 16:27 UTC
- parent
- 85ff923
- signature
- unsigned
4 files · +503 -266
M internal/app/browser/workspace_manager.go +157 -180
| @@ -31,9 +31,9 @@ type WorkspaceManager struct { | |||
| 31 | 31 | createPaneFn func(*webkit.WebView) (*BrowserPane, error) | |
| 32 | 32 | ||
| 33 | 33 | // Coordination fields for preventing duplicate events | |
| 34 | - | paneModeSource *webkit.WebView // Which webview initiated pane mode | |
| 35 | - | lastPaneModeEntry time.Time // When pane mode was last entered | |
| 36 | - | paneMutex sync.Mutex // Protects pane mode state | |
| 34 | + | paneModeSource *webkit.WebView // Which webview initiated pane mode | |
| 35 | + | paneModeActivePane *paneNode // Which pane node has pane mode active | |
| 36 | + | paneMutex sync.Mutex // Protects pane mode state | |
| 37 | 37 | ||
| 38 | 38 | // Focus throttling fields to prevent infinite loops | |
| 39 | 39 | lastFocusChange time.Time // When focus was last changed | |
| @@ -177,170 +177,139 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag | |||
| 177 | 177 | return manager | |
| 178 | 178 | } | |
| 179 | 179 | ||
| 180 | - | // OnWorkspaceMessage implements messaging.WorkspaceObserver. | |
| 181 | - | func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messaging.Message) { | |
| 182 | - | node := wm.viewToNode[source] | |
| 183 | - | if node == nil { | |
| 184 | - | log.Printf("[workspace] message from unknown webview: event=%s", msg.Event) | |
| 185 | - | return | |
| 180 | + | // shouldDebounce checks if an operation should be debounced based on timing | |
| 181 | + | func (wm *WorkspaceManager) shouldDebounce(source *webkit.WebView, threshold time.Duration) bool { | |
| 182 | + | if last, ok := wm.lastSplitMsg[source]; ok { | |
| 183 | + | if elapsed := time.Since(last); elapsed < threshold { | |
| 184 | + | log.Printf("[workspace] operation debounced: %.0fms", elapsed.Seconds()*1000) | |
| 185 | + | return true | |
| 186 | + | } | |
| 186 | 187 | } | |
| 188 | + | wm.lastSplitMsg[source] = time.Now() | |
| 189 | + | return false | |
| 190 | + | } | |
| 187 | 191 | ||
| 188 | - | switch event := strings.ToLower(msg.Event); event { | |
| 189 | - | case "pane-mode-entered": | |
| 190 | - | // Use mutex to prevent race conditions when multiple webviews try to enter pane mode | |
| 191 | - | wm.paneMutex.Lock() | |
| 192 | - | defer wm.paneMutex.Unlock() | |
| 192 | + | // withSplitLock executes an operation with atomic splitting lock protection | |
| 193 | + | func (wm *WorkspaceManager) withSplitLock(operation string, fn func() error) error { | |
| 194 | + | if !atomic.CompareAndSwapInt32(&wm.splitting, 0, 1) { | |
| 195 | + | log.Printf("[workspace] %s ignored: operation in progress", operation) | |
| 196 | + | return fmt.Errorf("operation in progress") | |
| 197 | + | } | |
| 193 | 198 | ||
| 194 | - | if active := wm.GetActiveNode(); active != nil && active != node { | |
| 195 | - | log.Printf("[workspace] pane-mode-entered ignored: source pane is not active (current=%p, source=%p)", active, node) | |
| 196 | - | return | |
| 199 | + | defer func() { | |
| 200 | + | atomic.StoreInt32(&wm.splitting, 0) | |
| 201 | + | if r := recover(); r != nil { | |
| 202 | + | log.Printf("[workspace] %s panicked: %v", operation, r) | |
| 203 | + | panic(r) | |
| 197 | 204 | } | |
| 205 | + | }() | |
| 198 | 206 | ||
| 199 | - | // Check if pane mode was already entered recently (within 200ms debounce window) | |
| 200 | - | if time.Since(wm.lastPaneModeEntry) < 200*time.Millisecond { | |
| 201 | - | log.Printf("[workspace] pane-mode-entered rejected: debounce protection (%.0fms ago) source=%p", | |
| 202 | - | time.Since(wm.lastPaneModeEntry).Seconds()*1000, source) | |
| 203 | - | return | |
| 204 | - | } | |
| 207 | + | return fn() | |
| 208 | + | } | |
| 205 | 209 | ||
| 206 | - | // Check if pane mode is already active from a different source | |
| 207 | - | if wm.paneModeActive && wm.paneModeSource != nil && wm.paneModeSource != source { | |
| 208 | - | log.Printf("[workspace] pane-mode-entered rejected: already active from different source (current=%p, new=%p)", | |
| 209 | - | wm.paneModeSource, source) | |
| 210 | - | return | |
| 211 | - | } | |
| 210 | + | // cleanupPopupStorage removes localStorage entries for a closed popup | |
| 211 | + | func (wm *WorkspaceManager) cleanupPopupStorage(parentNode, targetNode *paneNode, webviewID string) { | |
| 212 | + | if parentNode.pane == nil || parentNode.pane.WebView() == nil || targetNode.requestID == "" { | |
| 213 | + | return | |
| 214 | + | } | |
| 212 | 215 | ||
| 213 | - | log.Printf("[workspace] pane-mode-entered accepted: source=%p", source) | |
| 214 | - | wm.paneModeActive = true | |
| 215 | - | wm.paneModeSource = source | |
| 216 | - | wm.lastPaneModeEntry = time.Now() | |
| 217 | - | wm.SetActivePane(node, SourceProgrammatic) | |
| 218 | - | case "pane-confirmed", "pane-cancelled", "pane-mode-exited": | |
| 219 | - | // Debounce pane-mode-exited events to prevent duplicate focus calls | |
| 220 | - | if event == "pane-mode-exited" { | |
| 221 | - | if last, ok := wm.lastExitMsg[source]; ok { | |
| 222 | - | if time.Since(last) < 100*time.Millisecond { | |
| 223 | - | log.Printf("[workspace] pane-mode-exited ignored: debounce (%.0fms)", time.Since(last).Seconds()*1000) | |
| 224 | - | return | |
| 225 | - | } | |
| 226 | - | } | |
| 227 | - | wm.lastExitMsg[source] = time.Now() | |
| 228 | - | } | |
| 216 | + | requestID := targetNode.requestID | |
| 217 | + | parentWebViewID := parentNode.pane.WebView().ID() | |
| 218 | + | ||
| 219 | + | script := fmt.Sprintf(` | |
| 220 | + | try { | |
| 221 | + | localStorage.removeItem('popup_mapping_%d'); | |
| 222 | + | const keys = ['popup_%s_parent_info', 'popup_%s_parent_action', | |
| 223 | + | 'popup_%s_message_to_popup', 'popup_%s_message_to_parent', | |
| 224 | + | 'oauth_callback_%s']; | |
| 225 | + | keys.forEach(k => { try { localStorage.removeItem(k); } catch(e) {} }); | |
| 226 | + | console.log('[workspace] Cleaned popup localStorage'); | |
| 227 | + | } catch(e) { console.warn('[workspace] localStorage cleanup failed:', e); } | |
| 228 | + | `, parentWebViewID, requestID, requestID, requestID, requestID, webviewID) | |
| 229 | + | ||
| 230 | + | if err := parentNode.pane.WebView().InjectScript(script); err != nil { | |
| 231 | + | log.Printf("[workspace] localStorage cleanup failed: %v", err) | |
| 232 | + | } else { | |
| 233 | + | log.Printf("[workspace] Cleaned localStorage for popup requestId=%s webviewId=%s", requestID, webviewID) | |
| 234 | + | } | |
| 235 | + | } | |
| 229 | 236 | ||
| 230 | - | wm.paneMutex.Lock() | |
| 231 | - | wm.paneModeActive = false | |
| 232 | - | wm.paneModeSource = nil | |
| 233 | - | wm.paneMutex.Unlock() | |
| 237 | + | // OnWorkspaceMessage implements messaging.WorkspaceObserver. | |
| 238 | + | func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messaging.Message) { | |
| 239 | + | node := wm.viewToNode[source] | |
| 234 | 240 | ||
| 235 | - | // Only restore focus if the currently active pane is the same as the exiting pane. | |
| 236 | - | // If focus has moved (e.g., due to a split), don't steal it back. | |
| 237 | - | currentActive := wm.GetActiveNode() | |
| 238 | - | if currentActive == node { | |
| 239 | - | wm.focusAfterPaneMode(node) | |
| 240 | - | } else { | |
| 241 | - | log.Printf("[workspace] pane-mode-exited but focus already on different pane (%p != %p), preserving current focus", | |
| 242 | - | node, currentActive) | |
| 243 | - | } | |
| 244 | - | case "pane-close": | |
| 245 | - | if !wm.paneModeActive { | |
| 246 | - | log.Printf("[workspace] close requested outside pane mode; ignoring") | |
| 247 | - | break | |
| 241 | + | if msg.WebViewID != "" { | |
| 242 | + | if explicitNode := wm.findNodeByWebViewID(msg.WebViewID); explicitNode != nil { | |
| 243 | + | if explicitNode.pane != nil && explicitNode.pane.webView != nil { | |
| 244 | + | node = explicitNode | |
| 245 | + | source = explicitNode.pane.webView | |
| 246 | + | } | |
| 248 | 247 | } | |
| 248 | + | } | |
| 249 | 249 | ||
| 250 | - | wm.paneMutex.Lock() | |
| 251 | - | wm.paneModeActive = false | |
| 252 | - | wm.paneModeSource = nil | |
| 253 | - | wm.paneMutex.Unlock() | |
| 250 | + | if node == nil { | |
| 251 | + | log.Printf("[workspace] message from unknown webview: event=%s webviewId=%s", msg.Event, msg.WebViewID) | |
| 252 | + | return | |
| 253 | + | } | |
| 254 | 254 | ||
| 255 | - | // Don't focus the node that's about to be closed - closeCurrentPane() will handle focus | |
| 256 | - | wm.closeCurrentPane() | |
| 255 | + | switch event := strings.ToLower(msg.Event); event { | |
| 257 | 256 | case "pane-split": | |
| 258 | 257 | wm.SetActivePane(node, SourceProgrammatic) | |
| 259 | 258 | direction := strings.ToLower(msg.Direction) | |
| 260 | 259 | if direction == "" { | |
| 261 | 260 | direction = "right" | |
| 262 | 261 | } | |
| 263 | - | if last, ok := wm.lastSplitMsg[source]; ok { | |
| 264 | - | if time.Since(last) < 200*time.Millisecond { | |
| 265 | - | log.Printf("[workspace] split ignored: debounce (%.0fms)", time.Since(last).Seconds()*1000) | |
| 266 | - | return | |
| 267 | - | } | |
| 268 | - | } | |
| 269 | 262 | ||
| 270 | - | // CRITICAL FIX: Use atomic operations for splitting flag to prevent race conditions | |
| 271 | - | if !atomic.CompareAndSwapInt32(&wm.splitting, 0, 1) { | |
| 272 | - | log.Printf("[workspace] split ignored: operation already in progress") | |
| 263 | + | if wm.shouldDebounce(source, 200*time.Millisecond) { | |
| 273 | 264 | return | |
| 274 | 265 | } | |
| 275 | 266 | ||
| 276 | - | // CRITICAL FIX: Always clear splitting flag on exit (panic recovery) | |
| 277 | - | defer func() { | |
| 278 | - | atomic.StoreInt32(&wm.splitting, 0) | |
| 279 | - | if r := recover(); r != nil { | |
| 280 | - | log.Printf("[workspace] split operation panicked, flag cleared: %v", r) | |
| 281 | - | panic(r) // Re-panic to maintain error handling | |
| 267 | + | err := wm.withSplitLock("split", func() error { | |
| 268 | + | newNode, err := wm.SplitPane(node, direction) | |
| 269 | + | if err != nil { | |
| 270 | + | if glib.MainContextDefault().IsOwner() { | |
| 271 | + | glib.MainContextDefault().Iteration(false) | |
| 272 | + | } | |
| 273 | + | return err | |
| 282 | 274 | } | |
| 283 | - | }() | |
| 284 | - | ||
| 285 | - | wm.lastSplitMsg[source] = time.Now() | |
| 275 | + | wm.clonePaneState(node, newNode) | |
| 276 | + | return nil | |
| 277 | + | }) | |
| 286 | 278 | ||
| 287 | - | newNode, err := wm.SplitPane(node, direction) | |
| 288 | 279 | if err != nil { | |
| 289 | 280 | log.Printf("[workspace] split failed: %v", err) | |
| 290 | - | // CRITICAL FIX: Pump GTK events after validation failure to clear pending operations | |
| 291 | - | if glib.MainContextDefault().IsOwner() { | |
| 292 | - | glib.MainContextDefault().Iteration(false) | |
| 293 | - | } | |
| 294 | - | return | |
| 295 | 281 | } | |
| 296 | - | wm.clonePaneState(node, newNode) | |
| 282 | + | ||
| 297 | 283 | case "pane-stack": | |
| 298 | 284 | wm.SetActivePane(node, SourceProgrammatic) | |
| 299 | - | if last, ok := wm.lastSplitMsg[source]; ok { | |
| 300 | - | if time.Since(last) < 200*time.Millisecond { | |
| 301 | - | log.Printf("[workspace] stack ignored: debounce (%.0fms)", time.Since(last).Seconds()*1000) | |
| 302 | - | return | |
| 303 | - | } | |
| 304 | - | } | |
| 305 | 285 | ||
| 306 | - | // CRITICAL FIX: Use atomic operations for splitting flag | |
| 307 | - | if !atomic.CompareAndSwapInt32(&wm.splitting, 0, 1) { | |
| 308 | - | log.Printf("[workspace] stack ignored: operation already in progress") | |
| 286 | + | if wm.shouldDebounce(source, 200*time.Millisecond) { | |
| 309 | 287 | return | |
| 310 | 288 | } | |
| 311 | 289 | ||
| 312 | - | // CRITICAL FIX: Always clear splitting flag on exit (panic recovery) | |
| 313 | - | defer func() { | |
| 314 | - | atomic.StoreInt32(&wm.splitting, 0) | |
| 315 | - | if r := recover(); r != nil { | |
| 316 | - | log.Printf("[workspace] stack operation panicked, flag cleared: %v", r) | |
| 317 | - | panic(r) // Re-panic to maintain error handling | |
| 290 | + | err := wm.withSplitLock("stack", func() error { | |
| 291 | + | newNode, err := wm.stackedPaneManager.StackPane(node) | |
| 292 | + | if err != nil { | |
| 293 | + | if glib.MainContextDefault().IsOwner() { | |
| 294 | + | glib.MainContextDefault().Iteration(false) | |
| 295 | + | } | |
| 296 | + | return err | |
| 318 | 297 | } | |
| 319 | - | }() | |
| 298 | + | wm.clonePaneState(node, newNode) | |
| 299 | + | return nil | |
| 300 | + | }) | |
| 320 | 301 | ||
| 321 | - | wm.lastSplitMsg[source] = time.Now() | |
| 322 | - | ||
| 323 | - | newNode, err := wm.stackedPaneManager.StackPane(node) | |
| 324 | 302 | if err != nil { | |
| 325 | 303 | log.Printf("[workspace] stack failed: %v", err) | |
| 326 | - | // CRITICAL FIX: Pump GTK events after stack failure | |
| 327 | - | if glib.MainContextDefault().IsOwner() { | |
| 328 | - | glib.MainContextDefault().Iteration(false) | |
| 329 | - | } | |
| 330 | - | return | |
| 331 | 304 | } | |
| 332 | - | wm.clonePaneState(node, newNode) | |
| 333 | - | case "create-pane": | |
| 334 | - | log.Printf("[workspace] create-pane message ignored (native popup lifecycle enabled)") | |
| 335 | - | case "close-popup": | |
| 336 | - | log.Printf("[workspace] close-popup requested: webviewId=%s reason=%s", msg.WebViewID, msg.Reason) | |
| 337 | 305 | ||
| 306 | + | case "close-popup": | |
| 338 | 307 | if msg.WebViewID == "" { | |
| 339 | 308 | log.Printf("[workspace] close-popup: empty webviewId, ignoring") | |
| 340 | - | break | |
| 309 | + | return | |
| 341 | 310 | } | |
| 342 | 311 | ||
| 343 | - | // Find the popup pane by webview ID | |
| 312 | + | // Find popup pane by webview ID | |
| 344 | 313 | var targetNode *paneNode | |
| 345 | 314 | for webView, node := range wm.viewToNode { | |
| 346 | 315 | if webView != nil && fmt.Sprintf("%d", webView.ID()) == msg.WebViewID { | |
| @@ -351,87 +320,52 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa | |||
| 351 | 320 | ||
| 352 | 321 | if targetNode == nil { | |
| 353 | 322 | log.Printf("[workspace] close-popup: webview not found: %s", msg.WebViewID) | |
| 354 | - | break | |
| 323 | + | return | |
| 355 | 324 | } | |
| 356 | 325 | ||
| 357 | 326 | if !targetNode.isPopup { | |
| 358 | 327 | log.Printf("[workspace] close-popup: target is not a popup: %s", msg.WebViewID) | |
| 359 | - | break | |
| 328 | + | return | |
| 360 | 329 | } | |
| 361 | 330 | ||
| 362 | - | log.Printf("[workspace] Closing popup pane due to %s", msg.Reason) | |
| 331 | + | log.Printf("[workspace] Closing popup due to %s", msg.Reason) | |
| 363 | 332 | ||
| 364 | - | // Remove this popup from parent's activePopupChildren before closing | |
| 333 | + | // Remove from parent's active popup list | |
| 365 | 334 | if targetNode.parentPane != nil { | |
| 366 | 335 | parentNode := targetNode.parentPane | |
| 367 | 336 | for i, childID := range parentNode.activePopupChildren { | |
| 368 | 337 | if childID == msg.WebViewID { | |
| 369 | 338 | parentNode.activePopupChildren = append(parentNode.activePopupChildren[:i], parentNode.activePopupChildren[i+1:]...) | |
| 370 | - | log.Printf("[workspace] Removed popup %s from parent's activePopupChildren (remaining: %d)", msg.WebViewID, len(parentNode.activePopupChildren)) | |
| 339 | + | log.Printf("[workspace] Removed popup from parent (remaining: %d)", len(parentNode.activePopupChildren)) | |
| 371 | 340 | break | |
| 372 | 341 | } | |
| 373 | 342 | } | |
| 374 | 343 | ||
| 375 | - | // Clean up ALL localStorage entries for this popup in parent WebView | |
| 376 | - | // This includes popup_<requestId>_parent_info, popup_<requestId>_parent_action, etc. | |
| 377 | - | if parentNode.pane != nil && parentNode.pane.WebView() != nil && targetNode.requestID != "" { | |
| 378 | - | parentWebViewID := parentNode.pane.WebView().ID() | |
| 379 | - | requestID := targetNode.requestID | |
| 380 | - | ||
| 381 | - | cleanupScript := fmt.Sprintf(` | |
| 382 | - | try { | |
| 383 | - | // Clean up popup_mapping_<parentId> (from Go code) | |
| 384 | - | localStorage.removeItem('popup_mapping_%d'); | |
| 385 | - | ||
| 386 | - | // Clean up popup_<requestId>_* entries (from JavaScript) | |
| 387 | - | const requestId = '%s'; | |
| 388 | - | const keysToRemove = [ | |
| 389 | - | 'popup_' + requestId + '_parent_info', | |
| 390 | - | 'popup_' + requestId + '_parent_action', | |
| 391 | - | 'popup_' + requestId + '_message_to_popup', | |
| 392 | - | 'popup_' + requestId + '_message_to_parent', | |
| 393 | - | 'oauth_callback_' + '%s' | |
| 394 | - | ]; | |
| 395 | - | ||
| 396 | - | keysToRemove.forEach(key => { | |
| 397 | - | try { | |
| 398 | - | localStorage.removeItem(key); | |
| 399 | - | } catch(e) {} | |
| 400 | - | }); | |
| 401 | - | ||
| 402 | - | console.log('[workspace] Cleaned up popup localStorage for requestId:', requestId); | |
| 403 | - | } catch(e) { | |
| 404 | - | console.warn('[workspace] Failed to clean popup localStorage:', e); | |
| 405 | - | } | |
| 406 | - | `, parentWebViewID, requestID, msg.WebViewID) | |
| 407 | - | ||
| 408 | - | if err := parentNode.pane.WebView().InjectScript(cleanupScript); err != nil { | |
| 409 | - | log.Printf("[workspace] Failed to inject localStorage cleanup script: %v", err) | |
| 410 | - | } else { | |
| 411 | - | log.Printf("[workspace] Cleaned up localStorage for popup requestId=%s webviewId=%s", requestID, msg.WebViewID) | |
| 412 | - | } | |
| 413 | - | } | |
| 344 | + | wm.cleanupPopupStorage(parentNode, targetNode, msg.WebViewID) | |
| 414 | 345 | } | |
| 415 | 346 | ||
| 416 | - | // Close the popup pane | |
| 417 | 347 | if err := wm.ClosePane(targetNode); err != nil { | |
| 418 | - | log.Printf("[workspace] Failed to close popup pane: %v", err) | |
| 419 | - | } else { | |
| 420 | - | log.Printf("[workspace] Successfully closed popup pane: %s", msg.WebViewID) | |
| 421 | - | ||
| 422 | - | // REMOVED: Hide/show parent container sequence | |
| 423 | - | // This was causing a race condition with the focus change scheduled by ClosePane(). | |
| 424 | - | // GTK was sending focus events to widgets during hide/show operations, causing SIGSEGV. | |
| 425 | - | // ClosePane() already handles focus restoration properly via setFocusToLeaf(). | |
| 426 | - | // | |
| 427 | - | // If rendering issues occur after popup close, they should be fixed with a different | |
| 428 | - | // approach that doesn't manipulate widget visibility during focus transitions. | |
| 348 | + | log.Printf("[workspace] Failed to close popup: %v", err) | |
| 429 | 349 | } | |
| 350 | + | ||
| 430 | 351 | default: | |
| 431 | 352 | log.Printf("[workspace] unhandled workspace event: %s", msg.Event) | |
| 432 | 353 | } | |
| 433 | 354 | } | |
| 434 | 355 | ||
| 356 | + | func (wm *WorkspaceManager) findNodeByWebViewID(id string) *paneNode { | |
| 357 | + | if wm == nil || id == "" { | |
| 358 | + | return nil | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | for webView, node := range wm.viewToNode { | |
| 362 | + | if webView != nil && webView.IDString() == id { | |
| 363 | + | return node | |
| 364 | + | } | |
| 365 | + | } | |
| 366 | + | return nil | |
| 367 | + | } | |
| 368 | + | ||
| 435 | 369 | // GetActiveNode returns the currently active pane node | |
| 436 | 370 | func (wm *WorkspaceManager) GetActiveNode() *paneNode { | |
| 437 | 371 | if wm == nil || wm.focusStateMachine == nil { | |
| @@ -478,5 +412,48 @@ func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) { | |||
| 478 | 412 | return wm.FocusNeighbor(direction) | |
| 479 | 413 | }) | |
| 480 | 414 | ||
| 415 | + | // Register pane mode callbacks for Ctrl+P and pane mode actions | |
| 416 | + | webView.RegisterPaneModeHandler(func(action string) bool { | |
| 417 | + | if wm == nil { | |
| 418 | + | return false | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | if action == "enter" { | |
| 422 | + | wm.EnterPaneMode() | |
| 423 | + | return true | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | if action == "exit" { | |
| 427 | + | // Check if pane mode is active | |
| 428 | + | wm.paneMutex.Lock() | |
| 429 | + | wasActive := wm.paneModeActive | |
| 430 | + | wm.paneMutex.Unlock() | |
| 431 | + | ||
| 432 | + | if wasActive { | |
| 433 | + | wm.ExitPaneMode("escape") | |
| 434 | + | return true // Mode was active, we handled it | |
| 435 | + | } | |
| 436 | + | return false // No mode active, pass through to DOM | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | // For other actions, only handle if pane mode is active | |
| 440 | + | wm.paneMutex.Lock() | |
| 441 | + | isActive := wm.paneModeActive | |
| 442 | + | wm.paneMutex.Unlock() | |
| 443 | + | ||
| 444 | + | if !isActive { | |
| 445 | + | return false // Not in pane mode, allow normal behavior | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | // Pane mode is active, handle the action | |
| 449 | + | wm.HandlePaneAction(action) | |
| 450 | + | return true | |
| 451 | + | }, func() bool { | |
| 452 | + | // Check if pane mode is currently active | |
| 453 | + | wm.paneMutex.Lock() | |
| 454 | + | defer wm.paneMutex.Unlock() | |
| 455 | + | return wm.paneModeActive | |
| 456 | + | }) | |
| 457 | + | ||
| 481 | 458 | log.Printf("[workspace] Registered navigation handler for webview: %d", webView.ID()) | |
| 482 | 459 | } | |
| @@ -31,9 +31,9 @@ type WorkspaceManager struct { | |||||
| 31 | createPaneFn func(*webkit.WebView) (*BrowserPane, error) | 31 | createPaneFn func(*webkit.WebView) (*BrowserPane, error) | ||
| 32 | 32 | ||||
| 33 | // Coordination fields for preventing duplicate events | 33 | // Coordination fields for preventing duplicate events | ||
| 34 | - | paneModeSource *webkit.WebView // Which webview initiated pane mode | 34 | + | paneModeSource *webkit.WebView // Which webview initiated pane mode |
| 35 | - | lastPaneModeEntry time.Time // When pane mode was last entered | 35 | + | paneModeActivePane *paneNode // Which pane node has pane mode active |
| 36 | - | paneMutex sync.Mutex // Protects pane mode state | 36 | + | paneMutex sync.Mutex // Protects pane mode state |
| 37 | 37 | ||||
| 38 | // Focus throttling fields to prevent infinite loops | 38 | // Focus throttling fields to prevent infinite loops | ||
| 39 | lastFocusChange time.Time // When focus was last changed | 39 | lastFocusChange time.Time // When focus was last changed | ||
| @@ -177,170 +177,139 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag | |||||
| 177 | return manager | 177 | return manager | ||
| 178 | } | 178 | } | ||
| 179 | 179 | ||||
| 180 | - | // OnWorkspaceMessage implements messaging.WorkspaceObserver. | 180 | + | // shouldDebounce checks if an operation should be debounced based on timing |
| 181 | - | func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messaging.Message) { | 181 | + | func (wm *WorkspaceManager) shouldDebounce(source *webkit.WebView, threshold time.Duration) bool { |
| 182 | - | node := wm.viewToNode[source] | 182 | + | if last, ok := wm.lastSplitMsg[source]; ok { |
| 183 | - | if node == nil { | 183 | + | if elapsed := time.Since(last); elapsed < threshold { |
| 184 | - | log.Printf("[workspace] message from unknown webview: event=%s", msg.Event) | 184 | + | log.Printf("[workspace] operation debounced: %.0fms", elapsed.Seconds()*1000) |
| 185 | - | return | 185 | + | return true |
| 186 | + | } | |||
| 186 | } | 187 | } | ||
| 188 | + | wm.lastSplitMsg[source] = time.Now() | |||
| 189 | + | return false | |||
| 190 | + | } | |||
| 187 | 191 | ||||
| 188 | - | switch event := strings.ToLower(msg.Event); event { | 192 | + | // withSplitLock executes an operation with atomic splitting lock protection |
| 189 | - | case "pane-mode-entered": | 193 | + | func (wm *WorkspaceManager) withSplitLock(operation string, fn func() error) error { |
| 190 | - | // Use mutex to prevent race conditions when multiple webviews try to enter pane mode | 194 | + | if !atomic.CompareAndSwapInt32(&wm.splitting, 0, 1) { |
| 191 | - | wm.paneMutex.Lock() | 195 | + | log.Printf("[workspace] %s ignored: operation in progress", operation) |
| 192 | - | defer wm.paneMutex.Unlock() | 196 | + | return fmt.Errorf("operation in progress") |
| 197 | + | } | |||
| 193 | 198 | ||||
| 194 | - | if active := wm.GetActiveNode(); active != nil && active != node { | 199 | + | defer func() { |
| 195 | - | log.Printf("[workspace] pane-mode-entered ignored: source pane is not active (current=%p, source=%p)", active, node) | 200 | + | atomic.StoreInt32(&wm.splitting, 0) |
| 196 | - | return | 201 | + | if r := recover(); r != nil { |
| 202 | + | log.Printf("[workspace] %s panicked: %v", operation, r) | |||
| 203 | + | panic(r) | |||
| 197 | } | 204 | } | ||
| 205 | + | }() | |||
| 198 | 206 | ||||
| 199 | - | // Check if pane mode was already entered recently (within 200ms debounce window) | 207 | + | return fn() |
| 200 | - | if time.Since(wm.lastPaneModeEntry) < 200*time.Millisecond { | 208 | + | } |
| 201 | - | log.Printf("[workspace] pane-mode-entered rejected: debounce protection (%.0fms ago) source=%p", | |||
| 202 | - | time.Since(wm.lastPaneModeEntry).Seconds()*1000, source) | |||
| 203 | - | return | |||
| 204 | - | } | |||
| 205 | 209 | ||||
| 206 | - | // Check if pane mode is already active from a different source | 210 | + | // cleanupPopupStorage removes localStorage entries for a closed popup |
| 207 | - | if wm.paneModeActive && wm.paneModeSource != nil && wm.paneModeSource != source { | 211 | + | func (wm *WorkspaceManager) cleanupPopupStorage(parentNode, targetNode *paneNode, webviewID string) { |
| 208 | - | log.Printf("[workspace] pane-mode-entered rejected: already active from different source (current=%p, new=%p)", | 212 | + | if parentNode.pane == nil || parentNode.pane.WebView() == nil || targetNode.requestID == "" { |
| 209 | - | wm.paneModeSource, source) | 213 | + | return |
| 210 | - | return | 214 | + | } |
| 211 | - | } | |||
| 212 | 215 | ||||
| 213 | - | log.Printf("[workspace] pane-mode-entered accepted: source=%p", source) | 216 | + | requestID := targetNode.requestID |
| 214 | - | wm.paneModeActive = true | 217 | + | parentWebViewID := parentNode.pane.WebView().ID() |
| 215 | - | wm.paneModeSource = source | 218 | + | |
| 216 | - | wm.lastPaneModeEntry = time.Now() | 219 | + | script := fmt.Sprintf(` |
| 217 | - | wm.SetActivePane(node, SourceProgrammatic) | 220 | + | try { |
| 218 | - | case "pane-confirmed", "pane-cancelled", "pane-mode-exited": | 221 | + | localStorage.removeItem('popup_mapping_%d'); |
| 219 | - | // Debounce pane-mode-exited events to prevent duplicate focus calls | 222 | + | const keys = ['popup_%s_parent_info', 'popup_%s_parent_action', |
| 220 | - | if event == "pane-mode-exited" { | 223 | + | 'popup_%s_message_to_popup', 'popup_%s_message_to_parent', |
| 221 | - | if last, ok := wm.lastExitMsg[source]; ok { | 224 | + | 'oauth_callback_%s']; |
| 222 | - | if time.Since(last) < 100*time.Millisecond { | 225 | + | keys.forEach(k => { try { localStorage.removeItem(k); } catch(e) {} }); |
| 223 | - | log.Printf("[workspace] pane-mode-exited ignored: debounce (%.0fms)", time.Since(last).Seconds()*1000) | 226 | + | console.log('[workspace] Cleaned popup localStorage'); |
| 224 | - | return | 227 | + | } catch(e) { console.warn('[workspace] localStorage cleanup failed:', e); } |
| 225 | - | } | 228 | + | `, parentWebViewID, requestID, requestID, requestID, requestID, webviewID) |
| 226 | - | } | 229 | + | |
| 227 | - | wm.lastExitMsg[source] = time.Now() | 230 | + | if err := parentNode.pane.WebView().InjectScript(script); err != nil { |
| 228 | - | } | 231 | + | log.Printf("[workspace] localStorage cleanup failed: %v", err) |
| 232 | + | } else { | |||
| 233 | + | log.Printf("[workspace] Cleaned localStorage for popup requestId=%s webviewId=%s", requestID, webviewID) | |||
| 234 | + | } | |||
| 235 | + | } | |||
| 229 | 236 | ||||
| 230 | - | wm.paneMutex.Lock() | 237 | + | // OnWorkspaceMessage implements messaging.WorkspaceObserver. |
| 231 | - | wm.paneModeActive = false | 238 | + | func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messaging.Message) { |
| 232 | - | wm.paneModeSource = nil | 239 | + | node := wm.viewToNode[source] |
| 233 | - | wm.paneMutex.Unlock() | |||
| 234 | 240 | ||||
| 235 | - | // Only restore focus if the currently active pane is the same as the exiting pane. | 241 | + | if msg.WebViewID != "" { |
| 236 | - | // If focus has moved (e.g., due to a split), don't steal it back. | 242 | + | if explicitNode := wm.findNodeByWebViewID(msg.WebViewID); explicitNode != nil { |
| 237 | - | currentActive := wm.GetActiveNode() | 243 | + | if explicitNode.pane != nil && explicitNode.pane.webView != nil { |
| 238 | - | if currentActive == node { | 244 | + | node = explicitNode |
| 239 | - | wm.focusAfterPaneMode(node) | 245 | + | source = explicitNode.pane.webView |
| 240 | - | } else { | 246 | + | } |
| 241 | - | log.Printf("[workspace] pane-mode-exited but focus already on different pane (%p != %p), preserving current focus", | |||
| 242 | - | node, currentActive) | |||
| 243 | - | } | |||
| 244 | - | case "pane-close": | |||
| 245 | - | if !wm.paneModeActive { | |||
| 246 | - | log.Printf("[workspace] close requested outside pane mode; ignoring") | |||
| 247 | - | break | |||
| 248 | } | 247 | } | ||
| 248 | + | } | |||
| 249 | 249 | ||||
| 250 | - | wm.paneMutex.Lock() | 250 | + | if node == nil { |
| 251 | - | wm.paneModeActive = false | 251 | + | log.Printf("[workspace] message from unknown webview: event=%s webviewId=%s", msg.Event, msg.WebViewID) |
| 252 | - | wm.paneModeSource = nil | 252 | + | return |
| 253 | - | wm.paneMutex.Unlock() | 253 | + | } |
| 254 | 254 | ||||
| 255 | - | // Don't focus the node that's about to be closed - closeCurrentPane() will handle focus | 255 | + | switch event := strings.ToLower(msg.Event); event { |
| 256 | - | wm.closeCurrentPane() | |||
| 257 | case "pane-split": | 256 | case "pane-split": | ||
| 258 | wm.SetActivePane(node, SourceProgrammatic) | 257 | wm.SetActivePane(node, SourceProgrammatic) | ||
| 259 | direction := strings.ToLower(msg.Direction) | 258 | direction := strings.ToLower(msg.Direction) | ||
| 260 | if direction == "" { | 259 | if direction == "" { | ||
| 261 | direction = "right" | 260 | direction = "right" | ||
| 262 | } | 261 | } | ||
| 263 | - | if last, ok := wm.lastSplitMsg[source]; ok { | |||
| 264 | - | if time.Since(last) < 200*time.Millisecond { | |||
| 265 | - | log.Printf("[workspace] split ignored: debounce (%.0fms)", time.Since(last).Seconds()*1000) | |||
| 266 | - | return | |||
| 267 | - | } | |||
| 268 | - | } | |||
| 269 | 262 | ||||
| 270 | - | // CRITICAL FIX: Use atomic operations for splitting flag to prevent race conditions | 263 | + | if wm.shouldDebounce(source, 200*time.Millisecond) { |
| 271 | - | if !atomic.CompareAndSwapInt32(&wm.splitting, 0, 1) { | |||
| 272 | - | log.Printf("[workspace] split ignored: operation already in progress") | |||
| 273 | return | 264 | return | ||
| 274 | } | 265 | } | ||
| 275 | 266 | ||||
| 276 | - | // CRITICAL FIX: Always clear splitting flag on exit (panic recovery) | 267 | + | err := wm.withSplitLock("split", func() error { |
| 277 | - | defer func() { | 268 | + | newNode, err := wm.SplitPane(node, direction) |
| 278 | - | atomic.StoreInt32(&wm.splitting, 0) | 269 | + | if err != nil { |
| 279 | - | if r := recover(); r != nil { | 270 | + | if glib.MainContextDefault().IsOwner() { |
| 280 | - | log.Printf("[workspace] split operation panicked, flag cleared: %v", r) | 271 | + | glib.MainContextDefault().Iteration(false) |
| 281 | - | panic(r) // Re-panic to maintain error handling | 272 | + | } |
| 273 | + | return err | |||
| 282 | } | 274 | } | ||
| 283 | - | }() | 275 | + | wm.clonePaneState(node, newNode) |
| 284 | - | 276 | + | return nil | |
| 285 | - | wm.lastSplitMsg[source] = time.Now() | 277 | + | }) |
| 286 | 278 | ||||
| 287 | - | newNode, err := wm.SplitPane(node, direction) | |||
| 288 | if err != nil { | 279 | if err != nil { | ||
| 289 | log.Printf("[workspace] split failed: %v", err) | 280 | log.Printf("[workspace] split failed: %v", err) | ||
| 290 | - | // CRITICAL FIX: Pump GTK events after validation failure to clear pending operations | |||
| 291 | - | if glib.MainContextDefault().IsOwner() { | |||
| 292 | - | glib.MainContextDefault().Iteration(false) | |||
| 293 | - | } | |||
| 294 | - | return | |||
| 295 | } | 281 | } | ||
| 296 | - | wm.clonePaneState(node, newNode) | 282 | + | |
| 297 | case "pane-stack": | 283 | case "pane-stack": | ||
| 298 | wm.SetActivePane(node, SourceProgrammatic) | 284 | wm.SetActivePane(node, SourceProgrammatic) | ||
| 299 | - | if last, ok := wm.lastSplitMsg[source]; ok { | |||
| 300 | - | if time.Since(last) < 200*time.Millisecond { | |||
| 301 | - | log.Printf("[workspace] stack ignored: debounce (%.0fms)", time.Since(last).Seconds()*1000) | |||
| 302 | - | return | |||
| 303 | - | } | |||
| 304 | - | } | |||
| 305 | 285 | ||||
| 306 | - | // CRITICAL FIX: Use atomic operations for splitting flag | 286 | + | if wm.shouldDebounce(source, 200*time.Millisecond) { |
| 307 | - | if !atomic.CompareAndSwapInt32(&wm.splitting, 0, 1) { | |||
| 308 | - | log.Printf("[workspace] stack ignored: operation already in progress") | |||
| 309 | return | 287 | return | ||
| 310 | } | 288 | } | ||
| 311 | 289 | ||||
| 312 | - | // CRITICAL FIX: Always clear splitting flag on exit (panic recovery) | 290 | + | err := wm.withSplitLock("stack", func() error { |
| 313 | - | defer func() { | 291 | + | newNode, err := wm.stackedPaneManager.StackPane(node) |
| 314 | - | atomic.StoreInt32(&wm.splitting, 0) | 292 | + | if err != nil { |
| 315 | - | if r := recover(); r != nil { | 293 | + | if glib.MainContextDefault().IsOwner() { |
| 316 | - | log.Printf("[workspace] stack operation panicked, flag cleared: %v", r) | 294 | + | glib.MainContextDefault().Iteration(false) |
| 317 | - | panic(r) // Re-panic to maintain error handling | 295 | + | } |
| 296 | + | return err | |||
| 318 | } | 297 | } | ||
| 319 | - | }() | 298 | + | wm.clonePaneState(node, newNode) |
| 299 | + | return nil | |||
| 300 | + | }) | |||
| 320 | 301 | ||||
| 321 | - | wm.lastSplitMsg[source] = time.Now() | |||
| 322 | - | ||||
| 323 | - | newNode, err := wm.stackedPaneManager.StackPane(node) | |||
| 324 | if err != nil { | 302 | if err != nil { | ||
| 325 | log.Printf("[workspace] stack failed: %v", err) | 303 | log.Printf("[workspace] stack failed: %v", err) | ||
| 326 | - | // CRITICAL FIX: Pump GTK events after stack failure | |||
| 327 | - | if glib.MainContextDefault().IsOwner() { | |||
| 328 | - | glib.MainContextDefault().Iteration(false) | |||
| 329 | - | } | |||
| 330 | - | return | |||
| 331 | } | 304 | } | ||
| 332 | - | wm.clonePaneState(node, newNode) | |||
| 333 | - | case "create-pane": | |||
| 334 | - | log.Printf("[workspace] create-pane message ignored (native popup lifecycle enabled)") | |||
| 335 | - | case "close-popup": | |||
| 336 | - | log.Printf("[workspace] close-popup requested: webviewId=%s reason=%s", msg.WebViewID, msg.Reason) | |||
| 337 | 305 | ||||
| 306 | + | case "close-popup": | |||
| 338 | if msg.WebViewID == "" { | 307 | if msg.WebViewID == "" { | ||
| 339 | log.Printf("[workspace] close-popup: empty webviewId, ignoring") | 308 | log.Printf("[workspace] close-popup: empty webviewId, ignoring") | ||
| 340 | - | break | 309 | + | return |
| 341 | } | 310 | } | ||
| 342 | 311 | ||||
| 343 | - | // Find the popup pane by webview ID | 312 | + | // Find popup pane by webview ID |
| 344 | var targetNode *paneNode | 313 | var targetNode *paneNode | ||
| 345 | for webView, node := range wm.viewToNode { | 314 | for webView, node := range wm.viewToNode { | ||
| 346 | if webView != nil && fmt.Sprintf("%d", webView.ID()) == msg.WebViewID { | 315 | if webView != nil && fmt.Sprintf("%d", webView.ID()) == msg.WebViewID { | ||
| @@ -351,87 +320,52 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa | |||||
| 351 | 320 | ||||
| 352 | if targetNode == nil { | 321 | if targetNode == nil { | ||
| 353 | log.Printf("[workspace] close-popup: webview not found: %s", msg.WebViewID) | 322 | log.Printf("[workspace] close-popup: webview not found: %s", msg.WebViewID) | ||
| 354 | - | break | 323 | + | return |
| 355 | } | 324 | } | ||
| 356 | 325 | ||||
| 357 | if !targetNode.isPopup { | 326 | if !targetNode.isPopup { | ||
| 358 | log.Printf("[workspace] close-popup: target is not a popup: %s", msg.WebViewID) | 327 | log.Printf("[workspace] close-popup: target is not a popup: %s", msg.WebViewID) | ||
| 359 | - | break | 328 | + | return |
| 360 | } | 329 | } | ||
| 361 | 330 | ||||
| 362 | - | log.Printf("[workspace] Closing popup pane due to %s", msg.Reason) | 331 | + | log.Printf("[workspace] Closing popup due to %s", msg.Reason) |
| 363 | 332 | ||||
| 364 | - | // Remove this popup from parent's activePopupChildren before closing | 333 | + | // Remove from parent's active popup list |
| 365 | if targetNode.parentPane != nil { | 334 | if targetNode.parentPane != nil { | ||
| 366 | parentNode := targetNode.parentPane | 335 | parentNode := targetNode.parentPane | ||
| 367 | for i, childID := range parentNode.activePopupChildren { | 336 | for i, childID := range parentNode.activePopupChildren { | ||
| 368 | if childID == msg.WebViewID { | 337 | if childID == msg.WebViewID { | ||
| 369 | parentNode.activePopupChildren = append(parentNode.activePopupChildren[:i], parentNode.activePopupChildren[i+1:]...) | 338 | parentNode.activePopupChildren = append(parentNode.activePopupChildren[:i], parentNode.activePopupChildren[i+1:]...) | ||
| 370 | - | log.Printf("[workspace] Removed popup %s from parent's activePopupChildren (remaining: %d)", msg.WebViewID, len(parentNode.activePopupChildren)) | 339 | + | log.Printf("[workspace] Removed popup from parent (remaining: %d)", len(parentNode.activePopupChildren)) |
| 371 | break | 340 | break | ||
| 372 | } | 341 | } | ||
| 373 | } | 342 | } | ||
| 374 | 343 | ||||
| 375 | - | // Clean up ALL localStorage entries for this popup in parent WebView | 344 | + | wm.cleanupPopupStorage(parentNode, targetNode, msg.WebViewID) |
| 376 | - | // This includes popup_<requestId>_parent_info, popup_<requestId>_parent_action, etc. | |||
| 377 | - | if parentNode.pane != nil && parentNode.pane.WebView() != nil && targetNode.requestID != "" { | |||
| 378 | - | parentWebViewID := parentNode.pane.WebView().ID() | |||
| 379 | - | requestID := targetNode.requestID | |||
| 380 | - | ||||
| 381 | - | cleanupScript := fmt.Sprintf(` | |||
| 382 | - | try { | |||
| 383 | - | // Clean up popup_mapping_<parentId> (from Go code) | |||
| 384 | - | localStorage.removeItem('popup_mapping_%d'); | |||
| 385 | - | ||||
| 386 | - | // Clean up popup_<requestId>_* entries (from JavaScript) | |||
| 387 | - | const requestId = '%s'; | |||
| 388 | - | const keysToRemove = [ | |||
| 389 | - | 'popup_' + requestId + '_parent_info', | |||
| 390 | - | 'popup_' + requestId + '_parent_action', | |||
| 391 | - | 'popup_' + requestId + '_message_to_popup', | |||
| 392 | - | 'popup_' + requestId + '_message_to_parent', | |||
| 393 | - | 'oauth_callback_' + '%s' | |||
| 394 | - | ]; | |||
| 395 | - | ||||
| 396 | - | keysToRemove.forEach(key => { | |||
| 397 | - | try { | |||
| 398 | - | localStorage.removeItem(key); | |||
| 399 | - | } catch(e) {} | |||
| 400 | - | }); | |||
| 401 | - | ||||
| 402 | - | console.log('[workspace] Cleaned up popup localStorage for requestId:', requestId); | |||
| 403 | - | } catch(e) { | |||
| 404 | - | console.warn('[workspace] Failed to clean popup localStorage:', e); | |||
| 405 | - | } | |||
| 406 | - | `, parentWebViewID, requestID, msg.WebViewID) | |||
| 407 | - | ||||
| 408 | - | if err := parentNode.pane.WebView().InjectScript(cleanupScript); err != nil { | |||
| 409 | - | log.Printf("[workspace] Failed to inject localStorage cleanup script: %v", err) | |||
| 410 | - | } else { | |||
| 411 | - | log.Printf("[workspace] Cleaned up localStorage for popup requestId=%s webviewId=%s", requestID, msg.WebViewID) | |||
| 412 | - | } | |||
| 413 | - | } | |||
| 414 | } | 345 | } | ||
| 415 | 346 | ||||
| 416 | - | // Close the popup pane | |||
| 417 | if err := wm.ClosePane(targetNode); err != nil { | 347 | if err := wm.ClosePane(targetNode); err != nil { | ||
| 418 | - | log.Printf("[workspace] Failed to close popup pane: %v", err) | 348 | + | log.Printf("[workspace] Failed to close popup: %v", err) |
| 419 | - | } else { | |||
| 420 | - | log.Printf("[workspace] Successfully closed popup pane: %s", msg.WebViewID) | |||
| 421 | - | ||||
| 422 | - | // REMOVED: Hide/show parent container sequence | |||
| 423 | - | // This was causing a race condition with the focus change scheduled by ClosePane(). | |||
| 424 | - | // GTK was sending focus events to widgets during hide/show operations, causing SIGSEGV. | |||
| 425 | - | // ClosePane() already handles focus restoration properly via setFocusToLeaf(). | |||
| 426 | - | // | |||
| 427 | - | // If rendering issues occur after popup close, they should be fixed with a different | |||
| 428 | - | // approach that doesn't manipulate widget visibility during focus transitions. | |||
| 429 | } | 349 | } | ||
| 350 | + | ||||
| 430 | default: | 351 | default: | ||
| 431 | log.Printf("[workspace] unhandled workspace event: %s", msg.Event) | 352 | log.Printf("[workspace] unhandled workspace event: %s", msg.Event) | ||
| 432 | } | 353 | } | ||
| 433 | } | 354 | } | ||
| 434 | 355 | ||||
| 356 | + | func (wm *WorkspaceManager) findNodeByWebViewID(id string) *paneNode { | |||
| 357 | + | if wm == nil || id == "" { | |||
| 358 | + | return nil | |||
| 359 | + | } | |||
| 360 | + | ||||
| 361 | + | for webView, node := range wm.viewToNode { | |||
| 362 | + | if webView != nil && webView.IDString() == id { | |||
| 363 | + | return node | |||
| 364 | + | } | |||
| 365 | + | } | |||
| 366 | + | return nil | |||
| 367 | + | } | |||
| 368 | + | ||||
| 435 | // GetActiveNode returns the currently active pane node | 369 | // GetActiveNode returns the currently active pane node | ||
| 436 | func (wm *WorkspaceManager) GetActiveNode() *paneNode { | 370 | func (wm *WorkspaceManager) GetActiveNode() *paneNode { | ||
| 437 | if wm == nil || wm.focusStateMachine == nil { | 371 | if wm == nil || wm.focusStateMachine == nil { | ||
| @@ -478,5 +412,48 @@ func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) { | |||||
| 478 | return wm.FocusNeighbor(direction) | 412 | return wm.FocusNeighbor(direction) | ||
| 479 | }) | 413 | }) | ||
| 480 | 414 | ||||
| 415 | + | // Register pane mode callbacks for Ctrl+P and pane mode actions | |||
| 416 | + | webView.RegisterPaneModeHandler(func(action string) bool { | |||
| 417 | + | if wm == nil { | |||
| 418 | + | return false | |||
| 419 | + | } | |||
| 420 | + | ||||
| 421 | + | if action == "enter" { | |||
| 422 | + | wm.EnterPaneMode() | |||
| 423 | + | return true | |||
| 424 | + | } | |||
| 425 | + | ||||
| 426 | + | if action == "exit" { | |||
| 427 | + | // Check if pane mode is active | |||
| 428 | + | wm.paneMutex.Lock() | |||
| 429 | + | wasActive := wm.paneModeActive | |||
| 430 | + | wm.paneMutex.Unlock() | |||
| 431 | + | ||||
| 432 | + | if wasActive { | |||
| 433 | + | wm.ExitPaneMode("escape") | |||
| 434 | + | return true // Mode was active, we handled it | |||
| 435 | + | } | |||
| 436 | + | return false // No mode active, pass through to DOM | |||
| 437 | + | } | |||
| 438 | + | ||||
| 439 | + | // For other actions, only handle if pane mode is active | |||
| 440 | + | wm.paneMutex.Lock() | |||
| 441 | + | isActive := wm.paneModeActive | |||
| 442 | + | wm.paneMutex.Unlock() | |||
| 443 | + | ||||
| 444 | + | if !isActive { | |||
| 445 | + | return false // Not in pane mode, allow normal behavior | |||
| 446 | + | } | |||
| 447 | + | ||||
| 448 | + | // Pane mode is active, handle the action | |||
| 449 | + | wm.HandlePaneAction(action) | |||
| 450 | + | return true | |||
| 451 | + | }, func() bool { | |||
| 452 | + | // Check if pane mode is currently active | |||
| 453 | + | wm.paneMutex.Lock() | |||
| 454 | + | defer wm.paneMutex.Unlock() | |||
| 455 | + | return wm.paneModeActive | |||
| 456 | + | }) | |||
| 457 | + | ||||
| 481 | log.Printf("[workspace] Registered navigation handler for webview: %d", webView.ID()) | 458 | log.Printf("[workspace] Registered navigation handler for webview: %d", webView.ID()) | ||
| 482 | } | 459 | } | ||
A internal/app/browser/workspace_pane_mode.go +196 -0
| @@ -0,0 +1,196 @@ | |||
| 1 | + | // workspace_pane_mode.go - Backend-driven pane mode implementation | |
| 2 | + | package browser | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "log" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // EnterPaneMode activates pane mode on the currently focused pane | |
| 11 | + | // This is called directly from the keyboard bridge, not from JavaScript | |
| 12 | + | func (wm *WorkspaceManager) EnterPaneMode() { | |
| 13 | + | wm.paneMutex.Lock() | |
| 14 | + | defer wm.paneMutex.Unlock() | |
| 15 | + | ||
| 16 | + | // Get the currently focused pane | |
| 17 | + | activeNode := wm.GetActiveNode() | |
| 18 | + | if activeNode == nil { | |
| 19 | + | log.Printf("[pane-mode] Cannot enter pane mode: no active pane") | |
| 20 | + | return | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // Already in pane mode on this pane? | |
| 24 | + | if wm.paneModeActive && wm.paneModeActivePane == activeNode { | |
| 25 | + | log.Printf("[pane-mode] Pane mode already active on this pane") | |
| 26 | + | return | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | log.Printf("[pane-mode] Entering pane mode on pane %p", activeNode) | |
| 30 | + | ||
| 31 | + | wm.paneModeActive = true | |
| 32 | + | wm.paneModeActivePane = activeNode | |
| 33 | + | ||
| 34 | + | if activeNode.pane != nil && activeNode.pane.webView != nil { | |
| 35 | + | wm.paneModeSource = activeNode.pane.webView | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // Apply visual border to workspace root | |
| 39 | + | wm.applyPaneModeBorder() | |
| 40 | + | ||
| 41 | + | // Notify JavaScript for UI feedback (visual indicators) | |
| 42 | + | wm.dispatchPaneModeEvent("entered", "") | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // ExitPaneMode deactivates pane mode | |
| 46 | + | func (wm *WorkspaceManager) ExitPaneMode(reason string) { | |
| 47 | + | wm.paneMutex.Lock() | |
| 48 | + | defer wm.paneMutex.Unlock() | |
| 49 | + | ||
| 50 | + | if !wm.paneModeActive { | |
| 51 | + | return | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | log.Printf("[pane-mode] Exiting pane mode: %s", reason) | |
| 55 | + | ||
| 56 | + | wm.paneModeActive = false | |
| 57 | + | wm.paneModeActivePane = nil | |
| 58 | + | wm.paneModeSource = nil | |
| 59 | + | ||
| 60 | + | // Remove visual border from workspace root | |
| 61 | + | wm.removePaneModeBorder() | |
| 62 | + | ||
| 63 | + | // Notify JavaScript | |
| 64 | + | wm.dispatchPaneModeEvent("exited", reason) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // HandlePaneAction processes pane mode actions (close, split, etc.) | |
| 68 | + | // Called from keyboard bridge when user presses action keys in pane mode | |
| 69 | + | func (wm *WorkspaceManager) HandlePaneAction(action string) { | |
| 70 | + | wm.paneMutex.Lock() | |
| 71 | + | ||
| 72 | + | if !wm.paneModeActive { | |
| 73 | + | wm.paneMutex.Unlock() | |
| 74 | + | log.Printf("[pane-mode] Ignoring action '%s': pane mode not active", action) | |
| 75 | + | return | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | activePane := wm.paneModeActivePane | |
| 79 | + | wm.paneMutex.Unlock() | |
| 80 | + | ||
| 81 | + | if activePane == nil { | |
| 82 | + | log.Printf("[pane-mode] Ignoring action '%s': no active pane", action) | |
| 83 | + | wm.ExitPaneMode("no-active-pane") | |
| 84 | + | return | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | log.Printf("[pane-mode] Handling action '%s' on pane %p", action, activePane) | |
| 88 | + | ||
| 89 | + | switch action { | |
| 90 | + | case "close": | |
| 91 | + | wm.handlePaneClose(activePane) | |
| 92 | + | case "split-right", "split-left", "split-up", "split-down": | |
| 93 | + | wm.handlePaneSplit(activePane, action) | |
| 94 | + | default: | |
| 95 | + | log.Printf("[pane-mode] Unknown action: %s", action) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // Exit pane mode after action | |
| 99 | + | wm.ExitPaneMode(action) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // handlePaneClose closes the pane that has pane mode active | |
| 103 | + | func (wm *WorkspaceManager) handlePaneClose(node *paneNode) { | |
| 104 | + | if node == nil { | |
| 105 | + | return | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | log.Printf("[pane-mode] Closing pane %p", node) | |
| 109 | + | ||
| 110 | + | // Notify JavaScript first so it can show toast | |
| 111 | + | wm.dispatchPaneModeEvent("action", "close") | |
| 112 | + | ||
| 113 | + | // Close the pane | |
| 114 | + | if err := wm.ClosePane(node); err != nil { | |
| 115 | + | log.Printf("[pane-mode] Failed to close pane: %v", err) | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // handlePaneSplit splits the pane in the specified direction | |
| 120 | + | func (wm *WorkspaceManager) handlePaneSplit(node *paneNode, action string) { | |
| 121 | + | if node == nil { | |
| 122 | + | return | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // Extract direction from action (e.g., "split-right" -> "right") | |
| 126 | + | var direction string | |
| 127 | + | switch action { | |
| 128 | + | case "split-right": | |
| 129 | + | direction = "right" | |
| 130 | + | case "split-left": | |
| 131 | + | direction = "left" | |
| 132 | + | case "split-up": | |
| 133 | + | direction = "up" | |
| 134 | + | case "split-down": | |
| 135 | + | direction = "down" | |
| 136 | + | default: | |
| 137 | + | log.Printf("[pane-mode] Invalid split action: %s", action) | |
| 138 | + | return | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | log.Printf("[pane-mode] Splitting pane %p in direction %s", node, direction) | |
| 142 | + | ||
| 143 | + | // Notify JavaScript first so it can show toast | |
| 144 | + | wm.dispatchPaneModeEvent("action", action) | |
| 145 | + | ||
| 146 | + | // Perform the split (pass nil so it creates a fresh pane, not popup mode) | |
| 147 | + | if _, err := wm.splitNode(node, direction, nil); err != nil { | |
| 148 | + | log.Printf("[pane-mode] Failed to split pane: %v", err) | |
| 149 | + | return | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | log.Printf("[pane-mode] Split successful: direction=%s", direction) | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // dispatchPaneModeEvent sends a pane mode event to JavaScript for UI updates | |
| 156 | + | func (wm *WorkspaceManager) dispatchPaneModeEvent(event string, detail string) { | |
| 157 | + | if wm.paneModeActivePane == nil || wm.paneModeActivePane.pane == nil || wm.paneModeActivePane.pane.webView == nil { | |
| 158 | + | return | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | view := wm.paneModeActivePane.pane.webView | |
| 162 | + | data := map[string]interface{}{ | |
| 163 | + | "event": event, | |
| 164 | + | "detail": detail, | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | if err := view.DispatchCustomEvent("dumber:pane-mode", data); err != nil { | |
| 168 | + | log.Printf("[pane-mode] Failed to dispatch event: %v", err) | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // applyPaneModeBorder adds the pane mode CSS class to the workspace root container | |
| 173 | + | func (wm *WorkspaceManager) applyPaneModeBorder() { | |
| 174 | + | if wm.root == nil || wm.root.container == nil { | |
| 175 | + | return | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | // Add CSS class to root container for Zellij-style border | |
| 179 | + | if !webkit.WidgetHasCSSClass(wm.root.container, "workspace-pane-mode-active") { | |
| 180 | + | webkit.WidgetAddCSSClass(wm.root.container, "workspace-pane-mode-active") | |
| 181 | + | log.Printf("[pane-mode] Applied border to workspace root") | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | // removePaneModeBorder removes the pane mode CSS class from the workspace root container | |
| 186 | + | func (wm *WorkspaceManager) removePaneModeBorder() { | |
| 187 | + | if wm.root == nil || wm.root.container == nil { | |
| 188 | + | return | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | // Remove CSS class from root container | |
| 192 | + | if webkit.WidgetHasCSSClass(wm.root.container, "workspace-pane-mode-active") { | |
| 193 | + | webkit.WidgetRemoveCSSClass(wm.root.container, "workspace-pane-mode-active") | |
| 194 | + | log.Printf("[pane-mode] Removed border from workspace root") | |
| 195 | + | } | |
| 196 | + | } | |
| @@ -0,0 +1,196 @@ | |||||
| 1 | + | // workspace_pane_mode.go - Backend-driven pane mode implementation | |||
| 2 | + | package browser | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "log" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/pkg/webkit" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // EnterPaneMode activates pane mode on the currently focused pane | |||
| 11 | + | // This is called directly from the keyboard bridge, not from JavaScript | |||
| 12 | + | func (wm *WorkspaceManager) EnterPaneMode() { | |||
| 13 | + | wm.paneMutex.Lock() | |||
| 14 | + | defer wm.paneMutex.Unlock() | |||
| 15 | + | ||||
| 16 | + | // Get the currently focused pane | |||
| 17 | + | activeNode := wm.GetActiveNode() | |||
| 18 | + | if activeNode == nil { | |||
| 19 | + | log.Printf("[pane-mode] Cannot enter pane mode: no active pane") | |||
| 20 | + | return | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // Already in pane mode on this pane? | |||
| 24 | + | if wm.paneModeActive && wm.paneModeActivePane == activeNode { | |||
| 25 | + | log.Printf("[pane-mode] Pane mode already active on this pane") | |||
| 26 | + | return | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | log.Printf("[pane-mode] Entering pane mode on pane %p", activeNode) | |||
| 30 | + | ||||
| 31 | + | wm.paneModeActive = true | |||
| 32 | + | wm.paneModeActivePane = activeNode | |||
| 33 | + | ||||
| 34 | + | if activeNode.pane != nil && activeNode.pane.webView != nil { | |||
| 35 | + | wm.paneModeSource = activeNode.pane.webView | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // Apply visual border to workspace root | |||
| 39 | + | wm.applyPaneModeBorder() | |||
| 40 | + | ||||
| 41 | + | // Notify JavaScript for UI feedback (visual indicators) | |||
| 42 | + | wm.dispatchPaneModeEvent("entered", "") | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // ExitPaneMode deactivates pane mode | |||
| 46 | + | func (wm *WorkspaceManager) ExitPaneMode(reason string) { | |||
| 47 | + | wm.paneMutex.Lock() | |||
| 48 | + | defer wm.paneMutex.Unlock() | |||
| 49 | + | ||||
| 50 | + | if !wm.paneModeActive { | |||
| 51 | + | return | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | log.Printf("[pane-mode] Exiting pane mode: %s", reason) | |||
| 55 | + | ||||
| 56 | + | wm.paneModeActive = false | |||
| 57 | + | wm.paneModeActivePane = nil | |||
| 58 | + | wm.paneModeSource = nil | |||
| 59 | + | ||||
| 60 | + | // Remove visual border from workspace root | |||
| 61 | + | wm.removePaneModeBorder() | |||
| 62 | + | ||||
| 63 | + | // Notify JavaScript | |||
| 64 | + | wm.dispatchPaneModeEvent("exited", reason) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // HandlePaneAction processes pane mode actions (close, split, etc.) | |||
| 68 | + | // Called from keyboard bridge when user presses action keys in pane mode | |||
| 69 | + | func (wm *WorkspaceManager) HandlePaneAction(action string) { | |||
| 70 | + | wm.paneMutex.Lock() | |||
| 71 | + | ||||
| 72 | + | if !wm.paneModeActive { | |||
| 73 | + | wm.paneMutex.Unlock() | |||
| 74 | + | log.Printf("[pane-mode] Ignoring action '%s': pane mode not active", action) | |||
| 75 | + | return | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | activePane := wm.paneModeActivePane | |||
| 79 | + | wm.paneMutex.Unlock() | |||
| 80 | + | ||||
| 81 | + | if activePane == nil { | |||
| 82 | + | log.Printf("[pane-mode] Ignoring action '%s': no active pane", action) | |||
| 83 | + | wm.ExitPaneMode("no-active-pane") | |||
| 84 | + | return | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | log.Printf("[pane-mode] Handling action '%s' on pane %p", action, activePane) | |||
| 88 | + | ||||
| 89 | + | switch action { | |||
| 90 | + | case "close": | |||
| 91 | + | wm.handlePaneClose(activePane) | |||
| 92 | + | case "split-right", "split-left", "split-up", "split-down": | |||
| 93 | + | wm.handlePaneSplit(activePane, action) | |||
| 94 | + | default: | |||
| 95 | + | log.Printf("[pane-mode] Unknown action: %s", action) | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // Exit pane mode after action | |||
| 99 | + | wm.ExitPaneMode(action) | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // handlePaneClose closes the pane that has pane mode active | |||
| 103 | + | func (wm *WorkspaceManager) handlePaneClose(node *paneNode) { | |||
| 104 | + | if node == nil { | |||
| 105 | + | return | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | log.Printf("[pane-mode] Closing pane %p", node) | |||
| 109 | + | ||||
| 110 | + | // Notify JavaScript first so it can show toast | |||
| 111 | + | wm.dispatchPaneModeEvent("action", "close") | |||
| 112 | + | ||||
| 113 | + | // Close the pane | |||
| 114 | + | if err := wm.ClosePane(node); err != nil { | |||
| 115 | + | log.Printf("[pane-mode] Failed to close pane: %v", err) | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // handlePaneSplit splits the pane in the specified direction | |||
| 120 | + | func (wm *WorkspaceManager) handlePaneSplit(node *paneNode, action string) { | |||
| 121 | + | if node == nil { | |||
| 122 | + | return | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | // Extract direction from action (e.g., "split-right" -> "right") | |||
| 126 | + | var direction string | |||
| 127 | + | switch action { | |||
| 128 | + | case "split-right": | |||
| 129 | + | direction = "right" | |||
| 130 | + | case "split-left": | |||
| 131 | + | direction = "left" | |||
| 132 | + | case "split-up": | |||
| 133 | + | direction = "up" | |||
| 134 | + | case "split-down": | |||
| 135 | + | direction = "down" | |||
| 136 | + | default: | |||
| 137 | + | log.Printf("[pane-mode] Invalid split action: %s", action) | |||
| 138 | + | return | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | log.Printf("[pane-mode] Splitting pane %p in direction %s", node, direction) | |||
| 142 | + | ||||
| 143 | + | // Notify JavaScript first so it can show toast | |||
| 144 | + | wm.dispatchPaneModeEvent("action", action) | |||
| 145 | + | ||||
| 146 | + | // Perform the split (pass nil so it creates a fresh pane, not popup mode) | |||
| 147 | + | if _, err := wm.splitNode(node, direction, nil); err != nil { | |||
| 148 | + | log.Printf("[pane-mode] Failed to split pane: %v", err) | |||
| 149 | + | return | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | log.Printf("[pane-mode] Split successful: direction=%s", direction) | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | // dispatchPaneModeEvent sends a pane mode event to JavaScript for UI updates | |||
| 156 | + | func (wm *WorkspaceManager) dispatchPaneModeEvent(event string, detail string) { | |||
| 157 | + | if wm.paneModeActivePane == nil || wm.paneModeActivePane.pane == nil || wm.paneModeActivePane.pane.webView == nil { | |||
| 158 | + | return | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | view := wm.paneModeActivePane.pane.webView | |||
| 162 | + | data := map[string]interface{}{ | |||
| 163 | + | "event": event, | |||
| 164 | + | "detail": detail, | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | if err := view.DispatchCustomEvent("dumber:pane-mode", data); err != nil { | |||
| 168 | + | log.Printf("[pane-mode] Failed to dispatch event: %v", err) | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | // applyPaneModeBorder adds the pane mode CSS class to the workspace root container | |||
| 173 | + | func (wm *WorkspaceManager) applyPaneModeBorder() { | |||
| 174 | + | if wm.root == nil || wm.root.container == nil { | |||
| 175 | + | return | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | // Add CSS class to root container for Zellij-style border | |||
| 179 | + | if !webkit.WidgetHasCSSClass(wm.root.container, "workspace-pane-mode-active") { | |||
| 180 | + | webkit.WidgetAddCSSClass(wm.root.container, "workspace-pane-mode-active") | |||
| 181 | + | log.Printf("[pane-mode] Applied border to workspace root") | |||
| 182 | + | } | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | // removePaneModeBorder removes the pane mode CSS class from the workspace root container | |||
| 186 | + | func (wm *WorkspaceManager) removePaneModeBorder() { | |||
| 187 | + | if wm.root == nil || wm.root.container == nil { | |||
| 188 | + | return | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | // Remove CSS class from root container | |||
| 192 | + | if webkit.WidgetHasCSSClass(wm.root.container, "workspace-pane-mode-active") { | |||
| 193 | + | webkit.WidgetRemoveCSSClass(wm.root.container, "workspace-pane-mode-active") | |||
| 194 | + | log.Printf("[pane-mode] Removed border from workspace root") | |||
| 195 | + | } | |||
| 196 | + | } | |||
M pkg/webkit/keyboard_bridge.go +124 -71
| @@ -17,24 +17,29 @@ const ( | |||
| 17 | 17 | ||
| 18 | 18 | // GDK key constants for special keys | |
| 19 | 19 | const ( | |
| 20 | - | gdkKeyLeft = 0xff51 | |
| 21 | - | gdkKeyRight = 0xff53 | |
| 22 | - | gdkKeyUp = 0xff52 | |
| 23 | - | gdkKeyDown = 0xff54 | |
| 24 | - | gdkKeyF12 = 0xffbe | |
| 25 | - | gdkKeyPlus = 0x2b | |
| 26 | - | gdkKeyEqual = 0x3d | |
| 27 | - | gdkKeyMinus = 0x2d | |
| 20 | + | gdkKeyLeft = 0xff51 | |
| 21 | + | gdkKeyRight = 0xff53 | |
| 22 | + | gdkKeyUp = 0xff52 | |
| 23 | + | gdkKeyDown = 0xff54 | |
| 24 | + | gdkKeyEscape = 0xff1b | |
| 25 | + | gdkKeyF12 = 0xffbe | |
| 26 | + | gdkKeyPlus = 0x2b | |
| 27 | + | gdkKeyEqual = 0x3d | |
| 28 | + | gdkKeyMinus = 0x2d | |
| 28 | 29 | ) | |
| 29 | 30 | ||
| 30 | 31 | // Key name constants for shortcut mapping | |
| 31 | 32 | const ( | |
| 32 | 33 | keyP = "p" | |
| 33 | 34 | keyL = "l" | |
| 35 | + | keyR = "r" | |
| 36 | + | keyD = "d" | |
| 37 | + | keyU = "u" | |
| 34 | 38 | keyF = "f" | |
| 35 | 39 | keyC = "c" | |
| 36 | 40 | keyW = "w" | |
| 37 | - | keyR = "r" | |
| 41 | + | keyX = "x" | |
| 42 | + | keyEscape = "escape" | |
| 38 | 43 | keyArrowLeft = "arrowleft" | |
| 39 | 44 | keyArrowRight = "arrowright" | |
| 40 | 45 | keyArrowUp = "arrowup" | |
| @@ -43,34 +48,46 @@ const ( | |||
| 43 | 48 | keyPlus = "plus" | |
| 44 | 49 | keyMinus = "minus" | |
| 45 | 50 | keyZero = "0" | |
| 51 | + | altmod = "alt" | |
| 46 | 52 | ) | |
| 47 | 53 | ||
| 48 | - | // ShortcutRouting defines how a keyboard shortcut should be handled by the bridge | |
| 49 | - | type ShortcutRouting struct { | |
| 50 | - | forwardToJS bool // Should dispatch dumber:key event to JavaScript | |
| 51 | - | blockWebKit bool // Should block WebKit's default handling (return true from handler) | |
| 52 | - | description string // Human-readable description for debugging | |
| 53 | - | } | |
| 54 | + | // Pane mode action constants | |
| 55 | + | const ( | |
| 56 | + | actionClose = "close" | |
| 57 | + | actionSplitLeft = "split-left" | |
| 58 | + | actionSplitRight = "split-right" | |
| 59 | + | actionSplitDown = "split-down" | |
| 60 | + | actionSplitUp = "split-up" | |
| 61 | + | actionExit = "exit" | |
| 62 | + | actionEnter = "enter" | |
| 63 | + | ) | |
| 54 | 64 | ||
| 55 | - | // shortcutRoutingTable maps normalized shortcuts to their routing behavior | |
| 56 | - | // This centralizes all routing decisions in one place, avoiding magic strings and scattered logic | |
| 57 | - | var shortcutRoutingTable = map[string]ShortcutRouting{ | |
| 58 | - | // Workspace pane navigation - block WebKit to prevent page scrolling | |
| 59 | - | // Navigation is triggered via workspace navigation callback | |
| 60 | - | "alt+arrowleft": {forwardToJS: false, blockWebKit: true, description: "Navigate to left pane (callback handler)"}, | |
| 61 | - | "alt+arrowright": {forwardToJS: false, blockWebKit: true, description: "Navigate to right pane (callback handler)"}, | |
| 62 | - | "alt+arrowup": {forwardToJS: false, blockWebKit: true, description: "Navigate to upper pane (callback handler)"}, | |
| 63 | - | "alt+arrowdown": {forwardToJS: false, blockWebKit: true, description: "Navigate to lower pane (callback handler)"}, | |
| 64 | - | ||
| 65 | - | // Pane mode - forward to JavaScript but block WebKit's print dialog | |
| 66 | - | "cmdorctrl+p": {forwardToJS: true, blockWebKit: true, description: "Enter pane mode (JS handler + block print dialog)"}, | |
| 67 | - | } | |
| 65 | + | // Navigation direction constants | |
| 66 | + | const ( | |
| 67 | + | directionLeft = "left" | |
| 68 | + | directionRight = "right" | |
| 69 | + | directionUp = "up" | |
| 70 | + | directionDown = "down" | |
| 71 | + | ) | |
| 68 | 72 | ||
| 69 | - | // defaultRouting defines the default behavior for shortcuts not in the routing table | |
| 70 | - | var defaultRouting = ShortcutRouting{ | |
| 71 | - | forwardToJS: true, // Forward all shortcuts to JS by default | |
| 72 | - | blockWebKit: false, // Allow WebKit to handle (for reload, devtools, zoom, etc.) | |
| 73 | - | description: "Default: forward to JS, allow WebKit", | |
| 73 | + | // Map shortcuts to actions (unified for all keyboard handling) | |
| 74 | + | var shortcutActions = map[string]string{ | |
| 75 | + | // Pane mode actions (no modifier) | |
| 76 | + | keyX: actionClose, | |
| 77 | + | keyL: actionSplitLeft, | |
| 78 | + | keyR: actionSplitRight, | |
| 79 | + | keyD: actionSplitDown, | |
| 80 | + | keyU: actionSplitUp, | |
| 81 | + | keyArrowLeft: actionSplitLeft, | |
| 82 | + | keyArrowRight: actionSplitRight, | |
| 83 | + | keyArrowDown: actionSplitDown, | |
| 84 | + | keyArrowUp: actionSplitUp, | |
| 85 | + | ||
| 86 | + | // Navigation (alt modifier) | |
| 87 | + | altmod + "+" + keyArrowLeft: directionLeft, | |
| 88 | + | altmod + "+" + keyArrowRight: directionRight, | |
| 89 | + | altmod + "+" + keyArrowDown: directionDown, | |
| 90 | + | altmod + "+" + keyArrowUp: directionUp, | |
| 74 | 91 | } | |
| 75 | 92 | ||
| 76 | 93 | // AttachKeyboardBridge attaches an EventControllerKey to bridge keyboard events to JavaScript | |
| @@ -89,6 +106,13 @@ func (w *WebView) AttachKeyboardBridge() { | |||
| 89 | 106 | ||
| 90 | 107 | // Connect to key-pressed signal | |
| 91 | 108 | keyController.ConnectKeyPressed(func(keyval, keycode uint, state gdk.ModifierType) bool { | |
| 109 | + | // Check if pane mode is active - block ALL keys except pane mode actions | |
| 110 | + | w.mu.RLock() | |
| 111 | + | paneModeChecker := w.isPaneModeActive | |
| 112 | + | w.mu.RUnlock() | |
| 113 | + | ||
| 114 | + | isPaneModeActive := paneModeChecker != nil && paneModeChecker() | |
| 115 | + | ||
| 92 | 116 | // Build normalized shortcut string | |
| 93 | 117 | var parts []string | |
| 94 | 118 | ||
| @@ -117,14 +141,20 @@ func (w *WebView) AttachKeyboardBridge() { | |||
| 117 | 141 | keyName = keyP | |
| 118 | 142 | case 'l', 'L': | |
| 119 | 143 | keyName = keyL | |
| 144 | + | case 'r', 'R': | |
| 145 | + | keyName = keyR | |
| 146 | + | case 'd', 'D': | |
| 147 | + | keyName = keyD | |
| 148 | + | case 'u', 'U': | |
| 149 | + | keyName = keyU | |
| 120 | 150 | case 'f', 'F': | |
| 121 | 151 | keyName = keyF | |
| 122 | 152 | case 'c', 'C': | |
| 123 | 153 | keyName = keyC | |
| 124 | 154 | case 'w', 'W': | |
| 125 | 155 | keyName = keyW | |
| 126 | - | case 'r', 'R': | |
| 127 | - | keyName = keyR | |
| 156 | + | case 'x', 'X': | |
| 157 | + | keyName = keyX | |
| 128 | 158 | case gdkKeyLeft: | |
| 129 | 159 | keyName = keyArrowLeft | |
| 130 | 160 | case gdkKeyRight: | |
| @@ -133,6 +163,8 @@ func (w *WebView) AttachKeyboardBridge() { | |||
| 133 | 163 | keyName = keyArrowUp | |
| 134 | 164 | case gdkKeyDown: | |
| 135 | 165 | keyName = keyArrowDown | |
| 166 | + | case gdkKeyEscape: | |
| 167 | + | keyName = keyEscape | |
| 136 | 168 | case gdkKeyF12: | |
| 137 | 169 | keyName = keyF12 | |
| 138 | 170 | case gdkKeyPlus, gdkKeyEqual: | |
| @@ -162,55 +194,76 @@ func (w *WebView) AttachKeyboardBridge() { | |||
| 162 | 194 | } | |
| 163 | 195 | ||
| 164 | 196 | if shortcut != "" { | |
| 165 | - | // Look up routing behavior from table, use default if not found | |
| 166 | - | routing, exists := shortcutRoutingTable[shortcut] | |
| 167 | - | if !exists { | |
| 168 | - | routing = defaultRouting | |
| 197 | + | // Special case: Ctrl+P enters pane mode | |
| 198 | + | if shortcut == modifierCmdOrCtrl+"+"+keyP { | |
| 199 | + | w.mu.RLock() | |
| 200 | + | handler := w.onPaneModeShortcut | |
| 201 | + | w.mu.RUnlock() | |
| 202 | + | if handler != nil && handler(actionEnter) { | |
| 203 | + | log.Printf("[keyboard-bridge] Pane mode: enter") | |
| 204 | + | return true | |
| 205 | + | } | |
| 206 | + | return true // Block even if no handler | |
| 169 | 207 | } | |
| 170 | 208 | ||
| 171 | - | // Handle workspace navigation shortcuts via callback | |
| 172 | - | if shortcut == "alt+arrowup" || shortcut == "alt+arrowdown" || shortcut == "alt+arrowleft" || shortcut == "alt+arrowright" { | |
| 209 | + | // Special case: Escape exits active mode | |
| 210 | + | if shortcut == keyEscape { | |
| 173 | 211 | w.mu.RLock() | |
| 174 | - | handler := w.onWorkspaceNavigation | |
| 212 | + | handler := w.onPaneModeShortcut | |
| 175 | 213 | w.mu.RUnlock() | |
| 214 | + | if handler != nil && handler(actionExit) { | |
| 215 | + | log.Printf("[keyboard-bridge] Escape: exited mode") | |
| 216 | + | return true | |
| 217 | + | } | |
| 218 | + | return false // Pass through to DOM if no active mode | |
| 219 | + | } | |
| 176 | 220 | ||
| 177 | - | if handler != nil { | |
| 178 | - | var direction string | |
| 179 | - | switch shortcut { | |
| 180 | - | case "alt+arrowup": | |
| 181 | - | direction = "up" | |
| 182 | - | case "alt+arrowdown": | |
| 183 | - | direction = "down" | |
| 184 | - | case "alt+arrowleft": | |
| 185 | - | direction = "left" | |
| 186 | - | case "alt+arrowright": | |
| 187 | - | direction = "right" | |
| 188 | - | } | |
| 189 | - | if handler(direction) { | |
| 190 | - | log.Printf("[keyboard-bridge] Workspace navigation: %s (handled)", direction) | |
| 191 | - | return true // Block WebKit | |
| 221 | + | // Check if shortcut maps to an action | |
| 222 | + | if action, exists := shortcutActions[shortcut]; exists { | |
| 223 | + | // Navigation actions (alt+arrow) | |
| 224 | + | if action == directionLeft || action == directionRight || action == directionUp || action == directionDown { | |
| 225 | + | w.mu.RLock() | |
| 226 | + | handler := w.onWorkspaceNavigation | |
| 227 | + | w.mu.RUnlock() | |
| 228 | + | if handler != nil && handler(action) { | |
| 229 | + | log.Printf("[keyboard-bridge] Navigation: %s", action) | |
| 230 | + | return true | |
| 192 | 231 | } | |
| 232 | + | return true // Block anyway to prevent scrolling | |
| 193 | 233 | } | |
| 194 | - | log.Printf("[keyboard-bridge] Shortcut %s: %s (no handler or not handled)", shortcut, routing.description) | |
| 195 | - | return true // Block WebKit anyway to prevent scrolling | |
| 196 | - | } | |
| 197 | 234 | ||
| 198 | - | // Dispatch to JavaScript if routing says to | |
| 199 | - | if routing.forwardToJS { | |
| 200 | - | script := fmt.Sprintf(`document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:'%s'}}));`, shortcut) | |
| 201 | - | if err := w.InjectScript(script); err != nil { | |
| 202 | - | log.Printf("[keyboard-bridge] Failed to dispatch key event: %v", err) | |
| 235 | + | // Pane mode actions (x, l, r, d, u, arrows) | |
| 236 | + | w.mu.RLock() | |
| 237 | + | handler := w.onPaneModeShortcut | |
| 238 | + | w.mu.RUnlock() | |
| 239 | + | if handler != nil && handler(action) { | |
| 240 | + | log.Printf("[keyboard-bridge] Pane mode action: %s", action) | |
| 241 | + | return true | |
| 203 | 242 | } | |
| 204 | - | log.Printf("[keyboard-bridge] Forwarded shortcut to JS: %s", shortcut) | |
| 205 | - | } else { | |
| 206 | - | log.Printf("[keyboard-bridge] Shortcut %s: %s (not forwarded to JS)", shortcut, routing.description) | |
| 243 | + | return false // Not in pane mode, pass through | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | // Pane mode active: block all other shortcuts | |
| 247 | + | if isPaneModeActive { | |
| 248 | + | log.Printf("[keyboard-bridge] Blocking '%s' during pane mode", shortcut) | |
| 249 | + | return true | |
| 207 | 250 | } | |
| 208 | 251 | ||
| 209 | - | // Return routing decision to block or allow WebKit | |
| 210 | - | return routing.blockWebKit | |
| 252 | + | // Forward to JavaScript | |
| 253 | + | script := fmt.Sprintf(`document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:'%s'}}));`, shortcut) | |
| 254 | + | if err := w.InjectScript(script); err != nil { | |
| 255 | + | log.Printf("[keyboard-bridge] Failed to dispatch: %v", err) | |
| 256 | + | } | |
| 257 | + | return false // Allow WebKit to handle | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | // Pane mode active: block ALL non-shortcut keys | |
| 261 | + | if isPaneModeActive { | |
| 262 | + | log.Printf("[keyboard-bridge] Blocking key during pane mode: keyval=%d", keyval) | |
| 263 | + | return true // Block all other keys | |
| 211 | 264 | } | |
| 212 | 265 | ||
| 213 | - | // No shortcut matched - allow WebKit to handle normally | |
| 266 | + | // No shortcut matched and not in pane mode - allow WebKit to handle normally | |
| 214 | 267 | return false | |
| 215 | 268 | }) | |
| 216 | 269 | ||
| @@ -17,24 +17,29 @@ const ( | |||||
| 17 | 17 | ||||
| 18 | // GDK key constants for special keys | 18 | // GDK key constants for special keys | ||
| 19 | const ( | 19 | const ( | ||
| 20 | - | gdkKeyLeft = 0xff51 | 20 | + | gdkKeyLeft = 0xff51 |
| 21 | - | gdkKeyRight = 0xff53 | 21 | + | gdkKeyRight = 0xff53 |
| 22 | - | gdkKeyUp = 0xff52 | 22 | + | gdkKeyUp = 0xff52 |
| 23 | - | gdkKeyDown = 0xff54 | 23 | + | gdkKeyDown = 0xff54 |
| 24 | - | gdkKeyF12 = 0xffbe | 24 | + | gdkKeyEscape = 0xff1b |
| 25 | - | gdkKeyPlus = 0x2b | 25 | + | gdkKeyF12 = 0xffbe |
| 26 | - | gdkKeyEqual = 0x3d | 26 | + | gdkKeyPlus = 0x2b |
| 27 | - | gdkKeyMinus = 0x2d | 27 | + | gdkKeyEqual = 0x3d |
| 28 | + | gdkKeyMinus = 0x2d | |||
| 28 | ) | 29 | ) | ||
| 29 | 30 | ||||
| 30 | // Key name constants for shortcut mapping | 31 | // Key name constants for shortcut mapping | ||
| 31 | const ( | 32 | const ( | ||
| 32 | keyP = "p" | 33 | keyP = "p" | ||
| 33 | keyL = "l" | 34 | keyL = "l" | ||
| 35 | + | keyR = "r" | |||
| 36 | + | keyD = "d" | |||
| 37 | + | keyU = "u" | |||
| 34 | keyF = "f" | 38 | keyF = "f" | ||
| 35 | keyC = "c" | 39 | keyC = "c" | ||
| 36 | keyW = "w" | 40 | keyW = "w" | ||
| 37 | - | keyR = "r" | 41 | + | keyX = "x" |
| 42 | + | keyEscape = "escape" | |||
| 38 | keyArrowLeft = "arrowleft" | 43 | keyArrowLeft = "arrowleft" | ||
| 39 | keyArrowRight = "arrowright" | 44 | keyArrowRight = "arrowright" | ||
| 40 | keyArrowUp = "arrowup" | 45 | keyArrowUp = "arrowup" | ||
| @@ -43,34 +48,46 @@ const ( | |||||
| 43 | keyPlus = "plus" | 48 | keyPlus = "plus" | ||
| 44 | keyMinus = "minus" | 49 | keyMinus = "minus" | ||
| 45 | keyZero = "0" | 50 | keyZero = "0" | ||
| 51 | + | altmod = "alt" | |||
| 46 | ) | 52 | ) | ||
| 47 | 53 | ||||
| 48 | - | // ShortcutRouting defines how a keyboard shortcut should be handled by the bridge | 54 | + | // Pane mode action constants |
| 49 | - | type ShortcutRouting struct { | 55 | + | const ( |
| 50 | - | forwardToJS bool // Should dispatch dumber:key event to JavaScript | 56 | + | actionClose = "close" |
| 51 | - | blockWebKit bool // Should block WebKit's default handling (return true from handler) | 57 | + | actionSplitLeft = "split-left" |
| 52 | - | description string // Human-readable description for debugging | 58 | + | actionSplitRight = "split-right" |
| 53 | - | } | 59 | + | actionSplitDown = "split-down" |
| 60 | + | actionSplitUp = "split-up" | |||
| 61 | + | actionExit = "exit" | |||
| 62 | + | actionEnter = "enter" | |||
| 63 | + | ) | |||
| 54 | 64 | ||||
| 55 | - | // shortcutRoutingTable maps normalized shortcuts to their routing behavior | 65 | + | // Navigation direction constants |
| 56 | - | // This centralizes all routing decisions in one place, avoiding magic strings and scattered logic | 66 | + | const ( |
| 57 | - | var shortcutRoutingTable = map[string]ShortcutRouting{ | 67 | + | directionLeft = "left" |
| 58 | - | // Workspace pane navigation - block WebKit to prevent page scrolling | 68 | + | directionRight = "right" |
| 59 | - | // Navigation is triggered via workspace navigation callback | 69 | + | directionUp = "up" |
| 60 | - | "alt+arrowleft": {forwardToJS: false, blockWebKit: true, description: "Navigate to left pane (callback handler)"}, | 70 | + | directionDown = "down" |
| 61 | - | "alt+arrowright": {forwardToJS: false, blockWebKit: true, description: "Navigate to right pane (callback handler)"}, | 71 | + | ) |
| 62 | - | "alt+arrowup": {forwardToJS: false, blockWebKit: true, description: "Navigate to upper pane (callback handler)"}, | |||
| 63 | - | "alt+arrowdown": {forwardToJS: false, blockWebKit: true, description: "Navigate to lower pane (callback handler)"}, | |||
| 64 | - | ||||
| 65 | - | // Pane mode - forward to JavaScript but block WebKit's print dialog | |||
| 66 | - | "cmdorctrl+p": {forwardToJS: true, blockWebKit: true, description: "Enter pane mode (JS handler + block print dialog)"}, | |||
| 67 | - | } | |||
| 68 | 72 | ||||
| 69 | - | // defaultRouting defines the default behavior for shortcuts not in the routing table | 73 | + | // Map shortcuts to actions (unified for all keyboard handling) |
| 70 | - | var defaultRouting = ShortcutRouting{ | 74 | + | var shortcutActions = map[string]string{ |
| 71 | - | forwardToJS: true, // Forward all shortcuts to JS by default | 75 | + | // Pane mode actions (no modifier) |
| 72 | - | blockWebKit: false, // Allow WebKit to handle (for reload, devtools, zoom, etc.) | 76 | + | keyX: actionClose, |
| 73 | - | description: "Default: forward to JS, allow WebKit", | 77 | + | keyL: actionSplitLeft, |
| 78 | + | keyR: actionSplitRight, | |||
| 79 | + | keyD: actionSplitDown, | |||
| 80 | + | keyU: actionSplitUp, | |||
| 81 | + | keyArrowLeft: actionSplitLeft, | |||
| 82 | + | keyArrowRight: actionSplitRight, | |||
| 83 | + | keyArrowDown: actionSplitDown, | |||
| 84 | + | keyArrowUp: actionSplitUp, | |||
| 85 | + | ||||
| 86 | + | // Navigation (alt modifier) | |||
| 87 | + | altmod + "+" + keyArrowLeft: directionLeft, | |||
| 88 | + | altmod + "+" + keyArrowRight: directionRight, | |||
| 89 | + | altmod + "+" + keyArrowDown: directionDown, | |||
| 90 | + | altmod + "+" + keyArrowUp: directionUp, | |||
| 74 | } | 91 | } | ||
| 75 | 92 | ||||
| 76 | // AttachKeyboardBridge attaches an EventControllerKey to bridge keyboard events to JavaScript | 93 | // AttachKeyboardBridge attaches an EventControllerKey to bridge keyboard events to JavaScript | ||
| @@ -89,6 +106,13 @@ func (w *WebView) AttachKeyboardBridge() { | |||||
| 89 | 106 | ||||
| 90 | // Connect to key-pressed signal | 107 | // Connect to key-pressed signal | ||
| 91 | keyController.ConnectKeyPressed(func(keyval, keycode uint, state gdk.ModifierType) bool { | 108 | keyController.ConnectKeyPressed(func(keyval, keycode uint, state gdk.ModifierType) bool { | ||
| 109 | + | // Check if pane mode is active - block ALL keys except pane mode actions | |||
| 110 | + | w.mu.RLock() | |||
| 111 | + | paneModeChecker := w.isPaneModeActive | |||
| 112 | + | w.mu.RUnlock() | |||
| 113 | + | ||||
| 114 | + | isPaneModeActive := paneModeChecker != nil && paneModeChecker() | |||
| 115 | + | ||||
| 92 | // Build normalized shortcut string | 116 | // Build normalized shortcut string | ||
| 93 | var parts []string | 117 | var parts []string | ||
| 94 | 118 | ||||
| @@ -117,14 +141,20 @@ func (w *WebView) AttachKeyboardBridge() { | |||||
| 117 | keyName = keyP | 141 | keyName = keyP | ||
| 118 | case 'l', 'L': | 142 | case 'l', 'L': | ||
| 119 | keyName = keyL | 143 | keyName = keyL | ||
| 144 | + | case 'r', 'R': | |||
| 145 | + | keyName = keyR | |||
| 146 | + | case 'd', 'D': | |||
| 147 | + | keyName = keyD | |||
| 148 | + | case 'u', 'U': | |||
| 149 | + | keyName = keyU | |||
| 120 | case 'f', 'F': | 150 | case 'f', 'F': | ||
| 121 | keyName = keyF | 151 | keyName = keyF | ||
| 122 | case 'c', 'C': | 152 | case 'c', 'C': | ||
| 123 | keyName = keyC | 153 | keyName = keyC | ||
| 124 | case 'w', 'W': | 154 | case 'w', 'W': | ||
| 125 | keyName = keyW | 155 | keyName = keyW | ||
| 126 | - | case 'r', 'R': | 156 | + | case 'x', 'X': |
| 127 | - | keyName = keyR | 157 | + | keyName = keyX |
| 128 | case gdkKeyLeft: | 158 | case gdkKeyLeft: | ||
| 129 | keyName = keyArrowLeft | 159 | keyName = keyArrowLeft | ||
| 130 | case gdkKeyRight: | 160 | case gdkKeyRight: | ||
| @@ -133,6 +163,8 @@ func (w *WebView) AttachKeyboardBridge() { | |||||
| 133 | keyName = keyArrowUp | 163 | keyName = keyArrowUp | ||
| 134 | case gdkKeyDown: | 164 | case gdkKeyDown: | ||
| 135 | keyName = keyArrowDown | 165 | keyName = keyArrowDown | ||
| 166 | + | case gdkKeyEscape: | |||
| 167 | + | keyName = keyEscape | |||
| 136 | case gdkKeyF12: | 168 | case gdkKeyF12: | ||
| 137 | keyName = keyF12 | 169 | keyName = keyF12 | ||
| 138 | case gdkKeyPlus, gdkKeyEqual: | 170 | case gdkKeyPlus, gdkKeyEqual: | ||
| @@ -162,55 +194,76 @@ func (w *WebView) AttachKeyboardBridge() { | |||||
| 162 | } | 194 | } | ||
| 163 | 195 | ||||
| 164 | if shortcut != "" { | 196 | if shortcut != "" { | ||
| 165 | - | // Look up routing behavior from table, use default if not found | 197 | + | // Special case: Ctrl+P enters pane mode |
| 166 | - | routing, exists := shortcutRoutingTable[shortcut] | 198 | + | if shortcut == modifierCmdOrCtrl+"+"+keyP { |
| 167 | - | if !exists { | 199 | + | w.mu.RLock() |
| 168 | - | routing = defaultRouting | 200 | + | handler := w.onPaneModeShortcut |
| 201 | + | w.mu.RUnlock() | |||
| 202 | + | if handler != nil && handler(actionEnter) { | |||
| 203 | + | log.Printf("[keyboard-bridge] Pane mode: enter") | |||
| 204 | + | return true | |||
| 205 | + | } | |||
| 206 | + | return true // Block even if no handler | |||
| 169 | } | 207 | } | ||
| 170 | 208 | ||||
| 171 | - | // Handle workspace navigation shortcuts via callback | 209 | + | // Special case: Escape exits active mode |
| 172 | - | if shortcut == "alt+arrowup" || shortcut == "alt+arrowdown" || shortcut == "alt+arrowleft" || shortcut == "alt+arrowright" { | 210 | + | if shortcut == keyEscape { |
| 173 | w.mu.RLock() | 211 | w.mu.RLock() | ||
| 174 | - | handler := w.onWorkspaceNavigation | 212 | + | handler := w.onPaneModeShortcut |
| 175 | w.mu.RUnlock() | 213 | w.mu.RUnlock() | ||
| 214 | + | if handler != nil && handler(actionExit) { | |||
| 215 | + | log.Printf("[keyboard-bridge] Escape: exited mode") | |||
| 216 | + | return true | |||
| 217 | + | } | |||
| 218 | + | return false // Pass through to DOM if no active mode | |||
| 219 | + | } | |||
| 176 | 220 | ||||
| 177 | - | if handler != nil { | 221 | + | // Check if shortcut maps to an action |
| 178 | - | var direction string | 222 | + | if action, exists := shortcutActions[shortcut]; exists { |
| 179 | - | switch shortcut { | 223 | + | // Navigation actions (alt+arrow) |
| 180 | - | case "alt+arrowup": | 224 | + | if action == directionLeft || action == directionRight || action == directionUp || action == directionDown { |
| 181 | - | direction = "up" | 225 | + | w.mu.RLock() |
| 182 | - | case "alt+arrowdown": | 226 | + | handler := w.onWorkspaceNavigation |
| 183 | - | direction = "down" | 227 | + | w.mu.RUnlock() |
| 184 | - | case "alt+arrowleft": | 228 | + | if handler != nil && handler(action) { |
| 185 | - | direction = "left" | 229 | + | log.Printf("[keyboard-bridge] Navigation: %s", action) |
| 186 | - | case "alt+arrowright": | 230 | + | return true |
| 187 | - | direction = "right" | |||
| 188 | - | } | |||
| 189 | - | if handler(direction) { | |||
| 190 | - | log.Printf("[keyboard-bridge] Workspace navigation: %s (handled)", direction) | |||
| 191 | - | return true // Block WebKit | |||
| 192 | } | 231 | } | ||
| 232 | + | return true // Block anyway to prevent scrolling | |||
| 193 | } | 233 | } | ||
| 194 | - | log.Printf("[keyboard-bridge] Shortcut %s: %s (no handler or not handled)", shortcut, routing.description) | |||
| 195 | - | return true // Block WebKit anyway to prevent scrolling | |||
| 196 | - | } | |||
| 197 | 234 | ||||
| 198 | - | // Dispatch to JavaScript if routing says to | 235 | + | // Pane mode actions (x, l, r, d, u, arrows) |
| 199 | - | if routing.forwardToJS { | 236 | + | w.mu.RLock() |
| 200 | - | script := fmt.Sprintf(`document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:'%s'}}));`, shortcut) | 237 | + | handler := w.onPaneModeShortcut |
| 201 | - | if err := w.InjectScript(script); err != nil { | 238 | + | w.mu.RUnlock() |
| 202 | - | log.Printf("[keyboard-bridge] Failed to dispatch key event: %v", err) | 239 | + | if handler != nil && handler(action) { |
| 240 | + | log.Printf("[keyboard-bridge] Pane mode action: %s", action) | |||
| 241 | + | return true | |||
| 203 | } | 242 | } | ||
| 204 | - | log.Printf("[keyboard-bridge] Forwarded shortcut to JS: %s", shortcut) | 243 | + | return false // Not in pane mode, pass through |
| 205 | - | } else { | 244 | + | } |
| 206 | - | log.Printf("[keyboard-bridge] Shortcut %s: %s (not forwarded to JS)", shortcut, routing.description) | 245 | + | |
| 246 | + | // Pane mode active: block all other shortcuts | |||
| 247 | + | if isPaneModeActive { | |||
| 248 | + | log.Printf("[keyboard-bridge] Blocking '%s' during pane mode", shortcut) | |||
| 249 | + | return true | |||
| 207 | } | 250 | } | ||
| 208 | 251 | ||||
| 209 | - | // Return routing decision to block or allow WebKit | 252 | + | // Forward to JavaScript |
| 210 | - | return routing.blockWebKit | 253 | + | script := fmt.Sprintf(`document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:'%s'}}));`, shortcut) |
| 254 | + | if err := w.InjectScript(script); err != nil { | |||
| 255 | + | log.Printf("[keyboard-bridge] Failed to dispatch: %v", err) | |||
| 256 | + | } | |||
| 257 | + | return false // Allow WebKit to handle | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | // Pane mode active: block ALL non-shortcut keys | |||
| 261 | + | if isPaneModeActive { | |||
| 262 | + | log.Printf("[keyboard-bridge] Blocking key during pane mode: keyval=%d", keyval) | |||
| 263 | + | return true // Block all other keys | |||
| 211 | } | 264 | } | ||
| 212 | 265 | ||||
| 213 | - | // No shortcut matched - allow WebKit to handle normally | 266 | + | // No shortcut matched and not in pane mode - allow WebKit to handle normally |
| 214 | return false | 267 | return false | ||
| 215 | }) | 268 | }) | ||
| 216 | 269 | ||||
M pkg/webkit/webview.go +26 -15
| @@ -21,8 +21,8 @@ var ( | |||
| 21 | 21 | // WebView wraps a WebKitGTK WebView | |
| 22 | 22 | type WebView struct { | |
| 23 | 23 | view *webkit.WebView | |
| 24 | - | container *gtk.Box // Container that holds the WebView | |
| 25 | - | window *Window // Optional window (only if CreateWindow=true) | |
| 24 | + | container *gtk.Box // Container that holds the WebView | |
| 25 | + | window *Window // Optional window (only if CreateWindow=true) | |
| 26 | 26 | id uint64 | |
| 27 | 27 | ||
| 28 | 28 | // State | |
| @@ -31,19 +31,21 @@ type WebView struct { | |||
| 31 | 31 | mu sync.RWMutex | |
| 32 | 32 | ||
| 33 | 33 | // Event handlers | |
| 34 | - | onScriptMessage func(string) | |
| 35 | - | onTitleChanged func(string) | |
| 36 | - | onURIChanged func(string) | |
| 37 | - | onFaviconChanged func([]byte) | |
| 38 | - | onFaviconURIChanged func(pageURI, faviconURI string) | |
| 39 | - | onZoomChanged func(float64) | |
| 40 | - | onPopup func(string) *WebView // Deprecated: Use onPopupCreate instead | |
| 41 | - | onPopupCreate func(*webkit.NavigationAction) *WebView // New WebKit create signal handler | |
| 42 | - | onReadyToShow func() // WebKit ready-to-show signal handler | |
| 43 | - | onClose func() | |
| 44 | - | onNavigationPolicy func(url string, isUserGesture bool) bool | |
| 45 | - | onWindowTypeDetected func(WindowType, *WindowFeatures) | |
| 46 | - | onWorkspaceNavigation func(direction string) bool // Workspace pane navigation | |
| 34 | + | onScriptMessage func(string) | |
| 35 | + | onTitleChanged func(string) | |
| 36 | + | onURIChanged func(string) | |
| 37 | + | onFaviconChanged func([]byte) | |
| 38 | + | onFaviconURIChanged func(pageURI, faviconURI string) | |
| 39 | + | onZoomChanged func(float64) | |
| 40 | + | onPopup func(string) *WebView // Deprecated: Use onPopupCreate instead | |
| 41 | + | onPopupCreate func(*webkit.NavigationAction) *WebView // New WebKit create signal handler | |
| 42 | + | onReadyToShow func() // WebKit ready-to-show signal handler | |
| 43 | + | onClose func() | |
| 44 | + | onNavigationPolicy func(url string, isUserGesture bool) bool | |
| 45 | + | onWindowTypeDetected func(WindowType, *WindowFeatures) | |
| 46 | + | onWorkspaceNavigation func(direction string) bool // Workspace pane navigation | |
| 47 | + | onPaneModeShortcut func(action string) bool // Pane mode shortcuts (enter, actions, exit) | |
| 48 | + | isPaneModeActive func() bool // Check if pane mode is active | |
| 47 | 49 | } | |
| 48 | 50 | ||
| 49 | 51 | // NewWebView creates a new WebView with the given configuration | |
| @@ -762,6 +764,15 @@ func (w *WebView) RegisterWorkspaceNavigationHandler(handler func(direction stri | |||
| 762 | 764 | w.onWorkspaceNavigation = handler | |
| 763 | 765 | } | |
| 764 | 766 | ||
| 767 | + | // RegisterPaneModeHandler registers a handler for pane mode shortcuts | |
| 768 | + | // The handler receives the action ("enter", "close", "split-right", etc.) and returns true if handled | |
| 769 | + | func (w *WebView) RegisterPaneModeHandler(handler func(action string) bool, isActiveChecker func() bool) { | |
| 770 | + | w.mu.Lock() | |
| 771 | + | defer w.mu.Unlock() | |
| 772 | + | w.onPaneModeShortcut = handler | |
| 773 | + | w.isPaneModeActive = isActiveChecker | |
| 774 | + | } | |
| 775 | + | ||
| 765 | 776 | // GtkWebView returns the underlying gotk4 WebView for advanced operations | |
| 766 | 777 | // This is used when creating related views (popups) that need to share the same session | |
| 767 | 778 | func (w *WebView) GtkWebView() *webkit.WebView { | |
| @@ -21,8 +21,8 @@ var ( | |||||
| 21 | // WebView wraps a WebKitGTK WebView | 21 | // WebView wraps a WebKitGTK WebView | ||
| 22 | type WebView struct { | 22 | type WebView struct { | ||
| 23 | view *webkit.WebView | 23 | view *webkit.WebView | ||
| 24 | - | container *gtk.Box // Container that holds the WebView | 24 | + | container *gtk.Box // Container that holds the WebView |
| 25 | - | window *Window // Optional window (only if CreateWindow=true) | 25 | + | window *Window // Optional window (only if CreateWindow=true) |
| 26 | id uint64 | 26 | id uint64 | ||
| 27 | 27 | ||||
| 28 | // State | 28 | // State | ||
| @@ -31,19 +31,21 @@ type WebView struct { | |||||
| 31 | mu sync.RWMutex | 31 | mu sync.RWMutex | ||
| 32 | 32 | ||||
| 33 | // Event handlers | 33 | // Event handlers | ||
| 34 | - | onScriptMessage func(string) | 34 | + | onScriptMessage func(string) |
| 35 | - | onTitleChanged func(string) | 35 | + | onTitleChanged func(string) |
| 36 | - | onURIChanged func(string) | 36 | + | onURIChanged func(string) |
| 37 | - | onFaviconChanged func([]byte) | 37 | + | onFaviconChanged func([]byte) |
| 38 | - | onFaviconURIChanged func(pageURI, faviconURI string) | 38 | + | onFaviconURIChanged func(pageURI, faviconURI string) |
| 39 | - | onZoomChanged func(float64) | 39 | + | onZoomChanged func(float64) |
| 40 | - | onPopup func(string) *WebView // Deprecated: Use onPopupCreate instead | 40 | + | onPopup func(string) *WebView // Deprecated: Use onPopupCreate instead |
| 41 | - | onPopupCreate func(*webkit.NavigationAction) *WebView // New WebKit create signal handler | 41 | + | onPopupCreate func(*webkit.NavigationAction) *WebView // New WebKit create signal handler |
| 42 | - | onReadyToShow func() // WebKit ready-to-show signal handler | 42 | + | onReadyToShow func() // WebKit ready-to-show signal handler |
| 43 | - | onClose func() | 43 | + | onClose func() |
| 44 | - | onNavigationPolicy func(url string, isUserGesture bool) bool | 44 | + | onNavigationPolicy func(url string, isUserGesture bool) bool |
| 45 | - | onWindowTypeDetected func(WindowType, *WindowFeatures) | 45 | + | onWindowTypeDetected func(WindowType, *WindowFeatures) |
| 46 | - | onWorkspaceNavigation func(direction string) bool // Workspace pane navigation | 46 | + | onWorkspaceNavigation func(direction string) bool // Workspace pane navigation |
| 47 | + | onPaneModeShortcut func(action string) bool // Pane mode shortcuts (enter, actions, exit) | |||
| 48 | + | isPaneModeActive func() bool // Check if pane mode is active | |||
| 47 | } | 49 | } | ||
| 48 | 50 | ||||
| 49 | // NewWebView creates a new WebView with the given configuration | 51 | // NewWebView creates a new WebView with the given configuration | ||
| @@ -762,6 +764,15 @@ func (w *WebView) RegisterWorkspaceNavigationHandler(handler func(direction stri | |||||
| 762 | w.onWorkspaceNavigation = handler | 764 | w.onWorkspaceNavigation = handler | ||
| 763 | } | 765 | } | ||
| 764 | 766 | ||||
| 767 | + | // RegisterPaneModeHandler registers a handler for pane mode shortcuts | |||
| 768 | + | // The handler receives the action ("enter", "close", "split-right", etc.) and returns true if handled | |||
| 769 | + | func (w *WebView) RegisterPaneModeHandler(handler func(action string) bool, isActiveChecker func() bool) { | |||
| 770 | + | w.mu.Lock() | |||
| 771 | + | defer w.mu.Unlock() | |||
| 772 | + | w.onPaneModeShortcut = handler | |||
| 773 | + | w.isPaneModeActive = isActiveChecker | |||
| 774 | + | } | |||
| 775 | + | ||||
| 765 | // GtkWebView returns the underlying gotk4 WebView for advanced operations | 776 | // GtkWebView returns the underlying gotk4 WebView for advanced operations | ||
| 766 | // This is used when creating related views (popups) that need to share the same session | 777 | // This is used when creating related views (popups) that need to share the same session | ||
| 767 | func (w *WebView) GtkWebView() *webkit.WebView { | 778 | func (w *WebView) GtkWebView() *webkit.WebView { | ||