detached 9a95d24

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 {
3131 createPaneFn func(*webkit.WebView) (*BrowserPane, error)
3232
3333 // 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
3737
3838 // Focus throttling fields to prevent infinite loops
3939 lastFocusChange time.Time // When focus was last changed
@@ -177,170 +177,139 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag
177177 return manager
178178 }
179179
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+ }
186187 }
188+ wm.lastSplitMsg[source] = time.Now()
189+ return false
190+}
187191
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+ }
193198
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)
197204 }
205+ }()
198206
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+}
205209
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+ }
212215
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+}
229236
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]
234240
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+ }
248247 }
248+ }
249249
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+ }
254254
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 {
257256 case "pane-split":
258257 wm.SetActivePane(node, SourceProgrammatic)
259258 direction := strings.ToLower(msg.Direction)
260259 if direction == "" {
261260 direction = "right"
262261 }
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- }
269262
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) {
273264 return
274265 }
275266
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
282274 }
283- }()
284-
285- wm.lastSplitMsg[source] = time.Now()
275+ wm.clonePaneState(node, newNode)
276+ return nil
277+ })
286278
287- newNode, err := wm.SplitPane(node, direction)
288279 if err != nil {
289280 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
295281 }
296- wm.clonePaneState(node, newNode)
282+
297283 case "pane-stack":
298284 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- }
305285
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) {
309287 return
310288 }
311289
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
318297 }
319- }()
298+ wm.clonePaneState(node, newNode)
299+ return nil
300+ })
320301
321- wm.lastSplitMsg[source] = time.Now()
322-
323- newNode, err := wm.stackedPaneManager.StackPane(node)
324302 if err != nil {
325303 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
331304 }
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)
337305
306+ case "close-popup":
338307 if msg.WebViewID == "" {
339308 log.Printf("[workspace] close-popup: empty webviewId, ignoring")
340- break
309+ return
341310 }
342311
343- // Find the popup pane by webview ID
312+ // Find popup pane by webview ID
344313 var targetNode *paneNode
345314 for webView, node := range wm.viewToNode {
346315 if webView != nil && fmt.Sprintf("%d", webView.ID()) == msg.WebViewID {
@@ -351,87 +320,52 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa
351320
352321 if targetNode == nil {
353322 log.Printf("[workspace] close-popup: webview not found: %s", msg.WebViewID)
354- break
323+ return
355324 }
356325
357326 if !targetNode.isPopup {
358327 log.Printf("[workspace] close-popup: target is not a popup: %s", msg.WebViewID)
359- break
328+ return
360329 }
361330
362- log.Printf("[workspace] Closing popup pane due to %s", msg.Reason)
331+ log.Printf("[workspace] Closing popup due to %s", msg.Reason)
363332
364- // Remove this popup from parent's activePopupChildren before closing
333+ // Remove from parent's active popup list
365334 if targetNode.parentPane != nil {
366335 parentNode := targetNode.parentPane
367336 for i, childID := range parentNode.activePopupChildren {
368337 if childID == msg.WebViewID {
369338 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))
371340 break
372341 }
373342 }
374343
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)
414345 }
415346
416- // Close the popup pane
417347 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)
429349 }
350+
430351 default:
431352 log.Printf("[workspace] unhandled workspace event: %s", msg.Event)
432353 }
433354 }
434355
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+
435369 // GetActiveNode returns the currently active pane node
436370 func (wm *WorkspaceManager) GetActiveNode() *paneNode {
437371 if wm == nil || wm.focusStateMachine == nil {
@@ -478,5 +412,48 @@ func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) {
478412 return wm.FocusNeighbor(direction)
479413 })
480414
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+
481458 log.Printf("[workspace] Registered navigation handler for webview: %d", webView.ID())
482459 }
@@ -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 events33 // Coordination fields for preventing duplicate events
34- paneModeSource *webkit.WebView // Which webview initiated pane mode34+ paneModeSource *webkit.WebView // Which webview initiated pane mode
35- lastPaneModeEntry time.Time // When pane mode was last entered35+ paneModeActivePane *paneNode // Which pane node has pane mode active
36- paneMutex sync.Mutex // Protects pane mode state36+ paneMutex sync.Mutex // Protects pane mode state
37 37
38 // Focus throttling fields to prevent infinite loops38 // Focus throttling fields to prevent infinite loops
39 lastFocusChange time.Time // When focus was last changed39 lastFocusChange time.Time // When focus was last changed
@@ -177,170 +177,139 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag
177 return manager177 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- return185+ 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 mode194+ 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- return201+ 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 source210+// 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- return214+ }
211- }
212 215
213- log.Printf("[workspace] pane-mode-entered accepted: source=%p", source)216+ requestID := targetNode.requestID
214- wm.paneModeActive = true217+ parentWebViewID := parentNode.pane.WebView().ID()
215- wm.paneModeSource = source218+
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 calls222+ 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- return227+ } 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 = false238+func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messaging.Message) {
232- wm.paneModeSource = nil239+ 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 = false251+ log.Printf("[workspace] message from unknown webview: event=%s webviewId=%s", msg.Event, msg.WebViewID)
252- wm.paneModeSource = nil252+ return
253- wm.paneMutex.Unlock()253+ }
254 254
255- // Don't focus the node that's about to be closed - closeCurrentPane() will handle focus255+ 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 conditions263+ 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 return264 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 handling272+ }
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 flag286+ 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 return287 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 handling295+ }
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- break309+ return
341 }310 }
342 311
343- // Find the popup pane by webview ID312+ // Find popup pane by webview ID
344 var targetNode *paneNode313 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- break323+ 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- break328+ 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 closing333+ // Remove from parent's active popup list
365 if targetNode.parentPane != nil {334 if targetNode.parentPane != nil {
366 parentNode := targetNode.parentPane335 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 break340 break
372 }341 }
373 }342 }
374 343
375- // Clean up ALL localStorage entries for this popup in parent WebView344+ 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 node369 // 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 (
1717
1818 // GDK key constants for special keys
1919 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
2829 )
2930
3031 // Key name constants for shortcut mapping
3132 const (
3233 keyP = "p"
3334 keyL = "l"
35+ keyR = "r"
36+ keyD = "d"
37+ keyU = "u"
3438 keyF = "f"
3539 keyC = "c"
3640 keyW = "w"
37- keyR = "r"
41+ keyX = "x"
42+ keyEscape = "escape"
3843 keyArrowLeft = "arrowleft"
3944 keyArrowRight = "arrowright"
4045 keyArrowUp = "arrowup"
@@ -43,34 +48,46 @@ const (
4348 keyPlus = "plus"
4449 keyMinus = "minus"
4550 keyZero = "0"
51+ altmod = "alt"
4652 )
4753
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+)
5464
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+)
6872
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,
7491 }
7592
7693 // AttachKeyboardBridge attaches an EventControllerKey to bridge keyboard events to JavaScript
@@ -89,6 +106,13 @@ func (w *WebView) AttachKeyboardBridge() {
89106
90107 // Connect to key-pressed signal
91108 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+
92116 // Build normalized shortcut string
93117 var parts []string
94118
@@ -117,14 +141,20 @@ func (w *WebView) AttachKeyboardBridge() {
117141 keyName = keyP
118142 case 'l', 'L':
119143 keyName = keyL
144+ case 'r', 'R':
145+ keyName = keyR
146+ case 'd', 'D':
147+ keyName = keyD
148+ case 'u', 'U':
149+ keyName = keyU
120150 case 'f', 'F':
121151 keyName = keyF
122152 case 'c', 'C':
123153 keyName = keyC
124154 case 'w', 'W':
125155 keyName = keyW
126- case 'r', 'R':
127- keyName = keyR
156+ case 'x', 'X':
157+ keyName = keyX
128158 case gdkKeyLeft:
129159 keyName = keyArrowLeft
130160 case gdkKeyRight:
@@ -133,6 +163,8 @@ func (w *WebView) AttachKeyboardBridge() {
133163 keyName = keyArrowUp
134164 case gdkKeyDown:
135165 keyName = keyArrowDown
166+ case gdkKeyEscape:
167+ keyName = keyEscape
136168 case gdkKeyF12:
137169 keyName = keyF12
138170 case gdkKeyPlus, gdkKeyEqual:
@@ -162,55 +194,76 @@ func (w *WebView) AttachKeyboardBridge() {
162194 }
163195
164196 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
169207 }
170208
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 {
173211 w.mu.RLock()
174- handler := w.onWorkspaceNavigation
212+ handler := w.onPaneModeShortcut
175213 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+ }
176220
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
192231 }
232+ return true // Block anyway to prevent scrolling
193233 }
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- }
197234
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
203242 }
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
207250 }
208251
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
211264 }
212265
213- // No shortcut matched - allow WebKit to handle normally
266+ // No shortcut matched and not in pane mode - allow WebKit to handle normally
214267 return false
215268 })
216269
@@ -17,24 +17,29 @@ const (
17 17
18 // GDK key constants for special keys18 // GDK key constants for special keys
19 const (19 const (
20- gdkKeyLeft = 0xff5120+ gdkKeyLeft = 0xff51
21- gdkKeyRight = 0xff5321+ gdkKeyRight = 0xff53
22- gdkKeyUp = 0xff5222+ gdkKeyUp = 0xff52
23- gdkKeyDown = 0xff5423+ gdkKeyDown = 0xff54
24- gdkKeyF12 = 0xffbe24+ gdkKeyEscape = 0xff1b
25- gdkKeyPlus = 0x2b25+ gdkKeyF12 = 0xffbe
26- gdkKeyEqual = 0x3d26+ gdkKeyPlus = 0x2b
27- gdkKeyMinus = 0x2d27+ gdkKeyEqual = 0x3d
28+ gdkKeyMinus = 0x2d
28 )29 )
29 30
30 // Key name constants for shortcut mapping31 // 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 bridge54+// Pane mode action constants
49-type ShortcutRouting struct {55+const (
50- forwardToJS bool // Should dispatch dumber:key event to JavaScript56+ 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 debugging58+ 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 behavior65+// Navigation direction constants
56-// This centralizes all routing decisions in one place, avoiding magic strings and scattered logic66+const (
57-var shortcutRoutingTable = map[string]ShortcutRouting{67+ directionLeft = "left"
58- // Workspace pane navigation - block WebKit to prevent page scrolling68+ directionRight = "right"
59- // Navigation is triggered via workspace navigation callback69+ 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 table73+// 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 default75+ // 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 JavaScript93 // 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 signal107 // 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 string116 // Build normalized shortcut string
93 var parts []string117 var parts []string
94 118
@@ -117,14 +141,20 @@ func (w *WebView) AttachKeyboardBridge() {
117 keyName = keyP141 keyName = keyP
118 case 'l', 'L':142 case 'l', 'L':
119 keyName = keyL143 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 = keyF151 keyName = keyF
122 case 'c', 'C':152 case 'c', 'C':
123 keyName = keyC153 keyName = keyC
124 case 'w', 'W':154 case 'w', 'W':
125 keyName = keyW155 keyName = keyW
126- case 'r', 'R':156+ case 'x', 'X':
127- keyName = keyR157+ keyName = keyX
128 case gdkKeyLeft:158 case gdkKeyLeft:
129 keyName = keyArrowLeft159 keyName = keyArrowLeft
130 case gdkKeyRight:160 case gdkKeyRight:
@@ -133,6 +163,8 @@ func (w *WebView) AttachKeyboardBridge() {
133 keyName = keyArrowUp163 keyName = keyArrowUp
134 case gdkKeyDown:164 case gdkKeyDown:
135 keyName = keyArrowDown165 keyName = keyArrowDown
166+ case gdkKeyEscape:
167+ keyName = keyEscape
136 case gdkKeyF12:168 case gdkKeyF12:
137 keyName = keyF12169 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 found197+ // 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 = defaultRouting200+ 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 callback209+ // 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.onWorkspaceNavigation212+ 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 string222+ 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 to235+ // 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 WebKit252+ // Forward to JavaScript
210- return routing.blockWebKit253+ 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 normally266+ // No shortcut matched and not in pane mode - allow WebKit to handle normally
214 return false267 return false
215 })268 })
216 269
M pkg/webkit/webview.go +26 -15
@@ -21,8 +21,8 @@ var (
2121 // WebView wraps a WebKitGTK WebView
2222 type WebView struct {
2323 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)
2626 id uint64
2727
2828 // State
@@ -31,19 +31,21 @@ type WebView struct {
3131 mu sync.RWMutex
3232
3333 // 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
4749 }
4850
4951 // NewWebView creates a new WebView with the given configuration
@@ -762,6 +764,15 @@ func (w *WebView) RegisterWorkspaceNavigationHandler(handler func(direction stri
762764 w.onWorkspaceNavigation = handler
763765 }
764766
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+
765776 // GtkWebView returns the underlying gotk4 WebView for advanced operations
766777 // This is used when creating related views (popups) that need to share the same session
767778 func (w *WebView) GtkWebView() *webkit.WebView {
@@ -21,8 +21,8 @@ var (
21 // WebView wraps a WebKitGTK WebView21 // WebView wraps a WebKitGTK WebView
22 type WebView struct {22 type WebView struct {
23 view *webkit.WebView23 view *webkit.WebView
24- container *gtk.Box // Container that holds the WebView24+ 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 uint6426 id uint64
27 27
28 // State28 // State
@@ -31,19 +31,21 @@ type WebView struct {
31 mu sync.RWMutex31 mu sync.RWMutex
32 32
33 // Event handlers33 // 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 instead40+ onPopup func(string) *WebView // Deprecated: Use onPopupCreate instead
41- onPopupCreate func(*webkit.NavigationAction) *WebView // New WebKit create signal handler41+ onPopupCreate func(*webkit.NavigationAction) *WebView // New WebKit create signal handler
42- onReadyToShow func() // WebKit ready-to-show signal handler42+ onReadyToShow func() // WebKit ready-to-show signal handler
43- onClose func()43+ onClose func()
44- onNavigationPolicy func(url string, isUserGesture bool) bool44+ 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 navigation46+ 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 configuration51 // NewWebView creates a new WebView with the given configuration
@@ -762,6 +764,15 @@ func (w *WebView) RegisterWorkspaceNavigationHandler(handler func(direction stri
762 w.onWorkspaceNavigation = handler764 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 operations776 // GtkWebView returns the underlying gotk4 WebView for advanced operations
766 // This is used when creating related views (popups) that need to share the same session777 // 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 {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close