refactor(ui): move relay pane reuse state into a use case
The relay pane registry moves to usecase.RelayPaneReuse so the UI only wires lookup, navigation and focus. Reusing a pane now hides floating panes over its tab and resyncs floating focus, and the navigation-failure fallback is covered by a test.
- commit
- 987a989347cddfc858682a72fad3cd9f5608e58f
- author
- brice · · 2026-10-15 07:27 UTC
- parent
- 65f79a8
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +128 -87
A internal/application/usecase/relay_pane_reuse.go +69 -0
| @@ -0,0 +1,69 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "time" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 7 | + | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | type relayPaneRecord struct { | |
| 11 | + | paneID entity.PaneID | |
| 12 | + | openedAt time.Time | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | // RelayPaneReuse remembers panes opened for externally relayed URLs so a | |
| 16 | + | // repeated identical request (desktop apps retry OAuth URLs) reuses the pane | |
| 17 | + | // instead of creating duplicates. It is not safe for concurrent use; callers | |
| 18 | + | // confine it to the UI main thread. The zero value is ready to use. | |
| 19 | + | type RelayPaneReuse struct { | |
| 20 | + | entries map[string]relayPaneRecord | |
| 21 | + | // Now overrides the clock in tests; nil means time.Now. | |
| 22 | + | Now func() time.Time | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func (r *RelayPaneReuse) clock() time.Time { | |
| 26 | + | if r.Now != nil { | |
| 27 | + | return r.Now() | |
| 28 | + | } | |
| 29 | + | return time.Now() | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // Lookup returns the pane to reuse for url. Entries that expired or whose pane | |
| 33 | + | // no longer exists (per exists) are forgotten first. | |
| 34 | + | func (r *RelayPaneReuse) Lookup(url string, exists func(entity.PaneID) bool) (entity.PaneID, bool) { | |
| 35 | + | now := r.clock() | |
| 36 | + | for key, rec := range r.entries { | |
| 37 | + | if now.Sub(rec.openedAt) > domainurl.RelayReuseWindow || (exists != nil && !exists(rec.paneID)) { | |
| 38 | + | delete(r.entries, key) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | key := domainurl.RelayURLKey(url) | |
| 42 | + | rec, ok := r.entries[key] | |
| 43 | + | if !ok || !domainurl.ShouldReuseRelayPane(key, rec.openedAt, url, now, domainurl.RelayReuseWindow) { | |
| 44 | + | return "", false | |
| 45 | + | } | |
| 46 | + | return rec.paneID, true | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // Record remembers that paneID was created for url. | |
| 50 | + | func (r *RelayPaneReuse) Record(url string, paneID entity.PaneID) { | |
| 51 | + | key := domainurl.RelayURLKey(url) | |
| 52 | + | if key == "" || paneID == "" { | |
| 53 | + | return | |
| 54 | + | } | |
| 55 | + | if r.entries == nil { | |
| 56 | + | r.entries = make(map[string]relayPaneRecord) | |
| 57 | + | } | |
| 58 | + | r.entries[key] = relayPaneRecord{paneID: paneID, openedAt: r.clock()} | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // Forget drops the record for url. | |
| 62 | + | func (r *RelayPaneReuse) Forget(url string) { | |
| 63 | + | delete(r.entries, domainurl.RelayURLKey(url)) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // Len reports how many relay panes are remembered. | |
| 67 | + | func (r *RelayPaneReuse) Len() int { | |
| 68 | + | return len(r.entries) | |
| 69 | + | } | |
| @@ -0,0 +1,69 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "time" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 7 | + | domainurl "github.com/bnema/dumber/internal/domain/url" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | type relayPaneRecord struct { | |||
| 11 | + | paneID entity.PaneID | |||
| 12 | + | openedAt time.Time | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | // RelayPaneReuse remembers panes opened for externally relayed URLs so a | |||
| 16 | + | // repeated identical request (desktop apps retry OAuth URLs) reuses the pane | |||
| 17 | + | // instead of creating duplicates. It is not safe for concurrent use; callers | |||
| 18 | + | // confine it to the UI main thread. The zero value is ready to use. | |||
| 19 | + | type RelayPaneReuse struct { | |||
| 20 | + | entries map[string]relayPaneRecord | |||
| 21 | + | // Now overrides the clock in tests; nil means time.Now. | |||
| 22 | + | Now func() time.Time | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func (r *RelayPaneReuse) clock() time.Time { | |||
| 26 | + | if r.Now != nil { | |||
| 27 | + | return r.Now() | |||
| 28 | + | } | |||
| 29 | + | return time.Now() | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // Lookup returns the pane to reuse for url. Entries that expired or whose pane | |||
| 33 | + | // no longer exists (per exists) are forgotten first. | |||
| 34 | + | func (r *RelayPaneReuse) Lookup(url string, exists func(entity.PaneID) bool) (entity.PaneID, bool) { | |||
| 35 | + | now := r.clock() | |||
| 36 | + | for key, rec := range r.entries { | |||
| 37 | + | if now.Sub(rec.openedAt) > domainurl.RelayReuseWindow || (exists != nil && !exists(rec.paneID)) { | |||
| 38 | + | delete(r.entries, key) | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | key := domainurl.RelayURLKey(url) | |||
| 42 | + | rec, ok := r.entries[key] | |||
| 43 | + | if !ok || !domainurl.ShouldReuseRelayPane(key, rec.openedAt, url, now, domainurl.RelayReuseWindow) { | |||
| 44 | + | return "", false | |||
| 45 | + | } | |||
| 46 | + | return rec.paneID, true | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // Record remembers that paneID was created for url. | |||
| 50 | + | func (r *RelayPaneReuse) Record(url string, paneID entity.PaneID) { | |||
| 51 | + | key := domainurl.RelayURLKey(url) | |||
| 52 | + | if key == "" || paneID == "" { | |||
| 53 | + | return | |||
| 54 | + | } | |||
| 55 | + | if r.entries == nil { | |||
| 56 | + | r.entries = make(map[string]relayPaneRecord) | |||
| 57 | + | } | |||
| 58 | + | r.entries[key] = relayPaneRecord{paneID: paneID, openedAt: r.clock()} | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // Forget drops the record for url. | |||
| 62 | + | func (r *RelayPaneReuse) Forget(url string) { | |||
| 63 | + | delete(r.entries, domainurl.RelayURLKey(url)) | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // Len reports how many relay panes are remembered. | |||
| 67 | + | func (r *RelayPaneReuse) Len() int { | |||
| 68 | + | return len(r.entries) | |||
| 69 | + | } | |||
A internal/application/usecase/relay_pane_reuse_test.go +32 -0
| @@ -0,0 +1,32 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | + | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestRelayPaneReuse_PrunesExpiredAndMissingPanes(t *testing.T) { | |
| 14 | + | now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) | |
| 15 | + | reg := RelayPaneReuse{Now: func() time.Time { return now }} | |
| 16 | + | reg.Record("https://a.example/", "pane-a") | |
| 17 | + | reg.Record("https://b.example/", "pane-b") | |
| 18 | + | ||
| 19 | + | exists := func(id entity.PaneID) bool { return id != "pane-b" } | |
| 20 | + | _, ok := reg.Lookup("https://b.example/", exists) | |
| 21 | + | assert.False(t, ok) | |
| 22 | + | assert.Equal(t, 1, reg.Len()) | |
| 23 | + | ||
| 24 | + | id, ok := reg.Lookup("https://a.example/", exists) | |
| 25 | + | assert.True(t, ok) | |
| 26 | + | assert.Equal(t, entity.PaneID("pane-a"), id) | |
| 27 | + | ||
| 28 | + | now = now.Add(domainurl.RelayReuseWindow + time.Second) | |
| 29 | + | _, ok = reg.Lookup("https://a.example/", exists) | |
| 30 | + | assert.False(t, ok) | |
| 31 | + | assert.Equal(t, 0, reg.Len()) | |
| 32 | + | } | |
| @@ -0,0 +1,32 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 10 | + | domainurl "github.com/bnema/dumber/internal/domain/url" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestRelayPaneReuse_PrunesExpiredAndMissingPanes(t *testing.T) { | |||
| 14 | + | now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) | |||
| 15 | + | reg := RelayPaneReuse{Now: func() time.Time { return now }} | |||
| 16 | + | reg.Record("https://a.example/", "pane-a") | |||
| 17 | + | reg.Record("https://b.example/", "pane-b") | |||
| 18 | + | ||||
| 19 | + | exists := func(id entity.PaneID) bool { return id != "pane-b" } | |||
| 20 | + | _, ok := reg.Lookup("https://b.example/", exists) | |||
| 21 | + | assert.False(t, ok) | |||
| 22 | + | assert.Equal(t, 1, reg.Len()) | |||
| 23 | + | ||||
| 24 | + | id, ok := reg.Lookup("https://a.example/", exists) | |||
| 25 | + | assert.True(t, ok) | |||
| 26 | + | assert.Equal(t, entity.PaneID("pane-a"), id) | |||
| 27 | + | ||||
| 28 | + | now = now.Add(domainurl.RelayReuseWindow + time.Second) | |||
| 29 | + | _, ok = reg.Lookup("https://a.example/", exists) | |||
| 30 | + | assert.False(t, ok) | |||
| 31 | + | assert.Equal(t, 0, reg.Len()) | |||
| 32 | + | } | |||
M internal/ui/app.go +3 -3
| @@ -90,9 +90,9 @@ type App struct { | |||
| 90 | 90 | runtimeConfig *runtimeConfigState | |
| 91 | 91 | ||
| 92 | 92 | browserWindows map[string]*browserWindow | |
| 93 | - | browserWindowOrder []string // registration order of window IDs | |
| 94 | - | instanceWindows map[string]string // logical instance name -> browser window ID | |
| 95 | - | externalRelayPanes relayPaneRegistry // panes opened by the external URL relay (main thread only) | |
| 93 | + | browserWindowOrder []string // registration order of window IDs | |
| 94 | + | instanceWindows map[string]string // logical instance name -> browser window ID | |
| 95 | + | externalRelayPanes usecase.RelayPaneReuse // panes opened by the external URL relay (main thread only) | |
| 96 | 96 | lastFocusedWindowID string | |
| 97 | 97 | nativePopupWindows map[port.WebViewID]*nativePopupWindow | |
| 98 | 98 | browserWindowFactory func(context.Context, string) (*browserWindow, error) | |
| @@ -90,9 +90,9 @@ type App struct { | |||||
| 90 | runtimeConfig *runtimeConfigState | 90 | runtimeConfig *runtimeConfigState | ||
| 91 | 91 | ||||
| 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 | + | externalRelayPanes usecase.RelayPaneReuse // panes opened by the external URL relay (main thread only) |
| 96 | lastFocusedWindowID string | 96 | lastFocusedWindowID string | ||
| 97 | nativePopupWindows map[port.WebViewID]*nativePopupWindow | 97 | nativePopupWindows map[port.WebViewID]*nativePopupWindow | ||
| 98 | browserWindowFactory func(context.Context, string) (*browserWindow, error) | 98 | browserWindowFactory func(context.Context, string) (*browserWindow, error) | ||
M internal/ui/external_url_relay_reuse.go +9 -63
| @@ -2,71 +2,12 @@ package ui | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "time" | |
| 6 | 5 | ||
| 7 | 6 | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | 7 | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 9 | 8 | "github.com/bnema/dumber/internal/logging" | |
| 10 | 9 | ) | |
| 11 | 10 | ||
| 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 | 11 | // openRelayURLOnMainThread opens a relayed external URL, reusing the pane from | |
| 71 | 12 | // a recent identical request when it still exists. Must run on the main thread. | |
| 72 | 13 | func (a *App) openRelayURLOnMainThread(ctx context.Context, url string, cfg entity.RuntimeConfigSnapshot) error { | |
| @@ -83,7 +24,7 @@ func (a *App) openRelayURLOnMainThread(ctx context.Context, url string, cfg enti | |||
| 83 | 24 | // reuseRelayPane navigates and focuses the pane previously opened for url. | |
| 84 | 25 | // It reports false when there is nothing reusable and a new pane should be created. | |
| 85 | 26 | func (a *App) reuseRelayPane(ctx context.Context, url string) bool { | |
| 86 | - | paneID, ok := a.externalRelayPanes.lookup(url, func(id entity.PaneID) bool { | |
| 27 | + | paneID, ok := a.externalRelayPanes.Lookup(url, func(id entity.PaneID) bool { | |
| 87 | 28 | return a.browserWindowForPane(id) != nil | |
| 88 | 29 | }) | |
| 89 | 30 | if !ok { | |
| @@ -102,7 +43,7 @@ func (a *App) reuseRelayPane(ctx context.Context, url string) bool { | |||
| 102 | 43 | } | |
| 103 | 44 | } | |
| 104 | 45 | if tab == nil || a.navCoord == nil || a.contentCoord == nil { | |
| 105 | - | a.externalRelayPanes.forget(url) | |
| 46 | + | a.externalRelayPanes.Forget(url) | |
| 106 | 47 | return false | |
| 107 | 48 | } | |
| 108 | 49 | ||
| @@ -113,7 +54,7 @@ func (a *App) reuseRelayPane(ctx context.Context, url string) bool { | |||
| 113 | 54 | Str("pane_id", string(paneID)). | |
| 114 | 55 | Str("url_host", logging.SafeURLHost(url)). | |
| 115 | 56 | Msg("ui: external URL relay pane could not be navigated; opening a new pane") | |
| 116 | - | a.externalRelayPanes.forget(url) | |
| 57 | + | a.externalRelayPanes.Forget(url) | |
| 117 | 58 | return false | |
| 118 | 59 | } | |
| 119 | 60 | ||
| @@ -149,10 +90,15 @@ func (a *App) focusRelayPane(ctx context.Context, bw *browserWindow, tab *entity | |||
| 149 | 90 | tab.Workspace.ActivePaneID = paneID | |
| 150 | 91 | } | |
| 151 | 92 | ||
| 93 | + | // A floating pane over the tab would hide the relay pane and keep the | |
| 94 | + | // active-pane override pointing at itself. | |
| 95 | + | a.hideVisibleFloatingSessions(ctx, tab.ID, nil) | |
| 96 | + | ||
| 152 | 97 | if bw.mainWindow != nil { | |
| 153 | 98 | bw.mainWindow.Show() | |
| 154 | 99 | } | |
| 155 | 100 | a.activateBrowserWindow(bw) | |
| 101 | + | a.syncFloatingFocus() | |
| 156 | 102 | if wsView != nil { | |
| 157 | 103 | wsView.FocusPane(paneID) | |
| 158 | 104 | } | |
| @@ -170,5 +116,5 @@ func (a *App) recordRelayPane(url string) { | |||
| 170 | 116 | if active == nil || active.Pane == nil || domainurl.RelayURLKey(active.Pane.URI) != domainurl.RelayURLKey(url) { | |
| 171 | 117 | return | |
| 172 | 118 | } | |
| 173 | - | a.externalRelayPanes.record(url, active.Pane.ID) | |
| 119 | + | a.externalRelayPanes.Record(url, active.Pane.ID) | |
| 174 | 120 | } | |
| @@ -2,71 +2,12 @@ package ui | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | - | "time" | |||
| 6 | 5 | ||||
| 7 | "github.com/bnema/dumber/internal/domain/entity" | 6 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 8 | domainurl "github.com/bnema/dumber/internal/domain/url" | 7 | domainurl "github.com/bnema/dumber/internal/domain/url" | ||
| 9 | "github.com/bnema/dumber/internal/logging" | 8 | "github.com/bnema/dumber/internal/logging" | ||
| 10 | ) | 9 | ) | ||
| 11 | 10 | ||||
| 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 | 11 | // 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. | 12 | // 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 { | 13 | func (a *App) openRelayURLOnMainThread(ctx context.Context, url string, cfg entity.RuntimeConfigSnapshot) error { | ||
| @@ -83,7 +24,7 @@ func (a *App) openRelayURLOnMainThread(ctx context.Context, url string, cfg enti | |||||
| 83 | // reuseRelayPane navigates and focuses the pane previously opened for url. | 24 | // 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. | 25 | // 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 { | 26 | func (a *App) reuseRelayPane(ctx context.Context, url string) bool { | ||
| 86 | - | paneID, ok := a.externalRelayPanes.lookup(url, func(id entity.PaneID) bool { | 27 | + | paneID, ok := a.externalRelayPanes.Lookup(url, func(id entity.PaneID) bool { |
| 87 | return a.browserWindowForPane(id) != nil | 28 | return a.browserWindowForPane(id) != nil | ||
| 88 | }) | 29 | }) | ||
| 89 | if !ok { | 30 | if !ok { | ||
| @@ -102,7 +43,7 @@ func (a *App) reuseRelayPane(ctx context.Context, url string) bool { | |||||
| 102 | } | 43 | } | ||
| 103 | } | 44 | } | ||
| 104 | if tab == nil || a.navCoord == nil || a.contentCoord == nil { | 45 | if tab == nil || a.navCoord == nil || a.contentCoord == nil { | ||
| 105 | - | a.externalRelayPanes.forget(url) | 46 | + | a.externalRelayPanes.Forget(url) |
| 106 | return false | 47 | return false | ||
| 107 | } | 48 | } | ||
| 108 | 49 | ||||
| @@ -113,7 +54,7 @@ func (a *App) reuseRelayPane(ctx context.Context, url string) bool { | |||||
| 113 | Str("pane_id", string(paneID)). | 54 | Str("pane_id", string(paneID)). | ||
| 114 | Str("url_host", logging.SafeURLHost(url)). | 55 | Str("url_host", logging.SafeURLHost(url)). | ||
| 115 | Msg("ui: external URL relay pane could not be navigated; opening a new pane") | 56 | Msg("ui: external URL relay pane could not be navigated; opening a new pane") | ||
| 116 | - | a.externalRelayPanes.forget(url) | 57 | + | a.externalRelayPanes.Forget(url) |
| 117 | return false | 58 | return false | ||
| 118 | } | 59 | } | ||
| 119 | 60 | ||||
| @@ -149,10 +90,15 @@ func (a *App) focusRelayPane(ctx context.Context, bw *browserWindow, tab *entity | |||||
| 149 | tab.Workspace.ActivePaneID = paneID | 90 | tab.Workspace.ActivePaneID = paneID | ||
| 150 | } | 91 | } | ||
| 151 | 92 | ||||
| 93 | + | // A floating pane over the tab would hide the relay pane and keep the | |||
| 94 | + | // active-pane override pointing at itself. | |||
| 95 | + | a.hideVisibleFloatingSessions(ctx, tab.ID, nil) | |||
| 96 | + | ||||
| 152 | if bw.mainWindow != nil { | 97 | if bw.mainWindow != nil { | ||
| 153 | bw.mainWindow.Show() | 98 | bw.mainWindow.Show() | ||
| 154 | } | 99 | } | ||
| 155 | a.activateBrowserWindow(bw) | 100 | a.activateBrowserWindow(bw) | ||
| 101 | + | a.syncFloatingFocus() | |||
| 156 | if wsView != nil { | 102 | if wsView != nil { | ||
| 157 | wsView.FocusPane(paneID) | 103 | wsView.FocusPane(paneID) | ||
| 158 | } | 104 | } | ||
| @@ -170,5 +116,5 @@ func (a *App) recordRelayPane(url string) { | |||||
| 170 | if active == nil || active.Pane == nil || domainurl.RelayURLKey(active.Pane.URI) != domainurl.RelayURLKey(url) { | 116 | if active == nil || active.Pane == nil || domainurl.RelayURLKey(active.Pane.URI) != domainurl.RelayURLKey(url) { | ||
| 171 | return | 117 | return | ||
| 172 | } | 118 | } | ||
| 173 | - | a.externalRelayPanes.record(url, active.Pane.ID) | 119 | + | a.externalRelayPanes.Record(url, active.Pane.ID) |
| 174 | } | 120 | } | ||
M internal/ui/external_url_relay_reuse_test.go +15 -21
| @@ -58,7 +58,7 @@ func newRelayReuseHarness(t *testing.T) *relayReuseHarness { | |||
| 58 | 58 | contentCoord: contentCoord, | |
| 59 | 59 | navCoord: coordinator.NewNavigationCoordinator(ctx, nil, contentCoord), | |
| 60 | 60 | } | |
| 61 | - | app.externalRelayPanes.now = func() time.Time { return *h.clock } | |
| 61 | + | app.externalRelayPanes.Now = func() time.Time { return *h.clock } | |
| 62 | 62 | app.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{TabsUC: app.tabsUC}) | |
| 63 | 63 | app.tabCoord.SetOnTabCreated(func(_ context.Context, _ coordinator.TabTarget, tab *entity.Tab) { | |
| 64 | 64 | app.workspaceViews[tab.ID] = &component.WorkspaceView{} | |
| @@ -130,26 +130,20 @@ func TestApp_OpenExternalURLCreatesNewPaneWhenRelayPaneWasClosed(t *testing.T) { | |||
| 130 | 130 | ||
| 131 | 131 | assert.Len(t, h.tabs.Tabs, 2) | |
| 132 | 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") | |
| 133 | + | assert.Equal(t, 1, h.app.externalRelayPanes.Len(), "stale entry replaced by the new pane") | |
| 134 | 134 | } | |
| 135 | 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) | |
| 136 | + | func TestApp_OpenExternalURLCreatesNewPaneWhenRelayPaneCannotNavigate(t *testing.T) { | |
| 137 | + | ctx := context.Background() | |
| 138 | + | h := newRelayReuseHarness(t) | |
| 139 | + | ||
| 140 | + | require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL)) | |
| 141 | + | relayPaneID := h.tabs.ActiveTab().Workspace.ActivePane().Pane.ID | |
| 142 | + | // The pane still exists but its WebView is gone, so navigation fails. | |
| 143 | + | h.app.contentCoord.ReleaseWebView(ctx, relayPaneID) | |
| 144 | + | ||
| 145 | + | require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL)) | |
| 146 | + | ||
| 147 | + | assert.Len(t, h.tabs.Tabs, 3) | |
| 148 | + | assert.Equal(t, 1, h.app.externalRelayPanes.Len(), "failed entry replaced by the new pane") | |
| 155 | 149 | } | |
| @@ -58,7 +58,7 @@ func newRelayReuseHarness(t *testing.T) *relayReuseHarness { | |||||
| 58 | contentCoord: contentCoord, | 58 | contentCoord: contentCoord, | ||
| 59 | navCoord: coordinator.NewNavigationCoordinator(ctx, nil, contentCoord), | 59 | navCoord: coordinator.NewNavigationCoordinator(ctx, nil, contentCoord), | ||
| 60 | } | 60 | } | ||
| 61 | - | app.externalRelayPanes.now = func() time.Time { return *h.clock } | 61 | + | app.externalRelayPanes.Now = func() time.Time { return *h.clock } |
| 62 | app.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{TabsUC: app.tabsUC}) | 62 | app.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{TabsUC: app.tabsUC}) | ||
| 63 | app.tabCoord.SetOnTabCreated(func(_ context.Context, _ coordinator.TabTarget, tab *entity.Tab) { | 63 | app.tabCoord.SetOnTabCreated(func(_ context.Context, _ coordinator.TabTarget, tab *entity.Tab) { | ||
| 64 | app.workspaceViews[tab.ID] = &component.WorkspaceView{} | 64 | app.workspaceViews[tab.ID] = &component.WorkspaceView{} | ||
| @@ -130,26 +130,20 @@ func TestApp_OpenExternalURLCreatesNewPaneWhenRelayPaneWasClosed(t *testing.T) { | |||||
| 130 | 130 | ||||
| 131 | assert.Len(t, h.tabs.Tabs, 2) | 131 | assert.Len(t, h.tabs.Tabs, 2) | ||
| 132 | assert.NotEqual(t, relayTab.ID, h.tabs.ActiveTabID) | 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") | 133 | + | assert.Equal(t, 1, h.app.externalRelayPanes.Len(), "stale entry replaced by the new pane") |
| 134 | } | 134 | } | ||
| 135 | 135 | ||||
| 136 | - | func TestRelayPaneRegistry_PrunesExpiredAndMissingPanes(t *testing.T) { | 136 | + | func TestApp_OpenExternalURLCreatesNewPaneWhenRelayPaneCannotNavigate(t *testing.T) { |
| 137 | - | now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) | 137 | + | ctx := context.Background() |
| 138 | - | reg := relayPaneRegistry{now: func() time.Time { return now }} | 138 | + | h := newRelayReuseHarness(t) |
| 139 | - | reg.record("https://a.example/", "pane-a") | 139 | + | |
| 140 | - | reg.record("https://b.example/", "pane-b") | 140 | + | require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL)) |
| 141 | - | 141 | + | relayPaneID := h.tabs.ActiveTab().Workspace.ActivePane().Pane.ID | |
| 142 | - | exists := func(id entity.PaneID) bool { return id != "pane-b" } | 142 | + | // The pane still exists but its WebView is gone, so navigation fails. |
| 143 | - | _, ok := reg.lookup("https://b.example/", exists) | 143 | + | h.app.contentCoord.ReleaseWebView(ctx, relayPaneID) |
| 144 | - | assert.False(t, ok) | 144 | + | |
| 145 | - | assert.NotContains(t, reg.entries, "https://b.example/") | 145 | + | require.NoError(t, h.app.OpenExternalURL(ctx, relayTestURL)) |
| 146 | - | 146 | + | ||
| 147 | - | id, ok := reg.lookup("https://a.example/", exists) | 147 | + | assert.Len(t, h.tabs.Tabs, 3) |
| 148 | - | assert.True(t, ok) | 148 | + | assert.Equal(t, 1, h.app.externalRelayPanes.Len(), "failed entry replaced by the new pane") |
| 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 | } | 149 | } | ||