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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 100 | 100 | return false, nil | |
| 101 | 101 | } | |
| 102 | 102 | ||
| 103 | - | delivered, err := relay.DeliverOpenFreshWindow(ctx, browseURL) | |
| 103 | + | delivered, err := relay.DeliverOpenExternalURL(ctx, browseURL) | |
| 104 | 104 | if err != nil { | |
| 105 | 105 | if delivered && errors.Is(err, desktop.ErrBrowserLaunchRelayUnconfirmed) { | |
| 106 | 106 | return true, nil | |
| @@ -297,7 +297,7 @@ func runGUI(cfg *config.Config, timing startupTiming) int { | |||
| 297 | 297 | } | |
| 298 | 298 | if relaunchSetter, ok := engine.(port.AlreadyRunningAppRelaunchHandlerSetter); ok { | |
| 299 | 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 | 301 | log.Warn().Err(err).Str("url", url).Msg("failed to open relaunch browser window") | |
| 302 | 302 | } | |
| 303 | 303 | }) | |
| @@ -100,7 +100,7 @@ func tryForwardBrowseURLToRunningInstance(ctx context.Context, relay port.Browse | |||||
| 100 | return false, nil | 100 | 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, nil | 106 | 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) { | |||
| 230 | 230 | ||
| 231 | 231 | func TestTryForwardBrowseURLToRunningInstance_ForwardsDefaultStartupURL(t *testing.T) { | |
| 232 | 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 | 235 | forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "dumb://history") | |
| 236 | 236 | ||
| @@ -244,7 +244,7 @@ func TestTryForwardBrowseURLToRunningInstance_ForwardsDefaultStartupURL(t *testi | |||
| 244 | 244 | ||
| 245 | 245 | func TestTryForwardBrowseURLToRunningInstance_ReturnsTrueOnRelayHit(t *testing.T) { | |
| 246 | 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 | 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 | 259 | func TestTryForwardBrowseURLToRunningInstance_ReturnsFalseOnRelayMiss(t *testing.T) { | |
| 260 | 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 | 263 | forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com") | |
| 264 | 264 | ||
| @@ -272,7 +272,7 @@ func TestTryForwardBrowseURLToRunningInstance_ReturnsFalseOnRelayMiss(t *testing | |||
| 272 | 272 | ||
| 273 | 273 | func TestTryForwardBrowseURLToRunningInstance_ReturnsTrueOnUnconfirmedRelayDelivery(t *testing.T) { | |
| 274 | 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 | 277 | forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com") | |
| 278 | 278 | ||
| @@ -309,7 +309,7 @@ func TestLaunchStandaloneBrowserURL_PropagatesOtherErrors(t *testing.T) { | |||
| 309 | 309 | func TestTryForwardBrowseURLToRunningInstance_PropagatesError(t *testing.T) { | |
| 310 | 310 | relay := mocks.NewMockBrowserLaunchRelay(t) | |
| 311 | 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 | 314 | forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com") | |
| 315 | 315 | ||
| @@ -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 @@ | |||
| 114 | 114 | | `workspace.floating_pane.profiles.<name>.keys` | []string | | at least one key | | |
| 115 | 115 | | `workspace.floating_pane.profiles.<name>.url` | string | | required URL | | |
| 116 | 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 | 119 | | `workspace.browsing_contexts.behavior` | string | `split` | `split`, `stacked`, `tabbed`, `windowed` | | |
| 118 | 120 | | `workspace.browsing_contexts.placement` | string | `right` | `right`, `left`, `top`, `bottom` | | |
| 119 | 121 | | `workspace.browsing_contexts.open_in_new_pane` | bool | `true` | | | |
| @@ -121,6 +123,9 @@ | |||
| 121 | 123 | | `workspace.browsing_contexts.blank_target_behavior` | string | `stacked` | `split`, `stacked`, `tabbed` | | |
| 122 | 124 | | `workspace.browsing_contexts.enable_smart_detection` | bool | `true` | | | |
| 123 | 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 | 129 | | `workspace.styling.border_width` | int | `1` | | | |
| 125 | 130 | | `workspace.styling.border_color` | string | `@theme_selected_bg_color` | | | |
| 126 | 131 | | `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 ( | |||
| 5 | 5 | "io" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | - | // BrowserWindowOpener opens a fresh browser window. | |
| 8 | + | // BrowserWindowOpener opens an external URL. | |
| 9 | 9 | type BrowserWindowOpener interface { | |
| 10 | - | OpenFreshWindow(ctx context.Context, url string) error | |
| 10 | + | 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 | 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 | 16 | // The bool reports whether the relay accepted the request; false means the | |
| 17 | 17 | // caller should treat it as undelivered and try another path. | |
| 18 | 18 | // An error may still be returned with delivered=true when the relay accepted | |
| 19 | 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 | 21 | Listen(ctx context.Context, opener BrowserWindowOpener) (io.Closer, error) | |
| 22 | 22 | } | |
| 23 | 23 | ||
| @@ -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) error | 10 | + | 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 the | 16 | // 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 accepted | 18 | // 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 { | |||
| 39 | 39 | return &MockBrowserWindowOpener_Expecter{mock: &_m.Mock} | |
| 40 | 40 | } | |
| 41 | 41 | ||
| 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 { | |
| 44 | 44 | ret := _mock.Called(ctx, url) | |
| 45 | 45 | ||
| 46 | 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 | 50 | var r0 error | |
| @@ -56,19 +56,19 @@ func (_mock *MockBrowserWindowOpener) OpenFreshWindow(ctx context.Context, url s | |||
| 56 | 56 | 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' | |
| 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 { | |
| 61 | 61 | *mock.Call | |
| 62 | 62 | } | |
| 63 | 63 | ||
| 64 | - | // OpenFreshWindow is a helper method to define mock.On call | |
| 64 | + | // OpenExternalURL is a helper method to define mock.On call | |
| 65 | 65 | // - ctx context.Context | |
| 66 | 66 | // - 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)} | |
| 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 | 72 | _c.Call.Run(func(args mock.Arguments) { | |
| 73 | 73 | var arg0 context.Context | |
| 74 | 74 | if args[0] != nil { | |
| @@ -86,12 +86,12 @@ func (_c *MockBrowserWindowOpener_OpenFreshWindow_Call) Run(run func(ctx context | |||
| 86 | 86 | 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 | 90 | _c.Call.Return(err) | |
| 91 | 91 | 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 | 95 | _c.Call.Return(run) | |
| 96 | 96 | return _c | |
| 97 | 97 | } | |
| @@ -123,12 +123,12 @@ func (_m *MockBrowserLaunchRelay) EXPECT() *MockBrowserLaunchRelay_Expecter { | |||
| 123 | 123 | return &MockBrowserLaunchRelay_Expecter{mock: &_m.Mock} | |
| 124 | 124 | } | |
| 125 | 125 | ||
| 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) { | |
| 128 | 128 | ret := _mock.Called(ctx, url) | |
| 129 | 129 | ||
| 130 | 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 | 134 | var r0 bool | |
| @@ -149,19 +149,19 @@ func (_mock *MockBrowserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, | |||
| 149 | 149 | 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' | |
| 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 { | |
| 154 | 154 | *mock.Call | |
| 155 | 155 | } | |
| 156 | 156 | ||
| 157 | - | // DeliverOpenFreshWindow is a helper method to define mock.On call | |
| 157 | + | // DeliverOpenExternalURL is a helper method to define mock.On call | |
| 158 | 158 | // - ctx context.Context | |
| 159 | 159 | // - 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)} | |
| 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 | 165 | _c.Call.Run(func(args mock.Arguments) { | |
| 166 | 166 | var arg0 context.Context | |
| 167 | 167 | if args[0] != nil { | |
| @@ -179,12 +179,12 @@ func (_c *MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call) Run(run func(ctx c | |||
| 179 | 179 | 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 | 183 | _c.Call.Return(b, err) | |
| 184 | 184 | 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 | 188 | _c.Call.Return(run) | |
| 189 | 189 | return _c | |
| 190 | 190 | } | |
| @@ -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 MockBrowserWindowOpener | 42 | + | // 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 error | 50 | var r0 error | ||
| @@ -56,19 +56,19 @@ func (_mock *MockBrowserWindowOpener) OpenFreshWindow(ctx context.Context, url s | |||||
| 56 | return r0 | 56 | 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.Call | 61 | *mock.Call | ||
| 62 | } | 62 | } | ||
| 63 | 63 | ||||
| 64 | - | // OpenFreshWindow is a helper method to define mock.On call | 64 | + | // OpenExternalURL is a helper method to define mock.On call |
| 65 | // - ctx context.Context | 65 | // - ctx context.Context | ||
| 66 | // - url string | 66 | // - 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.Context | 73 | 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 _c | 86 | 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 _c | 91 | 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 _c | 96 | 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 MockBrowserLaunchRelay | 126 | + | // 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 bool | 134 | var r0 bool | ||
| @@ -149,19 +149,19 @@ func (_mock *MockBrowserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, | |||||
| 149 | return r0, r1 | 149 | 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.Call | 154 | *mock.Call | ||
| 155 | } | 155 | } | ||
| 156 | 156 | ||||
| 157 | - | // DeliverOpenFreshWindow is a helper method to define mock.On call | 157 | + | // DeliverOpenExternalURL is a helper method to define mock.On call |
| 158 | // - ctx context.Context | 158 | // - ctx context.Context | ||
| 159 | // - url string | 159 | // - 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.Context | 166 | 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 _c | 179 | 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 _c | 184 | 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 _c | 189 | return _c | ||
| 190 | } | 190 | } | ||
M internal/domain/entity/config_types.go +34 -7
| @@ -209,15 +209,42 @@ type BrowsingContextConfig struct { | |||
| 209 | 209 | // Deprecated: PopupBehaviorConfig is a compatibility alias for BrowsingContextConfig. | |
| 210 | 210 | 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 | 238 | // WorkspaceConfig holds all workspace layout and behavior settings. | |
| 213 | 239 | 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"` | |
| 221 | 248 | ||
| 222 | 249 | TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"` | |
| 223 | 250 | 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 = BrowsingContextConfig | 210 | 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 line | 250 | 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 ( | |||
| 50 | 50 | defaultResizeMinPanePercent = 10.0 | |
| 51 | 51 | defaultTabBarPosition = "bottom" | |
| 52 | 52 | defaultPopupPlacement = "right" | |
| 53 | + | defaultExternalLinkBehavior = ExternalLinkBehaviorWindowed | |
| 54 | + | defaultExternalLinkPlacement = ExternalLinkPlacementRight | |
| 53 | 55 | defaultFloatingPaneWidthPct = 0.82 | |
| 54 | 56 | defaultFloatingPaneHeightPct = 0.72 | |
| 55 | 57 | ||
| @@ -357,6 +359,10 @@ func DefaultConfig() *Config { | |||
| 357 | 359 | HeightPct: defaultFloatingPaneHeightPct, | |
| 358 | 360 | Profiles: map[string]FloatingPaneProfile{}, | |
| 359 | 361 | }, | |
| 362 | + | ExternalLinks: ExternalLinksConfig{ | |
| 363 | + | Behavior: defaultExternalLinkBehavior, | |
| 364 | + | Placement: defaultExternalLinkPlacement, | |
| 365 | + | }, | |
| 360 | 366 | TabBarPosition: defaultTabBarPosition, | |
| 361 | 367 | HideTabBarWhenSingleTab: true, | |
| 362 | 368 | BrowsingContexts: browsingContextDefaults, | |
| @@ -50,6 +50,8 @@ const ( | |||||
| 50 | defaultResizeMinPanePercent = 10.0 | 50 | defaultResizeMinPanePercent = 10.0 | ||
| 51 | defaultTabBarPosition = "bottom" | 51 | defaultTabBarPosition = "bottom" | ||
| 52 | defaultPopupPlacement = "right" | 52 | defaultPopupPlacement = "right" | ||
| 53 | + | defaultExternalLinkBehavior = ExternalLinkBehaviorWindowed | |||
| 54 | + | defaultExternalLinkPlacement = ExternalLinkPlacementRight | |||
| 53 | defaultFloatingPaneWidthPct = 0.82 | 55 | defaultFloatingPaneWidthPct = 0.82 | ||
| 54 | defaultFloatingPaneHeightPct = 0.72 | 56 | 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) { | |||
| 13 | 13 | assert.Equal(t, defaultMaxLogFiles, cfg.Logging.MaxFiles) | |
| 14 | 14 | assert.False(t, cfg.Logging.CaptureGTKLogs) | |
| 15 | 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 | 19 | // Engine defaults (replaces old Performance/Privacy sections) | |
| 18 | 20 | 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 { | |||
| 374 | 374 | } | |
| 375 | 375 | ||
| 376 | 376 | func normalizeConfig(config *Config) { | |
| 377 | + | normalizeExternalLinks(config) | |
| 377 | 378 | normalizeAppearance(config) | |
| 378 | 379 | normalizeMedia(config) | |
| 379 | 380 | normalizeEngineConfig(config) | |
| @@ -386,6 +387,20 @@ func normalizeBrowsingContexts(config *Config) { | |||
| 386 | 387 | 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 | 404 | func normalizeEngineConfig(config *Config) { | |
| 390 | 405 | // Normalize GSK renderer (engine.webkit) | |
| 391 | 406 | switch strings.ToLower(string(config.Engine.WebKit.GSKRenderer)) { | |
| @@ -703,6 +718,8 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) { | |||
| 703 | 718 | m.viper.SetDefault("workspace.floating_pane.width_pct", defaults.Workspace.FloatingPane.WidthPct) | |
| 704 | 719 | m.viper.SetDefault("workspace.floating_pane.height_pct", defaults.Workspace.FloatingPane.HeightPct) | |
| 705 | 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 | 723 | m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition) | |
| 707 | 724 | m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab) | |
| 708 | 725 | 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.BrowsingContexts | 387 | 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) { | |||
| 39 | 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 | 53 | func TestNormalizeConfig_EngineCookiePolicy(t *testing.T) { | |
| 43 | 54 | cfg := DefaultConfig() | |
| 44 | 55 | 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 | |||
| 45 | 45 | // WorkspaceStylingConfig defines visual styling for workspace panes. | |
| 46 | 46 | 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 | 71 | // PopupBehavior defines how popup windows should be opened. | |
| 49 | 72 | type PopupBehavior = entity.PopupBehavior | |
| 50 | 73 | ||
| @@ -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.WorkspaceStylingConfig | 46 | 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.PopupBehavior | 72 | 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 | |||
| 564 | 564 | Description: "Named floating pane URL profiles with keys, url, and optional desc", | |
| 565 | 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 | 584 | // Browsing contexts | |
| 568 | 585 | { | |
| 569 | 586 | 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 contexts | 584 | // 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 { | |||
| 24 | 24 | validationErrors = append(validationErrors, validateAppearance(config)...) | |
| 25 | 25 | validationErrors = append(validationErrors, validateSearchEngine(config)...) | |
| 26 | 26 | validationErrors = append(validationErrors, validatePopups(config)...) | |
| 27 | + | validationErrors = append(validationErrors, validateExternalLinks(config)...) | |
| 27 | 28 | validationErrors = append(validationErrors, validateWorkspaceStyling(config)...) | |
| 28 | 29 | validationErrors = append(validationErrors, validatePaneMode(config)...) | |
| 29 | 30 | validationErrors = append(validationErrors, validateTabBar(config)...) | |
| @@ -188,6 +189,28 @@ func validatePopups(config *Config) []string { | |||
| 188 | 189 | 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 | 214 | func validateWorkspaceStyling(config *Config) []string { | |
| 192 | 215 | var validationErrors []string | |
| 193 | 216 | 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 validationErrors | 189 | 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 []string | 215 | 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) { | |||
| 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 | 329 | func TestValidateConfig_WorkspaceNewPaneURLAllowsExistingAbsoluteLocalPath(t *testing.T) { | |
| 298 | 330 | tmpDir := t.TempDir() | |
| 299 | 331 | 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 | |||
| 68 | 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 | 72 | socketPath, err := r.socketPath() | |
| 73 | 73 | if err != nil { | |
| 74 | 74 | return false, err | |
| @@ -353,20 +353,20 @@ func (*browserLaunchRelayListener) handleConnection(ctx context.Context, conn *n | |||
| 353 | 353 | log.Debug(). | |
| 354 | 354 | Str("request_id", requestID). | |
| 355 | 355 | 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 { | |
| 358 | 358 | log.Warn().Err(err). | |
| 359 | 359 | Str("request_id", requestID). | |
| 360 | 360 | Str("url_host", safeURLHost(request.URL)). | |
| 361 | 361 | Dur("elapsed", time.Since(started)). | |
| 362 | - | Msg("browser launch relay opener failed") | |
| 362 | + | Msg("browser launch relay external URL opener failed") | |
| 363 | 363 | return | |
| 364 | 364 | } | |
| 365 | 365 | log.Debug(). | |
| 366 | 366 | Str("request_id", requestID). | |
| 367 | 367 | Str("url_host", safeURLHost(request.URL)). | |
| 368 | 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 | ||
| @@ -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, err | 74 | 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 | return | 363 | 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 ( | |||
| 17 | 17 | ||
| 18 | 18 | 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 | 21 | return f(ctx, url) | |
| 22 | 22 | } | |
| 23 | 23 | ||
| @@ -112,10 +112,10 @@ func overrideBrowserLaunchRequestID(t *testing.T, id string) func() { | |||
| 112 | 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 | 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 | 120 | require.NoError(t, err) | |
| 121 | 121 | assert.False(t, delivered) | |
| @@ -138,7 +138,7 @@ func TestBrowserLaunchRelay_SameProfileAndEngine_UsesSameSocket(t *testing.T) { | |||
| 138 | 138 | ||
| 139 | 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 | 142 | require.NoError(t, err) | |
| 143 | 143 | require.True(t, delivered) | |
| 144 | 144 | require.Equal(t, "https://example.com/same-namespace", <-received) | |
| @@ -151,7 +151,7 @@ func TestBrowserLaunchRelay_DevCEFAndDevWebKit_UseDifferentSockets(t *testing.T) | |||
| 151 | 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 | 155 | ipc := testIPC(shortTempDir(t)) | |
| 156 | 156 | relay := NewBrowserLaunchRelay(ipc) | |
| 157 | 157 | restore := overrideBrowserLaunchRequestID(t, "request-test-1") | |
| @@ -180,7 +180,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_SendsRequestIDAndAcceptsMatch | |||
| 180 | 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 | 185 | require.NoError(t, err) | |
| 186 | 186 | require.True(t, delivered) | |
| @@ -189,7 +189,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_SendsRequestIDAndAcceptsMatch | |||
| 189 | 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 | 193 | ipc := testIPC(shortTempDir(t)) | |
| 194 | 194 | relay := NewBrowserLaunchRelay(ipc) | |
| 195 | 195 | restore := overrideBrowserLaunchRequestID(t, "request-test-2") | |
| @@ -216,14 +216,14 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RejectsMismatchedResponseRequ | |||
| 216 | 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 | 221 | require.Error(t, err) | |
| 222 | 222 | require.True(t, delivered) | |
| 223 | 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 | 227 | ipc := testIPC(shortTempDir(t)) | |
| 228 | 228 | relay := NewBrowserLaunchRelay(ipc) | |
| 229 | 229 | ||
| @@ -239,7 +239,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RoundTrip(t *testing.T) { | |||
| 239 | 239 | ||
| 240 | 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 | 244 | require.NoError(t, err) | |
| 245 | 245 | assert.True(t, delivered) | |
| @@ -279,7 +279,7 @@ func TestBrowserLaunchRelay_SecondListenWhileLiveFailsWithoutReplacingListener(t | |||
| 279 | 279 | _, err = relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil })) | |
| 280 | 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 | 283 | require.NoError(t, err) | |
| 284 | 284 | require.True(t, delivered) | |
| 285 | 285 | ||
| @@ -310,7 +310,7 @@ func TestBrowserLaunchRelay_RebindsStaleSocketPath(t *testing.T) { | |||
| 310 | 310 | ||
| 311 | 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 | 314 | require.NoError(t, err) | |
| 315 | 315 | require.True(t, delivered) | |
| 316 | 316 | ||
| @@ -345,7 +345,7 @@ func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) { | |||
| 345 | 345 | require.NoError(t, err) | |
| 346 | 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 | 350 | require.NoError(t, err) | |
| 351 | 351 | assert.True(t, delivered) | |
| @@ -387,12 +387,12 @@ func TestBrowserLaunchRelay_ContextCancelStopsServing(t *testing.T) { | |||
| 387 | 387 | waitForSocketGone(t, ipc.BrowserLaunchSocket) | |
| 388 | 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 | 391 | require.NoError(t, err) | |
| 392 | 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 | 396 | ipc := testIPC(shortTempDir(t)) | |
| 397 | 397 | require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700)) | |
| 398 | 398 | ||
| @@ -419,13 +419,13 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t | |||
| 419 | 419 | ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) | |
| 420 | 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 | 423 | require.Error(t, err) | |
| 424 | 424 | require.ErrorIs(t, err, context.DeadlineExceeded) | |
| 425 | 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 | 429 | ipc := testIPC(shortTempDir(t)) | |
| 430 | 430 | require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700)) | |
| 431 | 431 | ||
| @@ -459,7 +459,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsUnconfirmedErrorOnNoDe | |||
| 459 | 459 | result := make(chan deliverResult, 1) | |
| 460 | 460 | go func() { | |
| 461 | 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 | 463 | result <- deliverResult{delivered: delivered, err: err} | |
| 464 | 464 | }() | |
| 465 | 465 | <-started | |
| @@ -470,7 +470,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsUnconfirmedErrorOnNoDe | |||
| 470 | 470 | require.Error(t, got.err) | |
| 471 | 471 | require.ErrorIs(t, got.err, ErrBrowserLaunchRelayUnconfirmed) | |
| 472 | 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 | 575 | result := make(chan deliverResult, 1) | |
| 576 | 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 | 578 | result <- deliverResult{delivered: delivered, err: err} | |
| 579 | 579 | }() | |
| 580 | 580 | ||
| @@ -17,7 +17,7 @@ import ( | |||||
| 17 | 17 | ||||
| 18 | type browserWindowOpenerFunc func(context.Context, string) error | 18 | 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 | <-started | 465 | <-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 { | |||
| 36 | 36 | } | |
| 37 | 37 | ||
| 38 | 38 | if l.relay != nil { | |
| 39 | - | delivered, err := l.relay.DeliverOpenFreshWindow(ctx, url) | |
| 39 | + | delivered, err := l.relay.DeliverOpenExternalURL(ctx, url) | |
| 40 | 40 | if err != nil { | |
| 41 | 41 | if delivered && errors.Is(err, ErrBrowserLaunchRelayUnconfirmed) { | |
| 42 | 42 | 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 ErrBrowserLaunchUnconfirmed | 42 | return ErrBrowserLaunchUnconfirmed | ||
M internal/infrastructure/desktop/browser_launcher_test.go +4 -4
| @@ -13,7 +13,7 @@ import ( | |||
| 13 | 13 | ||
| 14 | 14 | func TestBrowserLauncher_LaunchURL_ReturnsWithoutSpawningWhenRelayDelivers(t *testing.T) { | |
| 15 | 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 | 18 | launcher := NewBrowserLauncher(relay) | |
| 19 | 19 | spawned := false | |
| @@ -34,7 +34,7 @@ func TestBrowserLauncher_LaunchURL_ReturnsWithoutSpawningWhenRelayDelivers(t *te | |||
| 34 | 34 | ||
| 35 | 35 | func TestBrowserLauncher_LaunchURL_FallsBackToSpawnWhenRelayMisses(t *testing.T) { | |
| 36 | 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 | 39 | launcher := NewBrowserLauncher(relay) | |
| 40 | 40 | launcher.resolveExecutablePath = func() (string, error) { | |
| @@ -57,7 +57,7 @@ func TestBrowserLauncher_LaunchURL_FallsBackToSpawnWhenRelayMisses(t *testing.T) | |||
| 57 | 57 | ||
| 58 | 58 | func TestBrowserLauncher_LaunchURL_ReturnsUnconfirmedErrorWhenRelayDeliveryIsAmbiguous(t *testing.T) { | |
| 59 | 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 | 62 | launcher := NewBrowserLauncher(relay) | |
| 63 | 63 | launcher.resolveExecutablePath = func() (string, error) { | |
| @@ -78,7 +78,7 @@ func TestBrowserLauncher_LaunchURL_ReturnsUnconfirmedErrorWhenRelayDeliveryIsAmb | |||
| 78 | 78 | func TestBrowserLauncher_LaunchURL_PropagatesRelayError(t *testing.T) { | |
| 79 | 79 | relay := mocks.NewMockBrowserLaunchRelay(t) | |
| 80 | 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 | 83 | launcher := NewBrowserLauncher(relay) | |
| 84 | 84 | 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 := false | 19 | 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 { | |||
| 47 | 47 | 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 | 51 | return false, nil | |
| 52 | 52 | } | |
| 53 | 53 | ||
| @@ -1083,7 +1083,7 @@ func TestApp_GetWindowSnapshotStateReturnsUnavailableWhenMainThreadDispatchTimes | |||
| 1083 | 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 | 1087 | var factoryCalls int | |
| 1088 | 1088 | app := &App{ | |
| 1089 | 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 | 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 | 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 | 1106 | if factoryCalls != 0 { | |
| 1107 | 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 | 1112 | existingTab := entity.NewTab(entity.TabID("existing-tab"), entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane"))) | |
| 1113 | 1113 | existingTabs := entity.NewTabList() | |
| 1114 | 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 { | |
| 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) | |
| 1131 | 1131 | } | |
| 1132 | 1132 | if got := windowForTabCount(t, app); got != 2 { | |
| 1133 | 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 | 1379 | created := &browserWindow{id: "window-1", tabs: entity.NewTabList()} | |
| 1380 | 1380 | originalWindow := &window.MainWindow{} | |
| 1381 | 1381 | tabBar := &component.TabBar{} | |
| @@ -1406,8 +1406,8 @@ func TestApp_OpenFreshWindowRollsBackOnTabCreationFailure(t *testing.T) { | |||
| 1406 | 1406 | MainWindow: &window.MainWindow{}, | |
| 1407 | 1407 | }) | |
| 1408 | 1408 | ||
| 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") | |
| 1411 | 1411 | } | |
| 1412 | 1412 | if got := len(app.browserWindows); got != 1 { | |
| 1413 | 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 | 1430 | existingTabID := entity.TabID("existing-tab") | |
| 1431 | 1431 | createdTabID := entity.TabID("id-1") | |
| 1432 | 1432 | oldWindow := &window.MainWindow{} | |
| @@ -1463,8 +1463,8 @@ func TestApp_OpenFreshWindowTargetsNewWindowTabBar(t *testing.T) { | |||
| 1463 | 1463 | app.workspaceViews[tab.ID] = &component.WorkspaceView{} | |
| 1464 | 1464 | }) | |
| 1465 | 1465 | ||
| 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) | |
| 1468 | 1468 | } | |
| 1469 | 1469 | ||
| 1470 | 1470 | gotCreatedTabID := app.tabs.ActiveTabID | |
| @@ -47,7 +47,7 @@ type testBrowserLaunchRelay struct { | |||||
| 47 | closer *testCloser | 47 | 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, nil | 51 | 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 int | 1087 | 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.ActiveTabID | 1470 | gotCreatedTabID := app.tabs.ActiveTabID | ||
M internal/ui/browser_window.go +161 -8
| @@ -548,11 +548,11 @@ func (a *App) deterministicBrowserWindowFallback() *browserWindow { | |||
| 548 | 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 | 552 | log := logging.FromContext(ctx) | |
| 553 | 553 | log.Debug(). | |
| 554 | 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 | 557 | dispatch := a.dispatchOnMainThread | |
| 558 | 558 | if dispatch == nil { | |
| @@ -569,17 +569,18 @@ func (a *App) OpenFreshWindow(ctx context.Context, url string) error { | |||
| 569 | 569 | var windowCountAfter int | |
| 570 | 570 | var hasTabCoord bool | |
| 571 | 571 | var hasTabsUC bool | |
| 572 | - | result := dispatch("ui.open_fresh_window", func() { | |
| 572 | + | result := dispatch("ui.open_external_url", func() { | |
| 573 | 573 | windowCountBefore = len(a.browserWindows) | |
| 574 | 574 | hasTabCoord = a.tabCoord != nil | |
| 575 | 575 | hasTabsUC = a.tabsUC != nil | |
| 576 | + | cfg := a.runtimeConfigSnapshot() | |
| 576 | 577 | log.Debug(). | |
| 577 | 578 | Str("url_host", logging.SafeURLHost(url)). | |
| 578 | 579 | Int("window_count_before", windowCountBefore). | |
| 579 | 580 | Bool("has_tab_coord", hasTabCoord). | |
| 580 | 581 | 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) | |
| 583 | 584 | windowCountAfter = len(a.browserWindows) | |
| 584 | 585 | }) | |
| 585 | 586 | if !result.Completed() { | |
| @@ -587,7 +588,7 @@ func (a *App) OpenFreshWindow(ctx context.Context, url string) error { | |||
| 587 | 588 | Str("url_host", logging.SafeURLHost(url)). | |
| 588 | 589 | Dur("elapsed", result.Elapsed). | |
| 589 | 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 | 592 | return fmt.Errorf("main thread dispatch did not complete: %s", result.Status) | |
| 592 | 593 | } | |
| 593 | 594 | if openErr != nil { | |
| @@ -596,7 +597,7 @@ func (a *App) OpenFreshWindow(ctx context.Context, url string) error { | |||
| 596 | 597 | Dur("elapsed", result.Elapsed). | |
| 597 | 598 | Str("dispatch_status", string(result.Status)). | |
| 598 | 599 | Int("window_count_after", windowCountAfter). | |
| 599 | - | Msg("ui: open fresh window failed") | |
| 600 | + | Msg("ui: open external URL failed") | |
| 600 | 601 | return openErr | |
| 601 | 602 | } | |
| 602 | 603 | ||
| @@ -605,6 +606,158 @@ func (a *App) OpenFreshWindow(ctx context.Context, url string) error { | |||
| 605 | 606 | Dur("elapsed", result.Elapsed). | |
| 606 | 607 | Str("dispatch_status", string(result.Status)). | |
| 607 | 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 | 762 | return nil | |
| 610 | 763 | } | |
| @@ -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.dispatchOnMainThread | 557 | 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 int | 569 | var windowCountAfter int | ||
| 570 | var hasTabCoord bool | 570 | var hasTabCoord bool | ||
| 571 | var hasTabsUC bool | 571 | 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 != nil | 574 | hasTabCoord = a.tabCoord != nil | ||
| 575 | hasTabsUC = a.tabsUC != nil | 575 | 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 openErr | 601 | 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 nil | 762 | return nil | ||
| 610 | } | 763 | } | ||
M internal/ui/browser_window_test.go +11 -11
| @@ -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 | 451 | app := &App{browserWindows: make(map[string]*browserWindow)} | |
| 452 | 452 | ||
| 453 | 453 | dispatched := false | |
| 454 | 454 | createdURL := "" | |
| 455 | 455 | app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult { | |
| 456 | 456 | 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) | |
| 459 | 459 | } | |
| 460 | 460 | fn() | |
| 461 | 461 | return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchCompleted} | |
| @@ -465,11 +465,11 @@ func TestOpenFreshWindow_DispatchesAndTracksBrowserWindow(t *testing.T) { | |||
| 465 | 465 | return &browserWindow{id: "window-1", initialURL: url}, nil | |
| 466 | 466 | } | |
| 467 | 467 | ||
| 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) | |
| 470 | 470 | } | |
| 471 | 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 | 474 | if createdURL != "https://example.com" { | |
| 475 | 475 | t.Fatalf("browser window factory URL = %q, want %q", createdURL, "https://example.com") | |
| @@ -515,7 +515,7 @@ func TestOpenInitialBrowserWindowShellRegistersWithoutCreatingTab(t *testing.T) | |||
| 515 | 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 | 519 | app := &App{ | |
| 520 | 520 | tabs: entity.NewTabList(), | |
| 521 | 521 | tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil), | |
| @@ -535,8 +535,8 @@ func TestOpenFreshWindow_FirstWindowWithRequestedURLCreatesTabInAppAndWindow(t * | |||
| 535 | 535 | return true | |
| 536 | 536 | } | |
| 537 | 537 | ||
| 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) | |
| 540 | 540 | } | |
| 541 | 541 | ||
| 542 | 542 | windowTabs := app.browserWindows["window-1"].tabs | |
| @@ -553,7 +553,7 @@ func TestOpenFreshWindow_FirstWindowWithRequestedURLCreatesTabInAppAndWindow(t * | |||
| 553 | 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 | 557 | app := &App{browserWindows: make(map[string]*browserWindow)} | |
| 558 | 558 | app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult { | |
| 559 | 559 | fn() | |
| @@ -564,7 +564,7 @@ func TestOpenFreshWindow_PropagatesFactoryError(t *testing.T) { | |||
| 564 | 564 | 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 | 569 | if !errors.Is(err, wantErr) { | |
| 570 | 570 | 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 := false | 453 | 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 = true | 456 | 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}, nil | 465 | 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 true | 535 | 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"].tabs | 542 | 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, wantErr | 564 | 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 | |||
| 174 | 174 | func (c *WorkspaceCoordinator) splitWithInitialURL(ctx context.Context, direction usecase.SplitDirection, initialURL string) error { | |
| 175 | 175 | log := logging.FromContext(ctx) | |
| 176 | 176 | ||
| 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 | |
| 180 | 180 | } | |
| 181 | 181 | ||
| 182 | 182 | output, err := c.panesUC.Split(ctx, usecase.SplitPaneInput{ | |
| @@ -198,10 +198,9 @@ func (c *WorkspaceCoordinator) splitWithInitialURL(ctx context.Context, directio | |||
| 198 | 198 | ||
| 199 | 199 | // Update the workspace view | |
| 200 | 200 | 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 | + | } | |
| 205 | 204 | splitCtx.wsView.NotifyNewPaneCreated(ctx) | |
| 206 | 205 | } | |
| 207 | 206 | ||
| @@ -332,23 +331,27 @@ func setActiveStackIndexForChild(parent, child *entity.PaneNode) { | |||
| 332 | 331 | func (c *WorkspaceCoordinator) prepareSplit( | |
| 333 | 332 | ctx context.Context, | |
| 334 | 333 | direction usecase.SplitDirection, | |
| 335 | - | ) (*splitContext, bool) { | |
| 334 | + | ) (*splitContext, error) { | |
| 336 | 335 | log := logging.FromContext(ctx) | |
| 337 | 336 | if c.panesUC == nil { | |
| 338 | 337 | 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") | |
| 340 | 343 | } | |
| 341 | 344 | ||
| 342 | 345 | ws, wsView := c.getActiveWS() | |
| 343 | 346 | if ws == nil { | |
| 344 | 347 | log.Warn().Msg("no active workspace") | |
| 345 | - | return nil, false | |
| 348 | + | return nil, fmt.Errorf("no active workspace") | |
| 346 | 349 | } | |
| 347 | 350 | ||
| 348 | 351 | activePane := ws.ActivePane() | |
| 349 | - | if activePane == nil { | |
| 352 | + | if activePane == nil || activePane.Pane == nil { | |
| 350 | 353 | log.Warn().Msg("no active pane to split") | |
| 351 | - | return nil, false | |
| 354 | + | return nil, fmt.Errorf("no active pane to split") | |
| 352 | 355 | } | |
| 353 | 356 | ||
| 354 | 357 | // Check if active pane is inside a stack | |
| @@ -367,7 +370,7 @@ func (c *WorkspaceCoordinator) prepareSplit( | |||
| 367 | 370 | activePane: activePane, | |
| 368 | 371 | existingWidget: existingWidget, | |
| 369 | 372 | isStackSplit: isStackSplit, | |
| 370 | - | }, true | |
| 373 | + | }, nil | |
| 371 | 374 | } | |
| 372 | 375 | ||
| 373 | 376 | func (c *WorkspaceCoordinator) resolveSplitWidget( | |
| @@ -407,7 +410,7 @@ func (c *WorkspaceCoordinator) applySplitToView( | |||
| 407 | 410 | existingWidget layout.Widget, | |
| 408 | 411 | isStackSplit bool, | |
| 409 | 412 | oldActivePaneID entity.PaneID, | |
| 410 | - | ) { | |
| 413 | + | ) error { | |
| 411 | 414 | log := logging.FromContext(ctx) | |
| 412 | 415 | needsAttach := false | |
| 413 | 416 | ||
| @@ -423,12 +426,14 @@ func (c *WorkspaceCoordinator) applySplitToView( | |||
| 423 | 426 | log.Warn().Err(splitErr).Msg("incremental split failed, falling back to rebuild") | |
| 424 | 427 | if err := wsView.Rebuild(ctx); err != nil { | |
| 425 | 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 | 431 | needsAttach = true | |
| 428 | 432 | } | |
| 429 | 433 | } else { | |
| 430 | 434 | if err := wsView.Rebuild(ctx); err != nil { | |
| 431 | 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 | 438 | needsAttach = true | |
| 434 | 439 | } | |
| @@ -439,8 +444,10 @@ func (c *WorkspaceCoordinator) applySplitToView( | |||
| 439 | 444 | } | |
| 440 | 445 | if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil { | |
| 441 | 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 | 449 | wsView.FocusPane(ws.ActivePaneID) | |
| 450 | + | return nil | |
| 444 | 451 | } | |
| 445 | 452 | ||
| 446 | 453 | // doIncrementalStackSplit performs an incremental split around an existing stacked pane. | |
| @@ -1453,11 +1460,16 @@ func (c *WorkspaceCoordinator) syncStackedViewActive(ctx context.Context, wsView | |||
| 1453 | 1460 | // StackPane adds a new pane stacked on top of the active pane. | |
| 1454 | 1461 | // Uses CreateStack use case for new stacks, AddToStack for existing stacks. | |
| 1455 | 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 | 1468 | log := logging.FromContext(ctx) | |
| 1457 | 1469 | ||
| 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 | |
| 1461 | 1473 | } | |
| 1462 | 1474 | ||
| 1463 | 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 | 1481 | if stackCtx.activeNode.Parent != nil && stackCtx.activeNode.Parent.IsStacked { | |
| 1470 | 1482 | // Already in a stack - use AddToStack use case | |
| 1471 | 1483 | 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 | 1485 | if err != nil { | |
| 1474 | 1486 | log.Error().Err(err).Msg("failed to add pane to stack via use case") | |
| 1475 | 1487 | return err | |
| @@ -1484,7 +1496,7 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error { | |||
| 1484 | 1496 | } else if stackCtx.activeNode.IsStacked { | |
| 1485 | 1497 | // Active node is already a stack container - add to it | |
| 1486 | 1498 | 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 | 1500 | if err != nil { | |
| 1489 | 1501 | log.Error().Err(err).Msg("failed to add pane to stack via use case") | |
| 1490 | 1502 | return err | |
| @@ -1498,7 +1510,7 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error { | |||
| 1498 | 1510 | Msg("added to stack container via use case") | |
| 1499 | 1511 | } else { | |
| 1500 | 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 | 1514 | if err != nil { | |
| 1503 | 1515 | log.Error().Err(err).Msg("failed to create stack via use case") | |
| 1504 | 1516 | return err | |
| @@ -1539,23 +1551,28 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error { | |||
| 1539 | 1551 | wv, err := c.contentCoord.EnsureWebView(ctx, newPaneID) | |
| 1540 | 1552 | if err != nil { | |
| 1541 | 1553 | 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 | |
| 1553 | 1568 | } | |
| 1554 | 1569 | ||
| 1555 | 1570 | // Update workspace view | |
| 1556 | 1571 | if err := stackCtx.wsView.SetActivePaneID(newPaneID); err != nil { | |
| 1557 | 1572 | log.Warn().Err(err).Msg("failed to set active pane") | |
| 1573 | + | return err | |
| 1558 | 1574 | } | |
| 1575 | + | stackCtx.wsView.FocusPane(newPaneID) | |
| 1559 | 1576 | ||
| 1560 | 1577 | stackCtx.wsView.NotifyNewPaneCreated(ctx) | |
| 1561 | 1578 | ||
| @@ -1588,28 +1605,44 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error { | |||
| 1588 | 1605 | ||
| 1589 | 1606 | func (c *WorkspaceCoordinator) prepareStackPane( | |
| 1590 | 1607 | ctx context.Context, | |
| 1591 | - | ) (*stackPaneContext, bool) { | |
| 1608 | + | ) (*stackPaneContext, error) { | |
| 1592 | 1609 | log := logging.FromContext(ctx) | |
| 1593 | 1610 | if c.stackedPaneMgr == nil { | |
| 1594 | 1611 | 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") | |
| 1596 | 1629 | } | |
| 1597 | 1630 | ||
| 1598 | 1631 | ws, wsView := c.getActiveWS() | |
| 1599 | 1632 | if ws == nil { | |
| 1600 | 1633 | log.Warn().Msg("no active workspace") | |
| 1601 | - | return nil, false | |
| 1634 | + | return nil, fmt.Errorf("no active workspace") | |
| 1602 | 1635 | } | |
| 1603 | 1636 | ||
| 1604 | 1637 | activeNode := ws.ActivePane() | |
| 1605 | 1638 | if activeNode == nil || activeNode.Pane == nil { | |
| 1606 | 1639 | log.Warn().Msg("no active pane") | |
| 1607 | - | return nil, false | |
| 1640 | + | return nil, fmt.Errorf("no active pane") | |
| 1608 | 1641 | } | |
| 1609 | 1642 | ||
| 1610 | 1643 | if wsView == nil { | |
| 1611 | 1644 | log.Warn().Msg("no workspace view") | |
| 1612 | - | return nil, false | |
| 1645 | + | return nil, fmt.Errorf("no workspace view") | |
| 1613 | 1646 | } | |
| 1614 | 1647 | ||
| 1615 | 1648 | activePaneID := activeNode.Pane.ID | |
| @@ -1627,7 +1660,7 @@ func (c *WorkspaceCoordinator) prepareStackPane( | |||
| 1627 | 1660 | activeNode: activeNode, | |
| 1628 | 1661 | activePaneID: activePaneID, | |
| 1629 | 1662 | originalTitle: originalTitle, | |
| 1630 | - | }, true | |
| 1663 | + | }, nil | |
| 1631 | 1664 | } | |
| 1632 | 1665 | ||
| 1633 | 1666 | func (c *WorkspaceCoordinator) updateFirstStackTitle( | |
| @@ -1942,7 +1975,9 @@ func (c *WorkspaceCoordinator) insertPopupSplit(ctx context.Context, input conte | |||
| 1942 | 1975 | ||
| 1943 | 1976 | // Update UI | |
| 1944 | 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 | 1981 | c.attachPopupWebView(ctx, wsView, input) | |
| 1947 | 1982 | } | |
| 1948 | 1983 | ||
| @@ -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 nil | 179 | + | 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 view | 199 | // 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, 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") | |||
| 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, false | 348 | + | 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, false | 354 | + | return nil, fmt.Errorf("no active pane to split") |
| 352 | } | 355 | } | ||
| 353 | 356 | ||||
| 354 | // Check if active pane is inside a stack | 357 | // 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 | - | }, true | 373 | + | }, 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 := false | 415 | 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 = true | 431 | 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 = true | 438 | 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 nil | 1472 | + | 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 case | 1482 | // Already in a stack - use AddToStack use case | ||
| 1471 | stackNode = stackCtx.activeNode.Parent | 1483 | 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 err | 1487 | 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 it | 1497 | // Active node is already a stack container - add to it | ||
| 1486 | stackNode = stackCtx.activeNode | 1498 | 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 err | 1502 | 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 err | 1516 | 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 pane | 1561 | + | 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 view | 1570 | // 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, 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") | |||
| 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, false | 1634 | + | 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, false | 1640 | + | 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, false | 1645 | + | return nil, fmt.Errorf("no workspace view") |
| 1613 | } | 1646 | } | ||
| 1614 | 1647 | ||||
| 1615 | activePaneID := activeNode.Pane.ID | 1648 | 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 | - | }, true | 1663 | + | }, 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 UI | 1976 | // 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 | + | } | |||