detached 5992583

fix(browsing): keep featureless links in floating panes

commit
5992583c1b59ebfaa06cf9a47ecc989b2ea95ff9
author
brice · · 2026-07-27 07:46 UTC
parent
7fae873
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +140 -71
M docs/reference/floating-pane.md +2 -2
@@ -18,9 +18,9 @@ The floating pane is an overlay workspace pane for quick-access pages like searc
1818
1919 ### Links and popups
2020
21-New-page links, middle-clicks, and featureless `window.open()` calls from a floating pane open in a new Dumber window. OAuth flows and scripts that request popup dimensions or restricted browser chrome open in a related popup window so authentication and opener messaging continue to work.
21+New-page links, middle-clicks, and featureless `_blank` `window.open()` calls from a floating pane navigate that floating pane's current page. They do not open a separate Dumber window.
2222
23-Detached Dumber windows remain open when the source floating pane is hidden or closed.
23+OAuth flows and scripts that request popup dimensions or restricted browser chrome open in a related popup window so authentication and opener messaging continue to work. Named browsing contexts continue to reuse an existing live context when one is available.
2424
2525 ## Configuration
2626
@@ -18,9 +18,9 @@ The floating pane is an overlay workspace pane for quick-access pages like searc
18 18
19 ### Links and popups19 ### Links and popups
20 20
21-New-page links, middle-clicks, and featureless `window.open()` calls from a floating pane open in a new Dumber window. OAuth flows and scripts that request popup dimensions or restricted browser chrome open in a related popup window so authentication and opener messaging continue to work.21+New-page links, middle-clicks, and featureless `_blank` `window.open()` calls from a floating pane navigate that floating pane's current page. They do not open a separate Dumber window.
22 22
23-Detached Dumber windows remain open when the source floating pane is hidden or closed.23+OAuth flows and scripts that request popup dimensions or restricted browser chrome open in a related popup window so authentication and opener messaging continue to work. Named browsing contexts continue to reuse an existing live context when one is available.
24 24
25 ## Configuration25 ## Configuration
26 26
M internal/application/dto/browsing_context.go +11 -9
@@ -111,6 +111,7 @@ type HostDecisionKind string
111111
112112 const (
113113 HostDecisionReuseNamedPane HostDecisionKind = "reuse-named-pane"
114+ HostDecisionNavigateSource HostDecisionKind = "navigate-source"
114115 HostDecisionCreatePane HostDecisionKind = "create-pane"
115116 HostDecisionCreateBrowserWindow HostDecisionKind = "create-browser-window"
116117 HostDecisionCreateNativePopup HostDecisionKind = "create-native-popup"
@@ -122,15 +123,16 @@ const (
122123 type HostDecisionReasonCode string
123124
124125 const (
125- HostDecisionReasonEmptyTarget HostDecisionReasonCode = "empty-target"
126- HostDecisionReasonAuthNativePopup HostDecisionReasonCode = "auth-native-popup"
127- HostDecisionReasonNativeOpenerRequired HostDecisionReasonCode = "native-opener-required"
128- HostDecisionReasonAmbiguousOpenerNative HostDecisionReasonCode = "ambiguous-opener-native"
129- HostDecisionReasonNamedContextReuse HostDecisionReasonCode = "named-context-reuse"
130- HostDecisionReasonFloatingFeaturesPending HostDecisionReasonCode = "floating-features-pending"
131- HostDecisionReasonFloatingPopupFeatures HostDecisionReasonCode = "floating-popup-features"
132- HostDecisionReasonFloatingBrowserWindow HostDecisionReasonCode = "floating-browser-window"
133- HostDecisionReasonWorkspacePane HostDecisionReasonCode = "workspace-pane"
126+ HostDecisionReasonEmptyTarget HostDecisionReasonCode = "empty-target"
127+ HostDecisionReasonAuthNativePopup HostDecisionReasonCode = "auth-native-popup"
128+ HostDecisionReasonNativeOpenerRequired HostDecisionReasonCode = "native-opener-required"
129+ HostDecisionReasonAmbiguousOpenerNative HostDecisionReasonCode = "ambiguous-opener-native"
130+ HostDecisionReasonNamedContextReuse HostDecisionReasonCode = "named-context-reuse"
131+ HostDecisionReasonFloatingFeaturesPending HostDecisionReasonCode = "floating-features-pending"
132+ HostDecisionReasonFloatingPopupFeatures HostDecisionReasonCode = "floating-popup-features"
133+ HostDecisionReasonFloatingFeaturelessBlank HostDecisionReasonCode = "floating-featureless-blank"
134+ HostDecisionReasonFloatingBrowserWindow HostDecisionReasonCode = "floating-browser-window"
135+ HostDecisionReasonWorkspacePane HostDecisionReasonCode = "workspace-pane"
134136 )
135137
136138 // BrowsingContextFailureCode is a stable machine-readable operational failure.
@@ -111,6 +111,7 @@ type HostDecisionKind string
111 111
112 const (112 const (
113 HostDecisionReuseNamedPane HostDecisionKind = "reuse-named-pane"113 HostDecisionReuseNamedPane HostDecisionKind = "reuse-named-pane"
114+ HostDecisionNavigateSource HostDecisionKind = "navigate-source"
114 HostDecisionCreatePane HostDecisionKind = "create-pane"115 HostDecisionCreatePane HostDecisionKind = "create-pane"
115 HostDecisionCreateBrowserWindow HostDecisionKind = "create-browser-window"116 HostDecisionCreateBrowserWindow HostDecisionKind = "create-browser-window"
116 HostDecisionCreateNativePopup HostDecisionKind = "create-native-popup"117 HostDecisionCreateNativePopup HostDecisionKind = "create-native-popup"
@@ -122,15 +123,16 @@ const (
122 type HostDecisionReasonCode string123 type HostDecisionReasonCode string
123 124
124 const (125 const (
125- HostDecisionReasonEmptyTarget HostDecisionReasonCode = "empty-target"126+ HostDecisionReasonEmptyTarget HostDecisionReasonCode = "empty-target"
126- HostDecisionReasonAuthNativePopup HostDecisionReasonCode = "auth-native-popup"127+ HostDecisionReasonAuthNativePopup HostDecisionReasonCode = "auth-native-popup"
127- HostDecisionReasonNativeOpenerRequired HostDecisionReasonCode = "native-opener-required"128+ HostDecisionReasonNativeOpenerRequired HostDecisionReasonCode = "native-opener-required"
128- HostDecisionReasonAmbiguousOpenerNative HostDecisionReasonCode = "ambiguous-opener-native"129+ HostDecisionReasonAmbiguousOpenerNative HostDecisionReasonCode = "ambiguous-opener-native"
129- HostDecisionReasonNamedContextReuse HostDecisionReasonCode = "named-context-reuse"130+ HostDecisionReasonNamedContextReuse HostDecisionReasonCode = "named-context-reuse"
130- HostDecisionReasonFloatingFeaturesPending HostDecisionReasonCode = "floating-features-pending"131+ HostDecisionReasonFloatingFeaturesPending HostDecisionReasonCode = "floating-features-pending"
131- HostDecisionReasonFloatingPopupFeatures HostDecisionReasonCode = "floating-popup-features"132+ HostDecisionReasonFloatingPopupFeatures HostDecisionReasonCode = "floating-popup-features"
132- HostDecisionReasonFloatingBrowserWindow HostDecisionReasonCode = "floating-browser-window"133+ HostDecisionReasonFloatingFeaturelessBlank HostDecisionReasonCode = "floating-featureless-blank"
133- HostDecisionReasonWorkspacePane HostDecisionReasonCode = "workspace-pane"134+ HostDecisionReasonFloatingBrowserWindow HostDecisionReasonCode = "floating-browser-window"
135+ HostDecisionReasonWorkspacePane HostDecisionReasonCode = "workspace-pane"
134 )136 )
135 137
136 // BrowsingContextFailureCode is a stable machine-readable operational failure.138 // BrowsingContextFailureCode is a stable machine-readable operational failure.
M internal/infrastructure/webkit/webview.go +16 -10
@@ -51,10 +51,11 @@ const (
5151
5252 // PopupRequest contains information about a popup window request from the create signal.
5353 type PopupRequest struct {
54- TargetURI string
55- FrameName string // e.g., "_blank", custom name, or empty
56- IsUserGesture bool
57- ParentID WebViewID
54+ TargetURI string
55+ FrameName string // e.g., "_blank", custom name, or empty
56+ NavigationType webkit.NavigationType
57+ IsUserGesture bool
58+ ParentID WebViewID
5859 }
5960
6061 // webViewRegistry tracks all active WebViews.
@@ -606,10 +607,11 @@ func (wv *WebView) connectCreateSignal() {
606607 }
607608
608609 popupReq := PopupRequest{
609- TargetURI: targetURI,
610- FrameName: navAction.GetFrameName(),
611- IsUserGesture: navAction.IsUserGesture(),
612- ParentID: wv.id,
610+ TargetURI: targetURI,
611+ FrameName: navAction.GetFrameName(),
612+ NavigationType: navAction.GetNavigationType(),
613+ IsUserGesture: navAction.IsUserGesture(),
614+ ParentID: wv.id,
613615 }
614616
615617 wv.logger.Debug().Msg("create signal: invoking OnCreate handler")
@@ -1731,8 +1733,12 @@ func mapPopupRequest(req PopupRequest) port.PopupRequest {
17311733 PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
17321734 }
17331735 if strings.EqualFold(strings.TrimSpace(req.FrameName), "_blank") {
1734- mapped.TargetDisposition = dto.WindowDispositionNewTab
1735- mapped.PopupFeatures.State = dto.PopupFeaturesNone
1736+ if req.NavigationType == webkit.NavigationTypeLinkClickedValue {
1737+ mapped.TargetDisposition = dto.WindowDispositionNewTab
1738+ mapped.PopupFeatures.State = dto.PopupFeaturesNone
1739+ } else {
1740+ mapped.TargetDisposition = dto.WindowDispositionNewPopup
1741+ }
17361742 }
17371743 return mapped
17381744 }
@@ -51,10 +51,11 @@ const (
51 51
52 // PopupRequest contains information about a popup window request from the create signal.52 // PopupRequest contains information about a popup window request from the create signal.
53 type PopupRequest struct {53 type PopupRequest struct {
54- TargetURI string54+ TargetURI string
55- FrameName string // e.g., "_blank", custom name, or empty55+ FrameName string // e.g., "_blank", custom name, or empty
56- IsUserGesture bool56+ NavigationType webkit.NavigationType
57- ParentID WebViewID57+ IsUserGesture bool
58+ ParentID WebViewID
58 }59 }
59 60
60 // webViewRegistry tracks all active WebViews.61 // webViewRegistry tracks all active WebViews.
@@ -606,10 +607,11 @@ func (wv *WebView) connectCreateSignal() {
606 }607 }
607 608
608 popupReq := PopupRequest{609 popupReq := PopupRequest{
609- TargetURI: targetURI,610+ TargetURI: targetURI,
610- FrameName: navAction.GetFrameName(),611+ FrameName: navAction.GetFrameName(),
611- IsUserGesture: navAction.IsUserGesture(),612+ NavigationType: navAction.GetNavigationType(),
612- ParentID: wv.id,613+ IsUserGesture: navAction.IsUserGesture(),
614+ ParentID: wv.id,
613 }615 }
614 616
615 wv.logger.Debug().Msg("create signal: invoking OnCreate handler")617 wv.logger.Debug().Msg("create signal: invoking OnCreate handler")
@@ -1731,8 +1733,12 @@ func mapPopupRequest(req PopupRequest) port.PopupRequest {
1731 PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},1733 PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
1732 }1734 }
1733 if strings.EqualFold(strings.TrimSpace(req.FrameName), "_blank") {1735 if strings.EqualFold(strings.TrimSpace(req.FrameName), "_blank") {
1734- mapped.TargetDisposition = dto.WindowDispositionNewTab1736+ if req.NavigationType == webkit.NavigationTypeLinkClickedValue {
1735- mapped.PopupFeatures.State = dto.PopupFeaturesNone1737+ mapped.TargetDisposition = dto.WindowDispositionNewTab
1738+ mapped.PopupFeatures.State = dto.PopupFeaturesNone
1739+ } else {
1740+ mapped.TargetDisposition = dto.WindowDispositionNewPopup
1741+ }
1736 }1742 }
1737 return mapped1743 return mapped
1738 }1744 }
M internal/infrastructure/webkit/webview_popup_test.go +15 -3
@@ -4,6 +4,7 @@ import (
44 "testing"
55
66 "github.com/bnema/dumber/internal/application/dto"
7+ purewebkit "github.com/bnema/puregotk/v4/webkit"
78 "github.com/stretchr/testify/assert"
89 )
910
@@ -41,16 +42,27 @@ func TestResolvePopupFeatures(t *testing.T) {
4142 }
4243 }
4344
44-func TestMapPopupRequestClassifiesBlankAndDefersAmbiguousScript(t *testing.T) {
45+func TestMapPopupRequestClassifiesLinkBlankAsFeatureless(t *testing.T) {
4546 t.Parallel()
4647
47- blank := mapPopupRequest(PopupRequest{TargetURI: "https://example.com/new", FrameName: " _BLANK ", ParentID: 7})
48+ blank := mapPopupRequest(PopupRequest{
49+ TargetURI: "https://example.com/new", FrameName: " _BLANK ", ParentID: 7,
50+ NavigationType: purewebkit.NavigationTypeLinkClickedValue,
51+ })
4852 assert.Equal(t, dto.BrowserEngineWebKit, blank.Engine)
4953 assert.Equal(t, dto.WindowDispositionNewTab, blank.TargetDisposition)
5054 assert.Equal(t, dto.PopupFeaturesNone, blank.PopupFeatures.State)
55+}
56+
57+func TestMapPopupRequestDefersScriptBlankFeatures(t *testing.T) {
58+ t.Parallel()
5159
52- script := mapPopupRequest(PopupRequest{TargetURI: "https://example.com/popup", FrameName: "oauth", ParentID: 7})
60+ script := mapPopupRequest(PopupRequest{
61+ TargetURI: "https://example.com/popup", FrameName: "_blank", ParentID: 7,
62+ NavigationType: purewebkit.NavigationTypeOtherValue,
63+ })
5364 assert.Equal(t, dto.BrowserEngineWebKit, script.Engine)
65+ assert.Equal(t, dto.WindowDispositionNewPopup, script.TargetDisposition)
5466 assert.Equal(t, dto.PopupFeaturesUnknown, script.PopupFeatures.State)
5567 }
5668
@@ -4,6 +4,7 @@ import (
4 "testing"4 "testing"
5 5
6 "github.com/bnema/dumber/internal/application/dto"6 "github.com/bnema/dumber/internal/application/dto"
7+ purewebkit "github.com/bnema/puregotk/v4/webkit"
7 "github.com/stretchr/testify/assert"8 "github.com/stretchr/testify/assert"
8 )9 )
9 10
@@ -41,16 +42,27 @@ func TestResolvePopupFeatures(t *testing.T) {
41 }42 }
42 }43 }
43 44
44-func TestMapPopupRequestClassifiesBlankAndDefersAmbiguousScript(t *testing.T) {45+func TestMapPopupRequestClassifiesLinkBlankAsFeatureless(t *testing.T) {
45 t.Parallel()46 t.Parallel()
46 47
47- blank := mapPopupRequest(PopupRequest{TargetURI: "https://example.com/new", FrameName: " _BLANK ", ParentID: 7})48+ blank := mapPopupRequest(PopupRequest{
49+ TargetURI: "https://example.com/new", FrameName: " _BLANK ", ParentID: 7,
50+ NavigationType: purewebkit.NavigationTypeLinkClickedValue,
51+ })
48 assert.Equal(t, dto.BrowserEngineWebKit, blank.Engine)52 assert.Equal(t, dto.BrowserEngineWebKit, blank.Engine)
49 assert.Equal(t, dto.WindowDispositionNewTab, blank.TargetDisposition)53 assert.Equal(t, dto.WindowDispositionNewTab, blank.TargetDisposition)
50 assert.Equal(t, dto.PopupFeaturesNone, blank.PopupFeatures.State)54 assert.Equal(t, dto.PopupFeaturesNone, blank.PopupFeatures.State)
55+}
56+
57+func TestMapPopupRequestDefersScriptBlankFeatures(t *testing.T) {
58+ t.Parallel()
51 59
52- script := mapPopupRequest(PopupRequest{TargetURI: "https://example.com/popup", FrameName: "oauth", ParentID: 7})60+ script := mapPopupRequest(PopupRequest{
61+ TargetURI: "https://example.com/popup", FrameName: "_blank", ParentID: 7,
62+ NavigationType: purewebkit.NavigationTypeOtherValue,
63+ })
53 assert.Equal(t, dto.BrowserEngineWebKit, script.Engine)64 assert.Equal(t, dto.BrowserEngineWebKit, script.Engine)
65+ assert.Equal(t, dto.WindowDispositionNewPopup, script.TargetDisposition)
54 assert.Equal(t, dto.PopupFeaturesUnknown, script.PopupFeatures.State)66 assert.Equal(t, dto.PopupFeaturesUnknown, script.PopupFeatures.State)
55 }67 }
56 68
M internal/ui/coordinator/content/browsing_context_policy.go +5 -0
@@ -72,6 +72,11 @@ func decideFloatingHost(decision dto.HostDecision, req dto.NewBrowsingContextReq
7272 decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonFloatingPopupFeatures,
7373 "explicit popup features requested",
7474 )
75+ case reusableBrowsingContextName(req.TargetFrameName) == "":
76+ return completeHostDecision(
77+ decision, dto.HostDecisionNavigateSource, dto.HostDecisionReasonFloatingFeaturelessBlank,
78+ "featureless blank request navigates the source floating pane",
79+ )
7580 default:
7681 return completeHostDecision(
7782 decision, dto.HostDecisionCreateBrowserWindow, dto.HostDecisionReasonFloatingBrowserWindow,
@@ -72,6 +72,11 @@ func decideFloatingHost(decision dto.HostDecision, req dto.NewBrowsingContextReq
72 decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonFloatingPopupFeatures,72 decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonFloatingPopupFeatures,
73 "explicit popup features requested",73 "explicit popup features requested",
74 )74 )
75+ case reusableBrowsingContextName(req.TargetFrameName) == "":
76+ return completeHostDecision(
77+ decision, dto.HostDecisionNavigateSource, dto.HostDecisionReasonFloatingFeaturelessBlank,
78+ "featureless blank request navigates the source floating pane",
79+ )
75 default:80 default:
76 return completeHostDecision(81 return completeHostDecision(
77 decision, dto.HostDecisionCreateBrowserWindow, dto.HostDecisionReasonFloatingBrowserWindow,82 decision, dto.HostDecisionCreateBrowserWindow, dto.HostDecisionReasonFloatingBrowserWindow,
M internal/ui/coordinator/content/browsing_context_policy_test.go +2 -2
@@ -50,8 +50,8 @@ func TestBrowsingContextPolicyDecide(t *testing.T) {
5050 }{
5151 {name: "workspace behavior remains pane hosted", request: workspace, wantKind: dto.HostDecisionCreatePane, wantReason: dto.HostDecisionReasonWorkspacePane},
5252 {name: "zero source preserves workspace behavior", request: dto.NewBrowsingContextRequest{TargetURI: "https://example.com/docs", TriggerKind: dto.TriggerLinkNewPage}, wantKind: dto.HostDecisionCreatePane, wantReason: dto.HostDecisionReasonWorkspacePane},
53- {name: "floating blank target opens browser window", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/docs", TargetDisposition: dto.WindowDispositionNewTab, TriggerKind: dto.TriggerLinkNewPage, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}}, wantKind: dto.HostDecisionCreateBrowserWindow, wantReason: dto.HostDecisionReasonFloatingBrowserWindow},
54- {name: "floating featureless script opens browser window", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerScriptWindowOpen, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}}, wantKind: dto.HostDecisionCreateBrowserWindow, wantReason: dto.HostDecisionReasonFloatingBrowserWindow},
53+ {name: "floating blank target navigates source", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/docs", TargetFrameName: "_blank", TargetDisposition: dto.WindowDispositionNewTab, TriggerKind: dto.TriggerLinkNewPage, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}}, wantKind: dto.HostDecisionNavigateSource, wantReason: dto.HostDecisionReasonFloatingFeaturelessBlank},
54+ {name: "floating featureless script navigates source", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetFrameName: "_blank", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerScriptWindowOpen, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}}, wantKind: dto.HostDecisionNavigateSource, wantReason: dto.HostDecisionReasonFloatingFeaturelessBlank},
5555 {name: "floating featured script opens native popup", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerScriptWindowOpen, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 640, WidthSet: true}}, wantKind: dto.HostDecisionCreateNativePopup, wantReason: dto.HostDecisionReasonFloatingPopupFeatures},
5656 {name: "floating unknown script waits", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerScriptWindowOpen, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}, wantKind: dto.HostDecisionAwaitPopupFeatures, wantReason: dto.HostDecisionReasonFloatingFeaturesPending},
5757 {name: "floating named unknown waits", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetFrameName: "oauth", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerNamedTargetNavigation, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}, wantKind: dto.HostDecisionAwaitPopupFeatures, wantName: "oauth", wantReason: dto.HostDecisionReasonFloatingFeaturesPending},
@@ -50,8 +50,8 @@ func TestBrowsingContextPolicyDecide(t *testing.T) {
50 }{50 }{
51 {name: "workspace behavior remains pane hosted", request: workspace, wantKind: dto.HostDecisionCreatePane, wantReason: dto.HostDecisionReasonWorkspacePane},51 {name: "workspace behavior remains pane hosted", request: workspace, wantKind: dto.HostDecisionCreatePane, wantReason: dto.HostDecisionReasonWorkspacePane},
52 {name: "zero source preserves workspace behavior", request: dto.NewBrowsingContextRequest{TargetURI: "https://example.com/docs", TriggerKind: dto.TriggerLinkNewPage}, wantKind: dto.HostDecisionCreatePane, wantReason: dto.HostDecisionReasonWorkspacePane},52 {name: "zero source preserves workspace behavior", request: dto.NewBrowsingContextRequest{TargetURI: "https://example.com/docs", TriggerKind: dto.TriggerLinkNewPage}, wantKind: dto.HostDecisionCreatePane, wantReason: dto.HostDecisionReasonWorkspacePane},
53- {name: "floating blank target opens browser window", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/docs", TargetDisposition: dto.WindowDispositionNewTab, TriggerKind: dto.TriggerLinkNewPage, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}}, wantKind: dto.HostDecisionCreateBrowserWindow, wantReason: dto.HostDecisionReasonFloatingBrowserWindow},53+ {name: "floating blank target navigates source", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/docs", TargetFrameName: "_blank", TargetDisposition: dto.WindowDispositionNewTab, TriggerKind: dto.TriggerLinkNewPage, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}}, wantKind: dto.HostDecisionNavigateSource, wantReason: dto.HostDecisionReasonFloatingFeaturelessBlank},
54- {name: "floating featureless script opens browser window", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerScriptWindowOpen, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}}, wantKind: dto.HostDecisionCreateBrowserWindow, wantReason: dto.HostDecisionReasonFloatingBrowserWindow},54+ {name: "floating featureless script navigates source", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetFrameName: "_blank", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerScriptWindowOpen, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}}, wantKind: dto.HostDecisionNavigateSource, wantReason: dto.HostDecisionReasonFloatingFeaturelessBlank},
55 {name: "floating featured script opens native popup", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerScriptWindowOpen, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 640, WidthSet: true}}, wantKind: dto.HostDecisionCreateNativePopup, wantReason: dto.HostDecisionReasonFloatingPopupFeatures},55 {name: "floating featured script opens native popup", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerScriptWindowOpen, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 640, WidthSet: true}}, wantKind: dto.HostDecisionCreateNativePopup, wantReason: dto.HostDecisionReasonFloatingPopupFeatures},
56 {name: "floating unknown script waits", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerScriptWindowOpen, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}, wantKind: dto.HostDecisionAwaitPopupFeatures, wantReason: dto.HostDecisionReasonFloatingFeaturesPending},56 {name: "floating unknown script waits", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerScriptWindowOpen, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}, wantKind: dto.HostDecisionAwaitPopupFeatures, wantReason: dto.HostDecisionReasonFloatingFeaturesPending},
57 {name: "floating named unknown waits", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetFrameName: "oauth", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerNamedTargetNavigation, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}, wantKind: dto.HostDecisionAwaitPopupFeatures, wantName: "oauth", wantReason: dto.HostDecisionReasonFloatingFeaturesPending},57 {name: "floating named unknown waits", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetFrameName: "oauth", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerNamedTargetNavigation, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}, wantKind: dto.HostDecisionAwaitPopupFeatures, wantName: "oauth", wantReason: dto.HostDecisionReasonFloatingFeaturesPending},
M internal/ui/coordinator/content/popup_deferred.go +10 -0
@@ -16,6 +16,7 @@ func (pm *popupManager) awaitPopupFeatures(
1616 hooks popupCoordinatorHooks,
1717 parentPaneID entity.PaneID,
1818 parentWebViewID port.WebViewID,
19+ parentWebView port.WebView,
1920 parentURIAtOpen string,
2021 req port.PopupRequest,
2122 decision dto.HostDecision,
@@ -54,6 +55,7 @@ func (pm *popupManager) awaitPopupFeatures(
5455 IsUserGesture: req.IsUserGesture, PopupType: DetectPopupType(req.FrameName), CreatedAt: time.Now(),
5556 },
5657 Request: req, Decision: decision, StagingHost: staging, OwnerWindowID: ownerWindowID,
58+ ParentWebView: parentWebView,
5759 }
5860 pm.storeDeferredPopup(popupWV.ID(), pending)
5961 callbackCtx := logging.WithContext(context.Background(), *log)
@@ -180,6 +182,14 @@ func (pm *popupManager) resolvePendingPopupFeatures(
180182
181183 transferred := false
182184 switch decision.Kind {
185+ case dto.HostDecisionNavigateSource:
186+ if pending.ParentWebView == nil {
187+ logBrowsingContextFailure(*logging.FromContext(ctx), normalized, decision, dto.BrowsingContextFailureHostUnavailable, nil)
188+ break
189+ }
190+ if err := pending.ParentWebView.LoadURI(ctx, request.TargetURI); err != nil {
191+ logBrowsingContextFailure(*logging.FromContext(ctx), normalized, decision, dto.BrowsingContextFailureHostFailed, err)
192+ }
183193 case dto.HostDecisionCreateBrowserWindow:
184194 transferred = pm.openExistingPopupInBrowserWindow(
185195 ctx, hooks, pending.ParentPaneID, pending.ParentWebViewID,
@@ -16,6 +16,7 @@ func (pm *popupManager) awaitPopupFeatures(
16 hooks popupCoordinatorHooks,16 hooks popupCoordinatorHooks,
17 parentPaneID entity.PaneID,17 parentPaneID entity.PaneID,
18 parentWebViewID port.WebViewID,18 parentWebViewID port.WebViewID,
19+ parentWebView port.WebView,
19 parentURIAtOpen string,20 parentURIAtOpen string,
20 req port.PopupRequest,21 req port.PopupRequest,
21 decision dto.HostDecision,22 decision dto.HostDecision,
@@ -54,6 +55,7 @@ func (pm *popupManager) awaitPopupFeatures(
54 IsUserGesture: req.IsUserGesture, PopupType: DetectPopupType(req.FrameName), CreatedAt: time.Now(),55 IsUserGesture: req.IsUserGesture, PopupType: DetectPopupType(req.FrameName), CreatedAt: time.Now(),
55 },56 },
56 Request: req, Decision: decision, StagingHost: staging, OwnerWindowID: ownerWindowID,57 Request: req, Decision: decision, StagingHost: staging, OwnerWindowID: ownerWindowID,
58+ ParentWebView: parentWebView,
57 }59 }
58 pm.storeDeferredPopup(popupWV.ID(), pending)60 pm.storeDeferredPopup(popupWV.ID(), pending)
59 callbackCtx := logging.WithContext(context.Background(), *log)61 callbackCtx := logging.WithContext(context.Background(), *log)
@@ -180,6 +182,14 @@ func (pm *popupManager) resolvePendingPopupFeatures(
180 182
181 transferred := false183 transferred := false
182 switch decision.Kind {184 switch decision.Kind {
185+ case dto.HostDecisionNavigateSource:
186+ if pending.ParentWebView == nil {
187+ logBrowsingContextFailure(*logging.FromContext(ctx), normalized, decision, dto.BrowsingContextFailureHostUnavailable, nil)
188+ break
189+ }
190+ if err := pending.ParentWebView.LoadURI(ctx, request.TargetURI); err != nil {
191+ logBrowsingContextFailure(*logging.FromContext(ctx), normalized, decision, dto.BrowsingContextFailureHostFailed, err)
192+ }
183 case dto.HostDecisionCreateBrowserWindow:193 case dto.HostDecisionCreateBrowserWindow:
184 transferred = pm.openExistingPopupInBrowserWindow(194 transferred = pm.openExistingPopupInBrowserWindow(
185 ctx, hooks, pending.ParentPaneID, pending.ParentWebViewID,195 ctx, hooks, pending.ParentPaneID, pending.ParentWebViewID,
M internal/ui/coordinator/content/popup_manager.go +23 -1
@@ -43,6 +43,7 @@ type deferredPendingPopup struct {
4343 Decision dto.HostDecision
4444 StagingHost PopupStagingHost
4545 OwnerWindowID string
46+ ParentWebView port.WebView
4647 cleanupOnce sync.Once
4748 }
4849
@@ -590,12 +591,24 @@ func (pm *popupManager) handlePopupCreate(
590591 decision.Kind = dto.HostDecisionCreatePane
591592 decision.ReuseContextName = ""
592593 decision.Reason = "named browsing context unavailable; creating replacement pane"
594+ case dto.HostDecisionNavigateSource:
595+ popupWV, err := pm.createPopupWebView(ctx, parentID, req.TargetURI, false)
596+ if err != nil {
597+ logBrowsingContextFailure(*log, request, decision, dto.BrowsingContextFailureHostFailed, err)
598+ return nil
599+ }
600+ pm.setBrowsingContextDecision(popupWV, decision)
601+ popupWV.Destroy()
602+ if err := parentWV.LoadURI(ctx, req.TargetURI); err != nil {
603+ logBrowsingContextFailure(*log, request, decision, dto.BrowsingContextFailureHostFailed, err)
604+ }
605+ return nil
593606 case dto.HostDecisionCreateBrowserWindow:
594607 return pm.openBrowserWindow(ctx, hooks, parentPaneID, parentID, req, decision, pm.readyOnCreate(req.Engine))
595608 case dto.HostDecisionCreateNativePopup:
596609 return pm.openNativePopup(ctx, hooks, parentPaneID, parentID, parentURIAtOpen, req, decision)
597610 case dto.HostDecisionAwaitPopupFeatures:
598- return pm.awaitPopupFeatures(ctx, hooks, parentPaneID, parentID, parentURIAtOpen, req, decision)
611+ return pm.awaitPopupFeatures(ctx, hooks, parentPaneID, parentID, parentWV, parentURIAtOpen, req, decision)
599612 case dto.HostDecisionCreatePane:
600613 // Continue below.
601614 break
@@ -918,6 +931,15 @@ func (pm *popupManager) handleLinkMiddleClick(
918931 Str("decision", string(decision.Kind)).
919932 Str("reason", decision.Reason).
920933 Msg("middle-click browsing context host decision")
934+ if decision.Kind == dto.HostDecisionNavigateSource {
935+ if err := parentWV.LoadURI(ctx, uri); err != nil {
936+ log.Error().Err(err).
937+ Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
938+ Msg("failed to load URI in source floating pane")
939+ return false
940+ }
941+ return true
942+ }
921943 if decision.Kind == dto.HostDecisionCreateBrowserWindow {
922944 return pm.openMiddleClickBrowserWindow(ctx, hooks, parentPaneID, parentWV, uri, decision)
923945 }
@@ -43,6 +43,7 @@ type deferredPendingPopup struct {
43 Decision dto.HostDecision43 Decision dto.HostDecision
44 StagingHost PopupStagingHost44 StagingHost PopupStagingHost
45 OwnerWindowID string45 OwnerWindowID string
46+ ParentWebView port.WebView
46 cleanupOnce sync.Once47 cleanupOnce sync.Once
47 }48 }
48 49
@@ -590,12 +591,24 @@ func (pm *popupManager) handlePopupCreate(
590 decision.Kind = dto.HostDecisionCreatePane591 decision.Kind = dto.HostDecisionCreatePane
591 decision.ReuseContextName = ""592 decision.ReuseContextName = ""
592 decision.Reason = "named browsing context unavailable; creating replacement pane"593 decision.Reason = "named browsing context unavailable; creating replacement pane"
594+ case dto.HostDecisionNavigateSource:
595+ popupWV, err := pm.createPopupWebView(ctx, parentID, req.TargetURI, false)
596+ if err != nil {
597+ logBrowsingContextFailure(*log, request, decision, dto.BrowsingContextFailureHostFailed, err)
598+ return nil
599+ }
600+ pm.setBrowsingContextDecision(popupWV, decision)
601+ popupWV.Destroy()
602+ if err := parentWV.LoadURI(ctx, req.TargetURI); err != nil {
603+ logBrowsingContextFailure(*log, request, decision, dto.BrowsingContextFailureHostFailed, err)
604+ }
605+ return nil
593 case dto.HostDecisionCreateBrowserWindow:606 case dto.HostDecisionCreateBrowserWindow:
594 return pm.openBrowserWindow(ctx, hooks, parentPaneID, parentID, req, decision, pm.readyOnCreate(req.Engine))607 return pm.openBrowserWindow(ctx, hooks, parentPaneID, parentID, req, decision, pm.readyOnCreate(req.Engine))
595 case dto.HostDecisionCreateNativePopup:608 case dto.HostDecisionCreateNativePopup:
596 return pm.openNativePopup(ctx, hooks, parentPaneID, parentID, parentURIAtOpen, req, decision)609 return pm.openNativePopup(ctx, hooks, parentPaneID, parentID, parentURIAtOpen, req, decision)
597 case dto.HostDecisionAwaitPopupFeatures:610 case dto.HostDecisionAwaitPopupFeatures:
598- return pm.awaitPopupFeatures(ctx, hooks, parentPaneID, parentID, parentURIAtOpen, req, decision)611+ return pm.awaitPopupFeatures(ctx, hooks, parentPaneID, parentID, parentWV, parentURIAtOpen, req, decision)
599 case dto.HostDecisionCreatePane:612 case dto.HostDecisionCreatePane:
600 // Continue below.613 // Continue below.
601 break614 break
@@ -918,6 +931,15 @@ func (pm *popupManager) handleLinkMiddleClick(
918 Str("decision", string(decision.Kind)).931 Str("decision", string(decision.Kind)).
919 Str("reason", decision.Reason).932 Str("reason", decision.Reason).
920 Msg("middle-click browsing context host decision")933 Msg("middle-click browsing context host decision")
934+ if decision.Kind == dto.HostDecisionNavigateSource {
935+ if err := parentWV.LoadURI(ctx, uri); err != nil {
936+ log.Error().Err(err).
937+ Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
938+ Msg("failed to load URI in source floating pane")
939+ return false
940+ }
941+ return true
942+ }
921 if decision.Kind == dto.HostDecisionCreateBrowserWindow {943 if decision.Kind == dto.HostDecisionCreateBrowserWindow {
922 return pm.openMiddleClickBrowserWindow(ctx, hooks, parentPaneID, parentWV, uri, decision)944 return pm.openMiddleClickBrowserWindow(ctx, hooks, parentPaneID, parentWV, uri, decision)
923 }945 }
M internal/ui/coordinator/content/popup_test.go +56 -44
@@ -155,15 +155,14 @@ func newPopupCreateCoordinatorForTest(t *testing.T, popupID port.WebViewID) (con
155155 return ctx, parentPaneID, parentWV, popupWV, c
156156 }
157157
158-func TestPopupFloatingBlankOpensBrowserWindowWhenWorkspacePopupsDisabled(t *testing.T) {
158+func TestPopupFloatingWebKitLinkBlankNavigatesSourceAndDestroysCandidateExactlyOnce(t *testing.T) {
159159 ctx := context.Background()
160160 parentPaneID := entity.PaneID("floating")
161161 parentWV := mocks.NewMockWebView(t)
162162 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
163- popupWV := mocks.NewMockWebView(t)
164- popupWV.EXPECT().ID().Return(port.WebViewID(201)).Maybe()
165- popupWV.EXPECT().Generation().Return(uint64(1)).Maybe()
166- popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
163+ parentWV.EXPECT().LoadURI(mock.Anything, "https://example.com/new").Return(nil).Once()
164+ popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
165+ popupWV.EXPECT().Destroy().Once()
167166
168167 factory := mocks.NewMockWebViewFactory(t)
169168 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
@@ -174,39 +173,32 @@ func TestPopupFloatingBlankOpensBrowserWindowWhenWorkspacePopupsDisabled(t *test
174173 t.Fatal("floating request must not use workspace insertion")
175174 return nil
176175 })
177- called := false
178- c.SetOnOpenBrowserWindow(func(_ context.Context, input BrowserWindowInput) (BrowserWindowResult, error) {
179- called = true
180- assert.Equal(t, parentPaneID, input.ParentPaneID)
181- assert.Same(t, popupWV, input.PopupWebView)
182- assert.Equal(t, "https://example.com/new", input.TargetURI)
183- return BrowserWindowResult{WindowID: "detached-window"}, nil
176+ c.SetOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) {
177+ t.Fatal("featureless _blank must not open a browser window")
178+ return BrowserWindowResult{}, nil
184179 })
185180
186181 got := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
187- Engine: dto.BrowserEngineCEF,
182+ Engine: dto.BrowserEngineWebKit,
188183 TargetURI: "https://example.com/new",
189184 FrameName: "_blank",
190185 TargetDisposition: dto.WindowDispositionNewTab,
191186 PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone},
192187 })
193188
194- assert.Same(t, popupWV, got)
195- assert.True(t, called)
189+ assert.Nil(t, got, "featureless link popup must be discarded after source navigation")
190+ decision, ok := popupWV.BrowsingContextHostDecision()
191+ require.True(t, ok)
192+ assert.Equal(t, dto.HostDecisionNavigateSource, decision.Kind)
196193 }
197194
198-func TestPopupFloatingMiddleClickOpensBrowserWindowWhenWorkspacePopupsDisabled(t *testing.T) {
195+func TestPopupFloatingMiddleClickNavigatesSourceWhenWorkspacePopupsDisabled(t *testing.T) {
199196 ctx := context.Background()
200197 parentPaneID := entity.PaneID("floating")
201198 parentWV := mocks.NewMockWebView(t)
202- parentWV.EXPECT().ID().Return(port.WebViewID(101)).Maybe()
203- popupWV := mocks.NewMockWebView(t)
204- popupWV.EXPECT().ID().Return(port.WebViewID(202)).Maybe()
205- popupWV.EXPECT().Generation().Return(uint64(1)).Maybe()
206- popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
207- popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/middle").Return(nil).Once()
199+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
200+ parentWV.EXPECT().LoadURI(mock.Anything, "https://example.com/middle").Return(nil).Once()
208201 factory := mocks.NewMockWebViewFactory(t)
209- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
210202 c := &Coordinator{webViews: map[entity.PaneID]port.WebView{parentPaneID: parentWV}, popups: newPopupManager()}
211203 c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "detached-pane" })
212204 c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
@@ -214,9 +206,9 @@ func TestPopupFloatingMiddleClickOpensBrowserWindowWhenWorkspacePopupsDisabled(t
214206 t.Fatal("floating middle-click must not use workspace insertion")
215207 return nil
216208 })
217- c.SetOnOpenBrowserWindow(func(_ context.Context, input BrowserWindowInput) (BrowserWindowResult, error) {
218- assert.Same(t, popupWV, input.PopupWebView)
219- return BrowserWindowResult{WindowID: "detached-window"}, nil
209+ c.SetOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) {
210+ t.Fatal("floating middle-click must not open a browser window")
211+ return BrowserWindowResult{}, nil
220212 })
221213
222214 assert.True(t, c.handleLinkMiddleClick(ctx, parentPaneID, "https://example.com/middle"))
@@ -342,8 +334,8 @@ func TestPopupFloatingBrowserHostFailureDestroysWebViewExactlyOnce(t *testing.T)
342334 })
343335
344336 got := c.handlePopupCreate(context.Background(), "floating", parentWV, port.PopupRequest{
345- Engine: dto.BrowserEngineCEF, TargetURI: "https://example.com/new", FrameName: "_blank",
346- TargetDisposition: dto.WindowDispositionNewTab, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone},
337+ Engine: dto.BrowserEngineCEF, TargetURI: "https://example.com/new", FrameName: "shared",
338+ TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone},
347339 })
348340
349341 assert.Nil(t, got)
@@ -1233,14 +1225,14 @@ func TestPendingPopups_ConcurrentDeleteAndRead(t *testing.T) {
12331225 assert.Empty(t, c.popups.pendingPopups, "all preloaded popups should have been deleted")
12341226 }
12351227
1236-func TestPopupDeferredFeaturelessDetachesBeforeBrowserHost(t *testing.T) {
1228+func TestPopupDeferredFeaturelessBlankNavigatesSourceAndCleansStagingExactlyOnce(t *testing.T) {
12371229 ctx := context.Background()
12381230 parent := mocks.NewMockWebView(t)
12391231 parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
1232+ parent.EXPECT().LoadURI(mock.Anything, "https://example.com/open").Return(nil).Once()
12401233 popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}}
12411234 popup.EXPECT().ID().Return(port.WebViewID(201)).Maybe()
1242- popup.EXPECT().Generation().Return(uint64(1)).Maybe()
1243- popup.EXPECT().SetCallbacks(mock.Anything).Once()
1235+ popup.EXPECT().Destroy().Once()
12441236 factory := mocks.NewMockWebViewFactory(t)
12451237 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
12461238 staging := &popupStagingHostStub{}
@@ -1251,24 +1243,26 @@ func TestPopupDeferredFeaturelessDetachesBeforeBrowserHost(t *testing.T) {
12511243 staging.attached = true
12521244 return staging, nil
12531245 })
1254- c.SetOnOpenBrowserWindow(func(_ context.Context, input BrowserWindowInput) (BrowserWindowResult, error) {
1255- assert.True(t, staging.detached, "staging must detach before final host")
1256- assert.True(t, input.Ready)
1257- assert.Same(t, popup, input.PopupWebView)
1258- return BrowserWindowResult{WindowID: "browser"}, nil
1246+ c.SetOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) {
1247+ t.Fatal("featureless _blank must not open a browser window")
1248+ return BrowserWindowResult{}, nil
12591249 })
12601250 c.SetOnOpenNativePopup(func(context.Context, NativePopupInput) error {
1261- t.Fatal("featureless popup must not use native host")
1251+ t.Fatal("featureless _blank must not use native host")
12621252 return nil
12631253 })
12641254
1265- got := c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}})
1255+ got := c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{
1256+ Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", FrameName: "_blank",
1257+ TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
1258+ })
12661259 require.Same(t, popup, got)
12671260 assert.True(t, staging.attached)
12681261 assert.False(t, staging.detached)
12691262 popup.ready()
1263+ popup.ready()
12701264 assert.True(t, staging.detached)
1271- assert.Zero(t, staging.destroyCalls)
1265+ assert.Equal(t, 1, staging.destroyCalls)
12721266 }
12731267
12741268 func TestPopupDeferredFeaturedDetachesBeforeNativeHost(t *testing.T) {
@@ -1294,8 +1288,14 @@ func TestPopupDeferredFeaturedDetachesBeforeNativeHost(t *testing.T) {
12941288 return nil
12951289 })
12961290
1297- require.Same(t, popup, c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}))
1291+ require.Same(t, popup, c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{
1292+ Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", FrameName: "_blank",
1293+ TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
1294+ }))
12981295 popup.ready()
1296+ decision, ok := popup.BrowsingContextHostDecision()
1297+ require.True(t, ok)
1298+ assert.Equal(t, dto.HostDecisionCreateNativePopup, decision.Kind)
12991299 }
13001300
13011301 func TestPopupDeferredNamedReuseDestroysNewlyStagedWebViewExactlyOnce(t *testing.T) {
@@ -1393,7 +1393,10 @@ func TestPopupDeferredBrowserHostFailureCleansUpExactlyOnce(t *testing.T) {
13931393 return BrowserWindowResult{}, errors.New("host failed")
13941394 })
13951395
1396- require.Same(t, popup, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}))
1396+ require.Same(t, popup, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{
1397+ Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", FrameName: "shared",
1398+ TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
1399+ }))
13971400 popup.ready()
13981401 popup.ready()
13991402 assert.True(t, staging.detached)
@@ -1497,9 +1500,11 @@ func TestPopupDeferredUnknownAndCloseCleanupExactlyOnce(t *testing.T) {
14971500 // deferredPopupWebViewStub exposes the optional late-feature and lifecycle ports.
14981501 type deferredPopupWebViewStub struct {
14991502 *mocks.MockWebView
1500- features dto.PopupFeatures
1501- onReady func()
1502- onClose func()
1503+ features dto.PopupFeatures
1504+ onReady func()
1505+ onClose func()
1506+ browsingContextDecision dto.HostDecision
1507+ hasBrowsingContextDecision bool
15031508 }
15041509
15051510 func (s *deferredPopupWebViewStub) ResolvePopupFeatures() dto.PopupFeatures { return s.features }
@@ -1507,6 +1512,13 @@ func (s *deferredPopupWebViewStub) PrimePopupNavigation(string) {}
15071512 func (s *deferredPopupWebViewStub) SetOnReadyToShow(fn func()) { s.onReady = fn }
15081513 func (s *deferredPopupWebViewStub) SetOnClose(fn func()) { s.onClose = fn }
15091514 func (s *deferredPopupWebViewStub) Show() {}
1515+func (s *deferredPopupWebViewStub) SetBrowsingContextHostDecision(decision dto.HostDecision) {
1516+ s.browsingContextDecision = decision
1517+ s.hasBrowsingContextDecision = true
1518+}
1519+func (s *deferredPopupWebViewStub) BrowsingContextHostDecision() (dto.HostDecision, bool) {
1520+ return s.browsingContextDecision, s.hasBrowsingContextDecision
1521+}
15101522 func (s *deferredPopupWebViewStub) ready() {
15111523 if s.onReady != nil {
15121524 s.onReady()
@@ -155,15 +155,14 @@ func newPopupCreateCoordinatorForTest(t *testing.T, popupID port.WebViewID) (con
155 return ctx, parentPaneID, parentWV, popupWV, c155 return ctx, parentPaneID, parentWV, popupWV, c
156 }156 }
157 157
158-func TestPopupFloatingBlankOpensBrowserWindowWhenWorkspacePopupsDisabled(t *testing.T) {158+func TestPopupFloatingWebKitLinkBlankNavigatesSourceAndDestroysCandidateExactlyOnce(t *testing.T) {
159 ctx := context.Background()159 ctx := context.Background()
160 parentPaneID := entity.PaneID("floating")160 parentPaneID := entity.PaneID("floating")
161 parentWV := mocks.NewMockWebView(t)161 parentWV := mocks.NewMockWebView(t)
162 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()162 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
163- popupWV := mocks.NewMockWebView(t)163+ parentWV.EXPECT().LoadURI(mock.Anything, "https://example.com/new").Return(nil).Once()
164- popupWV.EXPECT().ID().Return(port.WebViewID(201)).Maybe()164+ popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
165- popupWV.EXPECT().Generation().Return(uint64(1)).Maybe()165+ popupWV.EXPECT().Destroy().Once()
166- popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
167 166
168 factory := mocks.NewMockWebViewFactory(t)167 factory := mocks.NewMockWebViewFactory(t)
169 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()168 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
@@ -174,39 +173,32 @@ func TestPopupFloatingBlankOpensBrowserWindowWhenWorkspacePopupsDisabled(t *test
174 t.Fatal("floating request must not use workspace insertion")173 t.Fatal("floating request must not use workspace insertion")
175 return nil174 return nil
176 })175 })
177- called := false176+ c.SetOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) {
178- c.SetOnOpenBrowserWindow(func(_ context.Context, input BrowserWindowInput) (BrowserWindowResult, error) {177+ t.Fatal("featureless _blank must not open a browser window")
179- called = true178+ return BrowserWindowResult{}, nil
180- assert.Equal(t, parentPaneID, input.ParentPaneID)
181- assert.Same(t, popupWV, input.PopupWebView)
182- assert.Equal(t, "https://example.com/new", input.TargetURI)
183- return BrowserWindowResult{WindowID: "detached-window"}, nil
184 })179 })
185 180
186 got := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{181 got := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
187- Engine: dto.BrowserEngineCEF,182+ Engine: dto.BrowserEngineWebKit,
188 TargetURI: "https://example.com/new",183 TargetURI: "https://example.com/new",
189 FrameName: "_blank",184 FrameName: "_blank",
190 TargetDisposition: dto.WindowDispositionNewTab,185 TargetDisposition: dto.WindowDispositionNewTab,
191 PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone},186 PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone},
192 })187 })
193 188
194- assert.Same(t, popupWV, got)189+ assert.Nil(t, got, "featureless link popup must be discarded after source navigation")
195- assert.True(t, called)190+ decision, ok := popupWV.BrowsingContextHostDecision()
191+ require.True(t, ok)
192+ assert.Equal(t, dto.HostDecisionNavigateSource, decision.Kind)
196 }193 }
197 194
198-func TestPopupFloatingMiddleClickOpensBrowserWindowWhenWorkspacePopupsDisabled(t *testing.T) {195+func TestPopupFloatingMiddleClickNavigatesSourceWhenWorkspacePopupsDisabled(t *testing.T) {
199 ctx := context.Background()196 ctx := context.Background()
200 parentPaneID := entity.PaneID("floating")197 parentPaneID := entity.PaneID("floating")
201 parentWV := mocks.NewMockWebView(t)198 parentWV := mocks.NewMockWebView(t)
202- parentWV.EXPECT().ID().Return(port.WebViewID(101)).Maybe()199+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
203- popupWV := mocks.NewMockWebView(t)200+ parentWV.EXPECT().LoadURI(mock.Anything, "https://example.com/middle").Return(nil).Once()
204- popupWV.EXPECT().ID().Return(port.WebViewID(202)).Maybe()
205- popupWV.EXPECT().Generation().Return(uint64(1)).Maybe()
206- popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
207- popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/middle").Return(nil).Once()
208 factory := mocks.NewMockWebViewFactory(t)201 factory := mocks.NewMockWebViewFactory(t)
209- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
210 c := &Coordinator{webViews: map[entity.PaneID]port.WebView{parentPaneID: parentWV}, popups: newPopupManager()}202 c := &Coordinator{webViews: map[entity.PaneID]port.WebView{parentPaneID: parentWV}, popups: newPopupManager()}
211 c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "detached-pane" })203 c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "detached-pane" })
212 c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })204 c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
@@ -214,9 +206,9 @@ func TestPopupFloatingMiddleClickOpensBrowserWindowWhenWorkspacePopupsDisabled(t
214 t.Fatal("floating middle-click must not use workspace insertion")206 t.Fatal("floating middle-click must not use workspace insertion")
215 return nil207 return nil
216 })208 })
217- c.SetOnOpenBrowserWindow(func(_ context.Context, input BrowserWindowInput) (BrowserWindowResult, error) {209+ c.SetOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) {
218- assert.Same(t, popupWV, input.PopupWebView)210+ t.Fatal("floating middle-click must not open a browser window")
219- return BrowserWindowResult{WindowID: "detached-window"}, nil211+ return BrowserWindowResult{}, nil
220 })212 })
221 213
222 assert.True(t, c.handleLinkMiddleClick(ctx, parentPaneID, "https://example.com/middle"))214 assert.True(t, c.handleLinkMiddleClick(ctx, parentPaneID, "https://example.com/middle"))
@@ -342,8 +334,8 @@ func TestPopupFloatingBrowserHostFailureDestroysWebViewExactlyOnce(t *testing.T)
342 })334 })
343 335
344 got := c.handlePopupCreate(context.Background(), "floating", parentWV, port.PopupRequest{336 got := c.handlePopupCreate(context.Background(), "floating", parentWV, port.PopupRequest{
345- Engine: dto.BrowserEngineCEF, TargetURI: "https://example.com/new", FrameName: "_blank",337+ Engine: dto.BrowserEngineCEF, TargetURI: "https://example.com/new", FrameName: "shared",
346- TargetDisposition: dto.WindowDispositionNewTab, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone},338+ TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone},
347 })339 })
348 340
349 assert.Nil(t, got)341 assert.Nil(t, got)
@@ -1233,14 +1225,14 @@ func TestPendingPopups_ConcurrentDeleteAndRead(t *testing.T) {
1233 assert.Empty(t, c.popups.pendingPopups, "all preloaded popups should have been deleted")1225 assert.Empty(t, c.popups.pendingPopups, "all preloaded popups should have been deleted")
1234 }1226 }
1235 1227
1236-func TestPopupDeferredFeaturelessDetachesBeforeBrowserHost(t *testing.T) {1228+func TestPopupDeferredFeaturelessBlankNavigatesSourceAndCleansStagingExactlyOnce(t *testing.T) {
1237 ctx := context.Background()1229 ctx := context.Background()
1238 parent := mocks.NewMockWebView(t)1230 parent := mocks.NewMockWebView(t)
1239 parent.EXPECT().ID().Return(port.WebViewID(101)).Once()1231 parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
1232+ parent.EXPECT().LoadURI(mock.Anything, "https://example.com/open").Return(nil).Once()
1240 popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}}1233 popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}}
1241 popup.EXPECT().ID().Return(port.WebViewID(201)).Maybe()1234 popup.EXPECT().ID().Return(port.WebViewID(201)).Maybe()
1242- popup.EXPECT().Generation().Return(uint64(1)).Maybe()1235+ popup.EXPECT().Destroy().Once()
1243- popup.EXPECT().SetCallbacks(mock.Anything).Once()
1244 factory := mocks.NewMockWebViewFactory(t)1236 factory := mocks.NewMockWebViewFactory(t)
1245 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()1237 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
1246 staging := &popupStagingHostStub{}1238 staging := &popupStagingHostStub{}
@@ -1251,24 +1243,26 @@ func TestPopupDeferredFeaturelessDetachesBeforeBrowserHost(t *testing.T) {
1251 staging.attached = true1243 staging.attached = true
1252 return staging, nil1244 return staging, nil
1253 })1245 })
1254- c.SetOnOpenBrowserWindow(func(_ context.Context, input BrowserWindowInput) (BrowserWindowResult, error) {1246+ c.SetOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) {
1255- assert.True(t, staging.detached, "staging must detach before final host")1247+ t.Fatal("featureless _blank must not open a browser window")
1256- assert.True(t, input.Ready)1248+ return BrowserWindowResult{}, nil
1257- assert.Same(t, popup, input.PopupWebView)
1258- return BrowserWindowResult{WindowID: "browser"}, nil
1259 })1249 })
1260 c.SetOnOpenNativePopup(func(context.Context, NativePopupInput) error {1250 c.SetOnOpenNativePopup(func(context.Context, NativePopupInput) error {
1261- t.Fatal("featureless popup must not use native host")1251+ t.Fatal("featureless _blank must not use native host")
1262 return nil1252 return nil
1263 })1253 })
1264 1254
1265- got := c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}})1255+ got := c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{
1256+ Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", FrameName: "_blank",
1257+ TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
1258+ })
1266 require.Same(t, popup, got)1259 require.Same(t, popup, got)
1267 assert.True(t, staging.attached)1260 assert.True(t, staging.attached)
1268 assert.False(t, staging.detached)1261 assert.False(t, staging.detached)
1269 popup.ready()1262 popup.ready()
1263+ popup.ready()
1270 assert.True(t, staging.detached)1264 assert.True(t, staging.detached)
1271- assert.Zero(t, staging.destroyCalls)1265+ assert.Equal(t, 1, staging.destroyCalls)
1272 }1266 }
1273 1267
1274 func TestPopupDeferredFeaturedDetachesBeforeNativeHost(t *testing.T) {1268 func TestPopupDeferredFeaturedDetachesBeforeNativeHost(t *testing.T) {
@@ -1294,8 +1288,14 @@ func TestPopupDeferredFeaturedDetachesBeforeNativeHost(t *testing.T) {
1294 return nil1288 return nil
1295 })1289 })
1296 1290
1297- require.Same(t, popup, c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}))1291+ require.Same(t, popup, c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{
1292+ Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", FrameName: "_blank",
1293+ TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
1294+ }))
1298 popup.ready()1295 popup.ready()
1296+ decision, ok := popup.BrowsingContextHostDecision()
1297+ require.True(t, ok)
1298+ assert.Equal(t, dto.HostDecisionCreateNativePopup, decision.Kind)
1299 }1299 }
1300 1300
1301 func TestPopupDeferredNamedReuseDestroysNewlyStagedWebViewExactlyOnce(t *testing.T) {1301 func TestPopupDeferredNamedReuseDestroysNewlyStagedWebViewExactlyOnce(t *testing.T) {
@@ -1393,7 +1393,10 @@ func TestPopupDeferredBrowserHostFailureCleansUpExactlyOnce(t *testing.T) {
1393 return BrowserWindowResult{}, errors.New("host failed")1393 return BrowserWindowResult{}, errors.New("host failed")
1394 })1394 })
1395 1395
1396- require.Same(t, popup, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}))1396+ require.Same(t, popup, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{
1397+ Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", FrameName: "shared",
1398+ TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
1399+ }))
1397 popup.ready()1400 popup.ready()
1398 popup.ready()1401 popup.ready()
1399 assert.True(t, staging.detached)1402 assert.True(t, staging.detached)
@@ -1497,9 +1500,11 @@ func TestPopupDeferredUnknownAndCloseCleanupExactlyOnce(t *testing.T) {
1497 // deferredPopupWebViewStub exposes the optional late-feature and lifecycle ports.1500 // deferredPopupWebViewStub exposes the optional late-feature and lifecycle ports.
1498 type deferredPopupWebViewStub struct {1501 type deferredPopupWebViewStub struct {
1499 *mocks.MockWebView1502 *mocks.MockWebView
1500- features dto.PopupFeatures1503+ features dto.PopupFeatures
1501- onReady func()1504+ onReady func()
1502- onClose func()1505+ onClose func()
1506+ browsingContextDecision dto.HostDecision
1507+ hasBrowsingContextDecision bool
1503 }1508 }
1504 1509
1505 func (s *deferredPopupWebViewStub) ResolvePopupFeatures() dto.PopupFeatures { return s.features }1510 func (s *deferredPopupWebViewStub) ResolvePopupFeatures() dto.PopupFeatures { return s.features }
@@ -1507,6 +1512,13 @@ func (s *deferredPopupWebViewStub) PrimePopupNavigation(string) {}
1507 func (s *deferredPopupWebViewStub) SetOnReadyToShow(fn func()) { s.onReady = fn }1512 func (s *deferredPopupWebViewStub) SetOnReadyToShow(fn func()) { s.onReady = fn }
1508 func (s *deferredPopupWebViewStub) SetOnClose(fn func()) { s.onClose = fn }1513 func (s *deferredPopupWebViewStub) SetOnClose(fn func()) { s.onClose = fn }
1509 func (s *deferredPopupWebViewStub) Show() {}1514 func (s *deferredPopupWebViewStub) Show() {}
1515+func (s *deferredPopupWebViewStub) SetBrowsingContextHostDecision(decision dto.HostDecision) {
1516+ s.browsingContextDecision = decision
1517+ s.hasBrowsingContextDecision = true
1518+}
1519+func (s *deferredPopupWebViewStub) BrowsingContextHostDecision() (dto.HostDecision, bool) {
1520+ return s.browsingContextDecision, s.hasBrowsingContextDecision
1521+}
1510 func (s *deferredPopupWebViewStub) ready() {1522 func (s *deferredPopupWebViewStub) ready() {
1511 if s.onReady != nil {1523 if s.onReady != nil {
1512 s.onReady()1524 s.onReady()

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