detached 41ddc34

feat(workspace): route external links by configuration

commit
41ddc3471679f4f2c467027f4bf3ea6ecda35563
author
brice · · 2026-08-13 03:41 UTC
committer
brice · · 2026-08-13 04:39 UTC
parent
2aeff58
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
24 files · +726 -142
M cmd/dumber/main.go +2 -2
@@ -100,7 +100,7 @@ func tryForwardBrowseURLToRunningInstance(ctx context.Context, relay port.Browse
100100 return false, nil
101101 }
102102
103- delivered, err := relay.DeliverOpenFreshWindow(ctx, browseURL)
103+ delivered, err := relay.DeliverOpenExternalURL(ctx, browseURL)
104104 if err != nil {
105105 if delivered && errors.Is(err, desktop.ErrBrowserLaunchRelayUnconfirmed) {
106106 return true, nil
@@ -297,7 +297,7 @@ func runGUI(cfg *config.Config, timing startupTiming) int {
297297 }
298298 if relaunchSetter, ok := engine.(port.AlreadyRunningAppRelaunchHandlerSetter); ok {
299299 relaunchSetter.SetAlreadyRunningAppRelaunchHandler(func(url string) {
300- if err := app.OpenFreshWindow(ctx, url); err != nil {
300+ if err := app.OpenExternalURL(ctx, url); err != nil {
301301 log.Warn().Err(err).Str("url", url).Msg("failed to open relaunch browser window")
302302 }
303303 })
@@ -100,7 +100,7 @@ func tryForwardBrowseURLToRunningInstance(ctx context.Context, relay port.Browse
100 return false, nil100 return false, nil
101 }101 }
102 102
103- delivered, err := relay.DeliverOpenFreshWindow(ctx, browseURL)103+ delivered, err := relay.DeliverOpenExternalURL(ctx, browseURL)
104 if err != nil {104 if err != nil {
105 if delivered && errors.Is(err, desktop.ErrBrowserLaunchRelayUnconfirmed) {105 if delivered && errors.Is(err, desktop.ErrBrowserLaunchRelayUnconfirmed) {
106 return true, nil106 return true, nil
@@ -297,7 +297,7 @@ func runGUI(cfg *config.Config, timing startupTiming) int {
297 }297 }
298 if relaunchSetter, ok := engine.(port.AlreadyRunningAppRelaunchHandlerSetter); ok {298 if relaunchSetter, ok := engine.(port.AlreadyRunningAppRelaunchHandlerSetter); ok {
299 relaunchSetter.SetAlreadyRunningAppRelaunchHandler(func(url string) {299 relaunchSetter.SetAlreadyRunningAppRelaunchHandler(func(url string) {
300- if err := app.OpenFreshWindow(ctx, url); err != nil {300+ if err := app.OpenExternalURL(ctx, url); err != nil {
301 log.Warn().Err(err).Str("url", url).Msg("failed to open relaunch browser window")301 log.Warn().Err(err).Str("url", url).Msg("failed to open relaunch browser window")
302 }302 }
303 })303 })
M cmd/dumber/main_test.go +5 -5
@@ -230,7 +230,7 @@ func TestPreInitializeAdwaitaForCEF_SkipsNonCEF(t *testing.T) {
230230
231231 func TestTryForwardBrowseURLToRunningInstance_ForwardsDefaultStartupURL(t *testing.T) {
232232 relay := mocks.NewMockBrowserLaunchRelay(t)
233- relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "dumb://history").Return(true, nil)
233+ relay.EXPECT().DeliverOpenExternalURL(context.Background(), "dumb://history").Return(true, nil)
234234
235235 forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "dumb://history")
236236
@@ -244,7 +244,7 @@ func TestTryForwardBrowseURLToRunningInstance_ForwardsDefaultStartupURL(t *testi
244244
245245 func TestTryForwardBrowseURLToRunningInstance_ReturnsTrueOnRelayHit(t *testing.T) {
246246 relay := mocks.NewMockBrowserLaunchRelay(t)
247- relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(true, nil)
247+ relay.EXPECT().DeliverOpenExternalURL(context.Background(), "https://example.com").Return(true, nil)
248248
249249 forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com")
250250
@@ -258,7 +258,7 @@ func TestTryForwardBrowseURLToRunningInstance_ReturnsTrueOnRelayHit(t *testing.T
258258
259259 func TestTryForwardBrowseURLToRunningInstance_ReturnsFalseOnRelayMiss(t *testing.T) {
260260 relay := mocks.NewMockBrowserLaunchRelay(t)
261- relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(false, nil)
261+ relay.EXPECT().DeliverOpenExternalURL(context.Background(), "https://example.com").Return(false, nil)
262262
263263 forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com")
264264
@@ -272,7 +272,7 @@ func TestTryForwardBrowseURLToRunningInstance_ReturnsFalseOnRelayMiss(t *testing
272272
273273 func TestTryForwardBrowseURLToRunningInstance_ReturnsTrueOnUnconfirmedRelayDelivery(t *testing.T) {
274274 relay := mocks.NewMockBrowserLaunchRelay(t)
275- relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(true, desktop.ErrBrowserLaunchRelayUnconfirmed)
275+ relay.EXPECT().DeliverOpenExternalURL(context.Background(), "https://example.com").Return(true, desktop.ErrBrowserLaunchRelayUnconfirmed)
276276
277277 forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com")
278278
@@ -309,7 +309,7 @@ func TestLaunchStandaloneBrowserURL_PropagatesOtherErrors(t *testing.T) {
309309 func TestTryForwardBrowseURLToRunningInstance_PropagatesError(t *testing.T) {
310310 relay := mocks.NewMockBrowserLaunchRelay(t)
311311 wantErr := errors.New("relay error")
312- relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(false, wantErr)
312+ relay.EXPECT().DeliverOpenExternalURL(context.Background(), "https://example.com").Return(false, wantErr)
313313
314314 forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com")
315315
@@ -230,7 +230,7 @@ func TestPreInitializeAdwaitaForCEF_SkipsNonCEF(t *testing.T) {
230 230
231 func TestTryForwardBrowseURLToRunningInstance_ForwardsDefaultStartupURL(t *testing.T) {231 func TestTryForwardBrowseURLToRunningInstance_ForwardsDefaultStartupURL(t *testing.T) {
232 relay := mocks.NewMockBrowserLaunchRelay(t)232 relay := mocks.NewMockBrowserLaunchRelay(t)
233- relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "dumb://history").Return(true, nil)233+ relay.EXPECT().DeliverOpenExternalURL(context.Background(), "dumb://history").Return(true, nil)
234 234
235 forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "dumb://history")235 forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "dumb://history")
236 236
@@ -244,7 +244,7 @@ func TestTryForwardBrowseURLToRunningInstance_ForwardsDefaultStartupURL(t *testi
244 244
245 func TestTryForwardBrowseURLToRunningInstance_ReturnsTrueOnRelayHit(t *testing.T) {245 func TestTryForwardBrowseURLToRunningInstance_ReturnsTrueOnRelayHit(t *testing.T) {
246 relay := mocks.NewMockBrowserLaunchRelay(t)246 relay := mocks.NewMockBrowserLaunchRelay(t)
247- relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(true, nil)247+ relay.EXPECT().DeliverOpenExternalURL(context.Background(), "https://example.com").Return(true, nil)
248 248
249 forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com")249 forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com")
250 250
@@ -258,7 +258,7 @@ func TestTryForwardBrowseURLToRunningInstance_ReturnsTrueOnRelayHit(t *testing.T
258 258
259 func TestTryForwardBrowseURLToRunningInstance_ReturnsFalseOnRelayMiss(t *testing.T) {259 func TestTryForwardBrowseURLToRunningInstance_ReturnsFalseOnRelayMiss(t *testing.T) {
260 relay := mocks.NewMockBrowserLaunchRelay(t)260 relay := mocks.NewMockBrowserLaunchRelay(t)
261- relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(false, nil)261+ relay.EXPECT().DeliverOpenExternalURL(context.Background(), "https://example.com").Return(false, nil)
262 262
263 forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com")263 forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com")
264 264
@@ -272,7 +272,7 @@ func TestTryForwardBrowseURLToRunningInstance_ReturnsFalseOnRelayMiss(t *testing
272 272
273 func TestTryForwardBrowseURLToRunningInstance_ReturnsTrueOnUnconfirmedRelayDelivery(t *testing.T) {273 func TestTryForwardBrowseURLToRunningInstance_ReturnsTrueOnUnconfirmedRelayDelivery(t *testing.T) {
274 relay := mocks.NewMockBrowserLaunchRelay(t)274 relay := mocks.NewMockBrowserLaunchRelay(t)
275- relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(true, desktop.ErrBrowserLaunchRelayUnconfirmed)275+ relay.EXPECT().DeliverOpenExternalURL(context.Background(), "https://example.com").Return(true, desktop.ErrBrowserLaunchRelayUnconfirmed)
276 276
277 forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com")277 forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com")
278 278
@@ -309,7 +309,7 @@ func TestLaunchStandaloneBrowserURL_PropagatesOtherErrors(t *testing.T) {
309 func TestTryForwardBrowseURLToRunningInstance_PropagatesError(t *testing.T) {309 func TestTryForwardBrowseURLToRunningInstance_PropagatesError(t *testing.T) {
310 relay := mocks.NewMockBrowserLaunchRelay(t)310 relay := mocks.NewMockBrowserLaunchRelay(t)
311 wantErr := errors.New("relay error")311 wantErr := errors.New("relay error")
312- relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(false, wantErr)312+ relay.EXPECT().DeliverOpenExternalURL(context.Background(), "https://example.com").Return(false, wantErr)
313 313
314 forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com")314 forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com")
315 315
M docs/reference/configuration.md +5 -0
@@ -114,6 +114,8 @@
114114 | `workspace.floating_pane.profiles.<name>.keys` | []string | | at least one key |
115115 | `workspace.floating_pane.profiles.<name>.url` | string | | required URL |
116116 | `workspace.floating_pane.profiles.<name>.desc` | string | | |
117+| `workspace.external_links.behavior` | string | `windowed` | `windowed`, `tabbed`, `split`, `stacked` |
118+| `workspace.external_links.placement` | string | `right` | `right`, `left`, `top`, `bottom` (used only with `split`) |
117119 | `workspace.browsing_contexts.behavior` | string | `split` | `split`, `stacked`, `tabbed`, `windowed` |
118120 | `workspace.browsing_contexts.placement` | string | `right` | `right`, `left`, `top`, `bottom` |
119121 | `workspace.browsing_contexts.open_in_new_pane` | bool | `true` | |
@@ -121,6 +123,9 @@
121123 | `workspace.browsing_contexts.blank_target_behavior` | string | `stacked` | `split`, `stacked`, `tabbed` |
122124 | `workspace.browsing_contexts.enable_smart_detection` | bool | `true` | |
123125 | `workspace.browsing_contexts.oauth_auto_close` | bool | `true` | |
126+
127+`workspace.external_links` controls URLs opened from another application. Non-`windowed` modes target the last-focused window in the current Dumber profile and engine; if that destination is unavailable, Dumber falls back to opening a new window. `placement` applies only to `split`.
128+
124129 | `workspace.styling.border_width` | int | `1` | |
125130 | `workspace.styling.border_color` | string | `@theme_selected_bg_color` | |
126131 | `workspace.styling.mode_border_width` | int | `4` | |
@@ -114,6 +114,8 @@
114 | `workspace.floating_pane.profiles.<name>.keys` | []string | | at least one key |114 | `workspace.floating_pane.profiles.<name>.keys` | []string | | at least one key |
115 | `workspace.floating_pane.profiles.<name>.url` | string | | required URL |115 | `workspace.floating_pane.profiles.<name>.url` | string | | required URL |
116 | `workspace.floating_pane.profiles.<name>.desc` | string | | |116 | `workspace.floating_pane.profiles.<name>.desc` | string | | |
117+| `workspace.external_links.behavior` | string | `windowed` | `windowed`, `tabbed`, `split`, `stacked` |
118+| `workspace.external_links.placement` | string | `right` | `right`, `left`, `top`, `bottom` (used only with `split`) |
117 | `workspace.browsing_contexts.behavior` | string | `split` | `split`, `stacked`, `tabbed`, `windowed` |119 | `workspace.browsing_contexts.behavior` | string | `split` | `split`, `stacked`, `tabbed`, `windowed` |
118 | `workspace.browsing_contexts.placement` | string | `right` | `right`, `left`, `top`, `bottom` |120 | `workspace.browsing_contexts.placement` | string | `right` | `right`, `left`, `top`, `bottom` |
119 | `workspace.browsing_contexts.open_in_new_pane` | bool | `true` | |121 | `workspace.browsing_contexts.open_in_new_pane` | bool | `true` | |
@@ -121,6 +123,9 @@
121 | `workspace.browsing_contexts.blank_target_behavior` | string | `stacked` | `split`, `stacked`, `tabbed` |123 | `workspace.browsing_contexts.blank_target_behavior` | string | `stacked` | `split`, `stacked`, `tabbed` |
122 | `workspace.browsing_contexts.enable_smart_detection` | bool | `true` | |124 | `workspace.browsing_contexts.enable_smart_detection` | bool | `true` | |
123 | `workspace.browsing_contexts.oauth_auto_close` | bool | `true` | |125 | `workspace.browsing_contexts.oauth_auto_close` | bool | `true` | |
126+
127+`workspace.external_links` controls URLs opened from another application. Non-`windowed` modes target the last-focused window in the current Dumber profile and engine; if that destination is unavailable, Dumber falls back to opening a new window. `placement` applies only to `split`.
128+
124 | `workspace.styling.border_width` | int | `1` | |129 | `workspace.styling.border_width` | int | `1` | |
125 | `workspace.styling.border_color` | string | `@theme_selected_bg_color` | |130 | `workspace.styling.border_color` | string | `@theme_selected_bg_color` | |
126 | `workspace.styling.mode_border_width` | int | `4` | |131 | `workspace.styling.mode_border_width` | int | `4` | |
M internal/application/port/browser_launch.go +5 -5
@@ -5,19 +5,19 @@ import (
55 "io"
66 )
77
8-// BrowserWindowOpener opens a fresh browser window.
8+// BrowserWindowOpener opens an external URL.
99 type BrowserWindowOpener interface {
10- OpenFreshWindow(ctx context.Context, url string) error
10+ OpenExternalURL(ctx context.Context, url string) error
1111 }
1212
13-// BrowserLaunchRelay delivers fresh-window launch requests.
13+// BrowserLaunchRelay delivers external URL launch requests.
1414 type BrowserLaunchRelay interface {
15- // DeliverOpenFreshWindow attempts to deliver a request to open a fresh window.
15+ // DeliverOpenExternalURL attempts to deliver a request to open an external URL.
1616 // The bool reports whether the relay accepted the request; false means the
1717 // caller should treat it as undelivered and try another path.
1818 // An error may still be returned with delivered=true when the relay accepted
1919 // the request but could not confirm completion before the caller timed out.
20- DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error)
20+ DeliverOpenExternalURL(ctx context.Context, url string) (bool, error)
2121 Listen(ctx context.Context, opener BrowserWindowOpener) (io.Closer, error)
2222 }
2323
@@ -5,19 +5,19 @@ import (
5 "io"5 "io"
6 )6 )
7 7
8-// BrowserWindowOpener opens a fresh browser window.8+// BrowserWindowOpener opens an external URL.
9 type BrowserWindowOpener interface {9 type BrowserWindowOpener interface {
10- OpenFreshWindow(ctx context.Context, url string) error10+ OpenExternalURL(ctx context.Context, url string) error
11 }11 }
12 12
13-// BrowserLaunchRelay delivers fresh-window launch requests.13+// BrowserLaunchRelay delivers external URL launch requests.
14 type BrowserLaunchRelay interface {14 type BrowserLaunchRelay interface {
15- // DeliverOpenFreshWindow attempts to deliver a request to open a fresh window.15+ // DeliverOpenExternalURL attempts to deliver a request to open an external URL.
16 // The bool reports whether the relay accepted the request; false means the16 // The bool reports whether the relay accepted the request; false means the
17 // caller should treat it as undelivered and try another path.17 // caller should treat it as undelivered and try another path.
18 // An error may still be returned with delivered=true when the relay accepted18 // An error may still be returned with delivered=true when the relay accepted
19 // the request but could not confirm completion before the caller timed out.19 // the request but could not confirm completion before the caller timed out.
20- DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error)20+ DeliverOpenExternalURL(ctx context.Context, url string) (bool, error)
21 Listen(ctx context.Context, opener BrowserWindowOpener) (io.Closer, error)21 Listen(ctx context.Context, opener BrowserWindowOpener) (io.Closer, error)
22 }22 }
23 23
M internal/application/port/mocks/browser_launch.go +22 -22
@@ -39,12 +39,12 @@ func (_m *MockBrowserWindowOpener) EXPECT() *MockBrowserWindowOpener_Expecter {
3939 return &MockBrowserWindowOpener_Expecter{mock: &_m.Mock}
4040 }
4141
42-// OpenFreshWindow provides a mock function for the type MockBrowserWindowOpener
43-func (_mock *MockBrowserWindowOpener) OpenFreshWindow(ctx context.Context, url string) error {
42+// OpenExternalURL provides a mock function for the type MockBrowserWindowOpener
43+func (_mock *MockBrowserWindowOpener) OpenExternalURL(ctx context.Context, url string) error {
4444 ret := _mock.Called(ctx, url)
4545
4646 if len(ret) == 0 {
47- panic("no return value specified for OpenFreshWindow")
47+ panic("no return value specified for OpenExternalURL")
4848 }
4949
5050 var r0 error
@@ -56,19 +56,19 @@ func (_mock *MockBrowserWindowOpener) OpenFreshWindow(ctx context.Context, url s
5656 return r0
5757 }
5858
59-// MockBrowserWindowOpener_OpenFreshWindow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OpenFreshWindow'
60-type MockBrowserWindowOpener_OpenFreshWindow_Call struct {
59+// MockBrowserWindowOpener_OpenExternalURL_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OpenExternalURL'
60+type MockBrowserWindowOpener_OpenExternalURL_Call struct {
6161 *mock.Call
6262 }
6363
64-// OpenFreshWindow is a helper method to define mock.On call
64+// OpenExternalURL is a helper method to define mock.On call
6565 // - ctx context.Context
6666 // - url string
67-func (_e *MockBrowserWindowOpener_Expecter) OpenFreshWindow(ctx any, url any) *MockBrowserWindowOpener_OpenFreshWindow_Call {
68- return &MockBrowserWindowOpener_OpenFreshWindow_Call{Call: _e.mock.On("OpenFreshWindow", ctx, url)}
67+func (_e *MockBrowserWindowOpener_Expecter) OpenExternalURL(ctx any, url any) *MockBrowserWindowOpener_OpenExternalURL_Call {
68+ return &MockBrowserWindowOpener_OpenExternalURL_Call{Call: _e.mock.On("OpenExternalURL", ctx, url)}
6969 }
7070
71-func (_c *MockBrowserWindowOpener_OpenFreshWindow_Call) Run(run func(ctx context.Context, url string)) *MockBrowserWindowOpener_OpenFreshWindow_Call {
71+func (_c *MockBrowserWindowOpener_OpenExternalURL_Call) Run(run func(ctx context.Context, url string)) *MockBrowserWindowOpener_OpenExternalURL_Call {
7272 _c.Call.Run(func(args mock.Arguments) {
7373 var arg0 context.Context
7474 if args[0] != nil {
@@ -86,12 +86,12 @@ func (_c *MockBrowserWindowOpener_OpenFreshWindow_Call) Run(run func(ctx context
8686 return _c
8787 }
8888
89-func (_c *MockBrowserWindowOpener_OpenFreshWindow_Call) Return(err error) *MockBrowserWindowOpener_OpenFreshWindow_Call {
89+func (_c *MockBrowserWindowOpener_OpenExternalURL_Call) Return(err error) *MockBrowserWindowOpener_OpenExternalURL_Call {
9090 _c.Call.Return(err)
9191 return _c
9292 }
9393
94-func (_c *MockBrowserWindowOpener_OpenFreshWindow_Call) RunAndReturn(run func(ctx context.Context, url string) error) *MockBrowserWindowOpener_OpenFreshWindow_Call {
94+func (_c *MockBrowserWindowOpener_OpenExternalURL_Call) RunAndReturn(run func(ctx context.Context, url string) error) *MockBrowserWindowOpener_OpenExternalURL_Call {
9595 _c.Call.Return(run)
9696 return _c
9797 }
@@ -123,12 +123,12 @@ func (_m *MockBrowserLaunchRelay) EXPECT() *MockBrowserLaunchRelay_Expecter {
123123 return &MockBrowserLaunchRelay_Expecter{mock: &_m.Mock}
124124 }
125125
126-// DeliverOpenFreshWindow provides a mock function for the type MockBrowserLaunchRelay
127-func (_mock *MockBrowserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error) {
126+// DeliverOpenExternalURL provides a mock function for the type MockBrowserLaunchRelay
127+func (_mock *MockBrowserLaunchRelay) DeliverOpenExternalURL(ctx context.Context, url string) (bool, error) {
128128 ret := _mock.Called(ctx, url)
129129
130130 if len(ret) == 0 {
131- panic("no return value specified for DeliverOpenFreshWindow")
131+ panic("no return value specified for DeliverOpenExternalURL")
132132 }
133133
134134 var r0 bool
@@ -149,19 +149,19 @@ func (_mock *MockBrowserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context,
149149 return r0, r1
150150 }
151151
152-// MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeliverOpenFreshWindow'
153-type MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call struct {
152+// MockBrowserLaunchRelay_DeliverOpenExternalURL_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeliverOpenExternalURL'
153+type MockBrowserLaunchRelay_DeliverOpenExternalURL_Call struct {
154154 *mock.Call
155155 }
156156
157-// DeliverOpenFreshWindow is a helper method to define mock.On call
157+// DeliverOpenExternalURL is a helper method to define mock.On call
158158 // - ctx context.Context
159159 // - url string
160-func (_e *MockBrowserLaunchRelay_Expecter) DeliverOpenFreshWindow(ctx any, url any) *MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call {
161- return &MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call{Call: _e.mock.On("DeliverOpenFreshWindow", ctx, url)}
160+func (_e *MockBrowserLaunchRelay_Expecter) DeliverOpenExternalURL(ctx any, url any) *MockBrowserLaunchRelay_DeliverOpenExternalURL_Call {
161+ return &MockBrowserLaunchRelay_DeliverOpenExternalURL_Call{Call: _e.mock.On("DeliverOpenExternalURL", ctx, url)}
162162 }
163163
164-func (_c *MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call) Run(run func(ctx context.Context, url string)) *MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call {
164+func (_c *MockBrowserLaunchRelay_DeliverOpenExternalURL_Call) Run(run func(ctx context.Context, url string)) *MockBrowserLaunchRelay_DeliverOpenExternalURL_Call {
165165 _c.Call.Run(func(args mock.Arguments) {
166166 var arg0 context.Context
167167 if args[0] != nil {
@@ -179,12 +179,12 @@ func (_c *MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call) Run(run func(ctx c
179179 return _c
180180 }
181181
182-func (_c *MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call) Return(b bool, err error) *MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call {
182+func (_c *MockBrowserLaunchRelay_DeliverOpenExternalURL_Call) Return(b bool, err error) *MockBrowserLaunchRelay_DeliverOpenExternalURL_Call {
183183 _c.Call.Return(b, err)
184184 return _c
185185 }
186186
187-func (_c *MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call) RunAndReturn(run func(ctx context.Context, url string) (bool, error)) *MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call {
187+func (_c *MockBrowserLaunchRelay_DeliverOpenExternalURL_Call) RunAndReturn(run func(ctx context.Context, url string) (bool, error)) *MockBrowserLaunchRelay_DeliverOpenExternalURL_Call {
188188 _c.Call.Return(run)
189189 return _c
190190 }
@@ -39,12 +39,12 @@ func (_m *MockBrowserWindowOpener) EXPECT() *MockBrowserWindowOpener_Expecter {
39 return &MockBrowserWindowOpener_Expecter{mock: &_m.Mock}39 return &MockBrowserWindowOpener_Expecter{mock: &_m.Mock}
40 }40 }
41 41
42-// OpenFreshWindow provides a mock function for the type MockBrowserWindowOpener42+// OpenExternalURL provides a mock function for the type MockBrowserWindowOpener
43-func (_mock *MockBrowserWindowOpener) OpenFreshWindow(ctx context.Context, url string) error {43+func (_mock *MockBrowserWindowOpener) OpenExternalURL(ctx context.Context, url string) error {
44 ret := _mock.Called(ctx, url)44 ret := _mock.Called(ctx, url)
45 45
46 if len(ret) == 0 {46 if len(ret) == 0 {
47- panic("no return value specified for OpenFreshWindow")47+ panic("no return value specified for OpenExternalURL")
48 }48 }
49 49
50 var r0 error50 var r0 error
@@ -56,19 +56,19 @@ func (_mock *MockBrowserWindowOpener) OpenFreshWindow(ctx context.Context, url s
56 return r056 return r0
57 }57 }
58 58
59-// MockBrowserWindowOpener_OpenFreshWindow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OpenFreshWindow'59+// MockBrowserWindowOpener_OpenExternalURL_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OpenExternalURL'
60-type MockBrowserWindowOpener_OpenFreshWindow_Call struct {60+type MockBrowserWindowOpener_OpenExternalURL_Call struct {
61 *mock.Call61 *mock.Call
62 }62 }
63 63
64-// OpenFreshWindow is a helper method to define mock.On call64+// OpenExternalURL is a helper method to define mock.On call
65 // - ctx context.Context65 // - ctx context.Context
66 // - url string66 // - url string
67-func (_e *MockBrowserWindowOpener_Expecter) OpenFreshWindow(ctx any, url any) *MockBrowserWindowOpener_OpenFreshWindow_Call {67+func (_e *MockBrowserWindowOpener_Expecter) OpenExternalURL(ctx any, url any) *MockBrowserWindowOpener_OpenExternalURL_Call {
68- return &MockBrowserWindowOpener_OpenFreshWindow_Call{Call: _e.mock.On("OpenFreshWindow", ctx, url)}68+ return &MockBrowserWindowOpener_OpenExternalURL_Call{Call: _e.mock.On("OpenExternalURL", ctx, url)}
69 }69 }
70 70
71-func (_c *MockBrowserWindowOpener_OpenFreshWindow_Call) Run(run func(ctx context.Context, url string)) *MockBrowserWindowOpener_OpenFreshWindow_Call {71+func (_c *MockBrowserWindowOpener_OpenExternalURL_Call) Run(run func(ctx context.Context, url string)) *MockBrowserWindowOpener_OpenExternalURL_Call {
72 _c.Call.Run(func(args mock.Arguments) {72 _c.Call.Run(func(args mock.Arguments) {
73 var arg0 context.Context73 var arg0 context.Context
74 if args[0] != nil {74 if args[0] != nil {
@@ -86,12 +86,12 @@ func (_c *MockBrowserWindowOpener_OpenFreshWindow_Call) Run(run func(ctx context
86 return _c86 return _c
87 }87 }
88 88
89-func (_c *MockBrowserWindowOpener_OpenFreshWindow_Call) Return(err error) *MockBrowserWindowOpener_OpenFreshWindow_Call {89+func (_c *MockBrowserWindowOpener_OpenExternalURL_Call) Return(err error) *MockBrowserWindowOpener_OpenExternalURL_Call {
90 _c.Call.Return(err)90 _c.Call.Return(err)
91 return _c91 return _c
92 }92 }
93 93
94-func (_c *MockBrowserWindowOpener_OpenFreshWindow_Call) RunAndReturn(run func(ctx context.Context, url string) error) *MockBrowserWindowOpener_OpenFreshWindow_Call {94+func (_c *MockBrowserWindowOpener_OpenExternalURL_Call) RunAndReturn(run func(ctx context.Context, url string) error) *MockBrowserWindowOpener_OpenExternalURL_Call {
95 _c.Call.Return(run)95 _c.Call.Return(run)
96 return _c96 return _c
97 }97 }
@@ -123,12 +123,12 @@ func (_m *MockBrowserLaunchRelay) EXPECT() *MockBrowserLaunchRelay_Expecter {
123 return &MockBrowserLaunchRelay_Expecter{mock: &_m.Mock}123 return &MockBrowserLaunchRelay_Expecter{mock: &_m.Mock}
124 }124 }
125 125
126-// DeliverOpenFreshWindow provides a mock function for the type MockBrowserLaunchRelay126+// DeliverOpenExternalURL provides a mock function for the type MockBrowserLaunchRelay
127-func (_mock *MockBrowserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error) {127+func (_mock *MockBrowserLaunchRelay) DeliverOpenExternalURL(ctx context.Context, url string) (bool, error) {
128 ret := _mock.Called(ctx, url)128 ret := _mock.Called(ctx, url)
129 129
130 if len(ret) == 0 {130 if len(ret) == 0 {
131- panic("no return value specified for DeliverOpenFreshWindow")131+ panic("no return value specified for DeliverOpenExternalURL")
132 }132 }
133 133
134 var r0 bool134 var r0 bool
@@ -149,19 +149,19 @@ func (_mock *MockBrowserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context,
149 return r0, r1149 return r0, r1
150 }150 }
151 151
152-// MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeliverOpenFreshWindow'152+// MockBrowserLaunchRelay_DeliverOpenExternalURL_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeliverOpenExternalURL'
153-type MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call struct {153+type MockBrowserLaunchRelay_DeliverOpenExternalURL_Call struct {
154 *mock.Call154 *mock.Call
155 }155 }
156 156
157-// DeliverOpenFreshWindow is a helper method to define mock.On call157+// DeliverOpenExternalURL is a helper method to define mock.On call
158 // - ctx context.Context158 // - ctx context.Context
159 // - url string159 // - url string
160-func (_e *MockBrowserLaunchRelay_Expecter) DeliverOpenFreshWindow(ctx any, url any) *MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call {160+func (_e *MockBrowserLaunchRelay_Expecter) DeliverOpenExternalURL(ctx any, url any) *MockBrowserLaunchRelay_DeliverOpenExternalURL_Call {
161- return &MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call{Call: _e.mock.On("DeliverOpenFreshWindow", ctx, url)}161+ return &MockBrowserLaunchRelay_DeliverOpenExternalURL_Call{Call: _e.mock.On("DeliverOpenExternalURL", ctx, url)}
162 }162 }
163 163
164-func (_c *MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call) Run(run func(ctx context.Context, url string)) *MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call {164+func (_c *MockBrowserLaunchRelay_DeliverOpenExternalURL_Call) Run(run func(ctx context.Context, url string)) *MockBrowserLaunchRelay_DeliverOpenExternalURL_Call {
165 _c.Call.Run(func(args mock.Arguments) {165 _c.Call.Run(func(args mock.Arguments) {
166 var arg0 context.Context166 var arg0 context.Context
167 if args[0] != nil {167 if args[0] != nil {
@@ -179,12 +179,12 @@ func (_c *MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call) Run(run func(ctx c
179 return _c179 return _c
180 }180 }
181 181
182-func (_c *MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call) Return(b bool, err error) *MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call {182+func (_c *MockBrowserLaunchRelay_DeliverOpenExternalURL_Call) Return(b bool, err error) *MockBrowserLaunchRelay_DeliverOpenExternalURL_Call {
183 _c.Call.Return(b, err)183 _c.Call.Return(b, err)
184 return _c184 return _c
185 }185 }
186 186
187-func (_c *MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call) RunAndReturn(run func(ctx context.Context, url string) (bool, error)) *MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call {187+func (_c *MockBrowserLaunchRelay_DeliverOpenExternalURL_Call) RunAndReturn(run func(ctx context.Context, url string) (bool, error)) *MockBrowserLaunchRelay_DeliverOpenExternalURL_Call {
188 _c.Call.Return(run)188 _c.Call.Return(run)
189 return _c189 return _c
190 }190 }
M internal/domain/entity/config_types.go +34 -7
@@ -209,15 +209,42 @@ type BrowsingContextConfig struct {
209209 // Deprecated: PopupBehaviorConfig is a compatibility alias for BrowsingContextConfig.
210210 type PopupBehaviorConfig = BrowsingContextConfig
211211
212+// ExternalLinkBehavior defines how externally opened URLs are placed in the workspace.
213+type ExternalLinkBehavior string
214+
215+const (
216+ ExternalLinkBehaviorWindowed ExternalLinkBehavior = "windowed"
217+ ExternalLinkBehaviorTabbed ExternalLinkBehavior = "tabbed"
218+ ExternalLinkBehaviorSplit ExternalLinkBehavior = "split"
219+ ExternalLinkBehaviorStacked ExternalLinkBehavior = "stacked"
220+)
221+
222+// ExternalLinkPlacement defines the direction used when splitting for an external URL.
223+type ExternalLinkPlacement string
224+
225+const (
226+ ExternalLinkPlacementRight ExternalLinkPlacement = "right"
227+ ExternalLinkPlacementLeft ExternalLinkPlacement = "left"
228+ ExternalLinkPlacementTop ExternalLinkPlacement = "top"
229+ ExternalLinkPlacementBottom ExternalLinkPlacement = "bottom"
230+)
231+
232+// ExternalLinksConfig controls how externally opened URLs are placed in the workspace.
233+type ExternalLinksConfig struct {
234+ Behavior ExternalLinkBehavior `mapstructure:"behavior" yaml:"behavior" toml:"behavior" json:"behavior"`
235+ Placement ExternalLinkPlacement `mapstructure:"placement" yaml:"placement" toml:"placement" json:"placement"`
236+}
237+
212238 // WorkspaceConfig holds all workspace layout and behavior settings.
213239 type WorkspaceConfig struct {
214- NewPaneURL string `mapstructure:"new_pane_url" yaml:"new_pane_url" toml:"new_pane_url" json:"new_pane_url"`
215- PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"`
216- TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"`
217- PageMode PageModeConfig `mapstructure:"page_mode" yaml:"page_mode" toml:"page_mode" json:"page_mode"`
218- ResizeMode ResizeModeConfig `mapstructure:"resize_mode" yaml:"resize_mode" toml:"resize_mode" json:"resize_mode"`
219- Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"`
220- FloatingPane FloatingPaneConfig `mapstructure:"floating_pane" yaml:"floating_pane" toml:"floating_pane" json:"floating_pane"`
240+ NewPaneURL string `mapstructure:"new_pane_url" yaml:"new_pane_url" toml:"new_pane_url" json:"new_pane_url"`
241+ PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"`
242+ TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"`
243+ PageMode PageModeConfig `mapstructure:"page_mode" yaml:"page_mode" toml:"page_mode" json:"page_mode"`
244+ ResizeMode ResizeModeConfig `mapstructure:"resize_mode" yaml:"resize_mode" toml:"resize_mode" json:"resize_mode"`
245+ Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"`
246+ FloatingPane FloatingPaneConfig `mapstructure:"floating_pane" yaml:"floating_pane" toml:"floating_pane" json:"floating_pane"`
247+ ExternalLinks ExternalLinksConfig `mapstructure:"external_links" yaml:"external_links" toml:"external_links" json:"external_links"`
221248
222249 TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`
223250 HideTabBarWhenSingleTab bool `mapstructure:"hide_tab_bar_when_single_tab" yaml:"hide_tab_bar_when_single_tab" toml:"hide_tab_bar_when_single_tab" json:"hide_tab_bar_when_single_tab"` //nolint:lll // struct tags must stay on one line
@@ -209,15 +209,42 @@ type BrowsingContextConfig struct {
209 // Deprecated: PopupBehaviorConfig is a compatibility alias for BrowsingContextConfig.209 // Deprecated: PopupBehaviorConfig is a compatibility alias for BrowsingContextConfig.
210 type PopupBehaviorConfig = BrowsingContextConfig210 type PopupBehaviorConfig = BrowsingContextConfig
211 211
212+// ExternalLinkBehavior defines how externally opened URLs are placed in the workspace.
213+type ExternalLinkBehavior string
214+
215+const (
216+ ExternalLinkBehaviorWindowed ExternalLinkBehavior = "windowed"
217+ ExternalLinkBehaviorTabbed ExternalLinkBehavior = "tabbed"
218+ ExternalLinkBehaviorSplit ExternalLinkBehavior = "split"
219+ ExternalLinkBehaviorStacked ExternalLinkBehavior = "stacked"
220+)
221+
222+// ExternalLinkPlacement defines the direction used when splitting for an external URL.
223+type ExternalLinkPlacement string
224+
225+const (
226+ ExternalLinkPlacementRight ExternalLinkPlacement = "right"
227+ ExternalLinkPlacementLeft ExternalLinkPlacement = "left"
228+ ExternalLinkPlacementTop ExternalLinkPlacement = "top"
229+ ExternalLinkPlacementBottom ExternalLinkPlacement = "bottom"
230+)
231+
232+// ExternalLinksConfig controls how externally opened URLs are placed in the workspace.
233+type ExternalLinksConfig struct {
234+ Behavior ExternalLinkBehavior `mapstructure:"behavior" yaml:"behavior" toml:"behavior" json:"behavior"`
235+ Placement ExternalLinkPlacement `mapstructure:"placement" yaml:"placement" toml:"placement" json:"placement"`
236+}
237+
212 // WorkspaceConfig holds all workspace layout and behavior settings.238 // WorkspaceConfig holds all workspace layout and behavior settings.
213 type WorkspaceConfig struct {239 type WorkspaceConfig struct {
214- NewPaneURL string `mapstructure:"new_pane_url" yaml:"new_pane_url" toml:"new_pane_url" json:"new_pane_url"`240+ NewPaneURL string `mapstructure:"new_pane_url" yaml:"new_pane_url" toml:"new_pane_url" json:"new_pane_url"`
215- PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"`241+ PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"`
216- TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"`242+ TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"`
217- PageMode PageModeConfig `mapstructure:"page_mode" yaml:"page_mode" toml:"page_mode" json:"page_mode"`243+ PageMode PageModeConfig `mapstructure:"page_mode" yaml:"page_mode" toml:"page_mode" json:"page_mode"`
218- ResizeMode ResizeModeConfig `mapstructure:"resize_mode" yaml:"resize_mode" toml:"resize_mode" json:"resize_mode"`244+ ResizeMode ResizeModeConfig `mapstructure:"resize_mode" yaml:"resize_mode" toml:"resize_mode" json:"resize_mode"`
219- Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"`245+ Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"`
220- FloatingPane FloatingPaneConfig `mapstructure:"floating_pane" yaml:"floating_pane" toml:"floating_pane" json:"floating_pane"`246+ FloatingPane FloatingPaneConfig `mapstructure:"floating_pane" yaml:"floating_pane" toml:"floating_pane" json:"floating_pane"`
247+ ExternalLinks ExternalLinksConfig `mapstructure:"external_links" yaml:"external_links" toml:"external_links" json:"external_links"`
221 248
222 TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`249 TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`
223 HideTabBarWhenSingleTab bool `mapstructure:"hide_tab_bar_when_single_tab" yaml:"hide_tab_bar_when_single_tab" toml:"hide_tab_bar_when_single_tab" json:"hide_tab_bar_when_single_tab"` //nolint:lll // struct tags must stay on one line250 HideTabBarWhenSingleTab bool `mapstructure:"hide_tab_bar_when_single_tab" yaml:"hide_tab_bar_when_single_tab" toml:"hide_tab_bar_when_single_tab" json:"hide_tab_bar_when_single_tab"` //nolint:lll // struct tags must stay on one line
M internal/infrastructure/config/defaults.go +6 -0
@@ -50,6 +50,8 @@ const (
5050 defaultResizeMinPanePercent = 10.0
5151 defaultTabBarPosition = "bottom"
5252 defaultPopupPlacement = "right"
53+ defaultExternalLinkBehavior = ExternalLinkBehaviorWindowed
54+ defaultExternalLinkPlacement = ExternalLinkPlacementRight
5355 defaultFloatingPaneWidthPct = 0.82
5456 defaultFloatingPaneHeightPct = 0.72
5557
@@ -357,6 +359,10 @@ func DefaultConfig() *Config {
357359 HeightPct: defaultFloatingPaneHeightPct,
358360 Profiles: map[string]FloatingPaneProfile{},
359361 },
362+ ExternalLinks: ExternalLinksConfig{
363+ Behavior: defaultExternalLinkBehavior,
364+ Placement: defaultExternalLinkPlacement,
365+ },
360366 TabBarPosition: defaultTabBarPosition,
361367 HideTabBarWhenSingleTab: true,
362368 BrowsingContexts: browsingContextDefaults,
@@ -50,6 +50,8 @@ const (
50 defaultResizeMinPanePercent = 10.050 defaultResizeMinPanePercent = 10.0
51 defaultTabBarPosition = "bottom"51 defaultTabBarPosition = "bottom"
52 defaultPopupPlacement = "right"52 defaultPopupPlacement = "right"
53+ defaultExternalLinkBehavior = ExternalLinkBehaviorWindowed
54+ defaultExternalLinkPlacement = ExternalLinkPlacementRight
53 defaultFloatingPaneWidthPct = 0.8255 defaultFloatingPaneWidthPct = 0.82
54 defaultFloatingPaneHeightPct = 0.7256 defaultFloatingPaneHeightPct = 0.72
55 57
@@ -357,6 +359,10 @@ func DefaultConfig() *Config {
357 HeightPct: defaultFloatingPaneHeightPct,359 HeightPct: defaultFloatingPaneHeightPct,
358 Profiles: map[string]FloatingPaneProfile{},360 Profiles: map[string]FloatingPaneProfile{},
359 },361 },
362+ ExternalLinks: ExternalLinksConfig{
363+ Behavior: defaultExternalLinkBehavior,
364+ Placement: defaultExternalLinkPlacement,
365+ },
360 TabBarPosition: defaultTabBarPosition,366 TabBarPosition: defaultTabBarPosition,
361 HideTabBarWhenSingleTab: true,367 HideTabBarWhenSingleTab: true,
362 BrowsingContexts: browsingContextDefaults,368 BrowsingContexts: browsingContextDefaults,
M internal/infrastructure/config/defaults_test.go +2 -0
@@ -13,6 +13,8 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
1313 assert.Equal(t, defaultMaxLogFiles, cfg.Logging.MaxFiles)
1414 assert.False(t, cfg.Logging.CaptureGTKLogs)
1515 assert.False(t, cfg.Media.ShowDiagnosticsOnStartup)
16+ assert.Equal(t, ExternalLinkBehaviorWindowed, cfg.Workspace.ExternalLinks.Behavior)
17+ assert.Equal(t, ExternalLinkPlacementRight, cfg.Workspace.ExternalLinks.Placement)
1618
1719 // Engine defaults (replaces old Performance/Privacy sections)
1820 assert.Equal(t, EngineTypeCEF, cfg.Engine.Type)
@@ -13,6 +13,8 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
13 assert.Equal(t, defaultMaxLogFiles, cfg.Logging.MaxFiles)13 assert.Equal(t, defaultMaxLogFiles, cfg.Logging.MaxFiles)
14 assert.False(t, cfg.Logging.CaptureGTKLogs)14 assert.False(t, cfg.Logging.CaptureGTKLogs)
15 assert.False(t, cfg.Media.ShowDiagnosticsOnStartup)15 assert.False(t, cfg.Media.ShowDiagnosticsOnStartup)
16+ assert.Equal(t, ExternalLinkBehaviorWindowed, cfg.Workspace.ExternalLinks.Behavior)
17+ assert.Equal(t, ExternalLinkPlacementRight, cfg.Workspace.ExternalLinks.Placement)
16 18
17 // Engine defaults (replaces old Performance/Privacy sections)19 // Engine defaults (replaces old Performance/Privacy sections)
18 assert.Equal(t, EngineTypeCEF, cfg.Engine.Type)20 assert.Equal(t, EngineTypeCEF, cfg.Engine.Type)
M internal/infrastructure/config/loader.go +17 -0
@@ -374,6 +374,7 @@ func ensureDatabasePath(config *Config) error {
374374 }
375375
376376 func normalizeConfig(config *Config) {
377+ normalizeExternalLinks(config)
377378 normalizeAppearance(config)
378379 normalizeMedia(config)
379380 normalizeEngineConfig(config)
@@ -386,6 +387,20 @@ func normalizeBrowsingContexts(config *Config) {
386387 config.Workspace.Popups = config.Workspace.BrowsingContexts
387388 }
388389
390+func normalizeExternalLinks(config *Config) {
391+ switch config.Workspace.ExternalLinks.Behavior {
392+ case ExternalLinkBehaviorWindowed, ExternalLinkBehaviorTabbed, ExternalLinkBehaviorSplit, ExternalLinkBehaviorStacked:
393+ default:
394+ config.Workspace.ExternalLinks.Behavior = defaultExternalLinkBehavior
395+ }
396+
397+ switch config.Workspace.ExternalLinks.Placement {
398+ case ExternalLinkPlacementRight, ExternalLinkPlacementLeft, ExternalLinkPlacementTop, ExternalLinkPlacementBottom:
399+ default:
400+ config.Workspace.ExternalLinks.Placement = defaultExternalLinkPlacement
401+ }
402+}
403+
389404 func normalizeEngineConfig(config *Config) {
390405 // Normalize GSK renderer (engine.webkit)
391406 switch strings.ToLower(string(config.Engine.WebKit.GSKRenderer)) {
@@ -703,6 +718,8 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
703718 m.viper.SetDefault("workspace.floating_pane.width_pct", defaults.Workspace.FloatingPane.WidthPct)
704719 m.viper.SetDefault("workspace.floating_pane.height_pct", defaults.Workspace.FloatingPane.HeightPct)
705720 m.viper.SetDefault("workspace.floating_pane.profiles", defaults.Workspace.FloatingPane.Profiles)
721+ m.viper.SetDefault("workspace.external_links.behavior", string(defaults.Workspace.ExternalLinks.Behavior))
722+ m.viper.SetDefault("workspace.external_links.placement", string(defaults.Workspace.ExternalLinks.Placement))
706723 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)
707724 m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)
708725 m.viper.SetDefault("workspace.switch_to_tab_on_move", defaults.Workspace.SwitchToTabOnMove)
@@ -374,6 +374,7 @@ func ensureDatabasePath(config *Config) error {
374 }374 }
375 375
376 func normalizeConfig(config *Config) {376 func normalizeConfig(config *Config) {
377+ normalizeExternalLinks(config)
377 normalizeAppearance(config)378 normalizeAppearance(config)
378 normalizeMedia(config)379 normalizeMedia(config)
379 normalizeEngineConfig(config)380 normalizeEngineConfig(config)
@@ -386,6 +387,20 @@ func normalizeBrowsingContexts(config *Config) {
386 config.Workspace.Popups = config.Workspace.BrowsingContexts387 config.Workspace.Popups = config.Workspace.BrowsingContexts
387 }388 }
388 389
390+func normalizeExternalLinks(config *Config) {
391+ switch config.Workspace.ExternalLinks.Behavior {
392+ case ExternalLinkBehaviorWindowed, ExternalLinkBehaviorTabbed, ExternalLinkBehaviorSplit, ExternalLinkBehaviorStacked:
393+ default:
394+ config.Workspace.ExternalLinks.Behavior = defaultExternalLinkBehavior
395+ }
396+
397+ switch config.Workspace.ExternalLinks.Placement {
398+ case ExternalLinkPlacementRight, ExternalLinkPlacementLeft, ExternalLinkPlacementTop, ExternalLinkPlacementBottom:
399+ default:
400+ config.Workspace.ExternalLinks.Placement = defaultExternalLinkPlacement
401+ }
402+}
403+
389 func normalizeEngineConfig(config *Config) {404 func normalizeEngineConfig(config *Config) {
390 // Normalize GSK renderer (engine.webkit)405 // Normalize GSK renderer (engine.webkit)
391 switch strings.ToLower(string(config.Engine.WebKit.GSKRenderer)) {406 switch strings.ToLower(string(config.Engine.WebKit.GSKRenderer)) {
@@ -703,6 +718,8 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
703 m.viper.SetDefault("workspace.floating_pane.width_pct", defaults.Workspace.FloatingPane.WidthPct)718 m.viper.SetDefault("workspace.floating_pane.width_pct", defaults.Workspace.FloatingPane.WidthPct)
704 m.viper.SetDefault("workspace.floating_pane.height_pct", defaults.Workspace.FloatingPane.HeightPct)719 m.viper.SetDefault("workspace.floating_pane.height_pct", defaults.Workspace.FloatingPane.HeightPct)
705 m.viper.SetDefault("workspace.floating_pane.profiles", defaults.Workspace.FloatingPane.Profiles)720 m.viper.SetDefault("workspace.floating_pane.profiles", defaults.Workspace.FloatingPane.Profiles)
721+ m.viper.SetDefault("workspace.external_links.behavior", string(defaults.Workspace.ExternalLinks.Behavior))
722+ m.viper.SetDefault("workspace.external_links.placement", string(defaults.Workspace.ExternalLinks.Placement))
706 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)723 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)
707 m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)724 m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)
708 m.viper.SetDefault("workspace.switch_to_tab_on_move", defaults.Workspace.SwitchToTabOnMove)725 m.viper.SetDefault("workspace.switch_to_tab_on_move", defaults.Workspace.SwitchToTabOnMove)
M internal/infrastructure/config/loader_test.go +11 -0
@@ -39,6 +39,17 @@ func TestSetEngineDefaults(t *testing.T) {
3939 assert.Empty(t, mgr.viper.GetString("runtime.prefix"))
4040 }
4141
42+func TestNormalizeExternalLinks(t *testing.T) {
43+ cfg := DefaultConfig()
44+ cfg.Workspace.ExternalLinks.Behavior = "invalid"
45+ cfg.Workspace.ExternalLinks.Placement = "invalid"
46+
47+ normalizeConfig(cfg)
48+
49+ assert.Equal(t, ExternalLinkBehaviorWindowed, cfg.Workspace.ExternalLinks.Behavior)
50+ assert.Equal(t, ExternalLinkPlacementRight, cfg.Workspace.ExternalLinks.Placement)
51+}
52+
4253 func TestNormalizeConfig_EngineCookiePolicy(t *testing.T) {
4354 cfg := DefaultConfig()
4455 cfg.Engine.CookiePolicy = CookiePolicy("INVALID")
@@ -39,6 +39,17 @@ func TestSetEngineDefaults(t *testing.T) {
39 assert.Empty(t, mgr.viper.GetString("runtime.prefix"))39 assert.Empty(t, mgr.viper.GetString("runtime.prefix"))
40 }40 }
41 41
42+func TestNormalizeExternalLinks(t *testing.T) {
43+ cfg := DefaultConfig()
44+ cfg.Workspace.ExternalLinks.Behavior = "invalid"
45+ cfg.Workspace.ExternalLinks.Placement = "invalid"
46+
47+ normalizeConfig(cfg)
48+
49+ assert.Equal(t, ExternalLinkBehaviorWindowed, cfg.Workspace.ExternalLinks.Behavior)
50+ assert.Equal(t, ExternalLinkPlacementRight, cfg.Workspace.ExternalLinks.Placement)
51+}
52+
42 func TestNormalizeConfig_EngineCookiePolicy(t *testing.T) {53 func TestNormalizeConfig_EngineCookiePolicy(t *testing.T) {
43 cfg := DefaultConfig()54 cfg := DefaultConfig()
44 cfg.Engine.CookiePolicy = CookiePolicy("INVALID")55 cfg.Engine.CookiePolicy = CookiePolicy("INVALID")
M internal/infrastructure/config/schema.go +23 -0
@@ -45,6 +45,29 @@ type FloatingPaneConfig = entity.FloatingPaneConfig
4545 // WorkspaceStylingConfig defines visual styling for workspace panes.
4646 type WorkspaceStylingConfig = entity.WorkspaceStylingConfig
4747
48+// ExternalLinkBehavior defines how externally opened URLs are placed in the workspace.
49+type ExternalLinkBehavior = entity.ExternalLinkBehavior
50+
51+const (
52+ ExternalLinkBehaviorWindowed = entity.ExternalLinkBehaviorWindowed
53+ ExternalLinkBehaviorTabbed = entity.ExternalLinkBehaviorTabbed
54+ ExternalLinkBehaviorSplit = entity.ExternalLinkBehaviorSplit
55+ ExternalLinkBehaviorStacked = entity.ExternalLinkBehaviorStacked
56+)
57+
58+// ExternalLinkPlacement defines the direction used when splitting for an external URL.
59+type ExternalLinkPlacement = entity.ExternalLinkPlacement
60+
61+const (
62+ ExternalLinkPlacementRight = entity.ExternalLinkPlacementRight
63+ ExternalLinkPlacementLeft = entity.ExternalLinkPlacementLeft
64+ ExternalLinkPlacementTop = entity.ExternalLinkPlacementTop
65+ ExternalLinkPlacementBottom = entity.ExternalLinkPlacementBottom
66+)
67+
68+// ExternalLinksConfig controls how externally opened URLs are placed in the workspace.
69+type ExternalLinksConfig = entity.ExternalLinksConfig
70+
4871 // PopupBehavior defines how popup windows should be opened.
4972 type PopupBehavior = entity.PopupBehavior
5073
@@ -45,6 +45,29 @@ type FloatingPaneConfig = entity.FloatingPaneConfig
45 // WorkspaceStylingConfig defines visual styling for workspace panes.45 // WorkspaceStylingConfig defines visual styling for workspace panes.
46 type WorkspaceStylingConfig = entity.WorkspaceStylingConfig46 type WorkspaceStylingConfig = entity.WorkspaceStylingConfig
47 47
48+// ExternalLinkBehavior defines how externally opened URLs are placed in the workspace.
49+type ExternalLinkBehavior = entity.ExternalLinkBehavior
50+
51+const (
52+ ExternalLinkBehaviorWindowed = entity.ExternalLinkBehaviorWindowed
53+ ExternalLinkBehaviorTabbed = entity.ExternalLinkBehaviorTabbed
54+ ExternalLinkBehaviorSplit = entity.ExternalLinkBehaviorSplit
55+ ExternalLinkBehaviorStacked = entity.ExternalLinkBehaviorStacked
56+)
57+
58+// ExternalLinkPlacement defines the direction used when splitting for an external URL.
59+type ExternalLinkPlacement = entity.ExternalLinkPlacement
60+
61+const (
62+ ExternalLinkPlacementRight = entity.ExternalLinkPlacementRight
63+ ExternalLinkPlacementLeft = entity.ExternalLinkPlacementLeft
64+ ExternalLinkPlacementTop = entity.ExternalLinkPlacementTop
65+ ExternalLinkPlacementBottom = entity.ExternalLinkPlacementBottom
66+)
67+
68+// ExternalLinksConfig controls how externally opened URLs are placed in the workspace.
69+type ExternalLinksConfig = entity.ExternalLinksConfig
70+
48 // PopupBehavior defines how popup windows should be opened.71 // PopupBehavior defines how popup windows should be opened.
49 type PopupBehavior = entity.PopupBehavior72 type PopupBehavior = entity.PopupBehavior
50 73
M internal/infrastructure/config/schema_provider.go +17 -0
@@ -564,6 +564,23 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
564564 Description: "Named floating pane URL profiles with keys, url, and optional desc",
565565 Section: SectionWorkspace,
566566 },
567+ // External links
568+ {
569+ Key: "workspace.external_links.behavior",
570+ Type: "string",
571+ Default: string(defaults.Workspace.ExternalLinks.Behavior),
572+ Description: "How URLs opened from external applications are placed",
573+ Values: []string{"windowed", "tabbed", "split", "stacked"},
574+ Section: SectionWorkspace,
575+ },
576+ {
577+ Key: "workspace.external_links.placement",
578+ Type: "string",
579+ Default: string(defaults.Workspace.ExternalLinks.Placement),
580+ Description: "Placement direction for split external links",
581+ Values: []string{"right", "left", "top", "bottom"},
582+ Section: SectionWorkspace,
583+ },
567584 // Browsing contexts
568585 {
569586 Key: "workspace.browsing_contexts.behavior",
@@ -564,6 +564,23 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
564 Description: "Named floating pane URL profiles with keys, url, and optional desc",564 Description: "Named floating pane URL profiles with keys, url, and optional desc",
565 Section: SectionWorkspace,565 Section: SectionWorkspace,
566 },566 },
567+ // External links
568+ {
569+ Key: "workspace.external_links.behavior",
570+ Type: "string",
571+ Default: string(defaults.Workspace.ExternalLinks.Behavior),
572+ Description: "How URLs opened from external applications are placed",
573+ Values: []string{"windowed", "tabbed", "split", "stacked"},
574+ Section: SectionWorkspace,
575+ },
576+ {
577+ Key: "workspace.external_links.placement",
578+ Type: "string",
579+ Default: string(defaults.Workspace.ExternalLinks.Placement),
580+ Description: "Placement direction for split external links",
581+ Values: []string{"right", "left", "top", "bottom"},
582+ Section: SectionWorkspace,
583+ },
567 // Browsing contexts584 // Browsing contexts
568 {585 {
569 Key: "workspace.browsing_contexts.behavior",586 Key: "workspace.browsing_contexts.behavior",
M internal/infrastructure/config/validation.go +23 -0
@@ -24,6 +24,7 @@ func validateConfig(config *Config) error {
2424 validationErrors = append(validationErrors, validateAppearance(config)...)
2525 validationErrors = append(validationErrors, validateSearchEngine(config)...)
2626 validationErrors = append(validationErrors, validatePopups(config)...)
27+ validationErrors = append(validationErrors, validateExternalLinks(config)...)
2728 validationErrors = append(validationErrors, validateWorkspaceStyling(config)...)
2829 validationErrors = append(validationErrors, validatePaneMode(config)...)
2930 validationErrors = append(validationErrors, validateTabBar(config)...)
@@ -188,6 +189,28 @@ func validatePopups(config *Config) []string {
188189 return validationErrors
189190 }
190191
192+func validateExternalLinks(config *Config) []string {
193+ var validationErrors []string
194+ switch config.Workspace.ExternalLinks.Behavior {
195+ case ExternalLinkBehaviorWindowed, ExternalLinkBehaviorTabbed, ExternalLinkBehaviorSplit, ExternalLinkBehaviorStacked:
196+ default:
197+ validationErrors = append(validationErrors, fmt.Sprintf(
198+ "workspace.external_links.behavior must be one of: windowed, tabbed, split, stacked (got: %s)",
199+ config.Workspace.ExternalLinks.Behavior,
200+ ))
201+ }
202+
203+ switch config.Workspace.ExternalLinks.Placement {
204+ case ExternalLinkPlacementRight, ExternalLinkPlacementLeft, ExternalLinkPlacementTop, ExternalLinkPlacementBottom:
205+ default:
206+ validationErrors = append(validationErrors, fmt.Sprintf(
207+ "workspace.external_links.placement must be one of: right, left, top, bottom (got: %s)",
208+ config.Workspace.ExternalLinks.Placement,
209+ ))
210+ }
211+ return validationErrors
212+}
213+
191214 func validateWorkspaceStyling(config *Config) []string {
192215 var validationErrors []string
193216 if config.Workspace.Styling.BorderWidth < 0 {
@@ -24,6 +24,7 @@ func validateConfig(config *Config) error {
24 validationErrors = append(validationErrors, validateAppearance(config)...)24 validationErrors = append(validationErrors, validateAppearance(config)...)
25 validationErrors = append(validationErrors, validateSearchEngine(config)...)25 validationErrors = append(validationErrors, validateSearchEngine(config)...)
26 validationErrors = append(validationErrors, validatePopups(config)...)26 validationErrors = append(validationErrors, validatePopups(config)...)
27+ validationErrors = append(validationErrors, validateExternalLinks(config)...)
27 validationErrors = append(validationErrors, validateWorkspaceStyling(config)...)28 validationErrors = append(validationErrors, validateWorkspaceStyling(config)...)
28 validationErrors = append(validationErrors, validatePaneMode(config)...)29 validationErrors = append(validationErrors, validatePaneMode(config)...)
29 validationErrors = append(validationErrors, validateTabBar(config)...)30 validationErrors = append(validationErrors, validateTabBar(config)...)
@@ -188,6 +189,28 @@ func validatePopups(config *Config) []string {
188 return validationErrors189 return validationErrors
189 }190 }
190 191
192+func validateExternalLinks(config *Config) []string {
193+ var validationErrors []string
194+ switch config.Workspace.ExternalLinks.Behavior {
195+ case ExternalLinkBehaviorWindowed, ExternalLinkBehaviorTabbed, ExternalLinkBehaviorSplit, ExternalLinkBehaviorStacked:
196+ default:
197+ validationErrors = append(validationErrors, fmt.Sprintf(
198+ "workspace.external_links.behavior must be one of: windowed, tabbed, split, stacked (got: %s)",
199+ config.Workspace.ExternalLinks.Behavior,
200+ ))
201+ }
202+
203+ switch config.Workspace.ExternalLinks.Placement {
204+ case ExternalLinkPlacementRight, ExternalLinkPlacementLeft, ExternalLinkPlacementTop, ExternalLinkPlacementBottom:
205+ default:
206+ validationErrors = append(validationErrors, fmt.Sprintf(
207+ "workspace.external_links.placement must be one of: right, left, top, bottom (got: %s)",
208+ config.Workspace.ExternalLinks.Placement,
209+ ))
210+ }
211+ return validationErrors
212+}
213+
191 func validateWorkspaceStyling(config *Config) []string {214 func validateWorkspaceStyling(config *Config) []string {
192 var validationErrors []string215 var validationErrors []string
193 if config.Workspace.Styling.BorderWidth < 0 {216 if config.Workspace.Styling.BorderWidth < 0 {
M internal/infrastructure/config/validation_test.go +32 -0
@@ -294,6 +294,38 @@ func TestValidateConfig_CEFConfig(t *testing.T) {
294294 }
295295 }
296296
297+func TestValidateConfig_ExternalLinksAcceptsSupportedValues(t *testing.T) {
298+ for _, behavior := range []ExternalLinkBehavior{
299+ ExternalLinkBehaviorWindowed,
300+ ExternalLinkBehaviorTabbed,
301+ ExternalLinkBehaviorSplit,
302+ ExternalLinkBehaviorStacked,
303+ } {
304+ for _, placement := range []ExternalLinkPlacement{
305+ ExternalLinkPlacementRight,
306+ ExternalLinkPlacementLeft,
307+ ExternalLinkPlacementTop,
308+ ExternalLinkPlacementBottom,
309+ } {
310+ cfg := DefaultConfig()
311+ cfg.Workspace.ExternalLinks.Behavior = behavior
312+ cfg.Workspace.ExternalLinks.Placement = placement
313+ require.NoError(t, validateConfig(cfg))
314+ }
315+ }
316+}
317+
318+func TestValidateConfig_ExternalLinksRejectsUnsupportedValues(t *testing.T) {
319+ cfg := DefaultConfig()
320+ cfg.Workspace.ExternalLinks.Behavior = "invalid"
321+ cfg.Workspace.ExternalLinks.Placement = "invalid"
322+
323+ err := validateConfig(cfg)
324+ require.Error(t, err)
325+ assert.Contains(t, err.Error(), "workspace.external_links.behavior")
326+ assert.Contains(t, err.Error(), "workspace.external_links.placement")
327+}
328+
297329 func TestValidateConfig_WorkspaceNewPaneURLAllowsExistingAbsoluteLocalPath(t *testing.T) {
298330 tmpDir := t.TempDir()
299331 path := filepath.Join(tmpDir, "page.html")
@@ -294,6 +294,38 @@ func TestValidateConfig_CEFConfig(t *testing.T) {
294 }294 }
295 }295 }
296 296
297+func TestValidateConfig_ExternalLinksAcceptsSupportedValues(t *testing.T) {
298+ for _, behavior := range []ExternalLinkBehavior{
299+ ExternalLinkBehaviorWindowed,
300+ ExternalLinkBehaviorTabbed,
301+ ExternalLinkBehaviorSplit,
302+ ExternalLinkBehaviorStacked,
303+ } {
304+ for _, placement := range []ExternalLinkPlacement{
305+ ExternalLinkPlacementRight,
306+ ExternalLinkPlacementLeft,
307+ ExternalLinkPlacementTop,
308+ ExternalLinkPlacementBottom,
309+ } {
310+ cfg := DefaultConfig()
311+ cfg.Workspace.ExternalLinks.Behavior = behavior
312+ cfg.Workspace.ExternalLinks.Placement = placement
313+ require.NoError(t, validateConfig(cfg))
314+ }
315+ }
316+}
317+
318+func TestValidateConfig_ExternalLinksRejectsUnsupportedValues(t *testing.T) {
319+ cfg := DefaultConfig()
320+ cfg.Workspace.ExternalLinks.Behavior = "invalid"
321+ cfg.Workspace.ExternalLinks.Placement = "invalid"
322+
323+ err := validateConfig(cfg)
324+ require.Error(t, err)
325+ assert.Contains(t, err.Error(), "workspace.external_links.behavior")
326+ assert.Contains(t, err.Error(), "workspace.external_links.placement")
327+}
328+
297 func TestValidateConfig_WorkspaceNewPaneURLAllowsExistingAbsoluteLocalPath(t *testing.T) {329 func TestValidateConfig_WorkspaceNewPaneURLAllowsExistingAbsoluteLocalPath(t *testing.T) {
298 tmpDir := t.TempDir()330 tmpDir := t.TempDir()
299 path := filepath.Join(tmpDir, "page.html")331 path := filepath.Join(tmpDir, "page.html")
M internal/infrastructure/desktop/browser_launch_relay.go +5 -5
@@ -68,7 +68,7 @@ func NewBrowserLaunchRelay(ipc runtimeprofile.IPCPaths) port.BrowserLaunchRelay
6868 return &browserLaunchRelay{ipc: ipc}
6969 }
7070
71-func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error) {
71+func (r *browserLaunchRelay) DeliverOpenExternalURL(ctx context.Context, url string) (bool, error) {
7272 socketPath, err := r.socketPath()
7373 if err != nil {
7474 return false, err
@@ -353,20 +353,20 @@ func (*browserLaunchRelayListener) handleConnection(ctx context.Context, conn *n
353353 log.Debug().
354354 Str("request_id", requestID).
355355 Str("url_host", safeURLHost(request.URL)).
356- Msg("browser launch relay calling browser window opener")
357- if err := opener.OpenFreshWindow(ctx, request.URL); err != nil {
356+ Msg("browser launch relay calling external URL opener")
357+ if err := opener.OpenExternalURL(ctx, request.URL); err != nil {
358358 log.Warn().Err(err).
359359 Str("request_id", requestID).
360360 Str("url_host", safeURLHost(request.URL)).
361361 Dur("elapsed", time.Since(started)).
362- Msg("browser launch relay opener failed")
362+ Msg("browser launch relay external URL opener failed")
363363 return
364364 }
365365 log.Debug().
366366 Str("request_id", requestID).
367367 Str("url_host", safeURLHost(request.URL)).
368368 Dur("elapsed", time.Since(started)).
369- Msg("browser launch relay opener returned success")
369+ Msg("browser launch relay external URL opener returned success")
370370 }()
371371 }
372372
@@ -68,7 +68,7 @@ func NewBrowserLaunchRelay(ipc runtimeprofile.IPCPaths) port.BrowserLaunchRelay
68 return &browserLaunchRelay{ipc: ipc}68 return &browserLaunchRelay{ipc: ipc}
69 }69 }
70 70
71-func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error) {71+func (r *browserLaunchRelay) DeliverOpenExternalURL(ctx context.Context, url string) (bool, error) {
72 socketPath, err := r.socketPath()72 socketPath, err := r.socketPath()
73 if err != nil {73 if err != nil {
74 return false, err74 return false, err
@@ -353,20 +353,20 @@ func (*browserLaunchRelayListener) handleConnection(ctx context.Context, conn *n
353 log.Debug().353 log.Debug().
354 Str("request_id", requestID).354 Str("request_id", requestID).
355 Str("url_host", safeURLHost(request.URL)).355 Str("url_host", safeURLHost(request.URL)).
356- Msg("browser launch relay calling browser window opener")356+ Msg("browser launch relay calling external URL opener")
357- if err := opener.OpenFreshWindow(ctx, request.URL); err != nil {357+ if err := opener.OpenExternalURL(ctx, request.URL); err != nil {
358 log.Warn().Err(err).358 log.Warn().Err(err).
359 Str("request_id", requestID).359 Str("request_id", requestID).
360 Str("url_host", safeURLHost(request.URL)).360 Str("url_host", safeURLHost(request.URL)).
361 Dur("elapsed", time.Since(started)).361 Dur("elapsed", time.Since(started)).
362- Msg("browser launch relay opener failed")362+ Msg("browser launch relay external URL opener failed")
363 return363 return
364 }364 }
365 log.Debug().365 log.Debug().
366 Str("request_id", requestID).366 Str("request_id", requestID).
367 Str("url_host", safeURLHost(request.URL)).367 Str("url_host", safeURLHost(request.URL)).
368 Dur("elapsed", time.Since(started)).368 Dur("elapsed", time.Since(started)).
369- Msg("browser launch relay opener returned success")369+ Msg("browser launch relay external URL opener returned success")
370 }()370 }()
371 }371 }
372 372
M internal/infrastructure/desktop/browser_launch_relay_test.go +20 -20
@@ -17,7 +17,7 @@ import (
1717
1818 type browserWindowOpenerFunc func(context.Context, string) error
1919
20-func (f browserWindowOpenerFunc) OpenFreshWindow(ctx context.Context, url string) error {
20+func (f browserWindowOpenerFunc) OpenExternalURL(ctx context.Context, url string) error {
2121 return f(ctx, url)
2222 }
2323
@@ -112,10 +112,10 @@ func overrideBrowserLaunchRequestID(t *testing.T, id string) func() {
112112 return func() { newBrowserLaunchRequestID = previous }
113113 }
114114
115-func TestBrowserLaunchRelay_DeliverOpenFreshWindow_MissingListenerReturnsFalseNil(t *testing.T) {
115+func TestBrowserLaunchRelay_DeliverOpenExternalURL_MissingListenerReturnsFalseNil(t *testing.T) {
116116 relay := NewBrowserLaunchRelay(testIPC(shortTempDir(t)))
117117
118- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com")
118+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com")
119119
120120 require.NoError(t, err)
121121 assert.False(t, delivered)
@@ -138,7 +138,7 @@ func TestBrowserLaunchRelay_SameProfileAndEngine_UsesSameSocket(t *testing.T) {
138138
139139 waitForSocket(t, ipc.BrowserLaunchSocket)
140140
141- delivered, err := clientRelay.DeliverOpenFreshWindow(context.Background(), "https://example.com/same-namespace")
141+ delivered, err := clientRelay.DeliverOpenExternalURL(context.Background(), "https://example.com/same-namespace")
142142 require.NoError(t, err)
143143 require.True(t, delivered)
144144 require.Equal(t, "https://example.com/same-namespace", <-received)
@@ -151,7 +151,7 @@ func TestBrowserLaunchRelay_DevCEFAndDevWebKit_UseDifferentSockets(t *testing.T)
151151 require.NotEqual(t, cefIPC.BrowserLaunchSocket, wkIPC.BrowserLaunchSocket)
152152 }
153153
154-func TestBrowserLaunchRelay_DeliverOpenFreshWindow_SendsRequestIDAndAcceptsMatchingResponse(t *testing.T) {
154+func TestBrowserLaunchRelay_DeliverOpenExternalURL_SendsRequestIDAndAcceptsMatchingResponse(t *testing.T) {
155155 ipc := testIPC(shortTempDir(t))
156156 relay := NewBrowserLaunchRelay(ipc)
157157 restore := overrideBrowserLaunchRequestID(t, "request-test-1")
@@ -180,7 +180,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_SendsRequestIDAndAcceptsMatch
180180 _ = json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: request.RequestID, Accepted: true})
181181 }()
182182
183- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/request-id")
183+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/request-id")
184184
185185 require.NoError(t, err)
186186 require.True(t, delivered)
@@ -189,7 +189,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_SendsRequestIDAndAcceptsMatch
189189 require.Equal(t, "https://example.com/request-id", request.URL)
190190 }
191191
192-func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RejectsMismatchedResponseRequestID(t *testing.T) {
192+func TestBrowserLaunchRelay_DeliverOpenExternalURL_RejectsMismatchedResponseRequestID(t *testing.T) {
193193 ipc := testIPC(shortTempDir(t))
194194 relay := NewBrowserLaunchRelay(ipc)
195195 restore := overrideBrowserLaunchRequestID(t, "request-test-2")
@@ -216,14 +216,14 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RejectsMismatchedResponseRequ
216216 _ = json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: "different-request", Accepted: true})
217217 }()
218218
219- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/request-id-mismatch")
219+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/request-id-mismatch")
220220
221221 require.Error(t, err)
222222 require.True(t, delivered)
223223 require.Contains(t, err.Error(), "mismatched browser launch relay response")
224224 }
225225
226-func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RoundTrip(t *testing.T) {
226+func TestBrowserLaunchRelay_DeliverOpenExternalURL_RoundTrip(t *testing.T) {
227227 ipc := testIPC(shortTempDir(t))
228228 relay := NewBrowserLaunchRelay(ipc)
229229
@@ -239,7 +239,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RoundTrip(t *testing.T) {
239239
240240 waitForSocket(t, ipc.BrowserLaunchSocket)
241241
242- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/new")
242+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/new")
243243
244244 require.NoError(t, err)
245245 assert.True(t, delivered)
@@ -279,7 +279,7 @@ func TestBrowserLaunchRelay_SecondListenWhileLiveFailsWithoutReplacingListener(t
279279 _, err = relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
280280 require.Error(t, err)
281281
282- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/live")
282+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/live")
283283 require.NoError(t, err)
284284 require.True(t, delivered)
285285
@@ -310,7 +310,7 @@ func TestBrowserLaunchRelay_RebindsStaleSocketPath(t *testing.T) {
310310
311311 waitForSocket(t, ipc.BrowserLaunchSocket)
312312
313- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/stale")
313+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/stale")
314314 require.NoError(t, err)
315315 require.True(t, delivered)
316316
@@ -345,7 +345,7 @@ func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) {
345345 require.NoError(t, err)
346346 require.NoError(t, conn.Close())
347347
348- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/still-works")
348+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/still-works")
349349
350350 require.NoError(t, err)
351351 assert.True(t, delivered)
@@ -387,12 +387,12 @@ func TestBrowserLaunchRelay_ContextCancelStopsServing(t *testing.T) {
387387 waitForSocketGone(t, ipc.BrowserLaunchSocket)
388388 require.NoError(t, closer.Close())
389389
390- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/after-cancel")
390+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/after-cancel")
391391 require.NoError(t, err)
392392 assert.False(t, delivered)
393393 }
394394
395-func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t *testing.T) {
395+func TestBrowserLaunchRelay_DeliverOpenExternalURL_RespectsResponseReadTimeout(t *testing.T) {
396396 ipc := testIPC(shortTempDir(t))
397397 require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700))
398398
@@ -419,13 +419,13 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t
419419 ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
420420 defer cancel()
421421
422- delivered, err := relay.DeliverOpenFreshWindow(ctx, "https://example.com/slow")
422+ delivered, err := relay.DeliverOpenExternalURL(ctx, "https://example.com/slow")
423423 require.Error(t, err)
424424 require.ErrorIs(t, err, context.DeadlineExceeded)
425425 assert.False(t, delivered)
426426 }
427427
428-func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsUnconfirmedErrorOnNoDeadlineTimeout(t *testing.T) {
428+func TestBrowserLaunchRelay_DeliverOpenExternalURL_ReturnsUnconfirmedErrorOnNoDeadlineTimeout(t *testing.T) {
429429 ipc := testIPC(shortTempDir(t))
430430 require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700))
431431
@@ -459,7 +459,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsUnconfirmedErrorOnNoDe
459459 result := make(chan deliverResult, 1)
460460 go func() {
461461 close(started)
462- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/slow")
462+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/slow")
463463 result <- deliverResult{delivered: delivered, err: err}
464464 }()
465465 <-started
@@ -470,7 +470,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsUnconfirmedErrorOnNoDe
470470 require.Error(t, got.err)
471471 require.ErrorIs(t, got.err, ErrBrowserLaunchRelayUnconfirmed)
472472 case <-time.After(browserLaunchIOTimeout * 3):
473- t.Fatal("DeliverOpenFreshWindow blocked without a caller deadline")
473+ t.Fatal("DeliverOpenExternalURL blocked without a caller deadline")
474474 }
475475 }
476476
@@ -574,7 +574,7 @@ func TestBrowserLaunchRelay_SilentClientDoesNotStallListener(t *testing.T) {
574574 }
575575 result := make(chan deliverResult, 1)
576576 go func() {
577- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/recovered")
577+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/recovered")
578578 result <- deliverResult{delivered: delivered, err: err}
579579 }()
580580
@@ -17,7 +17,7 @@ import (
17 17
18 type browserWindowOpenerFunc func(context.Context, string) error18 type browserWindowOpenerFunc func(context.Context, string) error
19 19
20-func (f browserWindowOpenerFunc) OpenFreshWindow(ctx context.Context, url string) error {20+func (f browserWindowOpenerFunc) OpenExternalURL(ctx context.Context, url string) error {
21 return f(ctx, url)21 return f(ctx, url)
22 }22 }
23 23
@@ -112,10 +112,10 @@ func overrideBrowserLaunchRequestID(t *testing.T, id string) func() {
112 return func() { newBrowserLaunchRequestID = previous }112 return func() { newBrowserLaunchRequestID = previous }
113 }113 }
114 114
115-func TestBrowserLaunchRelay_DeliverOpenFreshWindow_MissingListenerReturnsFalseNil(t *testing.T) {115+func TestBrowserLaunchRelay_DeliverOpenExternalURL_MissingListenerReturnsFalseNil(t *testing.T) {
116 relay := NewBrowserLaunchRelay(testIPC(shortTempDir(t)))116 relay := NewBrowserLaunchRelay(testIPC(shortTempDir(t)))
117 117
118- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com")118+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com")
119 119
120 require.NoError(t, err)120 require.NoError(t, err)
121 assert.False(t, delivered)121 assert.False(t, delivered)
@@ -138,7 +138,7 @@ func TestBrowserLaunchRelay_SameProfileAndEngine_UsesSameSocket(t *testing.T) {
138 138
139 waitForSocket(t, ipc.BrowserLaunchSocket)139 waitForSocket(t, ipc.BrowserLaunchSocket)
140 140
141- delivered, err := clientRelay.DeliverOpenFreshWindow(context.Background(), "https://example.com/same-namespace")141+ delivered, err := clientRelay.DeliverOpenExternalURL(context.Background(), "https://example.com/same-namespace")
142 require.NoError(t, err)142 require.NoError(t, err)
143 require.True(t, delivered)143 require.True(t, delivered)
144 require.Equal(t, "https://example.com/same-namespace", <-received)144 require.Equal(t, "https://example.com/same-namespace", <-received)
@@ -151,7 +151,7 @@ func TestBrowserLaunchRelay_DevCEFAndDevWebKit_UseDifferentSockets(t *testing.T)
151 require.NotEqual(t, cefIPC.BrowserLaunchSocket, wkIPC.BrowserLaunchSocket)151 require.NotEqual(t, cefIPC.BrowserLaunchSocket, wkIPC.BrowserLaunchSocket)
152 }152 }
153 153
154-func TestBrowserLaunchRelay_DeliverOpenFreshWindow_SendsRequestIDAndAcceptsMatchingResponse(t *testing.T) {154+func TestBrowserLaunchRelay_DeliverOpenExternalURL_SendsRequestIDAndAcceptsMatchingResponse(t *testing.T) {
155 ipc := testIPC(shortTempDir(t))155 ipc := testIPC(shortTempDir(t))
156 relay := NewBrowserLaunchRelay(ipc)156 relay := NewBrowserLaunchRelay(ipc)
157 restore := overrideBrowserLaunchRequestID(t, "request-test-1")157 restore := overrideBrowserLaunchRequestID(t, "request-test-1")
@@ -180,7 +180,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_SendsRequestIDAndAcceptsMatch
180 _ = json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: request.RequestID, Accepted: true})180 _ = json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: request.RequestID, Accepted: true})
181 }()181 }()
182 182
183- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/request-id")183+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/request-id")
184 184
185 require.NoError(t, err)185 require.NoError(t, err)
186 require.True(t, delivered)186 require.True(t, delivered)
@@ -189,7 +189,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_SendsRequestIDAndAcceptsMatch
189 require.Equal(t, "https://example.com/request-id", request.URL)189 require.Equal(t, "https://example.com/request-id", request.URL)
190 }190 }
191 191
192-func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RejectsMismatchedResponseRequestID(t *testing.T) {192+func TestBrowserLaunchRelay_DeliverOpenExternalURL_RejectsMismatchedResponseRequestID(t *testing.T) {
193 ipc := testIPC(shortTempDir(t))193 ipc := testIPC(shortTempDir(t))
194 relay := NewBrowserLaunchRelay(ipc)194 relay := NewBrowserLaunchRelay(ipc)
195 restore := overrideBrowserLaunchRequestID(t, "request-test-2")195 restore := overrideBrowserLaunchRequestID(t, "request-test-2")
@@ -216,14 +216,14 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RejectsMismatchedResponseRequ
216 _ = json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: "different-request", Accepted: true})216 _ = json.NewEncoder(conn).Encode(browserLaunchResponse{RequestID: "different-request", Accepted: true})
217 }()217 }()
218 218
219- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/request-id-mismatch")219+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/request-id-mismatch")
220 220
221 require.Error(t, err)221 require.Error(t, err)
222 require.True(t, delivered)222 require.True(t, delivered)
223 require.Contains(t, err.Error(), "mismatched browser launch relay response")223 require.Contains(t, err.Error(), "mismatched browser launch relay response")
224 }224 }
225 225
226-func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RoundTrip(t *testing.T) {226+func TestBrowserLaunchRelay_DeliverOpenExternalURL_RoundTrip(t *testing.T) {
227 ipc := testIPC(shortTempDir(t))227 ipc := testIPC(shortTempDir(t))
228 relay := NewBrowserLaunchRelay(ipc)228 relay := NewBrowserLaunchRelay(ipc)
229 229
@@ -239,7 +239,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RoundTrip(t *testing.T) {
239 239
240 waitForSocket(t, ipc.BrowserLaunchSocket)240 waitForSocket(t, ipc.BrowserLaunchSocket)
241 241
242- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/new")242+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/new")
243 243
244 require.NoError(t, err)244 require.NoError(t, err)
245 assert.True(t, delivered)245 assert.True(t, delivered)
@@ -279,7 +279,7 @@ func TestBrowserLaunchRelay_SecondListenWhileLiveFailsWithoutReplacingListener(t
279 _, err = relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil }))279 _, err = relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
280 require.Error(t, err)280 require.Error(t, err)
281 281
282- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/live")282+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/live")
283 require.NoError(t, err)283 require.NoError(t, err)
284 require.True(t, delivered)284 require.True(t, delivered)
285 285
@@ -310,7 +310,7 @@ func TestBrowserLaunchRelay_RebindsStaleSocketPath(t *testing.T) {
310 310
311 waitForSocket(t, ipc.BrowserLaunchSocket)311 waitForSocket(t, ipc.BrowserLaunchSocket)
312 312
313- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/stale")313+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/stale")
314 require.NoError(t, err)314 require.NoError(t, err)
315 require.True(t, delivered)315 require.True(t, delivered)
316 316
@@ -345,7 +345,7 @@ func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) {
345 require.NoError(t, err)345 require.NoError(t, err)
346 require.NoError(t, conn.Close())346 require.NoError(t, conn.Close())
347 347
348- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/still-works")348+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/still-works")
349 349
350 require.NoError(t, err)350 require.NoError(t, err)
351 assert.True(t, delivered)351 assert.True(t, delivered)
@@ -387,12 +387,12 @@ func TestBrowserLaunchRelay_ContextCancelStopsServing(t *testing.T) {
387 waitForSocketGone(t, ipc.BrowserLaunchSocket)387 waitForSocketGone(t, ipc.BrowserLaunchSocket)
388 require.NoError(t, closer.Close())388 require.NoError(t, closer.Close())
389 389
390- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/after-cancel")390+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/after-cancel")
391 require.NoError(t, err)391 require.NoError(t, err)
392 assert.False(t, delivered)392 assert.False(t, delivered)
393 }393 }
394 394
395-func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t *testing.T) {395+func TestBrowserLaunchRelay_DeliverOpenExternalURL_RespectsResponseReadTimeout(t *testing.T) {
396 ipc := testIPC(shortTempDir(t))396 ipc := testIPC(shortTempDir(t))
397 require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700))397 require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700))
398 398
@@ -419,13 +419,13 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t
419 ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)419 ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
420 defer cancel()420 defer cancel()
421 421
422- delivered, err := relay.DeliverOpenFreshWindow(ctx, "https://example.com/slow")422+ delivered, err := relay.DeliverOpenExternalURL(ctx, "https://example.com/slow")
423 require.Error(t, err)423 require.Error(t, err)
424 require.ErrorIs(t, err, context.DeadlineExceeded)424 require.ErrorIs(t, err, context.DeadlineExceeded)
425 assert.False(t, delivered)425 assert.False(t, delivered)
426 }426 }
427 427
428-func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsUnconfirmedErrorOnNoDeadlineTimeout(t *testing.T) {428+func TestBrowserLaunchRelay_DeliverOpenExternalURL_ReturnsUnconfirmedErrorOnNoDeadlineTimeout(t *testing.T) {
429 ipc := testIPC(shortTempDir(t))429 ipc := testIPC(shortTempDir(t))
430 require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700))430 require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700))
431 431
@@ -459,7 +459,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsUnconfirmedErrorOnNoDe
459 result := make(chan deliverResult, 1)459 result := make(chan deliverResult, 1)
460 go func() {460 go func() {
461 close(started)461 close(started)
462- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/slow")462+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/slow")
463 result <- deliverResult{delivered: delivered, err: err}463 result <- deliverResult{delivered: delivered, err: err}
464 }()464 }()
465 <-started465 <-started
@@ -470,7 +470,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsUnconfirmedErrorOnNoDe
470 require.Error(t, got.err)470 require.Error(t, got.err)
471 require.ErrorIs(t, got.err, ErrBrowserLaunchRelayUnconfirmed)471 require.ErrorIs(t, got.err, ErrBrowserLaunchRelayUnconfirmed)
472 case <-time.After(browserLaunchIOTimeout * 3):472 case <-time.After(browserLaunchIOTimeout * 3):
473- t.Fatal("DeliverOpenFreshWindow blocked without a caller deadline")473+ t.Fatal("DeliverOpenExternalURL blocked without a caller deadline")
474 }474 }
475 }475 }
476 476
@@ -574,7 +574,7 @@ func TestBrowserLaunchRelay_SilentClientDoesNotStallListener(t *testing.T) {
574 }574 }
575 result := make(chan deliverResult, 1)575 result := make(chan deliverResult, 1)
576 go func() {576 go func() {
577- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/recovered")577+ delivered, err := relay.DeliverOpenExternalURL(context.Background(), "https://example.com/recovered")
578 result <- deliverResult{delivered: delivered, err: err}578 result <- deliverResult{delivered: delivered, err: err}
579 }()579 }()
580 580
M internal/infrastructure/desktop/browser_launcher.go +1 -1
@@ -36,7 +36,7 @@ func (l *BrowserLauncher) LaunchURL(ctx context.Context, url string) error {
3636 }
3737
3838 if l.relay != nil {
39- delivered, err := l.relay.DeliverOpenFreshWindow(ctx, url)
39+ delivered, err := l.relay.DeliverOpenExternalURL(ctx, url)
4040 if err != nil {
4141 if delivered && errors.Is(err, ErrBrowserLaunchRelayUnconfirmed) {
4242 return ErrBrowserLaunchUnconfirmed
@@ -36,7 +36,7 @@ func (l *BrowserLauncher) LaunchURL(ctx context.Context, url string) error {
36 }36 }
37 37
38 if l.relay != nil {38 if l.relay != nil {
39- delivered, err := l.relay.DeliverOpenFreshWindow(ctx, url)39+ delivered, err := l.relay.DeliverOpenExternalURL(ctx, url)
40 if err != nil {40 if err != nil {
41 if delivered && errors.Is(err, ErrBrowserLaunchRelayUnconfirmed) {41 if delivered && errors.Is(err, ErrBrowserLaunchRelayUnconfirmed) {
42 return ErrBrowserLaunchUnconfirmed42 return ErrBrowserLaunchUnconfirmed
M internal/infrastructure/desktop/browser_launcher_test.go +4 -4
@@ -13,7 +13,7 @@ import (
1313
1414 func TestBrowserLauncher_LaunchURL_ReturnsWithoutSpawningWhenRelayDelivers(t *testing.T) {
1515 relay := mocks.NewMockBrowserLaunchRelay(t)
16- relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(true, nil)
16+ relay.EXPECT().DeliverOpenExternalURL(context.Background(), "https://example.com").Return(true, nil)
1717
1818 launcher := NewBrowserLauncher(relay)
1919 spawned := false
@@ -34,7 +34,7 @@ func TestBrowserLauncher_LaunchURL_ReturnsWithoutSpawningWhenRelayDelivers(t *te
3434
3535 func TestBrowserLauncher_LaunchURL_FallsBackToSpawnWhenRelayMisses(t *testing.T) {
3636 relay := mocks.NewMockBrowserLaunchRelay(t)
37- relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(false, nil)
37+ relay.EXPECT().DeliverOpenExternalURL(context.Background(), "https://example.com").Return(false, nil)
3838
3939 launcher := NewBrowserLauncher(relay)
4040 launcher.resolveExecutablePath = func() (string, error) {
@@ -57,7 +57,7 @@ func TestBrowserLauncher_LaunchURL_FallsBackToSpawnWhenRelayMisses(t *testing.T)
5757
5858 func TestBrowserLauncher_LaunchURL_ReturnsUnconfirmedErrorWhenRelayDeliveryIsAmbiguous(t *testing.T) {
5959 relay := mocks.NewMockBrowserLaunchRelay(t)
60- relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(true, ErrBrowserLaunchRelayUnconfirmed)
60+ relay.EXPECT().DeliverOpenExternalURL(context.Background(), "https://example.com").Return(true, ErrBrowserLaunchRelayUnconfirmed)
6161
6262 launcher := NewBrowserLauncher(relay)
6363 launcher.resolveExecutablePath = func() (string, error) {
@@ -78,7 +78,7 @@ func TestBrowserLauncher_LaunchURL_ReturnsUnconfirmedErrorWhenRelayDeliveryIsAmb
7878 func TestBrowserLauncher_LaunchURL_PropagatesRelayError(t *testing.T) {
7979 relay := mocks.NewMockBrowserLaunchRelay(t)
8080 wantErr := errors.New("relay exploded")
81- relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(false, wantErr)
81+ relay.EXPECT().DeliverOpenExternalURL(context.Background(), "https://example.com").Return(false, wantErr)
8282
8383 launcher := NewBrowserLauncher(relay)
8484 launcher.resolveExecutablePath = func() (string, error) {
@@ -13,7 +13,7 @@ import (
13 13
14 func TestBrowserLauncher_LaunchURL_ReturnsWithoutSpawningWhenRelayDelivers(t *testing.T) {14 func TestBrowserLauncher_LaunchURL_ReturnsWithoutSpawningWhenRelayDelivers(t *testing.T) {
15 relay := mocks.NewMockBrowserLaunchRelay(t)15 relay := mocks.NewMockBrowserLaunchRelay(t)
16- relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(true, nil)16+ relay.EXPECT().DeliverOpenExternalURL(context.Background(), "https://example.com").Return(true, nil)
17 17
18 launcher := NewBrowserLauncher(relay)18 launcher := NewBrowserLauncher(relay)
19 spawned := false19 spawned := false
@@ -34,7 +34,7 @@ func TestBrowserLauncher_LaunchURL_ReturnsWithoutSpawningWhenRelayDelivers(t *te
34 34
35 func TestBrowserLauncher_LaunchURL_FallsBackToSpawnWhenRelayMisses(t *testing.T) {35 func TestBrowserLauncher_LaunchURL_FallsBackToSpawnWhenRelayMisses(t *testing.T) {
36 relay := mocks.NewMockBrowserLaunchRelay(t)36 relay := mocks.NewMockBrowserLaunchRelay(t)
37- relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(false, nil)37+ relay.EXPECT().DeliverOpenExternalURL(context.Background(), "https://example.com").Return(false, nil)
38 38
39 launcher := NewBrowserLauncher(relay)39 launcher := NewBrowserLauncher(relay)
40 launcher.resolveExecutablePath = func() (string, error) {40 launcher.resolveExecutablePath = func() (string, error) {
@@ -57,7 +57,7 @@ func TestBrowserLauncher_LaunchURL_FallsBackToSpawnWhenRelayMisses(t *testing.T)
57 57
58 func TestBrowserLauncher_LaunchURL_ReturnsUnconfirmedErrorWhenRelayDeliveryIsAmbiguous(t *testing.T) {58 func TestBrowserLauncher_LaunchURL_ReturnsUnconfirmedErrorWhenRelayDeliveryIsAmbiguous(t *testing.T) {
59 relay := mocks.NewMockBrowserLaunchRelay(t)59 relay := mocks.NewMockBrowserLaunchRelay(t)
60- relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(true, ErrBrowserLaunchRelayUnconfirmed)60+ relay.EXPECT().DeliverOpenExternalURL(context.Background(), "https://example.com").Return(true, ErrBrowserLaunchRelayUnconfirmed)
61 61
62 launcher := NewBrowserLauncher(relay)62 launcher := NewBrowserLauncher(relay)
63 launcher.resolveExecutablePath = func() (string, error) {63 launcher.resolveExecutablePath = func() (string, error) {
@@ -78,7 +78,7 @@ func TestBrowserLauncher_LaunchURL_ReturnsUnconfirmedErrorWhenRelayDeliveryIsAmb
78 func TestBrowserLauncher_LaunchURL_PropagatesRelayError(t *testing.T) {78 func TestBrowserLauncher_LaunchURL_PropagatesRelayError(t *testing.T) {
79 relay := mocks.NewMockBrowserLaunchRelay(t)79 relay := mocks.NewMockBrowserLaunchRelay(t)
80 wantErr := errors.New("relay exploded")80 wantErr := errors.New("relay exploded")
81- relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(false, wantErr)81+ relay.EXPECT().DeliverOpenExternalURL(context.Background(), "https://example.com").Return(false, wantErr)
82 82
83 launcher := NewBrowserLauncher(relay)83 launcher := NewBrowserLauncher(relay)
84 launcher.resolveExecutablePath = func() (string, error) {84 launcher.resolveExecutablePath = func() (string, error) {
M internal/ui/app_browser_launch_test.go +14 -14
@@ -47,7 +47,7 @@ type testBrowserLaunchRelay struct {
4747 closer *testCloser
4848 }
4949
50-func (r *testBrowserLaunchRelay) DeliverOpenFreshWindow(context.Context, string) (bool, error) {
50+func (r *testBrowserLaunchRelay) DeliverOpenExternalURL(context.Context, string) (bool, error) {
5151 return false, nil
5252 }
5353
@@ -1083,7 +1083,7 @@ func TestApp_GetWindowSnapshotStateReturnsUnavailableWhenMainThreadDispatchTimes
10831083 require.Equal(t, -1, activeWindowIndex)
10841084 }
10851085
1086-func TestApp_OpenFreshWindowReportsMainThreadDispatchTimeout(t *testing.T) {
1086+func TestApp_OpenExternalURLReportsMainThreadDispatchTimeout(t *testing.T) {
10871087 var factoryCalls int
10881088 app := &App{
10891089 dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult {
@@ -1095,20 +1095,20 @@ func TestApp_OpenFreshWindowReportsMainThreadDispatchTimeout(t *testing.T) {
10951095 },
10961096 }
10971097
1098- err := app.OpenFreshWindow(context.Background(), "https://example.com")
1098+ err := app.OpenExternalURL(context.Background(), "https://example.com")
10991099
11001100 if err == nil {
1101- t.Fatal("OpenFreshWindow returned nil error, want dispatch timeout")
1101+ t.Fatal("OpenExternalURL returned nil error, want dispatch timeout")
11021102 }
11031103 if !strings.Contains(err.Error(), "main thread dispatch did not complete") {
1104- t.Fatalf("OpenFreshWindow error = %q, want dispatch timeout", err.Error())
1104+ t.Fatalf("OpenExternalURL error = %q, want dispatch timeout", err.Error())
11051105 }
11061106 if factoryCalls != 0 {
11071107 t.Fatalf("browserWindowFactory calls = %d, want 0", factoryCalls)
11081108 }
11091109 }
11101110
1111-func TestApp_OpenFreshWindowRecordsTabOwnership(t *testing.T) {
1111+func TestApp_OpenExternalURLRecordsTabOwnership(t *testing.T) {
11121112 existingTab := entity.NewTab(entity.TabID("existing-tab"), entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane")))
11131113 existingTabs := entity.NewTabList()
11141114 existingTabs.Add(existingTab)
@@ -1126,8 +1126,8 @@ func TestApp_OpenFreshWindowRecordsTabOwnership(t *testing.T) {
11261126 },
11271127 }
11281128
1129- if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {
1130- t.Fatalf("OpenFreshWindow returned error: %v", err)
1129+ if err := app.OpenExternalURL(context.Background(), "https://example.com"); err != nil {
1130+ t.Fatalf("OpenExternalURL returned error: %v", err)
11311131 }
11321132 if got := windowForTabCount(t, app); got != 2 {
11331133 t.Fatalf("windowForTab length = %d, want 2 (existing + new tab)", got)
@@ -1375,7 +1375,7 @@ func TestApp_RemoveBrowserWindowRebindsPromotedTabCoordinatorWindow(t *testing.T
13751375 }
13761376 }
13771377
1378-func TestApp_OpenFreshWindowRollsBackOnTabCreationFailure(t *testing.T) {
1378+func TestApp_OpenExternalURLRollsBackOnTabCreationFailure(t *testing.T) {
13791379 created := &browserWindow{id: "window-1", tabs: entity.NewTabList()}
13801380 originalWindow := &window.MainWindow{}
13811381 tabBar := &component.TabBar{}
@@ -1406,8 +1406,8 @@ func TestApp_OpenFreshWindowRollsBackOnTabCreationFailure(t *testing.T) {
14061406 MainWindow: &window.MainWindow{},
14071407 })
14081408
1409- if err := app.OpenFreshWindow(context.Background(), "https://example.com/fail"); err == nil {
1410- t.Fatalf("OpenFreshWindow = nil error, want failure")
1409+ if err := app.OpenExternalURL(context.Background(), "https://example.com/fail"); err == nil {
1410+ t.Fatalf("OpenExternalURL = nil error, want failure")
14111411 }
14121412 if got := len(app.browserWindows); got != 1 {
14131413 t.Fatalf("browserWindows length = %d, want 1 (existing window only)", got)
@@ -1426,7 +1426,7 @@ func TestApp_OpenFreshWindowRollsBackOnTabCreationFailure(t *testing.T) {
14261426 }
14271427 }
14281428
1429-func TestApp_OpenFreshWindowTargetsNewWindowTabBar(t *testing.T) {
1429+func TestApp_OpenExternalURLTargetsNewWindowTabBar(t *testing.T) {
14301430 existingTabID := entity.TabID("existing-tab")
14311431 createdTabID := entity.TabID("id-1")
14321432 oldWindow := &window.MainWindow{}
@@ -1463,8 +1463,8 @@ func TestApp_OpenFreshWindowTargetsNewWindowTabBar(t *testing.T) {
14631463 app.workspaceViews[tab.ID] = &component.WorkspaceView{}
14641464 })
14651465
1466- if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {
1467- t.Fatalf("OpenFreshWindow returned error: %v", err)
1466+ if err := app.OpenExternalURL(context.Background(), "https://example.com"); err != nil {
1467+ t.Fatalf("OpenExternalURL returned error: %v", err)
14681468 }
14691469
14701470 gotCreatedTabID := app.tabs.ActiveTabID
@@ -47,7 +47,7 @@ type testBrowserLaunchRelay struct {
47 closer *testCloser47 closer *testCloser
48 }48 }
49 49
50-func (r *testBrowserLaunchRelay) DeliverOpenFreshWindow(context.Context, string) (bool, error) {50+func (r *testBrowserLaunchRelay) DeliverOpenExternalURL(context.Context, string) (bool, error) {
51 return false, nil51 return false, nil
52 }52 }
53 53
@@ -1083,7 +1083,7 @@ func TestApp_GetWindowSnapshotStateReturnsUnavailableWhenMainThreadDispatchTimes
1083 require.Equal(t, -1, activeWindowIndex)1083 require.Equal(t, -1, activeWindowIndex)
1084 }1084 }
1085 1085
1086-func TestApp_OpenFreshWindowReportsMainThreadDispatchTimeout(t *testing.T) {1086+func TestApp_OpenExternalURLReportsMainThreadDispatchTimeout(t *testing.T) {
1087 var factoryCalls int1087 var factoryCalls int
1088 app := &App{1088 app := &App{
1089 dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult {1089 dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult {
@@ -1095,20 +1095,20 @@ func TestApp_OpenFreshWindowReportsMainThreadDispatchTimeout(t *testing.T) {
1095 },1095 },
1096 }1096 }
1097 1097
1098- err := app.OpenFreshWindow(context.Background(), "https://example.com")1098+ err := app.OpenExternalURL(context.Background(), "https://example.com")
1099 1099
1100 if err == nil {1100 if err == nil {
1101- t.Fatal("OpenFreshWindow returned nil error, want dispatch timeout")1101+ t.Fatal("OpenExternalURL returned nil error, want dispatch timeout")
1102 }1102 }
1103 if !strings.Contains(err.Error(), "main thread dispatch did not complete") {1103 if !strings.Contains(err.Error(), "main thread dispatch did not complete") {
1104- t.Fatalf("OpenFreshWindow error = %q, want dispatch timeout", err.Error())1104+ t.Fatalf("OpenExternalURL error = %q, want dispatch timeout", err.Error())
1105 }1105 }
1106 if factoryCalls != 0 {1106 if factoryCalls != 0 {
1107 t.Fatalf("browserWindowFactory calls = %d, want 0", factoryCalls)1107 t.Fatalf("browserWindowFactory calls = %d, want 0", factoryCalls)
1108 }1108 }
1109 }1109 }
1110 1110
1111-func TestApp_OpenFreshWindowRecordsTabOwnership(t *testing.T) {1111+func TestApp_OpenExternalURLRecordsTabOwnership(t *testing.T) {
1112 existingTab := entity.NewTab(entity.TabID("existing-tab"), entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane")))1112 existingTab := entity.NewTab(entity.TabID("existing-tab"), entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane")))
1113 existingTabs := entity.NewTabList()1113 existingTabs := entity.NewTabList()
1114 existingTabs.Add(existingTab)1114 existingTabs.Add(existingTab)
@@ -1126,8 +1126,8 @@ func TestApp_OpenFreshWindowRecordsTabOwnership(t *testing.T) {
1126 },1126 },
1127 }1127 }
1128 1128
1129- if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {1129+ if err := app.OpenExternalURL(context.Background(), "https://example.com"); err != nil {
1130- t.Fatalf("OpenFreshWindow returned error: %v", err)1130+ t.Fatalf("OpenExternalURL returned error: %v", err)
1131 }1131 }
1132 if got := windowForTabCount(t, app); got != 2 {1132 if got := windowForTabCount(t, app); got != 2 {
1133 t.Fatalf("windowForTab length = %d, want 2 (existing + new tab)", got)1133 t.Fatalf("windowForTab length = %d, want 2 (existing + new tab)", got)
@@ -1375,7 +1375,7 @@ func TestApp_RemoveBrowserWindowRebindsPromotedTabCoordinatorWindow(t *testing.T
1375 }1375 }
1376 }1376 }
1377 1377
1378-func TestApp_OpenFreshWindowRollsBackOnTabCreationFailure(t *testing.T) {1378+func TestApp_OpenExternalURLRollsBackOnTabCreationFailure(t *testing.T) {
1379 created := &browserWindow{id: "window-1", tabs: entity.NewTabList()}1379 created := &browserWindow{id: "window-1", tabs: entity.NewTabList()}
1380 originalWindow := &window.MainWindow{}1380 originalWindow := &window.MainWindow{}
1381 tabBar := &component.TabBar{}1381 tabBar := &component.TabBar{}
@@ -1406,8 +1406,8 @@ func TestApp_OpenFreshWindowRollsBackOnTabCreationFailure(t *testing.T) {
1406 MainWindow: &window.MainWindow{},1406 MainWindow: &window.MainWindow{},
1407 })1407 })
1408 1408
1409- if err := app.OpenFreshWindow(context.Background(), "https://example.com/fail"); err == nil {1409+ if err := app.OpenExternalURL(context.Background(), "https://example.com/fail"); err == nil {
1410- t.Fatalf("OpenFreshWindow = nil error, want failure")1410+ t.Fatalf("OpenExternalURL = nil error, want failure")
1411 }1411 }
1412 if got := len(app.browserWindows); got != 1 {1412 if got := len(app.browserWindows); got != 1 {
1413 t.Fatalf("browserWindows length = %d, want 1 (existing window only)", got)1413 t.Fatalf("browserWindows length = %d, want 1 (existing window only)", got)
@@ -1426,7 +1426,7 @@ func TestApp_OpenFreshWindowRollsBackOnTabCreationFailure(t *testing.T) {
1426 }1426 }
1427 }1427 }
1428 1428
1429-func TestApp_OpenFreshWindowTargetsNewWindowTabBar(t *testing.T) {1429+func TestApp_OpenExternalURLTargetsNewWindowTabBar(t *testing.T) {
1430 existingTabID := entity.TabID("existing-tab")1430 existingTabID := entity.TabID("existing-tab")
1431 createdTabID := entity.TabID("id-1")1431 createdTabID := entity.TabID("id-1")
1432 oldWindow := &window.MainWindow{}1432 oldWindow := &window.MainWindow{}
@@ -1463,8 +1463,8 @@ func TestApp_OpenFreshWindowTargetsNewWindowTabBar(t *testing.T) {
1463 app.workspaceViews[tab.ID] = &component.WorkspaceView{}1463 app.workspaceViews[tab.ID] = &component.WorkspaceView{}
1464 })1464 })
1465 1465
1466- if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {1466+ if err := app.OpenExternalURL(context.Background(), "https://example.com"); err != nil {
1467- t.Fatalf("OpenFreshWindow returned error: %v", err)1467+ t.Fatalf("OpenExternalURL returned error: %v", err)
1468 }1468 }
1469 1469
1470 gotCreatedTabID := app.tabs.ActiveTabID1470 gotCreatedTabID := app.tabs.ActiveTabID
M internal/ui/browser_window.go +161 -8
@@ -548,11 +548,11 @@ func (a *App) deterministicBrowserWindowFallback() *browserWindow {
548548 return a.browserWindows[ids[0]]
549549 }
550550
551-func (a *App) OpenFreshWindow(ctx context.Context, url string) error {
551+func (a *App) OpenExternalURL(ctx context.Context, url string) error {
552552 log := logging.FromContext(ctx)
553553 log.Debug().
554554 Str("url_host", logging.SafeURLHost(url)).
555- Msg("ui: open fresh window dispatch requested")
555+ Msg("ui: open external URL dispatch requested")
556556
557557 dispatch := a.dispatchOnMainThread
558558 if dispatch == nil {
@@ -569,17 +569,18 @@ func (a *App) OpenFreshWindow(ctx context.Context, url string) error {
569569 var windowCountAfter int
570570 var hasTabCoord bool
571571 var hasTabsUC bool
572- result := dispatch("ui.open_fresh_window", func() {
572+ result := dispatch("ui.open_external_url", func() {
573573 windowCountBefore = len(a.browserWindows)
574574 hasTabCoord = a.tabCoord != nil
575575 hasTabsUC = a.tabsUC != nil
576+ cfg := a.runtimeConfigSnapshot()
576577 log.Debug().
577578 Str("url_host", logging.SafeURLHost(url)).
578579 Int("window_count_before", windowCountBefore).
579580 Bool("has_tab_coord", hasTabCoord).
580581 Bool("has_tabs_uc", hasTabsUC).
581- Msg("ui: open fresh window main-thread work started")
582- openErr = a.openFreshWindow(ctx, url)
582+ Msg("ui: open external URL main-thread work started")
583+ openErr = a.openExternalURLOnMainThread(ctx, url, cfg)
583584 windowCountAfter = len(a.browserWindows)
584585 })
585586 if !result.Completed() {
@@ -587,7 +588,7 @@ func (a *App) OpenFreshWindow(ctx context.Context, url string) error {
587588 Str("url_host", logging.SafeURLHost(url)).
588589 Dur("elapsed", result.Elapsed).
589590 Str("dispatch_status", string(result.Status)).
590- Msg("ui: open fresh window skipped after main-thread dispatch did not complete")
591+ Msg("ui: open external URL skipped after main-thread dispatch did not complete")
591592 return fmt.Errorf("main thread dispatch did not complete: %s", result.Status)
592593 }
593594 if openErr != nil {
@@ -596,7 +597,7 @@ func (a *App) OpenFreshWindow(ctx context.Context, url string) error {
596597 Dur("elapsed", result.Elapsed).
597598 Str("dispatch_status", string(result.Status)).
598599 Int("window_count_after", windowCountAfter).
599- Msg("ui: open fresh window failed")
600+ Msg("ui: open external URL failed")
600601 return openErr
601602 }
602603
@@ -605,6 +606,158 @@ func (a *App) OpenFreshWindow(ctx context.Context, url string) error {
605606 Dur("elapsed", result.Elapsed).
606607 Str("dispatch_status", string(result.Status)).
607608 Int("window_count_after", windowCountAfter).
608- Msg("ui: open fresh window completed")
609+ Msg("ui: open external URL completed")
610+ return nil
611+}
612+
613+func (a *App) openExternalURLOnMainThread(ctx context.Context, url string, cfg entity.RuntimeConfigSnapshot) error {
614+ externalLinks := cfg.UI.Workspace.ExternalLinks
615+ if externalLinks.Behavior == entity.ExternalLinkBehaviorWindowed || externalLinks.Behavior == "" {
616+ return a.openFreshWindow(ctx, url)
617+ }
618+
619+ if err := a.openExternalURLInFocusedWindow(ctx, url, externalLinks); err == nil {
620+ return nil
621+ } else {
622+ logging.FromContext(ctx).Warn().
623+ Err(err).
624+ Str("url_host", logging.SafeURLHost(url)).
625+ Str("behavior", string(externalLinks.Behavior)).
626+ Msg("ui: external URL placement failed; opening fresh window")
627+ }
628+ return a.openFreshWindow(ctx, url)
629+}
630+
631+func (a *App) openExternalURLInFocusedWindow(ctx context.Context, url string, cfg entity.ExternalLinksConfig) error {
632+ target := a.lastFocusedBrowserWindow()
633+ if target == nil || target.id == "" || a.browserWindows[target.id] != target {
634+ return fmt.Errorf("last-focused browser window is missing or stale")
635+ }
636+ activeTab := a.activeTabForBrowserWindow(target)
637+ if activeTab == nil || activeTab.Workspace == nil || activeTab.Workspace.ActivePane() == nil {
638+ return fmt.Errorf("last-focused browser window has no active tab and pane")
639+ }
640+
641+ a.activateBrowserWindow(target)
642+ switch cfg.Behavior {
643+ case entity.ExternalLinkBehaviorTabbed:
644+ if a.tabCoord == nil {
645+ return fmt.Errorf("tab coordinator not available")
646+ }
647+ created, err := a.tabCoord.Create(ctx, a.tabTargetForBrowserWindow(target), url)
648+ if err != nil {
649+ a.rollbackExternalTabCreation(ctx, target, created)
650+ return err
651+ }
652+ if created == nil || created.Workspace == nil || created.Workspace.ActivePane() == nil ||
653+ created.Workspace.ActivePane().Pane == nil || created.Workspace.ActivePane().Pane.URI != url ||
654+ a.activeTabForBrowserWindow(target) != created || a.workspaceViews[created.ID] == nil {
655+ a.rollbackExternalTabCreation(ctx, target, created)
656+ return fmt.Errorf("created tab did not become an active UI target")
657+ }
658+ case entity.ExternalLinkBehaviorSplit:
659+ if a.wsCoord == nil {
660+ return fmt.Errorf("workspace coordinator not available")
661+ }
662+ before := activeTab.Workspace.ActivePaneID
663+ if err := a.wsCoord.SplitWithURL(ctx, externalLinkSplitDirection(cfg.Placement), url); err != nil {
664+ a.rollbackExternalPaneCreation(ctx, activeTab.Workspace, before)
665+ return err
666+ }
667+ if err := verifyExternalPaneCreation(activeTab.Workspace, before, url); err != nil {
668+ a.rollbackExternalPaneCreation(ctx, activeTab.Workspace, before)
669+ return err
670+ }
671+ case entity.ExternalLinkBehaviorStacked:
672+ if a.wsCoord == nil {
673+ return fmt.Errorf("workspace coordinator not available")
674+ }
675+ before := activeTab.Workspace.ActivePaneID
676+ if err := a.wsCoord.StackPaneWithURL(ctx, url); err != nil {
677+ a.rollbackExternalPaneCreation(ctx, activeTab.Workspace, before)
678+ return err
679+ }
680+ if err := verifyExternalPaneCreation(activeTab.Workspace, before, url); err != nil {
681+ a.rollbackExternalPaneCreation(ctx, activeTab.Workspace, before)
682+ return err
683+ }
684+ default:
685+ return fmt.Errorf("unsupported external URL behavior %q", cfg.Behavior)
686+ }
687+
688+ if target.mainWindow != nil {
689+ target.mainWindow.Show()
690+ }
691+ a.activateBrowserWindow(target)
692+ return nil
693+}
694+
695+func (a *App) rollbackExternalPaneCreation(ctx context.Context, ws *entity.Workspace, previous entity.PaneID) {
696+ if a.panesUC == nil || ws == nil || ws.ActivePaneID == "" || ws.ActivePaneID == previous {
697+ return
698+ }
699+ created := ws.ActivePane()
700+ if created == nil || created.Pane == nil {
701+ return
702+ }
703+ createdID := created.Pane.ID
704+ if _, err := a.panesUC.Close(ctx, ws, created); err != nil {
705+ logging.FromContext(ctx).Warn().Err(err).Msg("ui: failed to roll back external URL pane")
706+ return
707+ }
708+ ws.ActivePaneID = previous
709+ if a.contentCoord != nil {
710+ a.contentCoord.ReleaseWebView(ctx, createdID)
711+ }
712+ for tabID, view := range a.workspaceViews {
713+ owner := a.browserWindowForTab(tabID)
714+ if owner == nil || owner.tabs == nil || view == nil {
715+ continue
716+ }
717+ tab := owner.tabs.Find(tabID)
718+ if tab == nil || tab.Workspace == nil || tab.Workspace.ID != ws.ID {
719+ continue
720+ }
721+ if err := view.Rebuild(ctx); err != nil {
722+ logging.FromContext(ctx).Warn().Err(err).Msg("ui: failed to rebuild after external URL pane rollback")
723+ }
724+ break
725+ }
726+}
727+
728+func (a *App) rollbackExternalTabCreation(ctx context.Context, target *browserWindow, created *entity.Tab) {
729+ if target == nil || created == nil {
730+ return
731+ }
732+ if tabs := a.tabListForBrowserWindow(target); tabs != nil {
733+ tabs.Remove(created.ID)
734+ }
735+ if target.mainWindow != nil && target.mainWindow.TabBar() != nil {
736+ target.mainWindow.TabBar().RemoveTab(created.ID)
737+ }
738+ a.releaseTabWorkspace(ctx, created)
739+}
740+
741+func externalLinkSplitDirection(placement entity.ExternalLinkPlacement) usecase.SplitDirection {
742+ switch placement {
743+ case entity.ExternalLinkPlacementLeft:
744+ return usecase.SplitLeft
745+ case entity.ExternalLinkPlacementTop:
746+ return usecase.SplitUp
747+ case entity.ExternalLinkPlacementBottom:
748+ return usecase.SplitDown
749+ default:
750+ return usecase.SplitRight
751+ }
752+}
753+
754+func verifyExternalPaneCreation(ws *entity.Workspace, previous entity.PaneID, url string) error {
755+ if ws == nil || ws.ActivePaneID == "" || ws.ActivePaneID == previous {
756+ return fmt.Errorf("external URL pane was not activated")
757+ }
758+ active := ws.ActivePane()
759+ if active == nil || active.Pane == nil || active.Pane.URI != url {
760+ return fmt.Errorf("external URL pane was not created with the requested URL")
761+ }
609762 return nil
610763 }
@@ -548,11 +548,11 @@ func (a *App) deterministicBrowserWindowFallback() *browserWindow {
548 return a.browserWindows[ids[0]]548 return a.browserWindows[ids[0]]
549 }549 }
550 550
551-func (a *App) OpenFreshWindow(ctx context.Context, url string) error {551+func (a *App) OpenExternalURL(ctx context.Context, url string) error {
552 log := logging.FromContext(ctx)552 log := logging.FromContext(ctx)
553 log.Debug().553 log.Debug().
554 Str("url_host", logging.SafeURLHost(url)).554 Str("url_host", logging.SafeURLHost(url)).
555- Msg("ui: open fresh window dispatch requested")555+ Msg("ui: open external URL dispatch requested")
556 556
557 dispatch := a.dispatchOnMainThread557 dispatch := a.dispatchOnMainThread
558 if dispatch == nil {558 if dispatch == nil {
@@ -569,17 +569,18 @@ func (a *App) OpenFreshWindow(ctx context.Context, url string) error {
569 var windowCountAfter int569 var windowCountAfter int
570 var hasTabCoord bool570 var hasTabCoord bool
571 var hasTabsUC bool571 var hasTabsUC bool
572- result := dispatch("ui.open_fresh_window", func() {572+ result := dispatch("ui.open_external_url", func() {
573 windowCountBefore = len(a.browserWindows)573 windowCountBefore = len(a.browserWindows)
574 hasTabCoord = a.tabCoord != nil574 hasTabCoord = a.tabCoord != nil
575 hasTabsUC = a.tabsUC != nil575 hasTabsUC = a.tabsUC != nil
576+ cfg := a.runtimeConfigSnapshot()
576 log.Debug().577 log.Debug().
577 Str("url_host", logging.SafeURLHost(url)).578 Str("url_host", logging.SafeURLHost(url)).
578 Int("window_count_before", windowCountBefore).579 Int("window_count_before", windowCountBefore).
579 Bool("has_tab_coord", hasTabCoord).580 Bool("has_tab_coord", hasTabCoord).
580 Bool("has_tabs_uc", hasTabsUC).581 Bool("has_tabs_uc", hasTabsUC).
581- Msg("ui: open fresh window main-thread work started")582+ Msg("ui: open external URL main-thread work started")
582- openErr = a.openFreshWindow(ctx, url)583+ openErr = a.openExternalURLOnMainThread(ctx, url, cfg)
583 windowCountAfter = len(a.browserWindows)584 windowCountAfter = len(a.browserWindows)
584 })585 })
585 if !result.Completed() {586 if !result.Completed() {
@@ -587,7 +588,7 @@ func (a *App) OpenFreshWindow(ctx context.Context, url string) error {
587 Str("url_host", logging.SafeURLHost(url)).588 Str("url_host", logging.SafeURLHost(url)).
588 Dur("elapsed", result.Elapsed).589 Dur("elapsed", result.Elapsed).
589 Str("dispatch_status", string(result.Status)).590 Str("dispatch_status", string(result.Status)).
590- Msg("ui: open fresh window skipped after main-thread dispatch did not complete")591+ Msg("ui: open external URL skipped after main-thread dispatch did not complete")
591 return fmt.Errorf("main thread dispatch did not complete: %s", result.Status)592 return fmt.Errorf("main thread dispatch did not complete: %s", result.Status)
592 }593 }
593 if openErr != nil {594 if openErr != nil {
@@ -596,7 +597,7 @@ func (a *App) OpenFreshWindow(ctx context.Context, url string) error {
596 Dur("elapsed", result.Elapsed).597 Dur("elapsed", result.Elapsed).
597 Str("dispatch_status", string(result.Status)).598 Str("dispatch_status", string(result.Status)).
598 Int("window_count_after", windowCountAfter).599 Int("window_count_after", windowCountAfter).
599- Msg("ui: open fresh window failed")600+ Msg("ui: open external URL failed")
600 return openErr601 return openErr
601 }602 }
602 603
@@ -605,6 +606,158 @@ func (a *App) OpenFreshWindow(ctx context.Context, url string) error {
605 Dur("elapsed", result.Elapsed).606 Dur("elapsed", result.Elapsed).
606 Str("dispatch_status", string(result.Status)).607 Str("dispatch_status", string(result.Status)).
607 Int("window_count_after", windowCountAfter).608 Int("window_count_after", windowCountAfter).
608- Msg("ui: open fresh window completed")609+ Msg("ui: open external URL completed")
610+ return nil
611+}
612+
613+func (a *App) openExternalURLOnMainThread(ctx context.Context, url string, cfg entity.RuntimeConfigSnapshot) error {
614+ externalLinks := cfg.UI.Workspace.ExternalLinks
615+ if externalLinks.Behavior == entity.ExternalLinkBehaviorWindowed || externalLinks.Behavior == "" {
616+ return a.openFreshWindow(ctx, url)
617+ }
618+
619+ if err := a.openExternalURLInFocusedWindow(ctx, url, externalLinks); err == nil {
620+ return nil
621+ } else {
622+ logging.FromContext(ctx).Warn().
623+ Err(err).
624+ Str("url_host", logging.SafeURLHost(url)).
625+ Str("behavior", string(externalLinks.Behavior)).
626+ Msg("ui: external URL placement failed; opening fresh window")
627+ }
628+ return a.openFreshWindow(ctx, url)
629+}
630+
631+func (a *App) openExternalURLInFocusedWindow(ctx context.Context, url string, cfg entity.ExternalLinksConfig) error {
632+ target := a.lastFocusedBrowserWindow()
633+ if target == nil || target.id == "" || a.browserWindows[target.id] != target {
634+ return fmt.Errorf("last-focused browser window is missing or stale")
635+ }
636+ activeTab := a.activeTabForBrowserWindow(target)
637+ if activeTab == nil || activeTab.Workspace == nil || activeTab.Workspace.ActivePane() == nil {
638+ return fmt.Errorf("last-focused browser window has no active tab and pane")
639+ }
640+
641+ a.activateBrowserWindow(target)
642+ switch cfg.Behavior {
643+ case entity.ExternalLinkBehaviorTabbed:
644+ if a.tabCoord == nil {
645+ return fmt.Errorf("tab coordinator not available")
646+ }
647+ created, err := a.tabCoord.Create(ctx, a.tabTargetForBrowserWindow(target), url)
648+ if err != nil {
649+ a.rollbackExternalTabCreation(ctx, target, created)
650+ return err
651+ }
652+ if created == nil || created.Workspace == nil || created.Workspace.ActivePane() == nil ||
653+ created.Workspace.ActivePane().Pane == nil || created.Workspace.ActivePane().Pane.URI != url ||
654+ a.activeTabForBrowserWindow(target) != created || a.workspaceViews[created.ID] == nil {
655+ a.rollbackExternalTabCreation(ctx, target, created)
656+ return fmt.Errorf("created tab did not become an active UI target")
657+ }
658+ case entity.ExternalLinkBehaviorSplit:
659+ if a.wsCoord == nil {
660+ return fmt.Errorf("workspace coordinator not available")
661+ }
662+ before := activeTab.Workspace.ActivePaneID
663+ if err := a.wsCoord.SplitWithURL(ctx, externalLinkSplitDirection(cfg.Placement), url); err != nil {
664+ a.rollbackExternalPaneCreation(ctx, activeTab.Workspace, before)
665+ return err
666+ }
667+ if err := verifyExternalPaneCreation(activeTab.Workspace, before, url); err != nil {
668+ a.rollbackExternalPaneCreation(ctx, activeTab.Workspace, before)
669+ return err
670+ }
671+ case entity.ExternalLinkBehaviorStacked:
672+ if a.wsCoord == nil {
673+ return fmt.Errorf("workspace coordinator not available")
674+ }
675+ before := activeTab.Workspace.ActivePaneID
676+ if err := a.wsCoord.StackPaneWithURL(ctx, url); err != nil {
677+ a.rollbackExternalPaneCreation(ctx, activeTab.Workspace, before)
678+ return err
679+ }
680+ if err := verifyExternalPaneCreation(activeTab.Workspace, before, url); err != nil {
681+ a.rollbackExternalPaneCreation(ctx, activeTab.Workspace, before)
682+ return err
683+ }
684+ default:
685+ return fmt.Errorf("unsupported external URL behavior %q", cfg.Behavior)
686+ }
687+
688+ if target.mainWindow != nil {
689+ target.mainWindow.Show()
690+ }
691+ a.activateBrowserWindow(target)
692+ return nil
693+}
694+
695+func (a *App) rollbackExternalPaneCreation(ctx context.Context, ws *entity.Workspace, previous entity.PaneID) {
696+ if a.panesUC == nil || ws == nil || ws.ActivePaneID == "" || ws.ActivePaneID == previous {
697+ return
698+ }
699+ created := ws.ActivePane()
700+ if created == nil || created.Pane == nil {
701+ return
702+ }
703+ createdID := created.Pane.ID
704+ if _, err := a.panesUC.Close(ctx, ws, created); err != nil {
705+ logging.FromContext(ctx).Warn().Err(err).Msg("ui: failed to roll back external URL pane")
706+ return
707+ }
708+ ws.ActivePaneID = previous
709+ if a.contentCoord != nil {
710+ a.contentCoord.ReleaseWebView(ctx, createdID)
711+ }
712+ for tabID, view := range a.workspaceViews {
713+ owner := a.browserWindowForTab(tabID)
714+ if owner == nil || owner.tabs == nil || view == nil {
715+ continue
716+ }
717+ tab := owner.tabs.Find(tabID)
718+ if tab == nil || tab.Workspace == nil || tab.Workspace.ID != ws.ID {
719+ continue
720+ }
721+ if err := view.Rebuild(ctx); err != nil {
722+ logging.FromContext(ctx).Warn().Err(err).Msg("ui: failed to rebuild after external URL pane rollback")
723+ }
724+ break
725+ }
726+}
727+
728+func (a *App) rollbackExternalTabCreation(ctx context.Context, target *browserWindow, created *entity.Tab) {
729+ if target == nil || created == nil {
730+ return
731+ }
732+ if tabs := a.tabListForBrowserWindow(target); tabs != nil {
733+ tabs.Remove(created.ID)
734+ }
735+ if target.mainWindow != nil && target.mainWindow.TabBar() != nil {
736+ target.mainWindow.TabBar().RemoveTab(created.ID)
737+ }
738+ a.releaseTabWorkspace(ctx, created)
739+}
740+
741+func externalLinkSplitDirection(placement entity.ExternalLinkPlacement) usecase.SplitDirection {
742+ switch placement {
743+ case entity.ExternalLinkPlacementLeft:
744+ return usecase.SplitLeft
745+ case entity.ExternalLinkPlacementTop:
746+ return usecase.SplitUp
747+ case entity.ExternalLinkPlacementBottom:
748+ return usecase.SplitDown
749+ default:
750+ return usecase.SplitRight
751+ }
752+}
753+
754+func verifyExternalPaneCreation(ws *entity.Workspace, previous entity.PaneID, url string) error {
755+ if ws == nil || ws.ActivePaneID == "" || ws.ActivePaneID == previous {
756+ return fmt.Errorf("external URL pane was not activated")
757+ }
758+ active := ws.ActivePane()
759+ if active == nil || active.Pane == nil || active.Pane.URI != url {
760+ return fmt.Errorf("external URL pane was not created with the requested URL")
761+ }
609 return nil762 return nil
610 }763 }
M internal/ui/browser_window_test.go +11 -11
@@ -447,15 +447,15 @@ func TestBrowserWindow_RemovePromotedWindowClearsResizeModeBorderTarget(t *testi
447447 }
448448 }
449449
450-func TestOpenFreshWindow_DispatchesAndTracksBrowserWindow(t *testing.T) {
450+func TestOpenExternalURL_DispatchesAndTracksBrowserWindow(t *testing.T) {
451451 app := &App{browserWindows: make(map[string]*browserWindow)}
452452
453453 dispatched := false
454454 createdURL := ""
455455 app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult {
456456 dispatched = true
457- if label != "ui.open_fresh_window" {
458- t.Fatalf("dispatch label = %q, want ui.open_fresh_window", label)
457+ if label != "ui.open_external_url" {
458+ t.Fatalf("dispatch label = %q, want ui.open_external_url", label)
459459 }
460460 fn()
461461 return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchCompleted}
@@ -465,11 +465,11 @@ func TestOpenFreshWindow_DispatchesAndTracksBrowserWindow(t *testing.T) {
465465 return &browserWindow{id: "window-1", initialURL: url}, nil
466466 }
467467
468- if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {
469- t.Fatalf("OpenFreshWindow returned error: %v", err)
468+ if err := app.OpenExternalURL(context.Background(), "https://example.com"); err != nil {
469+ t.Fatalf("OpenExternalURL returned error: %v", err)
470470 }
471471 if !dispatched {
472- t.Fatalf("OpenFreshWindow did not use the main-thread dispatcher")
472+ t.Fatalf("OpenExternalURL did not use the main-thread dispatcher")
473473 }
474474 if createdURL != "https://example.com" {
475475 t.Fatalf("browser window factory URL = %q, want %q", createdURL, "https://example.com")
@@ -515,7 +515,7 @@ func TestOpenInitialBrowserWindowShellRegistersWithoutCreatingTab(t *testing.T)
515515 require.Equal(t, "https://example.com", app.browserWindows["window-1"].initialURL)
516516 }
517517
518-func TestOpenFreshWindow_FirstWindowWithRequestedURLCreatesTabInAppAndWindow(t *testing.T) {
518+func TestOpenExternalURL_FirstWindowWithRequestedURLCreatesTabInAppAndWindow(t *testing.T) {
519519 app := &App{
520520 tabs: entity.NewTabList(),
521521 tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
@@ -535,8 +535,8 @@ func TestOpenFreshWindow_FirstWindowWithRequestedURLCreatesTabInAppAndWindow(t *
535535 return true
536536 }
537537
538- if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {
539- t.Fatalf("OpenFreshWindow returned error: %v", err)
538+ if err := app.OpenExternalURL(context.Background(), "https://example.com"); err != nil {
539+ t.Fatalf("OpenExternalURL returned error: %v", err)
540540 }
541541
542542 windowTabs := app.browserWindows["window-1"].tabs
@@ -553,7 +553,7 @@ func TestOpenFreshWindow_FirstWindowWithRequestedURLCreatesTabInAppAndWindow(t *
553553 require.Same(t, app.browserWindows["window-1"], app.browserWindowForTab(tab.ID))
554554 }
555555
556-func TestOpenFreshWindow_PropagatesFactoryError(t *testing.T) {
556+func TestOpenExternalURL_PropagatesFactoryError(t *testing.T) {
557557 app := &App{browserWindows: make(map[string]*browserWindow)}
558558 app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult {
559559 fn()
@@ -564,7 +564,7 @@ func TestOpenFreshWindow_PropagatesFactoryError(t *testing.T) {
564564 return nil, wantErr
565565 }
566566
567- err := app.OpenFreshWindow(context.Background(), "https://example.com")
567+ err := app.OpenExternalURL(context.Background(), "https://example.com")
568568
569569 if !errors.Is(err, wantErr) {
570570 t.Fatalf("expected error %v, got %v", wantErr, err)
@@ -447,15 +447,15 @@ func TestBrowserWindow_RemovePromotedWindowClearsResizeModeBorderTarget(t *testi
447 }447 }
448 }448 }
449 449
450-func TestOpenFreshWindow_DispatchesAndTracksBrowserWindow(t *testing.T) {450+func TestOpenExternalURL_DispatchesAndTracksBrowserWindow(t *testing.T) {
451 app := &App{browserWindows: make(map[string]*browserWindow)}451 app := &App{browserWindows: make(map[string]*browserWindow)}
452 452
453 dispatched := false453 dispatched := false
454 createdURL := ""454 createdURL := ""
455 app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult {455 app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult {
456 dispatched = true456 dispatched = true
457- if label != "ui.open_fresh_window" {457+ if label != "ui.open_external_url" {
458- t.Fatalf("dispatch label = %q, want ui.open_fresh_window", label)458+ t.Fatalf("dispatch label = %q, want ui.open_external_url", label)
459 }459 }
460 fn()460 fn()
461 return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchCompleted}461 return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchCompleted}
@@ -465,11 +465,11 @@ func TestOpenFreshWindow_DispatchesAndTracksBrowserWindow(t *testing.T) {
465 return &browserWindow{id: "window-1", initialURL: url}, nil465 return &browserWindow{id: "window-1", initialURL: url}, nil
466 }466 }
467 467
468- if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {468+ if err := app.OpenExternalURL(context.Background(), "https://example.com"); err != nil {
469- t.Fatalf("OpenFreshWindow returned error: %v", err)469+ t.Fatalf("OpenExternalURL returned error: %v", err)
470 }470 }
471 if !dispatched {471 if !dispatched {
472- t.Fatalf("OpenFreshWindow did not use the main-thread dispatcher")472+ t.Fatalf("OpenExternalURL did not use the main-thread dispatcher")
473 }473 }
474 if createdURL != "https://example.com" {474 if createdURL != "https://example.com" {
475 t.Fatalf("browser window factory URL = %q, want %q", createdURL, "https://example.com")475 t.Fatalf("browser window factory URL = %q, want %q", createdURL, "https://example.com")
@@ -515,7 +515,7 @@ func TestOpenInitialBrowserWindowShellRegistersWithoutCreatingTab(t *testing.T)
515 require.Equal(t, "https://example.com", app.browserWindows["window-1"].initialURL)515 require.Equal(t, "https://example.com", app.browserWindows["window-1"].initialURL)
516 }516 }
517 517
518-func TestOpenFreshWindow_FirstWindowWithRequestedURLCreatesTabInAppAndWindow(t *testing.T) {518+func TestOpenExternalURL_FirstWindowWithRequestedURLCreatesTabInAppAndWindow(t *testing.T) {
519 app := &App{519 app := &App{
520 tabs: entity.NewTabList(),520 tabs: entity.NewTabList(),
521 tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),521 tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
@@ -535,8 +535,8 @@ func TestOpenFreshWindow_FirstWindowWithRequestedURLCreatesTabInAppAndWindow(t *
535 return true535 return true
536 }536 }
537 537
538- if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {538+ if err := app.OpenExternalURL(context.Background(), "https://example.com"); err != nil {
539- t.Fatalf("OpenFreshWindow returned error: %v", err)539+ t.Fatalf("OpenExternalURL returned error: %v", err)
540 }540 }
541 541
542 windowTabs := app.browserWindows["window-1"].tabs542 windowTabs := app.browserWindows["window-1"].tabs
@@ -553,7 +553,7 @@ func TestOpenFreshWindow_FirstWindowWithRequestedURLCreatesTabInAppAndWindow(t *
553 require.Same(t, app.browserWindows["window-1"], app.browserWindowForTab(tab.ID))553 require.Same(t, app.browserWindows["window-1"], app.browserWindowForTab(tab.ID))
554 }554 }
555 555
556-func TestOpenFreshWindow_PropagatesFactoryError(t *testing.T) {556+func TestOpenExternalURL_PropagatesFactoryError(t *testing.T) {
557 app := &App{browserWindows: make(map[string]*browserWindow)}557 app := &App{browserWindows: make(map[string]*browserWindow)}
558 app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult {558 app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult {
559 fn()559 fn()
@@ -564,7 +564,7 @@ func TestOpenFreshWindow_PropagatesFactoryError(t *testing.T) {
564 return nil, wantErr564 return nil, wantErr
565 }565 }
566 566
567- err := app.OpenFreshWindow(context.Background(), "https://example.com")567+ err := app.OpenExternalURL(context.Background(), "https://example.com")
568 568
569 if !errors.Is(err, wantErr) {569 if !errors.Is(err, wantErr) {
570 t.Fatalf("expected error %v, got %v", wantErr, err)570 t.Fatalf("expected error %v, got %v", wantErr, err)
M internal/ui/coordinator/workspace.go +73 -38
@@ -174,9 +174,9 @@ func (c *WorkspaceCoordinator) SplitWithURL(ctx context.Context, direction useca
174174 func (c *WorkspaceCoordinator) splitWithInitialURL(ctx context.Context, direction usecase.SplitDirection, initialURL string) error {
175175 log := logging.FromContext(ctx)
176176
177- splitCtx, ok := c.prepareSplit(ctx, direction)
178- if !ok {
179- return nil
177+ splitCtx, err := c.prepareSplit(ctx, direction)
178+ if err != nil {
179+ return err
180180 }
181181
182182 output, err := c.panesUC.Split(ctx, usecase.SplitPaneInput{
@@ -198,10 +198,9 @@ func (c *WorkspaceCoordinator) splitWithInitialURL(ctx context.Context, directio
198198
199199 // Update the workspace view
200200 if splitCtx.wsView != nil {
201- c.applySplitToView(ctx, splitCtx.wsView, splitCtx.ws, output, direction, splitCtx.existingWidget, splitCtx.isStackSplit, oldActivePaneID)
202- }
203-
204- if splitCtx.wsView != nil {
201+ if err := c.applySplitToView(ctx, splitCtx.wsView, splitCtx.ws, output, direction, splitCtx.existingWidget, splitCtx.isStackSplit, oldActivePaneID); err != nil {
202+ return err
203+ }
205204 splitCtx.wsView.NotifyNewPaneCreated(ctx)
206205 }
207206
@@ -332,23 +331,27 @@ func setActiveStackIndexForChild(parent, child *entity.PaneNode) {
332331 func (c *WorkspaceCoordinator) prepareSplit(
333332 ctx context.Context,
334333 direction usecase.SplitDirection,
335-) (*splitContext, bool) {
334+) (*splitContext, error) {
336335 log := logging.FromContext(ctx)
337336 if c.panesUC == nil {
338337 log.Warn().Msg("panes use case not available")
339- return nil, false
338+ return nil, fmt.Errorf("panes use case not available")
339+ }
340+ if c.getActiveWS == nil {
341+ log.Warn().Msg("active workspace provider not configured")
342+ return nil, fmt.Errorf("active workspace provider not configured")
340343 }
341344
342345 ws, wsView := c.getActiveWS()
343346 if ws == nil {
344347 log.Warn().Msg("no active workspace")
345- return nil, false
348+ return nil, fmt.Errorf("no active workspace")
346349 }
347350
348351 activePane := ws.ActivePane()
349- if activePane == nil {
352+ if activePane == nil || activePane.Pane == nil {
350353 log.Warn().Msg("no active pane to split")
351- return nil, false
354+ return nil, fmt.Errorf("no active pane to split")
352355 }
353356
354357 // Check if active pane is inside a stack
@@ -367,7 +370,7 @@ func (c *WorkspaceCoordinator) prepareSplit(
367370 activePane: activePane,
368371 existingWidget: existingWidget,
369372 isStackSplit: isStackSplit,
370- }, true
373+ }, nil
371374 }
372375
373376 func (c *WorkspaceCoordinator) resolveSplitWidget(
@@ -407,7 +410,7 @@ func (c *WorkspaceCoordinator) applySplitToView(
407410 existingWidget layout.Widget,
408411 isStackSplit bool,
409412 oldActivePaneID entity.PaneID,
410-) {
413+) error {
411414 log := logging.FromContext(ctx)
412415 needsAttach := false
413416
@@ -423,12 +426,14 @@ func (c *WorkspaceCoordinator) applySplitToView(
423426 log.Warn().Err(splitErr).Msg("incremental split failed, falling back to rebuild")
424427 if err := wsView.Rebuild(ctx); err != nil {
425428 log.Error().Err(err).Msg("failed to rebuild workspace view")
429+ return fmt.Errorf("rebuild workspace view after split: %w", err)
426430 }
427431 needsAttach = true
428432 }
429433 } else {
430434 if err := wsView.Rebuild(ctx); err != nil {
431435 log.Error().Err(err).Msg("failed to rebuild workspace view")
436+ return fmt.Errorf("rebuild workspace view after split: %w", err)
432437 }
433438 needsAttach = true
434439 }
@@ -439,8 +444,10 @@ func (c *WorkspaceCoordinator) applySplitToView(
439444 }
440445 if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {
441446 log.Warn().Err(err).Msg("failed to set active pane in workspace view")
447+ return fmt.Errorf("activate split pane: %w", err)
442448 }
443449 wsView.FocusPane(ws.ActivePaneID)
450+ return nil
444451 }
445452
446453 // doIncrementalStackSplit performs an incremental split around an existing stacked pane.
@@ -1453,11 +1460,16 @@ func (c *WorkspaceCoordinator) syncStackedViewActive(ctx context.Context, wsView
14531460 // StackPane adds a new pane stacked on top of the active pane.
14541461 // Uses CreateStack use case for new stacks, AddToStack for existing stacks.
14551462 func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1463+ return c.StackPaneWithURL(ctx, c.newPaneURL)
1464+}
1465+
1466+// StackPaneWithURL adds a new pane containing initialURL to the active stack.
1467+func (c *WorkspaceCoordinator) StackPaneWithURL(ctx context.Context, initialURL string) error {
14561468 log := logging.FromContext(ctx)
14571469
1458- stackCtx, ok := c.prepareStackPane(ctx)
1459- if !ok {
1460- return nil
1470+ stackCtx, err := c.prepareStackPane(ctx)
1471+ if err != nil {
1472+ return err
14611473 }
14621474
14631475 // Determine if we need to create a new stack or add to existing.
@@ -1469,7 +1481,7 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
14691481 if stackCtx.activeNode.Parent != nil && stackCtx.activeNode.Parent.IsStacked {
14701482 // Already in a stack - use AddToStack use case
14711483 stackNode = stackCtx.activeNode.Parent
1472- output, err := c.panesUC.AddToStack(ctx, stackCtx.ws, stackNode, nil, c.newPaneURL)
1484+ output, err := c.panesUC.AddToStack(ctx, stackCtx.ws, stackNode, nil, initialURL)
14731485 if err != nil {
14741486 log.Error().Err(err).Msg("failed to add pane to stack via use case")
14751487 return err
@@ -1484,7 +1496,7 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
14841496 } else if stackCtx.activeNode.IsStacked {
14851497 // Active node is already a stack container - add to it
14861498 stackNode = stackCtx.activeNode
1487- output, err := c.panesUC.AddToStack(ctx, stackCtx.ws, stackNode, nil, c.newPaneURL)
1499+ output, err := c.panesUC.AddToStack(ctx, stackCtx.ws, stackNode, nil, initialURL)
14881500 if err != nil {
14891501 log.Error().Err(err).Msg("failed to add pane to stack via use case")
14901502 return err
@@ -1498,7 +1510,7 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
14981510 Msg("added to stack container via use case")
14991511 } else {
15001512 // Need to create a new stack - use CreateStack use case.
1501- output, err := c.panesUC.CreateStack(ctx, stackCtx.ws, stackCtx.activeNode, c.newPaneURL)
1513+ output, err := c.panesUC.CreateStack(ctx, stackCtx.ws, stackCtx.activeNode, initialURL)
15021514 if err != nil {
15031515 log.Error().Err(err).Msg("failed to create stack via use case")
15041516 return err
@@ -1539,23 +1551,28 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
15391551 wv, err := c.contentCoord.EnsureWebView(ctx, newPaneID)
15401552 if err != nil {
15411553 log.Warn().Err(err).Msg("failed to get webview for new pane")
1542- } else {
1543- widget := c.contentCoord.WrapWidget(ctx, wv)
1544- if widget != nil {
1545- if err := stackCtx.wsView.SetWebViewWidget(newPaneID, widget); err != nil {
1546- log.Warn().Err(err).Str("pane_id", string(newPaneID)).Msg("failed to attach webview widget for stacked pane")
1547- }
1548- }
1549- // Load initial page for the new pane
1550- if err := wv.LoadURI(ctx, newPane.URI); err != nil {
1551- log.Warn().Err(err).Str("uri", newPane.URI).Msg("failed to load initial page")
1552- }
1554+ return err
1555+ }
1556+ widget := c.contentCoord.WrapWidget(ctx, wv)
1557+ if widget == nil {
1558+ return fmt.Errorf("wrap webview for stacked pane")
1559+ }
1560+ if err := stackCtx.wsView.SetWebViewWidget(newPaneID, widget); err != nil {
1561+ log.Warn().Err(err).Str("pane_id", string(newPaneID)).Msg("failed to attach webview widget for stacked pane")
1562+ return err
1563+ }
1564+ // Load initial page for the new pane.
1565+ if err := wv.LoadURI(ctx, newPane.URI); err != nil {
1566+ log.Warn().Err(err).Str("uri_host", logging.SafeURLHost(newPane.URI)).Msg("failed to load initial page")
1567+ return err
15531568 }
15541569
15551570 // Update workspace view
15561571 if err := stackCtx.wsView.SetActivePaneID(newPaneID); err != nil {
15571572 log.Warn().Err(err).Msg("failed to set active pane")
1573+ return err
15581574 }
1575+ stackCtx.wsView.FocusPane(newPaneID)
15591576
15601577 stackCtx.wsView.NotifyNewPaneCreated(ctx)
15611578
@@ -1588,28 +1605,44 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
15881605
15891606 func (c *WorkspaceCoordinator) prepareStackPane(
15901607 ctx context.Context,
1591-) (*stackPaneContext, bool) {
1608+) (*stackPaneContext, error) {
15921609 log := logging.FromContext(ctx)
15931610 if c.stackedPaneMgr == nil {
15941611 log.Warn().Msg("stacked pane manager not available")
1595- return nil, false
1612+ return nil, fmt.Errorf("stacked pane manager not available")
1613+ }
1614+ if c.panesUC == nil {
1615+ log.Warn().Msg("panes use case not available")
1616+ return nil, fmt.Errorf("panes use case not available")
1617+ }
1618+ if c.contentCoord == nil {
1619+ log.Warn().Msg("content coordinator not available")
1620+ return nil, fmt.Errorf("content coordinator not available")
1621+ }
1622+ if c.widgetFactory == nil {
1623+ log.Warn().Msg("widget factory not available")
1624+ return nil, fmt.Errorf("widget factory not available")
1625+ }
1626+ if c.getActiveWS == nil {
1627+ log.Warn().Msg("active workspace provider not configured")
1628+ return nil, fmt.Errorf("active workspace provider not configured")
15961629 }
15971630
15981631 ws, wsView := c.getActiveWS()
15991632 if ws == nil {
16001633 log.Warn().Msg("no active workspace")
1601- return nil, false
1634+ return nil, fmt.Errorf("no active workspace")
16021635 }
16031636
16041637 activeNode := ws.ActivePane()
16051638 if activeNode == nil || activeNode.Pane == nil {
16061639 log.Warn().Msg("no active pane")
1607- return nil, false
1640+ return nil, fmt.Errorf("no active pane")
16081641 }
16091642
16101643 if wsView == nil {
16111644 log.Warn().Msg("no workspace view")
1612- return nil, false
1645+ return nil, fmt.Errorf("no workspace view")
16131646 }
16141647
16151648 activePaneID := activeNode.Pane.ID
@@ -1627,7 +1660,7 @@ func (c *WorkspaceCoordinator) prepareStackPane(
16271660 activeNode: activeNode,
16281661 activePaneID: activePaneID,
16291662 originalTitle: originalTitle,
1630- }, true
1663+ }, nil
16311664 }
16321665
16331666 func (c *WorkspaceCoordinator) updateFirstStackTitle(
@@ -1942,7 +1975,9 @@ func (c *WorkspaceCoordinator) insertPopupSplit(ctx context.Context, input conte
19421975
19431976 // Update UI
19441977 if wsView != nil {
1945- c.applySplitToView(ctx, wsView, ws, output, direction, existingWidget, isStackSplit, input.ParentPaneID)
1978+ if err := c.applySplitToView(ctx, wsView, ws, output, direction, existingWidget, isStackSplit, input.ParentPaneID); err != nil {
1979+ return err
1980+ }
19461981 c.attachPopupWebView(ctx, wsView, input)
19471982 }
19481983
@@ -174,9 +174,9 @@ func (c *WorkspaceCoordinator) SplitWithURL(ctx context.Context, direction useca
174 func (c *WorkspaceCoordinator) splitWithInitialURL(ctx context.Context, direction usecase.SplitDirection, initialURL string) error {174 func (c *WorkspaceCoordinator) splitWithInitialURL(ctx context.Context, direction usecase.SplitDirection, initialURL string) error {
175 log := logging.FromContext(ctx)175 log := logging.FromContext(ctx)
176 176
177- splitCtx, ok := c.prepareSplit(ctx, direction)177+ splitCtx, err := c.prepareSplit(ctx, direction)
178- if !ok {178+ if err != nil {
179- return nil179+ return err
180 }180 }
181 181
182 output, err := c.panesUC.Split(ctx, usecase.SplitPaneInput{182 output, err := c.panesUC.Split(ctx, usecase.SplitPaneInput{
@@ -198,10 +198,9 @@ func (c *WorkspaceCoordinator) splitWithInitialURL(ctx context.Context, directio
198 198
199 // Update the workspace view199 // Update the workspace view
200 if splitCtx.wsView != nil {200 if splitCtx.wsView != nil {
201- c.applySplitToView(ctx, splitCtx.wsView, splitCtx.ws, output, direction, splitCtx.existingWidget, splitCtx.isStackSplit, oldActivePaneID)201+ if err := c.applySplitToView(ctx, splitCtx.wsView, splitCtx.ws, output, direction, splitCtx.existingWidget, splitCtx.isStackSplit, oldActivePaneID); err != nil {
202- }202+ return err
203-203+ }
204- if splitCtx.wsView != nil {
205 splitCtx.wsView.NotifyNewPaneCreated(ctx)204 splitCtx.wsView.NotifyNewPaneCreated(ctx)
206 }205 }
207 206
@@ -332,23 +331,27 @@ func setActiveStackIndexForChild(parent, child *entity.PaneNode) {
332 func (c *WorkspaceCoordinator) prepareSplit(331 func (c *WorkspaceCoordinator) prepareSplit(
333 ctx context.Context,332 ctx context.Context,
334 direction usecase.SplitDirection,333 direction usecase.SplitDirection,
335-) (*splitContext, bool) {334+) (*splitContext, error) {
336 log := logging.FromContext(ctx)335 log := logging.FromContext(ctx)
337 if c.panesUC == nil {336 if c.panesUC == nil {
338 log.Warn().Msg("panes use case not available")337 log.Warn().Msg("panes use case not available")
339- return nil, false338+ return nil, fmt.Errorf("panes use case not available")
339+ }
340+ if c.getActiveWS == nil {
341+ log.Warn().Msg("active workspace provider not configured")
342+ return nil, fmt.Errorf("active workspace provider not configured")
340 }343 }
341 344
342 ws, wsView := c.getActiveWS()345 ws, wsView := c.getActiveWS()
343 if ws == nil {346 if ws == nil {
344 log.Warn().Msg("no active workspace")347 log.Warn().Msg("no active workspace")
345- return nil, false348+ return nil, fmt.Errorf("no active workspace")
346 }349 }
347 350
348 activePane := ws.ActivePane()351 activePane := ws.ActivePane()
349- if activePane == nil {352+ if activePane == nil || activePane.Pane == nil {
350 log.Warn().Msg("no active pane to split")353 log.Warn().Msg("no active pane to split")
351- return nil, false354+ return nil, fmt.Errorf("no active pane to split")
352 }355 }
353 356
354 // Check if active pane is inside a stack357 // Check if active pane is inside a stack
@@ -367,7 +370,7 @@ func (c *WorkspaceCoordinator) prepareSplit(
367 activePane: activePane,370 activePane: activePane,
368 existingWidget: existingWidget,371 existingWidget: existingWidget,
369 isStackSplit: isStackSplit,372 isStackSplit: isStackSplit,
370- }, true373+ }, nil
371 }374 }
372 375
373 func (c *WorkspaceCoordinator) resolveSplitWidget(376 func (c *WorkspaceCoordinator) resolveSplitWidget(
@@ -407,7 +410,7 @@ func (c *WorkspaceCoordinator) applySplitToView(
407 existingWidget layout.Widget,410 existingWidget layout.Widget,
408 isStackSplit bool,411 isStackSplit bool,
409 oldActivePaneID entity.PaneID,412 oldActivePaneID entity.PaneID,
410-) {413+) error {
411 log := logging.FromContext(ctx)414 log := logging.FromContext(ctx)
412 needsAttach := false415 needsAttach := false
413 416
@@ -423,12 +426,14 @@ func (c *WorkspaceCoordinator) applySplitToView(
423 log.Warn().Err(splitErr).Msg("incremental split failed, falling back to rebuild")426 log.Warn().Err(splitErr).Msg("incremental split failed, falling back to rebuild")
424 if err := wsView.Rebuild(ctx); err != nil {427 if err := wsView.Rebuild(ctx); err != nil {
425 log.Error().Err(err).Msg("failed to rebuild workspace view")428 log.Error().Err(err).Msg("failed to rebuild workspace view")
429+ return fmt.Errorf("rebuild workspace view after split: %w", err)
426 }430 }
427 needsAttach = true431 needsAttach = true
428 }432 }
429 } else {433 } else {
430 if err := wsView.Rebuild(ctx); err != nil {434 if err := wsView.Rebuild(ctx); err != nil {
431 log.Error().Err(err).Msg("failed to rebuild workspace view")435 log.Error().Err(err).Msg("failed to rebuild workspace view")
436+ return fmt.Errorf("rebuild workspace view after split: %w", err)
432 }437 }
433 needsAttach = true438 needsAttach = true
434 }439 }
@@ -439,8 +444,10 @@ func (c *WorkspaceCoordinator) applySplitToView(
439 }444 }
440 if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {445 if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {
441 log.Warn().Err(err).Msg("failed to set active pane in workspace view")446 log.Warn().Err(err).Msg("failed to set active pane in workspace view")
447+ return fmt.Errorf("activate split pane: %w", err)
442 }448 }
443 wsView.FocusPane(ws.ActivePaneID)449 wsView.FocusPane(ws.ActivePaneID)
450+ return nil
444 }451 }
445 452
446 // doIncrementalStackSplit performs an incremental split around an existing stacked pane.453 // doIncrementalStackSplit performs an incremental split around an existing stacked pane.
@@ -1453,11 +1460,16 @@ func (c *WorkspaceCoordinator) syncStackedViewActive(ctx context.Context, wsView
1453 // StackPane adds a new pane stacked on top of the active pane.1460 // StackPane adds a new pane stacked on top of the active pane.
1454 // Uses CreateStack use case for new stacks, AddToStack for existing stacks.1461 // Uses CreateStack use case for new stacks, AddToStack for existing stacks.
1455 func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {1462 func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1463+ return c.StackPaneWithURL(ctx, c.newPaneURL)
1464+}
1465+
1466+// StackPaneWithURL adds a new pane containing initialURL to the active stack.
1467+func (c *WorkspaceCoordinator) StackPaneWithURL(ctx context.Context, initialURL string) error {
1456 log := logging.FromContext(ctx)1468 log := logging.FromContext(ctx)
1457 1469
1458- stackCtx, ok := c.prepareStackPane(ctx)1470+ stackCtx, err := c.prepareStackPane(ctx)
1459- if !ok {1471+ if err != nil {
1460- return nil1472+ return err
1461 }1473 }
1462 1474
1463 // Determine if we need to create a new stack or add to existing.1475 // Determine if we need to create a new stack or add to existing.
@@ -1469,7 +1481,7 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1469 if stackCtx.activeNode.Parent != nil && stackCtx.activeNode.Parent.IsStacked {1481 if stackCtx.activeNode.Parent != nil && stackCtx.activeNode.Parent.IsStacked {
1470 // Already in a stack - use AddToStack use case1482 // Already in a stack - use AddToStack use case
1471 stackNode = stackCtx.activeNode.Parent1483 stackNode = stackCtx.activeNode.Parent
1472- output, err := c.panesUC.AddToStack(ctx, stackCtx.ws, stackNode, nil, c.newPaneURL)1484+ output, err := c.panesUC.AddToStack(ctx, stackCtx.ws, stackNode, nil, initialURL)
1473 if err != nil {1485 if err != nil {
1474 log.Error().Err(err).Msg("failed to add pane to stack via use case")1486 log.Error().Err(err).Msg("failed to add pane to stack via use case")
1475 return err1487 return err
@@ -1484,7 +1496,7 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1484 } else if stackCtx.activeNode.IsStacked {1496 } else if stackCtx.activeNode.IsStacked {
1485 // Active node is already a stack container - add to it1497 // Active node is already a stack container - add to it
1486 stackNode = stackCtx.activeNode1498 stackNode = stackCtx.activeNode
1487- output, err := c.panesUC.AddToStack(ctx, stackCtx.ws, stackNode, nil, c.newPaneURL)1499+ output, err := c.panesUC.AddToStack(ctx, stackCtx.ws, stackNode, nil, initialURL)
1488 if err != nil {1500 if err != nil {
1489 log.Error().Err(err).Msg("failed to add pane to stack via use case")1501 log.Error().Err(err).Msg("failed to add pane to stack via use case")
1490 return err1502 return err
@@ -1498,7 +1510,7 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1498 Msg("added to stack container via use case")1510 Msg("added to stack container via use case")
1499 } else {1511 } else {
1500 // Need to create a new stack - use CreateStack use case.1512 // Need to create a new stack - use CreateStack use case.
1501- output, err := c.panesUC.CreateStack(ctx, stackCtx.ws, stackCtx.activeNode, c.newPaneURL)1513+ output, err := c.panesUC.CreateStack(ctx, stackCtx.ws, stackCtx.activeNode, initialURL)
1502 if err != nil {1514 if err != nil {
1503 log.Error().Err(err).Msg("failed to create stack via use case")1515 log.Error().Err(err).Msg("failed to create stack via use case")
1504 return err1516 return err
@@ -1539,23 +1551,28 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1539 wv, err := c.contentCoord.EnsureWebView(ctx, newPaneID)1551 wv, err := c.contentCoord.EnsureWebView(ctx, newPaneID)
1540 if err != nil {1552 if err != nil {
1541 log.Warn().Err(err).Msg("failed to get webview for new pane")1553 log.Warn().Err(err).Msg("failed to get webview for new pane")
1542- } else {1554+ return err
1543- widget := c.contentCoord.WrapWidget(ctx, wv)1555+ }
1544- if widget != nil {1556+ widget := c.contentCoord.WrapWidget(ctx, wv)
1545- if err := stackCtx.wsView.SetWebViewWidget(newPaneID, widget); err != nil {1557+ if widget == nil {
1546- log.Warn().Err(err).Str("pane_id", string(newPaneID)).Msg("failed to attach webview widget for stacked pane")1558+ return fmt.Errorf("wrap webview for stacked pane")
1547- }1559+ }
1548- }1560+ if err := stackCtx.wsView.SetWebViewWidget(newPaneID, widget); err != nil {
1549- // Load initial page for the new pane1561+ log.Warn().Err(err).Str("pane_id", string(newPaneID)).Msg("failed to attach webview widget for stacked pane")
1550- if err := wv.LoadURI(ctx, newPane.URI); err != nil {1562+ return err
1551- log.Warn().Err(err).Str("uri", newPane.URI).Msg("failed to load initial page")1563+ }
1552- }1564+ // Load initial page for the new pane.
1565+ if err := wv.LoadURI(ctx, newPane.URI); err != nil {
1566+ log.Warn().Err(err).Str("uri_host", logging.SafeURLHost(newPane.URI)).Msg("failed to load initial page")
1567+ return err
1553 }1568 }
1554 1569
1555 // Update workspace view1570 // Update workspace view
1556 if err := stackCtx.wsView.SetActivePaneID(newPaneID); err != nil {1571 if err := stackCtx.wsView.SetActivePaneID(newPaneID); err != nil {
1557 log.Warn().Err(err).Msg("failed to set active pane")1572 log.Warn().Err(err).Msg("failed to set active pane")
1573+ return err
1558 }1574 }
1575+ stackCtx.wsView.FocusPane(newPaneID)
1559 1576
1560 stackCtx.wsView.NotifyNewPaneCreated(ctx)1577 stackCtx.wsView.NotifyNewPaneCreated(ctx)
1561 1578
@@ -1588,28 +1605,44 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1588 1605
1589 func (c *WorkspaceCoordinator) prepareStackPane(1606 func (c *WorkspaceCoordinator) prepareStackPane(
1590 ctx context.Context,1607 ctx context.Context,
1591-) (*stackPaneContext, bool) {1608+) (*stackPaneContext, error) {
1592 log := logging.FromContext(ctx)1609 log := logging.FromContext(ctx)
1593 if c.stackedPaneMgr == nil {1610 if c.stackedPaneMgr == nil {
1594 log.Warn().Msg("stacked pane manager not available")1611 log.Warn().Msg("stacked pane manager not available")
1595- return nil, false1612+ return nil, fmt.Errorf("stacked pane manager not available")
1613+ }
1614+ if c.panesUC == nil {
1615+ log.Warn().Msg("panes use case not available")
1616+ return nil, fmt.Errorf("panes use case not available")
1617+ }
1618+ if c.contentCoord == nil {
1619+ log.Warn().Msg("content coordinator not available")
1620+ return nil, fmt.Errorf("content coordinator not available")
1621+ }
1622+ if c.widgetFactory == nil {
1623+ log.Warn().Msg("widget factory not available")
1624+ return nil, fmt.Errorf("widget factory not available")
1625+ }
1626+ if c.getActiveWS == nil {
1627+ log.Warn().Msg("active workspace provider not configured")
1628+ return nil, fmt.Errorf("active workspace provider not configured")
1596 }1629 }
1597 1630
1598 ws, wsView := c.getActiveWS()1631 ws, wsView := c.getActiveWS()
1599 if ws == nil {1632 if ws == nil {
1600 log.Warn().Msg("no active workspace")1633 log.Warn().Msg("no active workspace")
1601- return nil, false1634+ return nil, fmt.Errorf("no active workspace")
1602 }1635 }
1603 1636
1604 activeNode := ws.ActivePane()1637 activeNode := ws.ActivePane()
1605 if activeNode == nil || activeNode.Pane == nil {1638 if activeNode == nil || activeNode.Pane == nil {
1606 log.Warn().Msg("no active pane")1639 log.Warn().Msg("no active pane")
1607- return nil, false1640+ return nil, fmt.Errorf("no active pane")
1608 }1641 }
1609 1642
1610 if wsView == nil {1643 if wsView == nil {
1611 log.Warn().Msg("no workspace view")1644 log.Warn().Msg("no workspace view")
1612- return nil, false1645+ return nil, fmt.Errorf("no workspace view")
1613 }1646 }
1614 1647
1615 activePaneID := activeNode.Pane.ID1648 activePaneID := activeNode.Pane.ID
@@ -1627,7 +1660,7 @@ func (c *WorkspaceCoordinator) prepareStackPane(
1627 activeNode: activeNode,1660 activeNode: activeNode,
1628 activePaneID: activePaneID,1661 activePaneID: activePaneID,
1629 originalTitle: originalTitle,1662 originalTitle: originalTitle,
1630- }, true1663+ }, nil
1631 }1664 }
1632 1665
1633 func (c *WorkspaceCoordinator) updateFirstStackTitle(1666 func (c *WorkspaceCoordinator) updateFirstStackTitle(
@@ -1942,7 +1975,9 @@ func (c *WorkspaceCoordinator) insertPopupSplit(ctx context.Context, input conte
1942 1975
1943 // Update UI1976 // Update UI
1944 if wsView != nil {1977 if wsView != nil {
1945- c.applySplitToView(ctx, wsView, ws, output, direction, existingWidget, isStackSplit, input.ParentPaneID)1978+ if err := c.applySplitToView(ctx, wsView, ws, output, direction, existingWidget, isStackSplit, input.ParentPaneID); err != nil {
1979+ return err
1980+ }
1946 c.attachPopupWebView(ctx, wsView, input)1981 c.attachPopupWebView(ctx, wsView, input)
1947 }1982 }
1948 1983
A internal/ui/coordinator/workspace_external_url_test.go +97 -0
@@ -0,0 +1,97 @@
1+package coordinator
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/ui/component"
10+ contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
11+ "github.com/bnema/dumber/internal/ui/layout"
12+ "github.com/bnema/puregotk/v4/gtk"
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestWorkspaceCoordinator_SplitWithURLUsesExplicitURLAndActivatesPane(t *testing.T) {
18+ ctx := context.Background()
19+ ids := []string{"pane-2", "split-1"}
20+ index := 0
21+ ws := entity.NewWorkspace("ws-1", entity.NewPane("pane-1"))
22+ coord := NewWorkspaceCoordinator(ctx, WorkspaceCoordinatorConfig{
23+ PanesUC: usecase.NewManagePanesUseCase(func() string {
24+ id := ids[index]
25+ index++
26+ return id
27+ }, nil),
28+ NewPaneURL: "https://legacy.example/",
29+ GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) {
30+ return ws, nil
31+ },
32+ })
33+
34+ const explicitURL = "https://explicit.example/path?token=secret"
35+ require.NoError(t, coord.SplitWithURL(ctx, usecase.SplitLeft, explicitURL))
36+
37+ active := ws.ActivePane()
38+ require.NotNil(t, active)
39+ require.NotNil(t, active.Pane)
40+ assert.Equal(t, entity.PaneID("pane-2"), ws.ActivePaneID)
41+ assert.Equal(t, explicitURL, active.Pane.URI)
42+ assert.Equal(t, entity.SplitHorizontal, ws.Root.SplitDir)
43+ assert.Equal(t, entity.PaneID("pane-2"), ws.Root.Children[0].Pane.ID)
44+}
45+
46+func TestWorkspaceCoordinator_SplitReturnsErrorWhenCreationTargetMissing(t *testing.T) {
47+ coord := NewWorkspaceCoordinator(context.Background(), WorkspaceCoordinatorConfig{})
48+ require.Error(t, coord.SplitWithURL(context.Background(), usecase.SplitRight, "https://example.com"))
49+}
50+
51+func TestWorkspaceCoordinator_StackPaneUsesConfiguredURLThroughLegacyEntryPoint(t *testing.T) {
52+ const legacyURL = "https://legacy.example/"
53+ coord, ws := stackURLTestCoordinator(t, legacyURL)
54+
55+ // The test coordinator intentionally has no WebView pool. Creation reaches
56+ // that detectable UI failure only after the requested URL enters the model.
57+ require.ErrorContains(t, coord.StackPane(context.Background()), "webview pool not configured")
58+ assert.Equal(t, legacyURL, ws.ActivePane().Pane.URI)
59+}
60+
61+func TestWorkspaceCoordinator_StackPaneWithURLUsesExplicitURL(t *testing.T) {
62+ const explicitURL = "https://explicit.example/path?token=secret"
63+ coord, ws := stackURLTestCoordinator(t, "https://legacy.example/")
64+
65+ require.ErrorContains(t, coord.StackPaneWithURL(context.Background(), explicitURL), "webview pool not configured")
66+ assert.Equal(t, explicitURL, ws.ActivePane().Pane.URI)
67+}
68+
69+func stackURLTestCoordinator(t *testing.T, legacyURL string) (*WorkspaceCoordinator, *entity.Workspace) {
70+ t.Helper()
71+ if !gtk.InitCheck() {
72+ t.Skip("GTK native display prerequisite unavailable")
73+ }
74+ ctx := context.Background()
75+ factory := layout.NewGtkWidgetFactory()
76+ ws := entity.NewWorkspace("ws-1", entity.NewPane("pane-1"))
77+ wsView := component.NewWorkspaceView(ctx, factory)
78+ require.NoError(t, wsView.SetWorkspace(ctx, ws))
79+ ids := []string{"pane-2", "stack-1"}
80+ index := 0
81+ content := contentcoord.NewCoordinator(ctx, nil, nil, factory, nil, nil, nil, nil)
82+ coord := NewWorkspaceCoordinator(ctx, WorkspaceCoordinatorConfig{
83+ PanesUC: usecase.NewManagePanesUseCase(func() string {
84+ id := ids[index]
85+ index++
86+ return id
87+ }, nil),
88+ WidgetFactory: factory,
89+ StackedPaneMgr: component.NewStackedPaneManager(factory),
90+ ContentCoord: content,
91+ NewPaneURL: legacyURL,
92+ GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) {
93+ return ws, wsView
94+ },
95+ })
96+ return coord, ws
97+}
@@ -0,0 +1,97 @@
1+package coordinator
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/ui/component"
10+ contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
11+ "github.com/bnema/dumber/internal/ui/layout"
12+ "github.com/bnema/puregotk/v4/gtk"
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestWorkspaceCoordinator_SplitWithURLUsesExplicitURLAndActivatesPane(t *testing.T) {
18+ ctx := context.Background()
19+ ids := []string{"pane-2", "split-1"}
20+ index := 0
21+ ws := entity.NewWorkspace("ws-1", entity.NewPane("pane-1"))
22+ coord := NewWorkspaceCoordinator(ctx, WorkspaceCoordinatorConfig{
23+ PanesUC: usecase.NewManagePanesUseCase(func() string {
24+ id := ids[index]
25+ index++
26+ return id
27+ }, nil),
28+ NewPaneURL: "https://legacy.example/",
29+ GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) {
30+ return ws, nil
31+ },
32+ })
33+
34+ const explicitURL = "https://explicit.example/path?token=secret"
35+ require.NoError(t, coord.SplitWithURL(ctx, usecase.SplitLeft, explicitURL))
36+
37+ active := ws.ActivePane()
38+ require.NotNil(t, active)
39+ require.NotNil(t, active.Pane)
40+ assert.Equal(t, entity.PaneID("pane-2"), ws.ActivePaneID)
41+ assert.Equal(t, explicitURL, active.Pane.URI)
42+ assert.Equal(t, entity.SplitHorizontal, ws.Root.SplitDir)
43+ assert.Equal(t, entity.PaneID("pane-2"), ws.Root.Children[0].Pane.ID)
44+}
45+
46+func TestWorkspaceCoordinator_SplitReturnsErrorWhenCreationTargetMissing(t *testing.T) {
47+ coord := NewWorkspaceCoordinator(context.Background(), WorkspaceCoordinatorConfig{})
48+ require.Error(t, coord.SplitWithURL(context.Background(), usecase.SplitRight, "https://example.com"))
49+}
50+
51+func TestWorkspaceCoordinator_StackPaneUsesConfiguredURLThroughLegacyEntryPoint(t *testing.T) {
52+ const legacyURL = "https://legacy.example/"
53+ coord, ws := stackURLTestCoordinator(t, legacyURL)
54+
55+ // The test coordinator intentionally has no WebView pool. Creation reaches
56+ // that detectable UI failure only after the requested URL enters the model.
57+ require.ErrorContains(t, coord.StackPane(context.Background()), "webview pool not configured")
58+ assert.Equal(t, legacyURL, ws.ActivePane().Pane.URI)
59+}
60+
61+func TestWorkspaceCoordinator_StackPaneWithURLUsesExplicitURL(t *testing.T) {
62+ const explicitURL = "https://explicit.example/path?token=secret"
63+ coord, ws := stackURLTestCoordinator(t, "https://legacy.example/")
64+
65+ require.ErrorContains(t, coord.StackPaneWithURL(context.Background(), explicitURL), "webview pool not configured")
66+ assert.Equal(t, explicitURL, ws.ActivePane().Pane.URI)
67+}
68+
69+func stackURLTestCoordinator(t *testing.T, legacyURL string) (*WorkspaceCoordinator, *entity.Workspace) {
70+ t.Helper()
71+ if !gtk.InitCheck() {
72+ t.Skip("GTK native display prerequisite unavailable")
73+ }
74+ ctx := context.Background()
75+ factory := layout.NewGtkWidgetFactory()
76+ ws := entity.NewWorkspace("ws-1", entity.NewPane("pane-1"))
77+ wsView := component.NewWorkspaceView(ctx, factory)
78+ require.NoError(t, wsView.SetWorkspace(ctx, ws))
79+ ids := []string{"pane-2", "stack-1"}
80+ index := 0
81+ content := contentcoord.NewCoordinator(ctx, nil, nil, factory, nil, nil, nil, nil)
82+ coord := NewWorkspaceCoordinator(ctx, WorkspaceCoordinatorConfig{
83+ PanesUC: usecase.NewManagePanesUseCase(func() string {
84+ id := ids[index]
85+ index++
86+ return id
87+ }, nil),
88+ WidgetFactory: factory,
89+ StackedPaneMgr: component.NewStackedPaneManager(factory),
90+ ContentCoord: content,
91+ NewPaneURL: legacyURL,
92+ GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) {
93+ return ws, wsView
94+ },
95+ })
96+ return coord, ws
97+}
A internal/ui/external_link_routing_test.go +136 -0
@@ -0,0 +1,136 @@
1+package ui
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port/mocks"
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/infrastructure/config"
11+ "github.com/bnema/dumber/internal/shared/syncdispatch"
12+ "github.com/bnema/dumber/internal/ui/component"
13+ "github.com/bnema/dumber/internal/ui/coordinator"
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+func TestExternalLinkSplitDirectionPlacements(t *testing.T) {
19+ tests := map[entity.ExternalLinkPlacement]usecase.SplitDirection{
20+ entity.ExternalLinkPlacementLeft: usecase.SplitLeft,
21+ entity.ExternalLinkPlacementRight: usecase.SplitRight,
22+ entity.ExternalLinkPlacementTop: usecase.SplitUp,
23+ entity.ExternalLinkPlacementBottom: usecase.SplitDown,
24+ }
25+ for placement, want := range tests {
26+ t.Run(string(placement), func(t *testing.T) {
27+ assert.Equal(t, want, externalLinkSplitDirection(placement))
28+ })
29+ }
30+}
31+
32+func TestApp_OpenExternalURLTabbedTargetsLastFocusedWindowAndPreservesURL(t *testing.T) {
33+ ctx := context.Background()
34+ const originalURL = "https://example.com/path?token=secret#fragment"
35+ active := entity.NewTab("tab-1", "ws-1", entity.NewPane("pane-1"))
36+ tabs := entity.NewTabList()
37+ tabs.Add(active)
38+ focused := &browserWindow{id: "focused", tabs: tabs}
39+
40+ ids := []string{"tab-2", "ws-2", "pane-2"}
41+ index := 0
42+ app := &App{
43+ runtimeConfig: runtimeConfigStateForTest(&config.Config{Workspace: entity.WorkspaceConfig{ExternalLinks: entity.ExternalLinksConfig{Behavior: entity.ExternalLinkBehaviorTabbed}}}),
44+ tabsUC: usecase.NewManageTabsUseCase(func() string { id := ids[index]; index++; return id }, nil),
45+ browserWindows: map[string]*browserWindow{
46+ focused.id: focused,
47+ },
48+ browserWindowOrder: []string{focused.id},
49+ lastFocusedWindowID: focused.id,
50+ workspaceViews: map[entity.TabID]*component.WorkspaceView{},
51+ windowForTab: map[entity.TabID]*browserWindow{},
52+ }
53+ app.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{TabsUC: app.tabsUC})
54+ app.tabCoord.SetOnTabCreated(func(_ context.Context, _ coordinator.TabTarget, tab *entity.Tab) {
55+ app.workspaceViews[tab.ID] = &component.WorkspaceView{}
56+ app.setBrowserWindowForTab(tab.ID, focused)
57+ })
58+
59+ require.NoError(t, app.OpenExternalURL(ctx, originalURL))
60+ require.Len(t, tabs.Tabs, 2)
61+ created := tabs.ActiveTab()
62+ require.NotNil(t, created)
63+ require.NotNil(t, created.Workspace)
64+ assert.Equal(t, originalURL, created.Workspace.ActivePane().Pane.URI)
65+ assert.Same(t, focused, app.browserWindowForTab(created.ID))
66+}
67+
68+func TestApp_OpenExternalURLSnapshotsRuntimeConfigOnce(t *testing.T) {
69+ ctx := context.Background()
70+ provider := mocks.NewMockRuntimeConfigProvider(t)
71+ provider.EXPECT().Current().Return(entity.RuntimeConfigSnapshot{UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{ExternalLinks: entity.ExternalLinksConfig{Behavior: entity.ExternalLinkBehaviorWindowed}}}}).Once()
72+ var factoryCalls int
73+ var dispatchCalls int
74+ app := &App{
75+ runtimeConfig: newRuntimeConfigState(provider),
76+ dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult {
77+ dispatchCalls++
78+ fn()
79+ return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchInline}
80+ },
81+ browserWindowFactory: func(_ context.Context, _ string) (*browserWindow, error) {
82+ factoryCalls++
83+ return &browserWindow{id: "windowed", tabs: entity.NewTabList()}, nil
84+ },
85+ browserWindows: map[string]*browserWindow{},
86+ workspaceViews: map[entity.TabID]*component.WorkspaceView{},
87+ windowForTab: map[entity.TabID]*browserWindow{},
88+ }
89+
90+ require.NoError(t, app.OpenExternalURL(ctx, "https://example.com"))
91+ assert.Equal(t, 1, dispatchCalls)
92+ assert.Equal(t, 1, factoryCalls)
93+}
94+
95+func TestApp_OpenExternalURLFallsBackWhenLastFocusedWindowIsStale(t *testing.T) {
96+ ctx := context.Background()
97+ const originalURL = "https://example.com/stale?token=secret"
98+ staleTab := entity.NewTab("stale-tab", "stale-workspace", entity.NewPane("stale-pane"))
99+ staleTabs := entity.NewTabList()
100+ staleTabs.Add(staleTab)
101+ stale := &browserWindow{id: "stale", tabs: staleTabs}
102+ var factoryURL string
103+ app := &App{
104+ runtimeConfig: runtimeConfigStateForTest(&config.Config{Workspace: entity.WorkspaceConfig{ExternalLinks: entity.ExternalLinksConfig{Behavior: entity.ExternalLinkBehaviorTabbed}}}),
105+ browserWindows: map[string]*browserWindow{},
106+ lastFocusedWindowID: stale.id,
107+ windowForTab: map[entity.TabID]*browserWindow{staleTab.ID: stale},
108+ workspaceViews: map[entity.TabID]*component.WorkspaceView{},
109+ browserWindowFactory: func(_ context.Context, url string) (*browserWindow, error) {
110+ factoryURL = url
111+ return &browserWindow{id: "fallback", tabs: entity.NewTabList()}, nil
112+ },
113+ }
114+
115+ require.NoError(t, app.OpenExternalURL(ctx, originalURL))
116+ assert.Equal(t, originalURL, factoryURL)
117+}
118+
119+func TestApp_OpenExternalURLFallbackPreservesOriginalURL(t *testing.T) {
120+ ctx := context.Background()
121+ const originalURL = "https://example.com/path?token=secret#fragment"
122+ var factoryURL string
123+ app := &App{
124+ runtimeConfig: runtimeConfigStateForTest(&config.Config{Workspace: entity.WorkspaceConfig{ExternalLinks: entity.ExternalLinksConfig{Behavior: entity.ExternalLinkBehaviorSplit, Placement: entity.ExternalLinkPlacementLeft}}}),
125+ browserWindowFactory: func(_ context.Context, url string) (*browserWindow, error) {
126+ factoryURL = url
127+ return &browserWindow{id: "fallback", tabs: entity.NewTabList()}, nil
128+ },
129+ browserWindows: map[string]*browserWindow{},
130+ workspaceViews: map[entity.TabID]*component.WorkspaceView{},
131+ windowForTab: map[entity.TabID]*browserWindow{},
132+ }
133+
134+ require.NoError(t, app.OpenExternalURL(ctx, originalURL))
135+ assert.Equal(t, originalURL, factoryURL)
136+}
@@ -0,0 +1,136 @@
1+package ui
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port/mocks"
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/infrastructure/config"
11+ "github.com/bnema/dumber/internal/shared/syncdispatch"
12+ "github.com/bnema/dumber/internal/ui/component"
13+ "github.com/bnema/dumber/internal/ui/coordinator"
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+func TestExternalLinkSplitDirectionPlacements(t *testing.T) {
19+ tests := map[entity.ExternalLinkPlacement]usecase.SplitDirection{
20+ entity.ExternalLinkPlacementLeft: usecase.SplitLeft,
21+ entity.ExternalLinkPlacementRight: usecase.SplitRight,
22+ entity.ExternalLinkPlacementTop: usecase.SplitUp,
23+ entity.ExternalLinkPlacementBottom: usecase.SplitDown,
24+ }
25+ for placement, want := range tests {
26+ t.Run(string(placement), func(t *testing.T) {
27+ assert.Equal(t, want, externalLinkSplitDirection(placement))
28+ })
29+ }
30+}
31+
32+func TestApp_OpenExternalURLTabbedTargetsLastFocusedWindowAndPreservesURL(t *testing.T) {
33+ ctx := context.Background()
34+ const originalURL = "https://example.com/path?token=secret#fragment"
35+ active := entity.NewTab("tab-1", "ws-1", entity.NewPane("pane-1"))
36+ tabs := entity.NewTabList()
37+ tabs.Add(active)
38+ focused := &browserWindow{id: "focused", tabs: tabs}
39+
40+ ids := []string{"tab-2", "ws-2", "pane-2"}
41+ index := 0
42+ app := &App{
43+ runtimeConfig: runtimeConfigStateForTest(&config.Config{Workspace: entity.WorkspaceConfig{ExternalLinks: entity.ExternalLinksConfig{Behavior: entity.ExternalLinkBehaviorTabbed}}}),
44+ tabsUC: usecase.NewManageTabsUseCase(func() string { id := ids[index]; index++; return id }, nil),
45+ browserWindows: map[string]*browserWindow{
46+ focused.id: focused,
47+ },
48+ browserWindowOrder: []string{focused.id},
49+ lastFocusedWindowID: focused.id,
50+ workspaceViews: map[entity.TabID]*component.WorkspaceView{},
51+ windowForTab: map[entity.TabID]*browserWindow{},
52+ }
53+ app.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{TabsUC: app.tabsUC})
54+ app.tabCoord.SetOnTabCreated(func(_ context.Context, _ coordinator.TabTarget, tab *entity.Tab) {
55+ app.workspaceViews[tab.ID] = &component.WorkspaceView{}
56+ app.setBrowserWindowForTab(tab.ID, focused)
57+ })
58+
59+ require.NoError(t, app.OpenExternalURL(ctx, originalURL))
60+ require.Len(t, tabs.Tabs, 2)
61+ created := tabs.ActiveTab()
62+ require.NotNil(t, created)
63+ require.NotNil(t, created.Workspace)
64+ assert.Equal(t, originalURL, created.Workspace.ActivePane().Pane.URI)
65+ assert.Same(t, focused, app.browserWindowForTab(created.ID))
66+}
67+
68+func TestApp_OpenExternalURLSnapshotsRuntimeConfigOnce(t *testing.T) {
69+ ctx := context.Background()
70+ provider := mocks.NewMockRuntimeConfigProvider(t)
71+ provider.EXPECT().Current().Return(entity.RuntimeConfigSnapshot{UI: entity.RuntimeUIConfig{Workspace: entity.WorkspaceConfig{ExternalLinks: entity.ExternalLinksConfig{Behavior: entity.ExternalLinkBehaviorWindowed}}}}).Once()
72+ var factoryCalls int
73+ var dispatchCalls int
74+ app := &App{
75+ runtimeConfig: newRuntimeConfigState(provider),
76+ dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult {
77+ dispatchCalls++
78+ fn()
79+ return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchInline}
80+ },
81+ browserWindowFactory: func(_ context.Context, _ string) (*browserWindow, error) {
82+ factoryCalls++
83+ return &browserWindow{id: "windowed", tabs: entity.NewTabList()}, nil
84+ },
85+ browserWindows: map[string]*browserWindow{},
86+ workspaceViews: map[entity.TabID]*component.WorkspaceView{},
87+ windowForTab: map[entity.TabID]*browserWindow{},
88+ }
89+
90+ require.NoError(t, app.OpenExternalURL(ctx, "https://example.com"))
91+ assert.Equal(t, 1, dispatchCalls)
92+ assert.Equal(t, 1, factoryCalls)
93+}
94+
95+func TestApp_OpenExternalURLFallsBackWhenLastFocusedWindowIsStale(t *testing.T) {
96+ ctx := context.Background()
97+ const originalURL = "https://example.com/stale?token=secret"
98+ staleTab := entity.NewTab("stale-tab", "stale-workspace", entity.NewPane("stale-pane"))
99+ staleTabs := entity.NewTabList()
100+ staleTabs.Add(staleTab)
101+ stale := &browserWindow{id: "stale", tabs: staleTabs}
102+ var factoryURL string
103+ app := &App{
104+ runtimeConfig: runtimeConfigStateForTest(&config.Config{Workspace: entity.WorkspaceConfig{ExternalLinks: entity.ExternalLinksConfig{Behavior: entity.ExternalLinkBehaviorTabbed}}}),
105+ browserWindows: map[string]*browserWindow{},
106+ lastFocusedWindowID: stale.id,
107+ windowForTab: map[entity.TabID]*browserWindow{staleTab.ID: stale},
108+ workspaceViews: map[entity.TabID]*component.WorkspaceView{},
109+ browserWindowFactory: func(_ context.Context, url string) (*browserWindow, error) {
110+ factoryURL = url
111+ return &browserWindow{id: "fallback", tabs: entity.NewTabList()}, nil
112+ },
113+ }
114+
115+ require.NoError(t, app.OpenExternalURL(ctx, originalURL))
116+ assert.Equal(t, originalURL, factoryURL)
117+}
118+
119+func TestApp_OpenExternalURLFallbackPreservesOriginalURL(t *testing.T) {
120+ ctx := context.Background()
121+ const originalURL = "https://example.com/path?token=secret#fragment"
122+ var factoryURL string
123+ app := &App{
124+ runtimeConfig: runtimeConfigStateForTest(&config.Config{Workspace: entity.WorkspaceConfig{ExternalLinks: entity.ExternalLinksConfig{Behavior: entity.ExternalLinkBehaviorSplit, Placement: entity.ExternalLinkPlacementLeft}}}),
125+ browserWindowFactory: func(_ context.Context, url string) (*browserWindow, error) {
126+ factoryURL = url
127+ return &browserWindow{id: "fallback", tabs: entity.NewTabList()}, nil
128+ },
129+ browserWindows: map[string]*browserWindow{},
130+ workspaceViews: map[entity.TabID]*component.WorkspaceView{},
131+ windowForTab: map[entity.TabID]*browserWindow{},
132+ }
133+
134+ require.NoError(t, app.OpenExternalURL(ctx, originalURL))
135+ assert.Equal(t, originalURL, factoryURL)
136+}

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