detached 987a989

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
9090 runtimeConfig *runtimeConfigState
9191
9292 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)
9696 lastFocusedWindowID string
9797 nativePopupWindows map[port.WebViewID]*nativePopupWindow
9898 browserWindowFactory func(context.Context, string) (*browserWindow, error)
@@ -90,9 +90,9 @@ type App struct {
90 runtimeConfig *runtimeConfigState90 runtimeConfig *runtimeConfigState
91 91
92 browserWindows map[string]*browserWindow92 browserWindows map[string]*browserWindow
93- browserWindowOrder []string // registration order of window IDs93+ browserWindowOrder []string // registration order of window IDs
94- instanceWindows map[string]string // logical instance name -> browser window ID94+ 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 string96 lastFocusedWindowID string
97 nativePopupWindows map[port.WebViewID]*nativePopupWindow97 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
22
33 import (
44 "context"
5- "time"
65
76 "github.com/bnema/dumber/internal/domain/entity"
87 domainurl "github.com/bnema/dumber/internal/domain/url"
98 "github.com/bnema/dumber/internal/logging"
109 )
1110
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-
7011 // openRelayURLOnMainThread opens a relayed external URL, reusing the pane from
7112 // a recent identical request when it still exists. Must run on the main thread.
7213 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
8324 // reuseRelayPane navigates and focuses the pane previously opened for url.
8425 // It reports false when there is nothing reusable and a new pane should be created.
8526 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 {
8728 return a.browserWindowForPane(id) != nil
8829 })
8930 if !ok {
@@ -102,7 +43,7 @@ func (a *App) reuseRelayPane(ctx context.Context, url string) bool {
10243 }
10344 }
10445 if tab == nil || a.navCoord == nil || a.contentCoord == nil {
105- a.externalRelayPanes.forget(url)
46+ a.externalRelayPanes.Forget(url)
10647 return false
10748 }
10849
@@ -113,7 +54,7 @@ func (a *App) reuseRelayPane(ctx context.Context, url string) bool {
11354 Str("pane_id", string(paneID)).
11455 Str("url_host", logging.SafeURLHost(url)).
11556 Msg("ui: external URL relay pane could not be navigated; opening a new pane")
116- a.externalRelayPanes.forget(url)
57+ a.externalRelayPanes.Forget(url)
11758 return false
11859 }
11960
@@ -149,10 +90,15 @@ func (a *App) focusRelayPane(ctx context.Context, bw *browserWindow, tab *entity
14990 tab.Workspace.ActivePaneID = paneID
15091 }
15192
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+
15297 if bw.mainWindow != nil {
15398 bw.mainWindow.Show()
15499 }
155100 a.activateBrowserWindow(bw)
101+ a.syncFloatingFocus()
156102 if wsView != nil {
157103 wsView.FocusPane(paneID)
158104 }
@@ -170,5 +116,5 @@ func (a *App) recordRelayPane(url string) {
170116 if active == nil || active.Pane == nil || domainurl.RelayURLKey(active.Pane.URI) != domainurl.RelayURLKey(url) {
171117 return
172118 }
173- a.externalRelayPanes.record(url, active.Pane.ID)
119+ a.externalRelayPanes.Record(url, active.Pane.ID)
174120 }
@@ -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 from11 // 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) != nil28 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 false47 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 false58 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 = paneID90 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 return117 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 {
5858 contentCoord: contentCoord,
5959 navCoord: coordinator.NewNavigationCoordinator(ctx, nil, contentCoord),
6060 }
61- app.externalRelayPanes.now = func() time.Time { return *h.clock }
61+ app.externalRelayPanes.Now = func() time.Time { return *h.clock }
6262 app.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{TabsUC: app.tabsUC})
6363 app.tabCoord.SetOnTabCreated(func(_ context.Context, _ coordinator.TabTarget, tab *entity.Tab) {
6464 app.workspaceViews[tab.ID] = &component.WorkspaceView{}
@@ -130,26 +130,20 @@ func TestApp_OpenExternalURLCreatesNewPaneWhenRelayPaneWasClosed(t *testing.T) {
130130
131131 assert.Len(t, h.tabs.Tabs, 2)
132132 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")
134134 }
135135
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")
155149 }
@@ -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 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close