main 5041353

external_url_relay_reuse_test.go RawHistoryFolder

1package ui
2
3import (
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
20const relayTestURL = "https://claude.com/cai/login/app-google-auth?open_in_browser=1&hop_nonce=XYZ"
21
22type 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.
33func 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
75func 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
97func 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
110func 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
120func 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.Equal(t, 1, h.app.externalRelayPanes.Len(), "stale entry replaced by the new pane")
134}
135
136func 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")
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