detached 6d403db

feat(workspace): open _blank/gesture links in stacked mode by default

- New config option: workspace.popups.blank_target_behavior (split/stacked/tabbed)
- Default behavior: stacked (was split-right)
- Applies to: target="_blank" links, Ctrl+click, middle-click
- JavaScript popups still use workspace.popups.behavior config
- Prevents browser exit when closing parent pane
commit
6d403db5812bd1e23ae44e4eb9c0e023fdb9e052
author
bnema · · 2025-11-16 17:50 UTC
committer
bnema · · 2025-11-16 19:52 UTC
parent
81e87e8
signature
unsigned
6 files · +94 -20
M CHANGELOG.md +1 -0
@@ -12,6 +12,7 @@ All notable changes to this project will be documented in this file.
1212 - **Configurable default search engine**: Added `default_search_engine` config option with explicit URL template format. DuckDuckGo is now the default (was Google). Users can set any search engine with `%s` placeholder (e.g., `default_search_engine = "https://duckduckgo.com/?q=%s"`).
1313
1414 ### Changed
15+- **Target="_blank" and gesture link behavior**: Links with `target="_blank"`, Ctrl+click, and middle-click now open in stacked panes mode by default instead of split-right. New config option `workspace.popups.blank_target_behavior` (values: "split", "stacked", "tabbed") controls this behavior separately from JavaScript popups. Prevents browser exit when closing parent pane by marking _blank panes as popup-type.
1516 - **Config format**: TOML is now the default format (JSON/YAML still supported). Action bindings inverted to `action→keys` structure for maintainability with O(1) runtime lookup. Added comprehensive validation.
1617 - **Code cleanup**: Removed dead code files and emojis from GUI modules
1718 - **Bridge architecture**: Unified main-world to isolated-world bridge system with comprehensive documentation. Single dispatcher for all window function bridges (omnibox, favorites, toasts). Better separation between CustomEvents (shared between worlds) and window functions (require bridging).
@@ -12,6 +12,7 @@ All notable changes to this project will be documented in this file.
12 - **Configurable default search engine**: Added `default_search_engine` config option with explicit URL template format. DuckDuckGo is now the default (was Google). Users can set any search engine with `%s` placeholder (e.g., `default_search_engine = "https://duckduckgo.com/?q=%s"`).12 - **Configurable default search engine**: Added `default_search_engine` config option with explicit URL template format. DuckDuckGo is now the default (was Google). Users can set any search engine with `%s` placeholder (e.g., `default_search_engine = "https://duckduckgo.com/?q=%s"`).
13 13
14 ### Changed14 ### Changed
15+- **Target="_blank" and gesture link behavior**: Links with `target="_blank"`, Ctrl+click, and middle-click now open in stacked panes mode by default instead of split-right. New config option `workspace.popups.blank_target_behavior` (values: "split", "stacked", "tabbed") controls this behavior separately from JavaScript popups. Prevents browser exit when closing parent pane by marking _blank panes as popup-type.
15 - **Config format**: TOML is now the default format (JSON/YAML still supported). Action bindings inverted to `action→keys` structure for maintainability with O(1) runtime lookup. Added comprehensive validation.16 - **Config format**: TOML is now the default format (JSON/YAML still supported). Action bindings inverted to `action→keys` structure for maintainability with O(1) runtime lookup. Added comprehensive validation.
16 - **Code cleanup**: Removed dead code files and emojis from GUI modules17 - **Code cleanup**: Removed dead code files and emojis from GUI modules
17 - **Bridge architecture**: Unified main-world to isolated-world bridge system with comprehensive documentation. Single dispatcher for all window function bridges (omnibox, favorites, toasts). Better separation between CustomEvents (shared between worlds) and window functions (require bridging).18 - **Bridge architecture**: Unified main-world to isolated-world bridge system with comprehensive documentation. Single dispatcher for all window function bridges (omnibox, favorites, toasts). Better separation between CustomEvents (shared between worlds) and window functions (require bridging).
M internal/app/browser/workspace_manager.go +49 -8
@@ -463,7 +463,7 @@ func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) {
463463 return false
464464 }
465465
466- log.Printf("[workspace] Middle-click on link, opening in new pane: %s", linkURL)
466+ log.Printf("[workspace] Middle-click/Ctrl+click on link, opening in new pane: %s", linkURL)
467467
468468 // Get the node for this webview
469469 node := wm.GetNodeForWebView(webView)
@@ -472,16 +472,57 @@ func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) {
472472 return false
473473 }
474474
475- // Get split direction from popup placement config or use right as default
476- direction := "right"
477- if wm.app != nil && wm.app.config != nil && wm.app.config.Workspace.Popups.Placement != "" {
478- direction = strings.ToLower(wm.app.config.Workspace.Popups.Placement)
475+ // Get behavior from BlankTargetBehavior config
476+ behavior := "stacked" // Default
477+ if wm.app != nil && wm.app.config != nil && wm.app.config.Workspace.Popups.BlankTargetBehavior != "" {
478+ behavior = strings.ToLower(wm.app.config.Workspace.Popups.BlankTargetBehavior)
479+ }
480+
481+ var newNode *paneNode
482+ var err error
483+
484+ switch behavior {
485+ case "stacked":
486+ // Stack the current pane - this also creates a new pane in the stack
487+ log.Printf("[workspace] Using stacked behavior for gesture link")
488+ newNode, err = wm.stackedPaneManager.StackPane(node)
489+ if err != nil {
490+ log.Printf("[workspace] Failed to stack pane for gesture link: %v", err)
491+ return false
492+ }
493+ // newNode is the new pane that was added to the stack
494+ log.Printf("[workspace] StackPane created new pane in stack: %p", newNode)
495+
496+ case "split":
497+ // Regular split behavior
498+ log.Printf("[workspace] Using split behavior for gesture link")
499+ direction := "right"
500+ if wm.app.config.Workspace.Popups.Placement != "" {
501+ direction = strings.ToLower(wm.app.config.Workspace.Popups.Placement)
502+ }
503+ newNode, err = wm.SplitPane(node, direction)
504+
505+ case "tabbed":
506+ // Tabbed not yet implemented, fall back to split
507+ log.Printf("[workspace] WARNING: Tabbed behavior not yet implemented for gesture links, falling back to split")
508+ direction := "right"
509+ if wm.app.config.Workspace.Popups.Placement != "" {
510+ direction = strings.ToLower(wm.app.config.Workspace.Popups.Placement)
511+ }
512+ newNode, err = wm.SplitPane(node, direction)
513+
514+ default:
515+ // Unknown behavior, fall back to split
516+ log.Printf("[workspace] WARNING: Unknown behavior '%s' for gesture links, falling back to split", behavior)
517+ direction := "right"
518+ if wm.app.config.Workspace.Popups.Placement != "" {
519+ direction = strings.ToLower(wm.app.config.Workspace.Popups.Placement)
520+ }
521+ newNode, err = wm.SplitPane(node, direction)
479522 }
480523
481- // Split the pane
482- newNode, err := wm.SplitPane(node, direction)
483524 if err != nil {
484- log.Printf("[workspace] Failed to split pane for middle-click: %v", err)
525+ log.Printf("[workspace] Failed to handle gesture link: %v", err)
485526 return false
486527 }
487528
@@ -463,7 +463,7 @@ func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) {
463 return false463 return false
464 }464 }
465 465
466- log.Printf("[workspace] Middle-click on link, opening in new pane: %s", linkURL)466+ log.Printf("[workspace] Middle-click/Ctrl+click on link, opening in new pane: %s", linkURL)
467 467
468 // Get the node for this webview468 // Get the node for this webview
469 node := wm.GetNodeForWebView(webView)469 node := wm.GetNodeForWebView(webView)
@@ -472,16 +472,57 @@ func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) {
472 return false472 return false
473 }473 }
474 474
475- // Get split direction from popup placement config or use right as default475+ // Get behavior from BlankTargetBehavior config
476- direction := "right"476+ behavior := "stacked" // Default
477- if wm.app != nil && wm.app.config != nil && wm.app.config.Workspace.Popups.Placement != "" {477+ if wm.app != nil && wm.app.config != nil && wm.app.config.Workspace.Popups.BlankTargetBehavior != "" {
478- direction = strings.ToLower(wm.app.config.Workspace.Popups.Placement)478+ behavior = strings.ToLower(wm.app.config.Workspace.Popups.BlankTargetBehavior)
479+ }
480+
481+ var newNode *paneNode
482+ var err error
483+
484+ switch behavior {
485+ case "stacked":
486+ // Stack the current pane - this also creates a new pane in the stack
487+ log.Printf("[workspace] Using stacked behavior for gesture link")
488+ newNode, err = wm.stackedPaneManager.StackPane(node)
489+ if err != nil {
490+ log.Printf("[workspace] Failed to stack pane for gesture link: %v", err)
491+ return false
492+ }
493+ // newNode is the new pane that was added to the stack
494+ log.Printf("[workspace] StackPane created new pane in stack: %p", newNode)
495+
496+ case "split":
497+ // Regular split behavior
498+ log.Printf("[workspace] Using split behavior for gesture link")
499+ direction := "right"
500+ if wm.app.config.Workspace.Popups.Placement != "" {
501+ direction = strings.ToLower(wm.app.config.Workspace.Popups.Placement)
502+ }
503+ newNode, err = wm.SplitPane(node, direction)
504+
505+ case "tabbed":
506+ // Tabbed not yet implemented, fall back to split
507+ log.Printf("[workspace] WARNING: Tabbed behavior not yet implemented for gesture links, falling back to split")
508+ direction := "right"
509+ if wm.app.config.Workspace.Popups.Placement != "" {
510+ direction = strings.ToLower(wm.app.config.Workspace.Popups.Placement)
511+ }
512+ newNode, err = wm.SplitPane(node, direction)
513+
514+ default:
515+ // Unknown behavior, fall back to split
516+ log.Printf("[workspace] WARNING: Unknown behavior '%s' for gesture links, falling back to split", behavior)
517+ direction := "right"
518+ if wm.app.config.Workspace.Popups.Placement != "" {
519+ direction = strings.ToLower(wm.app.config.Workspace.Popups.Placement)
520+ }
521+ newNode, err = wm.SplitPane(node, direction)
479 }522 }
480 523
481- // Split the pane
482- newNode, err := wm.SplitPane(node, direction)
483 if err != nil {524 if err != nil {
484- log.Printf("[workspace] Failed to split pane for middle-click: %v", err)525+ log.Printf("[workspace] Failed to handle gesture link: %v", err)
485 return false526 return false
486 }527 }
487 528
M internal/app/browser/workspace_popup.go +37 -6
@@ -409,9 +409,39 @@ func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) {
409409
410410 log.Printf("[workspace] Created popup pane, now inserting into workspace")
411411
412- // NOW it's safe to insert into workspace based on configured behavior
413- behavior := wm.app.config.Workspace.Popups.Behavior
414- log.Printf("[workspace] Popup behavior: %s", behavior)
412+ // Determine window type from node (set during setupPopupHandling)
413+ node := wm.viewToNode[wrappedView]
414+ windowType := webkit.WindowTypePopup
415+ if node != nil {
416+ windowType = node.windowType
417+ }
418+
419+ // Select behavior based on window type:
420+ // - WindowTypeTab (_blank links): Use BlankTargetBehavior config
421+ // - WindowTypePopup (JavaScript popups): Use global Behavior config
422+ var behaviorStr string
423+ if windowType == webkit.WindowTypeTab {
424+ behaviorStr = wm.app.config.Workspace.Popups.BlankTargetBehavior
425+ log.Printf("[workspace] WindowTypeTab (_blank link) detected, using BlankTargetBehavior: %s", behaviorStr)
426+ } else {
427+ behaviorStr = string(wm.app.config.Workspace.Popups.Behavior)
428+ log.Printf("[workspace] WindowTypePopup detected, using Behavior: %s", behaviorStr)
429+ }
430+
431+ // Map string to PopupBehavior enum
432+ var behavior config.PopupBehavior
433+ switch behaviorStr {
434+ case "split":
435+ behavior = config.PopupBehaviorSplit
436+ case "stacked":
437+ behavior = config.PopupBehaviorStacked
438+ case "tabbed":
439+ behavior = config.PopupBehaviorTabbed
440+ default:
441+ behavior = config.PopupBehaviorSplit // Fallback
442+ }
443+
444+ log.Printf("[workspace] Final behavior: %s", behavior)
415445
416446 switch behavior {
417447 case config.PopupBehaviorSplit:
@@ -481,12 +511,13 @@ func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) {
481511 }
482512
483513 // Configure the popup node
484- node := wm.viewToNode[wrappedView]
514+ // Note: windowType should already be set correctly from setupPopupHandling
515+ // Don't overwrite it here - preserve WindowTypeTab for _blank links
516+ node = wm.viewToNode[wrappedView]
485517 if node != nil {
486- node.windowType = webkit.WindowTypePopup
487518 node.isRelated = true
488519 node.parentPane = info.parentNode
489- node.isPopup = true
520+ node.isPopup = true // Mark as popup to prevent browser exit when parent closes
490521 node.autoClose = wm.shouldAutoClose(info.url)
491522 node.popupID = popupID // Store popupID for close signal lookup
492523
@@ -409,9 +409,39 @@ func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) {
409 409
410 log.Printf("[workspace] Created popup pane, now inserting into workspace")410 log.Printf("[workspace] Created popup pane, now inserting into workspace")
411 411
412- // NOW it's safe to insert into workspace based on configured behavior412+ // Determine window type from node (set during setupPopupHandling)
413- behavior := wm.app.config.Workspace.Popups.Behavior413+ node := wm.viewToNode[wrappedView]
414- log.Printf("[workspace] Popup behavior: %s", behavior)414+ windowType := webkit.WindowTypePopup
415+ if node != nil {
416+ windowType = node.windowType
417+ }
418+
419+ // Select behavior based on window type:
420+ // - WindowTypeTab (_blank links): Use BlankTargetBehavior config
421+ // - WindowTypePopup (JavaScript popups): Use global Behavior config
422+ var behaviorStr string
423+ if windowType == webkit.WindowTypeTab {
424+ behaviorStr = wm.app.config.Workspace.Popups.BlankTargetBehavior
425+ log.Printf("[workspace] WindowTypeTab (_blank link) detected, using BlankTargetBehavior: %s", behaviorStr)
426+ } else {
427+ behaviorStr = string(wm.app.config.Workspace.Popups.Behavior)
428+ log.Printf("[workspace] WindowTypePopup detected, using Behavior: %s", behaviorStr)
429+ }
430+
431+ // Map string to PopupBehavior enum
432+ var behavior config.PopupBehavior
433+ switch behaviorStr {
434+ case "split":
435+ behavior = config.PopupBehaviorSplit
436+ case "stacked":
437+ behavior = config.PopupBehaviorStacked
438+ case "tabbed":
439+ behavior = config.PopupBehaviorTabbed
440+ default:
441+ behavior = config.PopupBehaviorSplit // Fallback
442+ }
443+
444+ log.Printf("[workspace] Final behavior: %s", behavior)
415 445
416 switch behavior {446 switch behavior {
417 case config.PopupBehaviorSplit:447 case config.PopupBehaviorSplit:
@@ -481,12 +511,13 @@ func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) {
481 }511 }
482 512
483 // Configure the popup node513 // Configure the popup node
484- node := wm.viewToNode[wrappedView]514+ // Note: windowType should already be set correctly from setupPopupHandling
515+ // Don't overwrite it here - preserve WindowTypeTab for _blank links
516+ node = wm.viewToNode[wrappedView]
485 if node != nil {517 if node != nil {
486- node.windowType = webkit.WindowTypePopup
487 node.isRelated = true518 node.isRelated = true
488 node.parentPane = info.parentNode519 node.parentPane = info.parentNode
489- node.isPopup = true520+ node.isPopup = true // Mark as popup to prevent browser exit when parent closes
490 node.autoClose = wm.shouldAutoClose(info.url)521 node.autoClose = wm.shouldAutoClose(info.url)
491 node.popupID = popupID // Store popupID for close signal lookup522 node.popupID = popupID // Store popupID for close signal lookup
492 523
M internal/config/config.go +3 -2
@@ -282,8 +282,9 @@ type PopupBehaviorConfig struct {
282282 // FollowPaneContext determines if popup placement follows parent pane context
283283 FollowPaneContext bool `mapstructure:"follow_pane_context" yaml:"follow_pane_context" json:"follow_pane_context"`
284284
285- // BlankTargetBehavior determines how to handle window.open(url, "_blank") intents
286- // Accepted values: "pane" (default) or "tab" (future support)
285+ // BlankTargetBehavior determines how target="_blank" links are opened
286+ // Accepted values: "split", "stacked" (default), "tabbed"
287+ // This is separate from Behavior which controls JavaScript popups
287288 BlankTargetBehavior string `mapstructure:"blank_target_behavior" yaml:"blank_target_behavior" json:"blank_target_behavior"`
288289
289290 // EnableSmartDetection uses WebKitWindowProperties to detect popup vs tab intents
@@ -282,8 +282,9 @@ type PopupBehaviorConfig struct {
282 // FollowPaneContext determines if popup placement follows parent pane context282 // FollowPaneContext determines if popup placement follows parent pane context
283 FollowPaneContext bool `mapstructure:"follow_pane_context" yaml:"follow_pane_context" json:"follow_pane_context"`283 FollowPaneContext bool `mapstructure:"follow_pane_context" yaml:"follow_pane_context" json:"follow_pane_context"`
284 284
285- // BlankTargetBehavior determines how to handle window.open(url, "_blank") intents285+ // BlankTargetBehavior determines how target="_blank" links are opened
286- // Accepted values: "pane" (default) or "tab" (future support)286+ // Accepted values: "split", "stacked" (default), "tabbed"
287+ // This is separate from Behavior which controls JavaScript popups
287 BlankTargetBehavior string `mapstructure:"blank_target_behavior" yaml:"blank_target_behavior" json:"blank_target_behavior"`288 BlankTargetBehavior string `mapstructure:"blank_target_behavior" yaml:"blank_target_behavior" json:"blank_target_behavior"`
288 289
289 // EnableSmartDetection uses WebKitWindowProperties to detect popup vs tab intents290 // EnableSmartDetection uses WebKitWindowProperties to detect popup vs tab intents
M internal/config/defaults.go +2 -2
@@ -166,11 +166,11 @@ func DefaultConfig() *Config {
166166 PreviousTab: "ctrl+shift+tab",
167167 },
168168 Popups: PopupBehaviorConfig{
169- Behavior: PopupBehaviorSplit, // Default: open popups in split panes
169+ Behavior: PopupBehaviorSplit, // Default: open JavaScript popups in split panes
170170 Placement: defaultPopupPlacement,
171171 OpenInNewPane: true,
172172 FollowPaneContext: true,
173- BlankTargetBehavior: "pane", // Default to pane, future: "tab"
173+ BlankTargetBehavior: "stacked", // Default: open _blank links in stacked mode
174174 EnableSmartDetection: true, // Use WindowProperties to detect popup vs tab
175175 OAuthAutoClose: true, // Auto-close OAuth popups on success
176176 },
@@ -166,11 +166,11 @@ func DefaultConfig() *Config {
166 PreviousTab: "ctrl+shift+tab",166 PreviousTab: "ctrl+shift+tab",
167 },167 },
168 Popups: PopupBehaviorConfig{168 Popups: PopupBehaviorConfig{
169- Behavior: PopupBehaviorSplit, // Default: open popups in split panes169+ Behavior: PopupBehaviorSplit, // Default: open JavaScript popups in split panes
170 Placement: defaultPopupPlacement,170 Placement: defaultPopupPlacement,
171 OpenInNewPane: true,171 OpenInNewPane: true,
172 FollowPaneContext: true,172 FollowPaneContext: true,
173- BlankTargetBehavior: "pane", // Default to pane, future: "tab"173+ BlankTargetBehavior: "stacked", // Default: open _blank links in stacked mode
174 EnableSmartDetection: true, // Use WindowProperties to detect popup vs tab174 EnableSmartDetection: true, // Use WindowProperties to detect popup vs tab
175 OAuthAutoClose: true, // Auto-close OAuth popups on success175 OAuthAutoClose: true, // Auto-close OAuth popups on success
176 },176 },
M internal/config/validation.go +2 -2
@@ -60,10 +60,10 @@ func validateConfig(config *Config) error {
6060
6161 // Validate blank target behavior
6262 switch config.Workspace.Popups.BlankTargetBehavior {
63- case "pane", "tab":
63+ case "split", "stacked", "tabbed":
6464 // Valid
6565 default:
66- validationErrors = append(validationErrors, fmt.Sprintf("workspace.popups.blank_target_behavior must be one of: pane, tab (got: %s)", config.Workspace.Popups.BlankTargetBehavior))
66+ validationErrors = append(validationErrors, fmt.Sprintf("workspace.popups.blank_target_behavior must be one of: split, stacked, tabbed (got: %s)", config.Workspace.Popups.BlankTargetBehavior))
6767 }
6868
6969 // Validate workspace styling values
@@ -60,10 +60,10 @@ func validateConfig(config *Config) error {
60 60
61 // Validate blank target behavior61 // Validate blank target behavior
62 switch config.Workspace.Popups.BlankTargetBehavior {62 switch config.Workspace.Popups.BlankTargetBehavior {
63- case "pane", "tab":63+ case "split", "stacked", "tabbed":
64 // Valid64 // Valid
65 default:65 default:
66- validationErrors = append(validationErrors, fmt.Sprintf("workspace.popups.blank_target_behavior must be one of: pane, tab (got: %s)", config.Workspace.Popups.BlankTargetBehavior))66+ validationErrors = append(validationErrors, fmt.Sprintf("workspace.popups.blank_target_behavior must be one of: split, stacked, tabbed (got: %s)", config.Workspace.Popups.BlankTargetBehavior))
67 }67 }
68 68
69 // Validate workspace styling values69 // Validate workspace styling values

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