fix(ui): reuse the relay pane for repeated external URL requests
Desktop apps retry the same OAuth URL through the browser launch relay. Each retry used to stack a new pane, leaving failed or blank ones behind. A request for the same URL within two minutes now reloads and focuses the pane opened by the first request.
- commit
- f6fdf4db512f681fd4bf3696861216383cfe68b0
- author
- brice · · 2026-10-15 06:06 UTC
- parent
- 2baceed
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +423 -1
A internal/domain/url/relay_reuse.go +33 -0
| @@ -0,0 +1,33 @@ | |||
| 1 | + | package url | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "time" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // RelayReuseWindow is how long a pane opened for an externally relayed URL | |
| 9 | + | // remains eligible to be reused when the same URL is relayed again. | |
| 10 | + | const RelayReuseWindow = 2 * time.Minute | |
| 11 | + | ||
| 12 | + | // RelayURLKey returns the canonical key identifying a relayed URL for reuse | |
| 13 | + | // decisions. | |
| 14 | + | func RelayURLKey(raw string) string { | |
| 15 | + | return Normalize(strings.TrimSpace(raw)) | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | // ShouldReuseRelayPane reports whether a pane that was opened for recordedURL | |
| 19 | + | // at openedAt should be reused for requestedURL at now, instead of creating a | |
| 20 | + | // new pane. It is true only when both URLs are non-empty and identical after | |
| 21 | + | // normalization and the pane is no older than window. A pane recorded in the | |
| 22 | + | // future (clock anomaly) or with a non-positive window is never reused. | |
| 23 | + | func ShouldReuseRelayPane(recordedURL string, openedAt time.Time, requestedURL string, now time.Time, window time.Duration) bool { | |
| 24 | + | if window <= 0 { | |
| 25 | + | return false | |
| 26 | + | } | |
| 27 | + | requested := RelayURLKey(requestedURL) | |
| 28 | + | if requested == "" || requested != RelayURLKey(recordedURL) { | |
| 29 | + | return false | |
| 30 | + | } | |
| 31 | + | age := now.Sub(openedAt) | |
| 32 | + | return age >= 0 && age <= window | |
| 33 | + | } | |
| @@ -0,0 +1,33 @@ | |||||
| 1 | + | package url | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "time" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // RelayReuseWindow is how long a pane opened for an externally relayed URL | |||
| 9 | + | // remains eligible to be reused when the same URL is relayed again. | |||
| 10 | + | const RelayReuseWindow = 2 * time.Minute | |||
| 11 | + | ||||
| 12 | + | // RelayURLKey returns the canonical key identifying a relayed URL for reuse | |||
| 13 | + | // decisions. | |||
| 14 | + | func RelayURLKey(raw string) string { | |||
| 15 | + | return Normalize(strings.TrimSpace(raw)) | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | // ShouldReuseRelayPane reports whether a pane that was opened for recordedURL | |||
| 19 | + | // at openedAt should be reused for requestedURL at now, instead of creating a | |||
| 20 | + | // new pane. It is true only when both URLs are non-empty and identical after | |||
| 21 | + | // normalization and the pane is no older than window. A pane recorded in the | |||
| 22 | + | // future (clock anomaly) or with a non-positive window is never reused. | |||
| 23 | + | func ShouldReuseRelayPane(recordedURL string, openedAt time.Time, requestedURL string, now time.Time, window time.Duration) bool { | |||
| 24 | + | if window <= 0 { | |||
| 25 | + | return false | |||
| 26 | + | } | |||
| 27 | + | requested := RelayURLKey(requestedURL) | |||
| 28 | + | if requested == "" || requested != RelayURLKey(recordedURL) { | |||
| 29 | + | return false | |||
| 30 | + | } | |||
| 31 | + | age := now.Sub(openedAt) | |||
| 32 | + | return age >= 0 && age <= window | |||
| 33 | + | } | |||
A internal/domain/url/relay_reuse_test.go +45 -0
| @@ -0,0 +1,45 @@ | |||
| 1 | + | package url | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestShouldReuseRelayPane(t *testing.T) { | |
| 11 | + | const u = "https://claude.com/cai/login/app-google-auth?open_in_browser=1&hop_nonce=XYZ" | |
| 12 | + | opened := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) | |
| 13 | + | ||
| 14 | + | tests := []struct { | |
| 15 | + | name string | |
| 16 | + | recorded string | |
| 17 | + | requested string | |
| 18 | + | now time.Time | |
| 19 | + | window time.Duration | |
| 20 | + | want bool | |
| 21 | + | }{ | |
| 22 | + | {"same url within window", u, u, opened.Add(30 * time.Second), RelayReuseWindow, true}, | |
| 23 | + | {"same url at exact window boundary", u, u, opened.Add(RelayReuseWindow), RelayReuseWindow, true}, | |
| 24 | + | {"same url just after window", u, u, opened.Add(RelayReuseWindow + time.Nanosecond), RelayReuseWindow, false}, | |
| 25 | + | {"different nonce", u, "https://claude.com/cai/login/app-google-auth?open_in_browser=1&hop_nonce=ABC", opened.Add(time.Second), RelayReuseWindow, false}, | |
| 26 | + | {"different host", u, "https://example.com/", opened.Add(time.Second), RelayReuseWindow, false}, | |
| 27 | + | {"equivalent after normalization", "example.com/path", "https://example.com/path", opened.Add(time.Second), RelayReuseWindow, true}, | |
| 28 | + | {"surrounding whitespace ignored", u, " " + u + "\n", opened.Add(time.Second), RelayReuseWindow, true}, | |
| 29 | + | {"empty requested", "", "", opened, RelayReuseWindow, false}, | |
| 30 | + | {"empty recorded", "", u, opened, RelayReuseWindow, false}, | |
| 31 | + | {"recorded in the future", u, u, opened.Add(-time.Second), RelayReuseWindow, false}, | |
| 32 | + | {"zero window", u, u, opened, 0, false}, | |
| 33 | + | {"negative window", u, u, opened, -time.Second, false}, | |
| 34 | + | } | |
| 35 | + | for _, tt := range tests { | |
| 36 | + | t.Run(tt.name, func(t *testing.T) { | |
| 37 | + | assert.Equal(t, tt.want, ShouldReuseRelayPane(tt.recorded, opened, tt.requested, tt.now, tt.window)) | |
| 38 | + | }) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestRelayURLKey_Normalizes(t *testing.T) { | |
| 43 | + | assert.Equal(t, "https://example.com", RelayURLKey(" example.com ")) | |
| 44 | + | assert.Empty(t, RelayURLKey(" ")) | |
| 45 | + | } | |
| @@ -0,0 +1,45 @@ | |||||
| 1 | + | package url | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestShouldReuseRelayPane(t *testing.T) { | |||
| 11 | + | const u = "https://claude.com/cai/login/app-google-auth?open_in_browser=1&hop_nonce=XYZ" | |||
| 12 | + | opened := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) | |||
| 13 | + | ||||
| 14 | + | tests := []struct { | |||
| 15 | + | name string | |||
| 16 | + | recorded string | |||
| 17 | + | requested string | |||
| 18 | + | now time.Time | |||
| 19 | + | window time.Duration | |||
| 20 | + | want bool | |||
| 21 | + | }{ | |||
| 22 | + | {"same url within window", u, u, opened.Add(30 * time.Second), RelayReuseWindow, true}, | |||
| 23 | + | {"same url at exact window boundary", u, u, opened.Add(RelayReuseWindow), RelayReuseWindow, true}, | |||
| 24 | + | {"same url just after window", u, u, opened.Add(RelayReuseWindow + time.Nanosecond), RelayReuseWindow, false}, | |||
| 25 | + | {"different nonce", u, "https://claude.com/cai/login/app-google-auth?open_in_browser=1&hop_nonce=ABC", opened.Add(time.Second), RelayReuseWindow, false}, | |||
| 26 | + | {"different host", u, "https://example.com/", opened.Add(time.Second), RelayReuseWindow, false}, | |||
| 27 | + | {"equivalent after normalization", "example.com/path", "https://example.com/path", opened.Add(time.Second), RelayReuseWindow, true}, | |||
| 28 | + | {"surrounding whitespace ignored", u, " " + u + "\n", opened.Add(time.Second), RelayReuseWindow, true}, | |||
| 29 | + | {"empty requested", "", "", opened, RelayReuseWindow, false}, | |||
| 30 | + | {"empty recorded", "", u, opened, RelayReuseWindow, false}, | |||
| 31 | + | {"recorded in the future", u, u, opened.Add(-time.Second), RelayReuseWindow, false}, | |||
| 32 | + | {"zero window", u, u, opened, 0, false}, | |||
| 33 | + | {"negative window", u, u, opened, -time.Second, false}, | |||
| 34 | + | } | |||
| 35 | + | for _, tt := range tests { | |||
| 36 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 37 | + | assert.Equal(t, tt.want, ShouldReuseRelayPane(tt.recorded, opened, tt.requested, tt.now, tt.window)) | |||
| 38 | + | }) | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestRelayURLKey_Normalizes(t *testing.T) { | |||
| 43 | + | assert.Equal(t, "https://example.com", RelayURLKey(" example.com ")) | |||
| 44 | + | assert.Empty(t, RelayURLKey(" ")) | |||
| 45 | + | } | |||
M internal/ui/app.go +1 -0
| @@ -92,6 +92,7 @@ type App struct { | |||
| 92 | 92 | browserWindows map[string]*browserWindow | |
| 93 | 93 | browserWindowOrder []string // registration order of window IDs | |
| 94 | 94 | instanceWindows map[string]string // logical instance name -> browser window ID | |
| 95 | + | externalRelayPanes relayPaneRegistry // panes opened by the external URL relay (main thread only) | |
| 95 | 96 | lastFocusedWindowID string | |
| 96 | 97 | nativePopupWindows map[port.WebViewID]*nativePopupWindow | |
| 97 | 98 | browserWindowFactory func(context.Context, string) (*browserWindow, error) | |
| @@ -92,6 +92,7 @@ type App struct { | |||||
| 92 | browserWindows map[string]*browserWindow | 92 | browserWindows map[string]*browserWindow | ||
| 93 | browserWindowOrder []string // registration order of window IDs | 93 | browserWindowOrder []string // registration order of window IDs | ||
| 94 | instanceWindows map[string]string // logical instance name -> browser window ID | 94 | instanceWindows map[string]string // logical instance name -> browser window ID | ||
| 95 | + | externalRelayPanes relayPaneRegistry // panes opened by the external URL relay (main thread only) | |||
| 95 | lastFocusedWindowID string | 96 | lastFocusedWindowID string | ||
| 96 | nativePopupWindows map[port.WebViewID]*nativePopupWindow | 97 | nativePopupWindows map[port.WebViewID]*nativePopupWindow | ||
| 97 | browserWindowFactory func(context.Context, string) (*browserWindow, error) | 98 | browserWindowFactory func(context.Context, string) (*browserWindow, error) | ||
M internal/ui/browser_window.go +1 -1
| @@ -634,7 +634,7 @@ func (a *App) dispatchWindowURLWork(ctx context.Context, label, url string, work | |||
| 634 | 634 | func (a *App) OpenExternalURL(ctx context.Context, url string) error { | |
| 635 | 635 | url = domainurl.Normalize(url) | |
| 636 | 636 | return a.dispatchWindowURLWork(ctx, "ui.open_external_url", url, func(ctx context.Context) error { | |
| 637 | - | return a.openExternalURLOnMainThread(ctx, url, a.runtimeConfigSnapshot()) | |
| 637 | + | return a.openRelayURLOnMainThread(ctx, url, a.runtimeConfigSnapshot()) | |
| 638 | 638 | }) | |
| 639 | 639 | } | |
| 640 | 640 | ||
| @@ -634,7 +634,7 @@ func (a *App) dispatchWindowURLWork(ctx context.Context, label, url string, work | |||||
| 634 | func (a *App) OpenExternalURL(ctx context.Context, url string) error { | 634 | func (a *App) OpenExternalURL(ctx context.Context, url string) error { | ||
| 635 | url = domainurl.Normalize(url) | 635 | url = domainurl.Normalize(url) | ||
| 636 | return a.dispatchWindowURLWork(ctx, "ui.open_external_url", url, func(ctx context.Context) error { | 636 | return a.dispatchWindowURLWork(ctx, "ui.open_external_url", url, func(ctx context.Context) error { | ||
| 637 | - | return a.openExternalURLOnMainThread(ctx, url, a.runtimeConfigSnapshot()) | 637 | + | return a.openRelayURLOnMainThread(ctx, url, a.runtimeConfigSnapshot()) |
| 638 | }) | 638 | }) | ||
| 639 | } | 639 | } | ||
| 640 | 640 | ||||
M internal/ui/coordinator/workspace.go +14 -0
| @@ -304,6 +304,20 @@ func comparablePaneURL(raw string) string { | |||
| 304 | 304 | return "dumb:" + page | |
| 305 | 305 | } | |
| 306 | 306 | ||
| 307 | + | // FocusPaneByID makes an existing pane the active pane of ws, revealing it in | |
| 308 | + | // its stack when needed and giving it keyboard focus in wsView. | |
| 309 | + | func (c *WorkspaceCoordinator) FocusPaneByID( | |
| 310 | + | ctx context.Context, | |
| 311 | + | ws *entity.Workspace, | |
| 312 | + | wsView *component.WorkspaceView, | |
| 313 | + | paneID entity.PaneID, | |
| 314 | + | ) { | |
| 315 | + | if ws == nil || ws.FindPane(paneID) == nil { | |
| 316 | + | return | |
| 317 | + | } | |
| 318 | + | c.focusExistingPane(ctx, ws, wsView, paneID) | |
| 319 | + | } | |
| 320 | + | ||
| 307 | 321 | func (c *WorkspaceCoordinator) focusExistingPane( | |
| 308 | 322 | ctx context.Context, | |
| 309 | 323 | ws *entity.Workspace, | |
| @@ -304,6 +304,20 @@ func comparablePaneURL(raw string) string { | |||||
| 304 | return "dumb:" + page | 304 | return "dumb:" + page | ||
| 305 | } | 305 | } | ||
| 306 | 306 | ||||
| 307 | + | // FocusPaneByID makes an existing pane the active pane of ws, revealing it in | |||
| 308 | + | // its stack when needed and giving it keyboard focus in wsView. | |||
| 309 | + | func (c *WorkspaceCoordinator) FocusPaneByID( | |||
| 310 | + | ctx context.Context, | |||
| 311 | + | ws *entity.Workspace, | |||
| 312 | + | wsView *component.WorkspaceView, | |||
| 313 | + | paneID entity.PaneID, | |||
| 314 | + | ) { | |||
| 315 | + | if ws == nil || ws.FindPane(paneID) == nil { | |||
| 316 | + | return | |||
| 317 | + | } | |||
| 318 | + | c.focusExistingPane(ctx, ws, wsView, paneID) | |||
| 319 | + | } | |||
| 320 | + | ||||
| 307 | func (c *WorkspaceCoordinator) focusExistingPane( | 321 | func (c *WorkspaceCoordinator) focusExistingPane( | ||
| 308 | ctx context.Context, | 322 | ctx context.Context, | ||
| 309 | ws *entity.Workspace, | 323 | ws *entity.Workspace, | ||
A internal/ui/external_url_relay_reuse.go +174 -0
| @@ -0,0 +1,174 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | + | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 9 | + | "github.com/bnema/dumber/internal/logging" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // relayPaneRecord remembers a pane created for an externally relayed URL. | |
| 13 | + | type relayPaneRecord struct { | |
| 14 | + | paneID entity.PaneID | |
| 15 | + | openedAt time.Time | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | // relayPaneRegistry tracks panes opened by the external URL relay so repeated | |
| 19 | + | // identical relay requests can reuse the existing pane instead of stacking | |
| 20 | + | // duplicates. It is only touched on the GTK main thread and needs no locking. | |
| 21 | + | // The zero value is ready to use. | |
| 22 | + | type relayPaneRegistry struct { | |
| 23 | + | entries map[string]relayPaneRecord | |
| 24 | + | now func() time.Time // test hook; defaults to time.Now | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func (r *relayPaneRegistry) clock() time.Time { | |
| 28 | + | if r.now != nil { | |
| 29 | + | return r.now() | |
| 30 | + | } | |
| 31 | + | return time.Now() | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // prune forgets entries that expired or whose pane no longer exists. | |
| 35 | + | func (r *relayPaneRegistry) prune(exists func(entity.PaneID) bool) { | |
| 36 | + | now := r.clock() | |
| 37 | + | for key, rec := range r.entries { | |
| 38 | + | if now.Sub(rec.openedAt) > domainurl.RelayReuseWindow || (exists != nil && !exists(rec.paneID)) { | |
| 39 | + | delete(r.entries, key) | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // lookup returns the pane to reuse for url, if any. | |
| 45 | + | func (r *relayPaneRegistry) lookup(url string, exists func(entity.PaneID) bool) (entity.PaneID, bool) { | |
| 46 | + | r.prune(exists) | |
| 47 | + | key := domainurl.RelayURLKey(url) | |
| 48 | + | rec, ok := r.entries[key] | |
| 49 | + | if !ok || !domainurl.ShouldReuseRelayPane(key, rec.openedAt, url, r.clock(), domainurl.RelayReuseWindow) { | |
| 50 | + | return "", false | |
| 51 | + | } | |
| 52 | + | return rec.paneID, true | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func (r *relayPaneRegistry) record(url string, paneID entity.PaneID) { | |
| 56 | + | key := domainurl.RelayURLKey(url) | |
| 57 | + | if key == "" || paneID == "" { | |
| 58 | + | return | |
| 59 | + | } | |
| 60 | + | if r.entries == nil { | |
| 61 | + | r.entries = make(map[string]relayPaneRecord) | |
| 62 | + | } | |
| 63 | + | r.entries[key] = relayPaneRecord{paneID: paneID, openedAt: r.clock()} | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func (r *relayPaneRegistry) forget(url string) { | |
| 67 | + | delete(r.entries, domainurl.RelayURLKey(url)) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // openRelayURLOnMainThread opens a relayed external URL, reusing the pane from | |
| 71 | + | // a recent identical request when it still exists. Must run on the main thread. | |
| 72 | + | func (a *App) openRelayURLOnMainThread(ctx context.Context, url string, cfg entity.RuntimeConfigSnapshot) error { | |
| 73 | + | if a.reuseRelayPane(ctx, url) { | |
| 74 | + | return nil | |
| 75 | + | } | |
| 76 | + | if err := a.openExternalURLOnMainThread(ctx, url, cfg); err != nil { | |
| 77 | + | return err | |
| 78 | + | } | |
| 79 | + | a.recordRelayPane(url) | |
| 80 | + | return nil | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // reuseRelayPane navigates and focuses the pane previously opened for url. | |
| 84 | + | // It reports false when there is nothing reusable and a new pane should be created. | |
| 85 | + | func (a *App) reuseRelayPane(ctx context.Context, url string) bool { | |
| 86 | + | paneID, ok := a.externalRelayPanes.lookup(url, func(id entity.PaneID) bool { | |
| 87 | + | return a.browserWindowForPane(id) != nil | |
| 88 | + | }) | |
| 89 | + | if !ok { | |
| 90 | + | return false | |
| 91 | + | } | |
| 92 | + | log := logging.FromContext(ctx) | |
| 93 | + | ||
| 94 | + | bw := a.browserWindowForPane(paneID) | |
| 95 | + | var tab *entity.Tab | |
| 96 | + | if bw != nil && bw.tabs != nil { | |
| 97 | + | for _, candidate := range bw.tabs.Tabs { | |
| 98 | + | if candidate != nil && candidate.Workspace != nil && candidate.Workspace.FindPane(paneID) != nil { | |
| 99 | + | tab = candidate | |
| 100 | + | break | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | if tab == nil || a.navCoord == nil || a.contentCoord == nil { | |
| 105 | + | a.externalRelayPanes.forget(url) | |
| 106 | + | return false | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // Reload the URL first: when the pane cannot be navigated, fall back to | |
| 110 | + | // creating a fresh pane instead of focusing a dead one. | |
| 111 | + | if err := a.navCoord.NavigateWebView(ctx, url, paneID, a.contentCoord.GetWebView(paneID)); err != nil { | |
| 112 | + | log.Warn().Err(err). | |
| 113 | + | Str("pane_id", string(paneID)). | |
| 114 | + | Str("url_host", logging.SafeURLHost(url)). | |
| 115 | + | Msg("ui: external URL relay pane could not be navigated; opening a new pane") | |
| 116 | + | a.externalRelayPanes.forget(url) | |
| 117 | + | return false | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | a.focusRelayPane(ctx, bw, tab, paneID) | |
| 121 | + | log.Debug(). | |
| 122 | + | Str("pane_id", string(paneID)). | |
| 123 | + | Str("url_host", logging.SafeURLHost(url)). | |
| 124 | + | Msg("ui: external URL reused relay pane") | |
| 125 | + | return true | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func (a *App) focusRelayPane(ctx context.Context, bw *browserWindow, tab *entity.Tab, paneID entity.PaneID) { | |
| 129 | + | if bw.tabs.ActiveTabID != tab.ID { | |
| 130 | + | if a.tabCoord != nil { | |
| 131 | + | if err := a.tabCoord.Switch(ctx, a.tabTargetForBrowserWindow(bw), tab.ID); err != nil { | |
| 132 | + | logging.FromContext(ctx).Warn().Err(err). | |
| 133 | + | Str("tab_id", string(tab.ID)). | |
| 134 | + | Msg("ui: failed to switch to relay pane tab") | |
| 135 | + | } | |
| 136 | + | } else { | |
| 137 | + | bw.tabs.SetActive(tab.ID) | |
| 138 | + | if bw.mainWindow != nil && bw.mainWindow.TabBar() != nil { | |
| 139 | + | bw.mainWindow.TabBar().SetActive(tab.ID) | |
| 140 | + | } | |
| 141 | + | a.switchWorkspaceView(ctx, tab.ID) | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | wsView := a.workspaceViews[tab.ID] | |
| 146 | + | if a.wsCoord != nil { | |
| 147 | + | a.wsCoord.FocusPaneByID(ctx, tab.Workspace, wsView, paneID) | |
| 148 | + | } else { | |
| 149 | + | tab.Workspace.ActivePaneID = paneID | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | if bw.mainWindow != nil { | |
| 153 | + | bw.mainWindow.Show() | |
| 154 | + | } | |
| 155 | + | a.activateBrowserWindow(bw) | |
| 156 | + | if wsView != nil { | |
| 157 | + | wsView.FocusPane(paneID) | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // recordRelayPane remembers the pane that was just created for url. After a | |
| 162 | + | // successful placement the new pane is the active pane of the last-focused | |
| 163 | + | // window's active tab (tabbed, split, stacked, and fresh window alike). | |
| 164 | + | func (a *App) recordRelayPane(url string) { | |
| 165 | + | ws := a.activeWorkspaceForBrowserWindow(a.lastFocusedBrowserWindow()) | |
| 166 | + | if ws == nil { | |
| 167 | + | return | |
| 168 | + | } | |
| 169 | + | active := ws.ActivePane() | |
| 170 | + | if active == nil || active.Pane == nil || domainurl.RelayURLKey(active.Pane.URI) != domainurl.RelayURLKey(url) { | |
| 171 | + | return | |
| 172 | + | } | |
| 173 | + | a.externalRelayPanes.record(url, active.Pane.ID) | |
| 174 | + | } | |
| @@ -0,0 +1,174 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 8 | + | domainurl "github.com/bnema/dumber/internal/domain/url" | |||
| 9 | + | "github.com/bnema/dumber/internal/logging" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // relayPaneRecord remembers a pane created for an externally relayed URL. | |||
| 13 | + | type relayPaneRecord struct { | |||
| 14 | + | paneID entity.PaneID | |||
| 15 | + | openedAt time.Time | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | // relayPaneRegistry tracks panes opened by the external URL relay so repeated | |||
| 19 | + | // identical relay requests can reuse the existing pane instead of stacking | |||
| 20 | + | // duplicates. It is only touched on the GTK main thread and needs no locking. | |||
| 21 | + | // The zero value is ready to use. | |||
| 22 | + | type relayPaneRegistry struct { | |||
| 23 | + | entries map[string]relayPaneRecord | |||
| 24 | + | now func() time.Time // test hook; defaults to time.Now | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func (r *relayPaneRegistry) clock() time.Time { | |||
| 28 | + | if r.now != nil { | |||
| 29 | + | return r.now() | |||
| 30 | + | } | |||
| 31 | + | return time.Now() | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // prune forgets entries that expired or whose pane no longer exists. | |||
| 35 | + | func (r *relayPaneRegistry) prune(exists func(entity.PaneID) bool) { | |||
| 36 | + | now := r.clock() | |||
| 37 | + | for key, rec := range r.entries { | |||
| 38 | + | if now.Sub(rec.openedAt) > domainurl.RelayReuseWindow || (exists != nil && !exists(rec.paneID)) { | |||
| 39 | + | delete(r.entries, key) | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // lookup returns the pane to reuse for url, if any. | |||
| 45 | + | func (r *relayPaneRegistry) lookup(url string, exists func(entity.PaneID) bool) (entity.PaneID, bool) { | |||
| 46 | + | r.prune(exists) | |||
| 47 | + | key := domainurl.RelayURLKey(url) | |||
| 48 | + | rec, ok := r.entries[key] | |||
| 49 | + | if !ok || !domainurl.ShouldReuseRelayPane(key, rec.openedAt, url, r.clock(), domainurl.RelayReuseWindow) { | |||
| 50 | + | return "", false | |||
| 51 | + | } | |||
| 52 | + | return rec.paneID, true | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func (r *relayPaneRegistry) record(url string, paneID entity.PaneID) { | |||
| 56 | + | key := domainurl.RelayURLKey(url) | |||
| 57 | + | if key == "" || paneID == "" { | |||
| 58 | + | return | |||
| 59 | + | } | |||
| 60 | + | if r.entries == nil { | |||
| 61 | + | r.entries = make(map[string]relayPaneRecord) | |||
| 62 | + | } | |||
| 63 | + | r.entries[key] = relayPaneRecord{paneID: paneID, openedAt: r.clock()} | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func (r *relayPaneRegistry) forget(url string) { | |||
| 67 | + | delete(r.entries, domainurl.RelayURLKey(url)) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // openRelayURLOnMainThread opens a relayed external URL, reusing the pane from | |||
| 71 | + | // a recent identical request when it still exists. Must run on the main thread. | |||
| 72 | + | func (a *App) openRelayURLOnMainThread(ctx context.Context, url string, cfg entity.RuntimeConfigSnapshot) error { | |||
| 73 | + | if a.reuseRelayPane(ctx, url) { | |||
| 74 | + | return nil | |||
| 75 | + | } | |||
| 76 | + | if err := a.openExternalURLOnMainThread(ctx, url, cfg); err != nil { | |||
| 77 | + | return err | |||
| 78 | + | } | |||
| 79 | + | a.recordRelayPane(url) | |||
| 80 | + | return nil | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // reuseRelayPane navigates and focuses the pane previously opened for url. | |||
| 84 | + | // It reports false when there is nothing reusable and a new pane should be created. | |||
| 85 | + | func (a *App) reuseRelayPane(ctx context.Context, url string) bool { | |||
| 86 | + | paneID, ok := a.externalRelayPanes.lookup(url, func(id entity.PaneID) bool { | |||
| 87 | + | return a.browserWindowForPane(id) != nil | |||
| 88 | + | }) | |||
| 89 | + | if !ok { | |||
| 90 | + | return false | |||
| 91 | + | } | |||
| 92 | + | log := logging.FromContext(ctx) | |||
| 93 | + | ||||
| 94 | + | bw := a.browserWindowForPane(paneID) | |||
| 95 | + | var tab *entity.Tab | |||
| 96 | + | if bw != nil && bw.tabs != nil { | |||
| 97 | + | for _, candidate := range bw.tabs.Tabs { | |||
| 98 | + | if candidate != nil && candidate.Workspace != nil && candidate.Workspace.FindPane(paneID) != nil { | |||
| 99 | + | tab = candidate | |||
| 100 | + | break | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | if tab == nil || a.navCoord == nil || a.contentCoord == nil { | |||
| 105 | + | a.externalRelayPanes.forget(url) | |||
| 106 | + | return false | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // Reload the URL first: when the pane cannot be navigated, fall back to | |||
| 110 | + | // creating a fresh pane instead of focusing a dead one. | |||
| 111 | + | if err := a.navCoord.NavigateWebView(ctx, url, paneID, a.contentCoord.GetWebView(paneID)); err != nil { | |||
| 112 | + | log.Warn().Err(err). | |||
| 113 | + | Str("pane_id", string(paneID)). | |||
| 114 | + | Str("url_host", logging.SafeURLHost(url)). | |||
| 115 | + | Msg("ui: external URL relay pane could not be navigated; opening a new pane") | |||
| 116 | + | a.externalRelayPanes.forget(url) | |||
| 117 | + | return false | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | a.focusRelayPane(ctx, bw, tab, paneID) | |||
| 121 | + | log.Debug(). | |||
| 122 | + | Str("pane_id", string(paneID)). | |||
| 123 | + | Str("url_host", logging.SafeURLHost(url)). | |||
| 124 | + | Msg("ui: external URL reused relay pane") | |||
| 125 | + | return true | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func (a *App) focusRelayPane(ctx context.Context, bw *browserWindow, tab *entity.Tab, paneID entity.PaneID) { | |||
| 129 | + | if bw.tabs.ActiveTabID != tab.ID { | |||
| 130 | + | if a.tabCoord != nil { | |||
| 131 | + | if err := a.tabCoord.Switch(ctx, a.tabTargetForBrowserWindow(bw), tab.ID); err != nil { | |||
| 132 | + | logging.FromContext(ctx).Warn().Err(err). | |||
| 133 | + | Str("tab_id", string(tab.ID)). | |||
| 134 | + | Msg("ui: failed to switch to relay pane tab") | |||
| 135 | + | } | |||
| 136 | + | } else { | |||
| 137 | + | bw.tabs.SetActive(tab.ID) | |||
| 138 | + | if bw.mainWindow != nil && bw.mainWindow.TabBar() != nil { | |||
| 139 | + | bw.mainWindow.TabBar().SetActive(tab.ID) | |||
| 140 | + | } | |||
| 141 | + | a.switchWorkspaceView(ctx, tab.ID) | |||
| 142 | + | } | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | wsView := a.workspaceViews[tab.ID] | |||
| 146 | + | if a.wsCoord != nil { | |||
| 147 | + | a.wsCoord.FocusPaneByID(ctx, tab.Workspace, wsView, paneID) | |||
| 148 | + | } else { | |||
| 149 | + | tab.Workspace.ActivePaneID = paneID | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | if bw.mainWindow != nil { | |||
| 153 | + | bw.mainWindow.Show() | |||
| 154 | + | } | |||
| 155 | + | a.activateBrowserWindow(bw) | |||
| 156 | + | if wsView != nil { | |||
| 157 | + | wsView.FocusPane(paneID) | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | // recordRelayPane remembers the pane that was just created for url. After a | |||
| 162 | + | // successful placement the new pane is the active pane of the last-focused | |||
| 163 | + | // window's active tab (tabbed, split, stacked, and fresh window alike). | |||
| 164 | + | func (a *App) recordRelayPane(url string) { | |||
| 165 | + | ws := a.activeWorkspaceForBrowserWindow(a.lastFocusedBrowserWindow()) | |||
| 166 | + | if ws == nil { | |||
| 167 | + | return | |||
| 168 | + | } | |||
| 169 | + | active := ws.ActivePane() | |||
| 170 | + | if active == nil || active.Pane == nil || domainurl.RelayURLKey(active.Pane.URI) != domainurl.RelayURLKey(url) { | |||
| 171 | + | return | |||
| 172 | + | } | |||
| 173 | + | a.externalRelayPanes.record(url, active.Pane.ID) | |||
| 174 | + | } | |||
A internal/ui/external_url_relay_reuse_test.go +155 -0
| @@ -0,0 +1,155 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |
| 9 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | + | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 12 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 13 | + | "github.com/bnema/dumber/internal/ui/component" | |
| 14 | + | "github.com/bnema/dumber/internal/ui/coordinator" | |
| 15 | + | contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content" | |
| 16 | + | "github.com/stretchr/testify/assert" | |
| 17 | + | "github.com/stretchr/testify/require" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | const relayTestURL = "https://claude.com/cai/login/app-google-auth?open_in_browser=1&hop_nonce=XYZ" | |
| 21 | + | ||
| 22 | + | type relayReuseHarness struct { | |
| 23 | + | app *App | |
| 24 | + | tabs *entity.TabList | |
| 25 | + | window *browserWindow | |
| 26 | + | webview map[entity.PaneID]*recordingWebView | |
| 27 | + | clock *time.Time | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // newRelayReuseHarness builds an App with one window holding one tab, using the | |
| 31 | + | // tabbed external-link behavior. New tabs get a recording webview registered so | |
| 32 | + | // reuse can navigate them. | |
| 33 | + | func newRelayReuseHarness(t *testing.T) *relayReuseHarness { | |
| 34 | + | t.Helper() | |
| 35 | + | ctx := context.Background() | |
| 36 | + | ||
| 37 | + | original := entity.NewTab("tab-1", "ws-1", entity.NewPane("pane-1")) | |
| 38 | + | tabs := entity.NewTabList() | |
| 39 | + | tabs.Add(original) | |
| 40 | + | window := &browserWindow{id: "focused", tabs: tabs} | |
| 41 | + | ||
| 42 | + | ids := []string{"tab-2", "ws-2", "pane-2", "tab-3", "ws-3", "pane-3"} | |
| 43 | + | index := 0 | |
| 44 | + | now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) | |
| 45 | + | h := &relayReuseHarness{tabs: tabs, window: window, webview: map[entity.PaneID]*recordingWebView{}, clock: &now} | |
| 46 | + | ||
| 47 | + | contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil) | |
| 48 | + | app := &App{ | |
| 49 | + | runtimeConfig: runtimeConfigStateForTest(&config.Config{Workspace: entity.WorkspaceConfig{ExternalLinks: entity.ExternalLinksConfig{Behavior: entity.ExternalLinkBehaviorTabbed}}}), | |
| 50 | + | tabsUC: usecase.NewManageTabsUseCase(func() string { id := ids[index]; index++; return id }, nil), | |
| 51 | + | browserWindows: map[string]*browserWindow{ | |
| 52 | + | window.id: window, | |
| 53 | + | }, | |
| 54 | + | browserWindowOrder: []string{window.id}, | |
| 55 | + | lastFocusedWindowID: window.id, | |
| 56 | + | workspaceViews: map[entity.TabID]*component.WorkspaceView{}, | |
| 57 | + | windowForTab: map[entity.TabID]*browserWindow{}, | |
| 58 | + | contentCoord: contentCoord, | |
| 59 | + | navCoord: coordinator.NewNavigationCoordinator(ctx, nil, contentCoord), | |
| 60 | + | } | |
| 61 | + | app.externalRelayPanes.now = func() time.Time { return *h.clock } | |
| 62 | + | app.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{TabsUC: app.tabsUC}) | |
| 63 | + | app.tabCoord.SetOnTabCreated(func(_ context.Context, _ coordinator.TabTarget, tab *entity.Tab) { | |
| 64 | + | app.workspaceViews[tab.ID] = &component.WorkspaceView{} | |
| 65 | + | app.setBrowserWindowForTab(tab.ID, window) | |
| 66 | + | paneID := tab.Workspace.ActivePane().Pane.ID | |
| 67 | + | wv := &recordingWebView{id: port.WebViewID(100 + len(h.webview))} | |
| 68 | + | h.webview[paneID] = wv | |
| 69 | + | contentCoord.RegisterPopupWebView(paneID, wv) | |
| 70 | + | }) | |
| 71 | + | h.app = app | |
| 72 | + | return h | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func TestApp_OpenExternalURLReusesRecentRelayPane(t *testing.T) { | |
| 76 | + | ctx := context.Background() | |
| 77 | + | h := newRelayReuseHarness(t) | |
| 78 | + | ||
| 79 | + | require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL)) | |
| 80 | + | require.Len(t, h.tabs.Tabs, 2) | |
| 81 | + | relayTab := h.tabs.ActiveTab() | |
| 82 | + | relayPaneID := relayTab.Workspace.ActivePane().Pane.ID | |
| 83 | + | assert.False(t, h.webview[relayPaneID].loadURICalled) | |
| 84 | + | ||
| 85 | + | // The user moves on to another tab, then the app retries the same URL. | |
| 86 | + | h.tabs.SetActive("tab-1") | |
| 87 | + | *h.clock = h.clock.Add(30 * time.Second) | |
| 88 | + | ||
| 89 | + | require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL)) | |
| 90 | + | ||
| 91 | + | assert.Len(t, h.tabs.Tabs, 2, "no new tab must be created") | |
| 92 | + | assert.Equal(t, relayTab.ID, h.tabs.ActiveTabID, "relay tab must be focused again") | |
| 93 | + | assert.True(t, h.webview[relayPaneID].loadURICalled) | |
| 94 | + | assert.Equal(t, relayTestURL, h.webview[relayPaneID].loadURILastURI) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestApp_OpenExternalURLCreatesNewPaneAfterReuseWindowExpires(t *testing.T) { | |
| 98 | + | ctx := context.Background() | |
| 99 | + | h := newRelayReuseHarness(t) | |
| 100 | + | ||
| 101 | + | require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL)) | |
| 102 | + | require.Len(t, h.tabs.Tabs, 2) | |
| 103 | + | ||
| 104 | + | *h.clock = h.clock.Add(domainurl.RelayReuseWindow + time.Second) | |
| 105 | + | require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL)) | |
| 106 | + | ||
| 107 | + | assert.Len(t, h.tabs.Tabs, 3) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func TestApp_OpenExternalURLCreatesNewPaneForDifferentURL(t *testing.T) { | |
| 111 | + | ctx := context.Background() | |
| 112 | + | h := newRelayReuseHarness(t) | |
| 113 | + | ||
| 114 | + | require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL)) | |
| 115 | + | require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL+"2")) | |
| 116 | + | ||
| 117 | + | assert.Len(t, h.tabs.Tabs, 3) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func TestApp_OpenExternalURLCreatesNewPaneWhenRelayPaneWasClosed(t *testing.T) { | |
| 121 | + | ctx := context.Background() | |
| 122 | + | h := newRelayReuseHarness(t) | |
| 123 | + | ||
| 124 | + | require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL)) | |
| 125 | + | relayTab := h.tabs.ActiveTab() | |
| 126 | + | h.tabs.Remove(relayTab.ID) | |
| 127 | + | require.Len(t, h.tabs.Tabs, 1) | |
| 128 | + | ||
| 129 | + | require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL)) | |
| 130 | + | ||
| 131 | + | assert.Len(t, h.tabs.Tabs, 2) | |
| 132 | + | assert.NotEqual(t, relayTab.ID, h.tabs.ActiveTabID) | |
| 133 | + | assert.Len(t, h.app.externalRelayPanes.entries, 1, "stale entry replaced by the new pane") | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func TestRelayPaneRegistry_PrunesExpiredAndMissingPanes(t *testing.T) { | |
| 137 | + | now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) | |
| 138 | + | reg := relayPaneRegistry{now: func() time.Time { return now }} | |
| 139 | + | reg.record("https://a.example/", "pane-a") | |
| 140 | + | reg.record("https://b.example/", "pane-b") | |
| 141 | + | ||
| 142 | + | exists := func(id entity.PaneID) bool { return id != "pane-b" } | |
| 143 | + | _, ok := reg.lookup("https://b.example/", exists) | |
| 144 | + | assert.False(t, ok) | |
| 145 | + | assert.NotContains(t, reg.entries, "https://b.example/") | |
| 146 | + | ||
| 147 | + | id, ok := reg.lookup("https://a.example/", exists) | |
| 148 | + | assert.True(t, ok) | |
| 149 | + | assert.Equal(t, entity.PaneID("pane-a"), id) | |
| 150 | + | ||
| 151 | + | now = now.Add(domainurl.RelayReuseWindow + time.Second) | |
| 152 | + | _, ok = reg.lookup("https://a.example/", exists) | |
| 153 | + | assert.False(t, ok) | |
| 154 | + | assert.Empty(t, reg.entries) | |
| 155 | + | } | |
| @@ -0,0 +1,155 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 9 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 11 | + | domainurl "github.com/bnema/dumber/internal/domain/url" | |||
| 12 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 13 | + | "github.com/bnema/dumber/internal/ui/component" | |||
| 14 | + | "github.com/bnema/dumber/internal/ui/coordinator" | |||
| 15 | + | contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content" | |||
| 16 | + | "github.com/stretchr/testify/assert" | |||
| 17 | + | "github.com/stretchr/testify/require" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | const relayTestURL = "https://claude.com/cai/login/app-google-auth?open_in_browser=1&hop_nonce=XYZ" | |||
| 21 | + | ||||
| 22 | + | type relayReuseHarness struct { | |||
| 23 | + | app *App | |||
| 24 | + | tabs *entity.TabList | |||
| 25 | + | window *browserWindow | |||
| 26 | + | webview map[entity.PaneID]*recordingWebView | |||
| 27 | + | clock *time.Time | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // newRelayReuseHarness builds an App with one window holding one tab, using the | |||
| 31 | + | // tabbed external-link behavior. New tabs get a recording webview registered so | |||
| 32 | + | // reuse can navigate them. | |||
| 33 | + | func newRelayReuseHarness(t *testing.T) *relayReuseHarness { | |||
| 34 | + | t.Helper() | |||
| 35 | + | ctx := context.Background() | |||
| 36 | + | ||||
| 37 | + | original := entity.NewTab("tab-1", "ws-1", entity.NewPane("pane-1")) | |||
| 38 | + | tabs := entity.NewTabList() | |||
| 39 | + | tabs.Add(original) | |||
| 40 | + | window := &browserWindow{id: "focused", tabs: tabs} | |||
| 41 | + | ||||
| 42 | + | ids := []string{"tab-2", "ws-2", "pane-2", "tab-3", "ws-3", "pane-3"} | |||
| 43 | + | index := 0 | |||
| 44 | + | now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) | |||
| 45 | + | h := &relayReuseHarness{tabs: tabs, window: window, webview: map[entity.PaneID]*recordingWebView{}, clock: &now} | |||
| 46 | + | ||||
| 47 | + | contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil) | |||
| 48 | + | app := &App{ | |||
| 49 | + | runtimeConfig: runtimeConfigStateForTest(&config.Config{Workspace: entity.WorkspaceConfig{ExternalLinks: entity.ExternalLinksConfig{Behavior: entity.ExternalLinkBehaviorTabbed}}}), | |||
| 50 | + | tabsUC: usecase.NewManageTabsUseCase(func() string { id := ids[index]; index++; return id }, nil), | |||
| 51 | + | browserWindows: map[string]*browserWindow{ | |||
| 52 | + | window.id: window, | |||
| 53 | + | }, | |||
| 54 | + | browserWindowOrder: []string{window.id}, | |||
| 55 | + | lastFocusedWindowID: window.id, | |||
| 56 | + | workspaceViews: map[entity.TabID]*component.WorkspaceView{}, | |||
| 57 | + | windowForTab: map[entity.TabID]*browserWindow{}, | |||
| 58 | + | contentCoord: contentCoord, | |||
| 59 | + | navCoord: coordinator.NewNavigationCoordinator(ctx, nil, contentCoord), | |||
| 60 | + | } | |||
| 61 | + | app.externalRelayPanes.now = func() time.Time { return *h.clock } | |||
| 62 | + | app.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{TabsUC: app.tabsUC}) | |||
| 63 | + | app.tabCoord.SetOnTabCreated(func(_ context.Context, _ coordinator.TabTarget, tab *entity.Tab) { | |||
| 64 | + | app.workspaceViews[tab.ID] = &component.WorkspaceView{} | |||
| 65 | + | app.setBrowserWindowForTab(tab.ID, window) | |||
| 66 | + | paneID := tab.Workspace.ActivePane().Pane.ID | |||
| 67 | + | wv := &recordingWebView{id: port.WebViewID(100 + len(h.webview))} | |||
| 68 | + | h.webview[paneID] = wv | |||
| 69 | + | contentCoord.RegisterPopupWebView(paneID, wv) | |||
| 70 | + | }) | |||
| 71 | + | h.app = app | |||
| 72 | + | return h | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func TestApp_OpenExternalURLReusesRecentRelayPane(t *testing.T) { | |||
| 76 | + | ctx := context.Background() | |||
| 77 | + | h := newRelayReuseHarness(t) | |||
| 78 | + | ||||
| 79 | + | require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL)) | |||
| 80 | + | require.Len(t, h.tabs.Tabs, 2) | |||
| 81 | + | relayTab := h.tabs.ActiveTab() | |||
| 82 | + | relayPaneID := relayTab.Workspace.ActivePane().Pane.ID | |||
| 83 | + | assert.False(t, h.webview[relayPaneID].loadURICalled) | |||
| 84 | + | ||||
| 85 | + | // The user moves on to another tab, then the app retries the same URL. | |||
| 86 | + | h.tabs.SetActive("tab-1") | |||
| 87 | + | *h.clock = h.clock.Add(30 * time.Second) | |||
| 88 | + | ||||
| 89 | + | require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL)) | |||
| 90 | + | ||||
| 91 | + | assert.Len(t, h.tabs.Tabs, 2, "no new tab must be created") | |||
| 92 | + | assert.Equal(t, relayTab.ID, h.tabs.ActiveTabID, "relay tab must be focused again") | |||
| 93 | + | assert.True(t, h.webview[relayPaneID].loadURICalled) | |||
| 94 | + | assert.Equal(t, relayTestURL, h.webview[relayPaneID].loadURILastURI) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func TestApp_OpenExternalURLCreatesNewPaneAfterReuseWindowExpires(t *testing.T) { | |||
| 98 | + | ctx := context.Background() | |||
| 99 | + | h := newRelayReuseHarness(t) | |||
| 100 | + | ||||
| 101 | + | require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL)) | |||
| 102 | + | require.Len(t, h.tabs.Tabs, 2) | |||
| 103 | + | ||||
| 104 | + | *h.clock = h.clock.Add(domainurl.RelayReuseWindow + time.Second) | |||
| 105 | + | require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL)) | |||
| 106 | + | ||||
| 107 | + | assert.Len(t, h.tabs.Tabs, 3) | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func TestApp_OpenExternalURLCreatesNewPaneForDifferentURL(t *testing.T) { | |||
| 111 | + | ctx := context.Background() | |||
| 112 | + | h := newRelayReuseHarness(t) | |||
| 113 | + | ||||
| 114 | + | require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL)) | |||
| 115 | + | require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL+"2")) | |||
| 116 | + | ||||
| 117 | + | assert.Len(t, h.tabs.Tabs, 3) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestApp_OpenExternalURLCreatesNewPaneWhenRelayPaneWasClosed(t *testing.T) { | |||
| 121 | + | ctx := context.Background() | |||
| 122 | + | h := newRelayReuseHarness(t) | |||
| 123 | + | ||||
| 124 | + | require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL)) | |||
| 125 | + | relayTab := h.tabs.ActiveTab() | |||
| 126 | + | h.tabs.Remove(relayTab.ID) | |||
| 127 | + | require.Len(t, h.tabs.Tabs, 1) | |||
| 128 | + | ||||
| 129 | + | require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL)) | |||
| 130 | + | ||||
| 131 | + | assert.Len(t, h.tabs.Tabs, 2) | |||
| 132 | + | assert.NotEqual(t, relayTab.ID, h.tabs.ActiveTabID) | |||
| 133 | + | assert.Len(t, h.app.externalRelayPanes.entries, 1, "stale entry replaced by the new pane") | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func TestRelayPaneRegistry_PrunesExpiredAndMissingPanes(t *testing.T) { | |||
| 137 | + | now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) | |||
| 138 | + | reg := relayPaneRegistry{now: func() time.Time { return now }} | |||
| 139 | + | reg.record("https://a.example/", "pane-a") | |||
| 140 | + | reg.record("https://b.example/", "pane-b") | |||
| 141 | + | ||||
| 142 | + | exists := func(id entity.PaneID) bool { return id != "pane-b" } | |||
| 143 | + | _, ok := reg.lookup("https://b.example/", exists) | |||
| 144 | + | assert.False(t, ok) | |||
| 145 | + | assert.NotContains(t, reg.entries, "https://b.example/") | |||
| 146 | + | ||||
| 147 | + | id, ok := reg.lookup("https://a.example/", exists) | |||
| 148 | + | assert.True(t, ok) | |||
| 149 | + | assert.Equal(t, entity.PaneID("pane-a"), id) | |||
| 150 | + | ||||
| 151 | + | now = now.Add(domainurl.RelayReuseWindow + time.Second) | |||
| 152 | + | _, ok = reg.lookup("https://a.example/", exists) | |||
| 153 | + | assert.False(t, ok) | |||
| 154 | + | assert.Empty(t, reg.entries) | |||
| 155 | + | } | |||