main 5041353

tab_test.go RawHistoryFolder

1package coordinator
2
3import (
4	"context"
5	"strconv"
6	"testing"
7
8	"github.com/bnema/dumber/internal/application/usecase"
9	"github.com/bnema/dumber/internal/domain/entity"
10	"github.com/bnema/dumber/internal/ui/window"
11	"github.com/stretchr/testify/assert"
12	"github.com/stretchr/testify/require"
13)
14
15// TestTabCoordinator_CloseTargetEmptyDoesNotQuitGlobally verifies that
16// closing the only tab in one window fires onCurrentWindowEmpty and does NOT
17// call onQuit when another browser window still has tabs.
18func TestTabCoordinator_CloseTargetEmptyDoesNotQuitGlobally(t *testing.T) {
19	ctx := context.Background()
20
21	// Two independent TabLists, each with one tab.
22	firstTabs := entity.NewTabList()
23	firstTabs.Add(entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first"))))
24
25	secondTabs := entity.NewTabList()
26	secondTabs.Add(entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second"))))
27
28	gen := counterIDGen()
29	tabsUC := usecase.NewManageTabsUseCase(gen, nil)
30
31	coord := NewTabCoordinator(ctx, TabCoordinatorConfig{
32		TabsUC: tabsUC,
33	})
34
35	firstMainWindow := &window.MainWindow{}
36	secondMainWindow := &window.MainWindow{}
37	firstTarget := TabTarget{Tabs: firstTabs, MainWindow: firstMainWindow}
38	secondTarget := TabTarget{Tabs: secondTabs, MainWindow: secondMainWindow}
39
40	var (
41		quitCalled        bool
42		windowEmptyCalled bool
43		windowEmptyTarget *window.MainWindow
44	)
45
46	coord.SetOnQuit(func() {
47		quitCalled = true
48	})
49	coord.SetOnCurrentWindowEmpty(func(_ context.Context, target TabTarget) {
50		windowEmptyCalled = true
51		windowEmptyTarget = target.MainWindow
52	})
53
54	// Close the only tab in the first window.
55	err := coord.Close(ctx, firstTarget)
56	require.NoError(t, err, "Close on first target returned error")
57
58	// onQuit must NOT be called (second window still has tabs).
59	assert.False(t, quitCalled, "onQuit was called, but second window still has tabs")
60
61	// onCurrentWindowEmpty must be called for the first window.
62	assert.True(t, windowEmptyCalled, "onCurrentWindowEmpty was not called for the emptied window")
63	assert.Same(t, firstMainWindow, windowEmptyTarget)
64
65	// firstTabs must be empty.
66	assert.Equal(t, 0, firstTabs.Count())
67
68	// secondTabs must be unchanged (still has its tab).
69	assert.Equal(t, 1, secondTabs.Count())
70	assert.Equal(t, entity.TabID("second-tab"), secondTabs.ActiveTabID)
71
72	// Closing the last tab globally (second window's only tab) should also fire window-empty.
73	windowEmptyCalled = false
74	windowEmptyTarget = nil
75	err = coord.Close(ctx, secondTarget)
76	require.NoError(t, err, "Close on second target returned error")
77
78	// onQuit must still NOT be called (App decides when to quit).
79	assert.False(t, quitCalled, "onQuit was called on last window; onQuit is no longer used by Close")
80
81	// onCurrentWindowEmpty must be called for the second window.
82	assert.True(t, windowEmptyCalled, "onCurrentWindowEmpty was not called for the last window")
83	assert.Same(t, secondMainWindow, windowEmptyTarget)
84}
85
86// counterIDGen returns a function that generates sequential IDs.
87func counterIDGen() func() string {
88	var counter int
89	return func() string {
90		counter++
91		return strconv.Itoa(counter)
92	}
93}
94
95func TestTabCoordinator_SetMainWindowNilClearsCurrentTargetWindowPreservingTabs(t *testing.T) {
96	tabs := entity.NewTabList()
97	mainWindow := &window.MainWindow{}
98	coord := NewTabCoordinator(context.Background(), TabCoordinatorConfig{
99		Tabs:       tabs,
100		MainWindow: mainWindow,
101	})
102
103	coord.SetMainWindow(nil)
104
105	got := coord.CurrentTarget()
106	if got.MainWindow != nil {
107		t.Fatalf("current target main window = %p, want nil", got.MainWindow)
108	}
109	if got.Tabs != tabs {
110		t.Fatalf("current target tabs = %p, want %p", got.Tabs, tabs)
111	}
112}
113
114func TestTabCoordinator_SwitchByIndexUsesTargetTabListOnly(t *testing.T) {
115	ctx := context.Background()
116
117	// Create two independent TabLists, each with two tabs.
118	firstTabs := entity.NewTabList()
119	firstTabs.Add(entity.NewTab(entity.TabID("first-tab-1"), entity.WorkspaceID("ws-first-1"), entity.NewPane(entity.PaneID("pane-first-1"))))
120	firstTabs.Add(entity.NewTab(entity.TabID("first-tab-2"), entity.WorkspaceID("ws-first-2"), entity.NewPane(entity.PaneID("pane-first-2"))))
121
122	secondTabs := entity.NewTabList()
123	secondTabs.Add(entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1"))))
124	secondTabs.Add(entity.NewTab(entity.TabID("second-tab-2"), entity.WorkspaceID("ws-second-2"), entity.NewPane(entity.PaneID("pane-second-2"))))
125
126	gen := counterIDGen()
127	tabsUC := usecase.NewManageTabsUseCase(gen, nil)
128
129	// Create TabCoordinator without global tabs (target-driven).
130	coord := NewTabCoordinator(ctx, TabCoordinatorConfig{
131		TabsUC: tabsUC,
132		// No Tabs, no MainWindow — target is provided per-call.
133	})
134
135	firstTarget := TabTarget{Tabs: firstTabs}
136	secondTarget := TabTarget{Tabs: secondTabs}
137
138	// Confirm initial active tab IDs are as expected (first tab in each list).
139	if got := firstTabs.ActiveTabID; got != entity.TabID("first-tab-1") {
140		t.Fatalf("firstTabs.ActiveTabID = %q, want %q", got, "first-tab-1")
141	}
142	if got := secondTabs.ActiveTabID; got != entity.TabID("second-tab-1") {
143		t.Fatalf("secondTabs.ActiveTabID = %q, want %q", got, "second-tab-1")
144	}
145
146	// Switch second window to its second tab (index 1).
147	err := coord.SwitchByIndex(ctx, secondTarget, 1)
148	if err != nil {
149		t.Fatalf("SwitchByIndex on second target failed: %v", err)
150	}
151
152	_ = firstTarget // first target is deliberately not passed to SwitchByIndex below
153
154	// Assert firstTabs.ActiveTabID is unchanged.
155	if got := firstTabs.ActiveTabID; got != entity.TabID("first-tab-1") {
156		t.Fatalf("firstTabs.ActiveTabID = %q, want %q (should be unchanged)", got, "first-tab-1")
157	}
158
159	if got := secondTabs.PreviousActiveTabID; got != entity.TabID("second-tab-1") {
160		t.Fatalf("secondTabs.PreviousActiveTabID = %q, want %q", got, "second-tab-1")
161	}
162
163	// Assert secondTabs.ActiveTabID is now the second window's tab 2.
164	if got := secondTabs.ActiveTabID; got != entity.TabID("second-tab-2") {
165		t.Fatalf("secondTabs.ActiveTabID = %q, want %q", got, "second-tab-2")
166	}
167}
168
169func TestTabCoordinator_CreateAndSwitchCallbacksCarryTarget(t *testing.T) {
170	ctx := context.Background()
171
172	firstTabs := entity.NewTabList()
173	firstTabs.Add(entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first"))))
174	secondTabs := entity.NewTabList()
175	secondTabs.Add(entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1"))))
176	secondTabs.Add(entity.NewTab(entity.TabID("second-tab-2"), entity.WorkspaceID("ws-second-2"), entity.NewPane(entity.PaneID("pane-second-2"))))
177
178	coord := NewTabCoordinator(ctx, TabCoordinatorConfig{TabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil)})
179	coord.SetCurrentTarget(TabTarget{Tabs: firstTabs})
180
181	secondTarget := TabTarget{Tabs: secondTabs}
182	var createdTarget TabTarget
183	var switchedTargets []TabTarget
184	coord.SetOnTabCreated(func(_ context.Context, target TabTarget, tab *entity.Tab) {
185		createdTarget = target
186	})
187	coord.SetOnTabSwitched(func(_ context.Context, target TabTarget, tab *entity.Tab) {
188		switchedTargets = append(switchedTargets, target)
189	})
190
191	if _, err := coord.Create(ctx, secondTarget, "https://example.com"); err != nil {
192		t.Fatalf("Create returned error: %v", err)
193	}
194	if createdTarget.Tabs != secondTabs {
195		t.Fatalf("created callback target tabs = %p, want %p", createdTarget.Tabs, secondTabs)
196	}
197	if len(switchedTargets) != 1 || switchedTargets[0].Tabs != secondTabs {
198		t.Fatalf("create switch callback targets = %#v, want second target", switchedTargets)
199	}
200
201	switchedTargets = nil
202	if err := coord.Switch(ctx, secondTarget, entity.TabID("second-tab-1")); err != nil {
203		t.Fatalf("Switch returned error: %v", err)
204	}
205	if len(switchedTargets) != 1 || switchedTargets[0].Tabs != secondTabs {
206		t.Fatalf("switch callback targets = %#v, want second target", switchedTargets)
207	}
208	if firstTabs.ActiveTabID != entity.TabID("first-tab") {
209		t.Fatalf("first target active tab changed to %q", firstTabs.ActiveTabID)
210	}
211}
212
213// TestTabCoordinator_SwitchByIndexCreatingCreatesMissingTabs verifies that
214// SwitchByIndexCreating creates new tabs when the requested index exceeds
215// the current tab count, and only modifies the specified target.
216func TestTabCoordinator_SwitchByIndexCreatingCreatesMissingTabs(t *testing.T) {
217	ctx := context.Background()
218
219	firstTabs := entity.NewTabList()
220	firstTabs.Add(entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first"))))
221
222	secondTabs := entity.NewTabList()
223	secondTabs.Add(entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1"))))
224
225	coord := NewTabCoordinator(ctx, TabCoordinatorConfig{TabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil)})
226
227	secondTarget := TabTarget{Tabs: secondTabs}
228
229	// Call SwitchByIndexCreating with index 3 (0-based), which is beyond the
230	// current 1 tab. This should create 3 new tabs (indices 1, 2, 3) and
231	// activate index 3.
232	err := coord.SwitchByIndexCreating(ctx, secondTarget, 3, "about:blank")
233	require.NoError(t, err, "SwitchByIndexCreating should not return error")
234
235	// secondTabs should have 4 tabs now (1 existing + 3 created).
236	require.Equal(t, 4, secondTabs.Count(), "second target should have 4 tabs after creating up to index 3")
237	require.Greater(t, len(secondTabs.Tabs), 3, "secondTabs.Tabs must have at least 4 elements")
238	assert.Equal(t, secondTabs.Tabs[3].ID, secondTabs.ActiveTabID, "active tab should be at index 3")
239
240	// Verify created tabs/panes have URI "about:blank".
241	for i := 1; i <= 3; i++ {
242		require.Less(t, i, len(secondTabs.Tabs), "tab index %d out of range", i)
243		require.NotNil(t, secondTabs.Tabs[i].Workspace, "tab %d workspace should not be nil", i)
244		pane := secondTabs.Tabs[i].Workspace.ActivePane()
245		require.NotNil(t, pane, "tab %d ActivePane should not be nil", i)
246		require.NotNil(t, pane.Pane, "tab %d pane.Pane should not be nil", i)
247		assert.Equal(t, "about:blank", pane.Pane.URI, "tab %d pane URI should be about:blank", i)
248	}
249
250	// firstTabs must be completely unchanged.
251	assert.Equal(t, 1, firstTabs.Count(), "first target should still have 1 tab")
252	assert.Equal(t, entity.TabID("first-tab"), firstTabs.ActiveTabID, "first target active tab should be unchanged")
253}
254
255// TestTabCoordinator_SwitchByIndexCreatingMissingURLError verifies that
256// SwitchByIndexCreating returns an error when the initialURL is empty.
257func TestTabCoordinator_SwitchByIndexCreatingMissingURLError(t *testing.T) {
258	ctx := context.Background()
259
260	targetTabs := entity.NewTabList()
261	targetTabs.Add(entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1"))))
262
263	coord := NewTabCoordinator(ctx, TabCoordinatorConfig{TabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil)})
264
265	target := TabTarget{Tabs: targetTabs}
266
267	// Call with empty URL — should return an error.
268	err := coord.SwitchByIndexCreating(ctx, target, 1, "")
269	require.Error(t, err, "SwitchByIndexCreating with empty URL should return error")
270
271	// Tab count should remain 1.
272	assert.Equal(t, 1, targetTabs.Count(), "tab count should remain 1 after error")
273}

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