main 5041353

app_eject_pane_to_window_test.go RawHistoryFolder

1package ui
2
3import (
4	"context"
5	"testing"
6
7	"github.com/bnema/dumber/internal/application/usecase"
8	"github.com/bnema/dumber/internal/domain/entity"
9	"github.com/bnema/dumber/internal/ui/component"
10)
11
12func TestApp_EjectActivePaneToWindowRegistersTargetOwnership(t *testing.T) {
13	app, sourceWindow, sourceTab := newEjectTestApp(t, stackedTab("source-tab-a", "source-ws-a", "pane-a", "pane-b", "pane-c"))
14	// ActivePaneID is the source of truth. Keep ActiveStackIndex stale to cover
15	// ejection after stack/UI state drift with more than two panes.
16	sourceTab.Workspace.ActivePaneID = "pane-b"
17	sourceTab.Workspace.Root.ActiveStackIndex = 0
18
19	if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {
20		t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
21	}
22
23	if got := len(app.browserWindows); got != 2 {
24		t.Fatalf("browserWindows length = %d, want 2", got)
25	}
26	if app.browserWindows[sourceWindow.id] == nil {
27		t.Fatal("source window should remain registered")
28	}
29	targetWindow := app.browserWindows["target-window"]
30	if targetWindow == nil {
31		t.Fatal("target window was not registered")
32	}
33	if got := targetWindow.tabs.Count(); got != 1 {
34		t.Fatalf("target tab count = %d, want 1", got)
35	}
36	newTab := targetWindow.tabs.Tabs[0]
37	if got := app.windowForTab[newTab.ID]; got != targetWindow {
38		t.Fatalf("windowForTab[%s] = %p, want target %p", newTab.ID, got, targetWindow)
39	}
40	if got := newTab.Workspace.ActivePaneID; got != entity.PaneID("pane-b") {
41		t.Fatalf("target active pane = %q, want pane-b", got)
42	}
43	if got := sourceTab.Workspace.FindPane("pane-b"); got != nil {
44		t.Fatal("source workspace still contains moved pane")
45	}
46	if got := sourceTab.Workspace.ActivePaneID; got != entity.PaneID("pane-c") {
47		t.Fatalf("source active pane = %q, want pane-c", got)
48	}
49	if got := sourceTab.Workspace.Root.ActiveStackIndex; got != 1 {
50		t.Fatalf("source active stack index = %d, want 1", got)
51	}
52	if got := app.lastFocusedWindowID; got != targetWindow.id {
53		t.Fatalf("lastFocusedWindowID = %q, want %q", got, targetWindow.id)
54	}
55}
56
57func TestApp_EjectActivePaneToWindowRetainsSourceWindowOnRemainingActiveTab(t *testing.T) {
58	sourceTab := entity.NewTab("source-tab", "source-ws", entity.NewPane("pane-a"))
59	otherTab := entity.NewTab("other-tab", "other-ws", entity.NewPane("pane-b"))
60	app, sourceWindow, _ := newEjectTestApp(t, sourceTab)
61	sourceWindow.tabs.Add(otherTab)
62	app.setBrowserWindowForTab(otherTab.ID, sourceWindow)
63	sourceWindow.tabs.SetActive(sourceTab.ID)
64
65	if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {
66		t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
67	}
68
69	if app.browserWindows[sourceWindow.id] == nil {
70		t.Fatal("source window should remain registered when another tab remains")
71	}
72	if got := sourceWindow.tabs.ActiveTabID; got != otherTab.ID {
73		t.Fatalf("source active tab = %q, want %q", got, otherTab.ID)
74	}
75	if got := app.windowForTab[sourceTab.ID]; got != nil {
76		t.Fatalf("closed source tab ownership remained: %p", got)
77	}
78	if got := app.windowForTab[otherTab.ID]; got != sourceWindow {
79		t.Fatalf("remaining tab owner = %p, want source window %p", got, sourceWindow)
80	}
81}
82
83func TestApp_EjectActivePaneToWindowRemovesEmptySourceWindow(t *testing.T) {
84	sourceTab := entity.NewTab("source-tab", "source-ws", entity.NewPane("pane-a"))
85	app, sourceWindow, _ := newEjectTestApp(t, sourceTab)
86
87	if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {
88		t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
89	}
90
91	if app.browserWindows[sourceWindow.id] != nil {
92		t.Fatal("empty source window should be removed")
93	}
94	if got := len(app.browserWindows); got != 1 {
95		t.Fatalf("browserWindows length = %d, want replacement target only", got)
96	}
97	if got := app.windowForTab[sourceTab.ID]; got != nil {
98		t.Fatalf("source tab ownership remained after source tab closed: %p", got)
99	}
100	for _, bw := range app.browserWindows {
101		if bw.tabs == nil || bw.tabs.Count() == 0 {
102			t.Fatalf("registered zero-tab window remains: %#v", bw)
103		}
104	}
105}
106
107func TestApp_EjectActivePaneToWindowSnapshotRetainsSourceWindowWhenTabsRemain(t *testing.T) {
108	app, sourceWindow, sourceTab := newEjectTestApp(t, stackedTab("source-tab", "source-ws", "pane-a", "pane-b", "pane-c"))
109	sourceTab.Workspace.ActivePaneID = "pane-b"
110
111	if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {
112		t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
113	}
114
115	windows, activeWindowIndex := app.GetWindowSnapshotState()
116	if got := len(windows); got != 2 {
117		t.Fatalf("snapshot window count = %d, want 2", got)
118	}
119	if got := activeWindowIndex; got != 1 {
120		t.Fatalf("active window index = %d, want target index 1", got)
121	}
122	if got := windows[0].WindowID; got != entity.WindowID(sourceWindow.id) {
123		t.Fatalf("first snapshot window = %q, want source %q", got, sourceWindow.id)
124	}
125	if got := windows[0].Tabs.Count(); got != 1 {
126		t.Fatalf("source snapshot tab count = %d, want 1", got)
127	}
128	if windows[0].Tabs.Find(sourceTab.ID) == nil {
129		t.Fatalf("source snapshot missing retained tab %q", sourceTab.ID)
130	}
131	assertTargetSnapshotContainsPane(t, windows[1], "pane-b")
132}
133
134func TestApp_EjectActivePaneToWindowHandlesThreePaneSplitTree(t *testing.T) {
135	app, sourceWindow, sourceTab := newEjectTestApp(t, splitTreeTab("source-tab", "source-ws", "pane-a", "pane-b", "pane-c"))
136	sourceTab.Workspace.ActivePaneID = "pane-c"
137
138	if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {
139		t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
140	}
141
142	assertEjectWindowCount(t, app, sourceWindow)
143	assertEjectedThreePaneSplitSource(t, sourceTab)
144	assertEjectedThreePaneSplitTarget(t, app, "pane-c")
145}
146
147func TestApp_EjectActivePaneToWindowSnapshotReplacesEmptySourceWindow(t *testing.T) {
148	sourceTab := entity.NewTab("source-tab", "source-ws", entity.NewPane("pane-a"))
149	app, _, _ := newEjectTestApp(t, sourceTab)
150
151	if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {
152		t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
153	}
154
155	windows, activeWindowIndex := app.GetWindowSnapshotState()
156	if got := len(windows); got != 1 {
157		t.Fatalf("snapshot window count = %d, want replacement target only", got)
158	}
159	if got := activeWindowIndex; got != 0 {
160		t.Fatalf("active window index = %d, want target index 0", got)
161	}
162	assertTargetSnapshotContainsPane(t, windows[0], "pane-a")
163}
164
165func TestApp_EjectActivePaneToWindowTargetUICreationFailureRestoresState(t *testing.T) {
166	app, sourceWindow, sourceTab := newEjectTestApp(t, stackedTab("source-tab", "source-ws", "pane-a", "pane-b"))
167	app.workspaceViewCreateOverride = nil
168	sourceTab.Workspace.ActivePaneID = "pane-b"
169
170	err := app.EjectActivePaneToWindow(context.Background(), "")
171	if err == nil {
172		t.Fatal("EjectActivePaneToWindow returned nil error, want target UI creation error")
173	}
174
175	if got := len(app.browserWindows); got != 1 {
176		t.Fatalf("browserWindows length = %d, want restored source only", got)
177	}
178	if app.browserWindows[sourceWindow.id] != sourceWindow {
179		t.Fatal("source window should remain registered after rollback")
180	}
181	if app.browserWindows["target-window"] != nil {
182		t.Fatal("target window should be removed after rollback")
183	}
184	if got := sourceWindow.tabs.Count(); got != 1 {
185		t.Fatalf("source tab count = %d, want restored source tab", got)
186	}
187	restoredTab := sourceWindow.tabs.Find(sourceTab.ID)
188	if restoredTab == nil {
189		t.Fatal("source tab missing after rollback")
190	}
191	if restoredTab.Workspace.FindPane("pane-a") == nil || restoredTab.Workspace.FindPane("pane-b") == nil {
192		t.Fatal("source workspace panes were not restored after rollback")
193	}
194	if got := app.windowForTab[sourceTab.ID]; got != sourceWindow {
195		t.Fatalf("source tab owner = %p, want source window %p", got, sourceWindow)
196	}
197	if got := app.windowForTabCount(); got != 1 {
198		t.Fatalf("windowForTab length = %d, want restored source only", got)
199	}
200}
201
202func TestApp_EjectActivePaneToWindowUsecaseFailureRollsBackTargetWindow(t *testing.T) {
203	app, _, sourceTab := newEjectTestApp(t, stackedTab("source-tab", "source-ws", "pane-a", "pane-b"))
204	sourceTab.Workspace.ActivePaneID = "missing-pane"
205
206	if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {
207		t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
208	}
209
210	if got := len(app.browserWindows); got != 1 {
211		t.Fatalf("browserWindows length = %d, want unchanged source only", got)
212	}
213	if app.browserWindows["target-window"] != nil {
214		t.Fatal("target window should not be registered after failure/no-op")
215	}
216	if got := app.windowForTabCount(); got != 1 {
217		t.Fatalf("windowForTab length = %d, want unchanged source only", got)
218	}
219}
220
221func TestApp_EjectActivePaneToWindowNoopsWhenRequestedPaneIsNotActiveWorkspacePane(t *testing.T) {
222	app, _, sourceTab := newEjectTestApp(t, stackedTab("source-tab", "source-ws", "pane-a", "pane-b"))
223	sourceTab.Workspace.ActivePaneID = "pane-a"
224
225	if err := app.EjectActivePaneToWindow(context.Background(), "floating-pane:source-tab:default"); err != nil {
226		t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
227	}
228
229	if got := len(app.browserWindows); got != 1 {
230		t.Fatalf("browserWindows length = %d, want unchanged source only", got)
231	}
232	if sourceTab.Workspace.FindPane("pane-a") == nil || sourceTab.Workspace.FindPane("pane-b") == nil {
233		t.Fatal("source workspace changed after mismatched requested pane no-op")
234	}
235}
236
237func TestApp_EjectActivePaneToWindowNoopsForInternalSystemPane(t *testing.T) {
238	pane := entity.NewPane("pane-a")
239	pane.URI = "dumb://history"
240	app, _, sourceTab := newEjectTestApp(t, entity.NewTab("source-tab", "source-ws", pane))
241
242	if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {
243		t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
244	}
245
246	if got := len(app.browserWindows); got != 1 {
247		t.Fatalf("browserWindows length = %d, want unchanged source only", got)
248	}
249	if sourceTab.Workspace.FindPane("pane-a") == nil {
250		t.Fatal("source system pane was moved")
251	}
252}
253
254func newEjectTestApp(t *testing.T, sourceTab *entity.Tab) (*App, *browserWindow, *entity.Tab) {
255	t.Helper()
256	sourceTabs := entity.NewTabList()
257	sourceTabs.Add(sourceTab)
258	sourceWindow := &browserWindow{id: "source-window", tabs: sourceTabs}
259	ids := []string{"new-tab", "new-workspace"}
260	idIndex := 0
261	app := &App{
262		tabs:                entity.NewTabList(),
263		browserWindows:      map[string]*browserWindow{sourceWindow.id: sourceWindow},
264		browserWindowOrder:  []string{sourceWindow.id},
265		lastFocusedWindowID: sourceWindow.id,
266		windowForTab:        map[entity.TabID]*browserWindow{sourceTab.ID: sourceWindow},
267		workspaceViews:      map[entity.TabID]*component.WorkspaceView{},
268		extractPaneToTabListUC: usecase.NewExtractPaneToTabListUseCase(func() string {
269			if idIndex >= len(ids) {
270				return "extra-id"
271			}
272			id := ids[idIndex]
273			idIndex++
274			return id
275		}),
276		browserWindowFactory: func(context.Context, string) (*browserWindow, error) {
277			return &browserWindow{id: "target-window", tabs: entity.NewTabList()}, nil
278		},
279	}
280	app.workspaceViewCreateOverride = func(_ context.Context, tab *entity.Tab) bool {
281		if tab == nil {
282			return false
283		}
284		app.workspaceViews[tab.ID] = &component.WorkspaceView{}
285		return true
286	}
287	app.tabs.Add(sourceTab)
288	return app, sourceWindow, sourceTab
289}
290
291func stackedTab(tabID entity.TabID, workspaceID entity.WorkspaceID, paneIDs ...entity.PaneID) *entity.Tab {
292	children := make([]*entity.PaneNode, 0, len(paneIDs))
293	stack := &entity.PaneNode{ID: "stack", IsStacked: true}
294	for _, paneID := range paneIDs {
295		child := &entity.PaneNode{ID: string(paneID) + "-node", Pane: entity.NewPane(paneID), Parent: stack}
296		children = append(children, child)
297	}
298	stack.Children = children
299	workspace := &entity.Workspace{ID: workspaceID, Root: stack, ActivePaneID: paneIDs[0]}
300	return &entity.Tab{ID: tabID, Workspace: workspace}
301}
302
303func assertEjectWindowCount(t *testing.T, app *App, sourceWindow *browserWindow) {
304	t.Helper()
305	if got := len(app.browserWindows); got != 2 {
306		t.Fatalf("browserWindows length = %d, want source and target", got)
307	}
308	if app.browserWindows[sourceWindow.id] == nil {
309		t.Fatal("source window should remain registered")
310	}
311}
312
313func assertEjectedThreePaneSplitSource(t *testing.T, sourceTab *entity.Tab) {
314	t.Helper()
315	if got := sourceTab.Workspace.PaneCount(); got != 2 {
316		t.Fatalf("source pane count = %d, want 2", got)
317	}
318	if sourceTab.Workspace.FindPane("pane-a") == nil || sourceTab.Workspace.FindPane("pane-b") == nil {
319		t.Fatal("source workspace missing remaining split panes")
320	}
321	if got := sourceTab.Workspace.FindPane("pane-c"); got != nil {
322		t.Fatal("source workspace still contains moved split pane")
323	}
324	if got := sourceTab.Workspace.ActivePaneID; got != entity.PaneID("pane-b") {
325		t.Fatalf("source active pane = %q, want pane-b", got)
326	}
327	assertPromotedLeftSplitRoot(t, sourceTab.Workspace.Root)
328}
329
330func assertPromotedLeftSplitRoot(t *testing.T, root *entity.PaneNode) {
331	t.Helper()
332	if root == nil || root.ID != "left-split" || !root.IsSplit() {
333		t.Fatalf("source root = %#v, want promoted left split", root)
334	}
335	if got := root.Parent; got != nil {
336		t.Fatalf("source root parent = %#v, want nil", got)
337	}
338	assertLeafPaneID(t, root.Left(), "pane-a")
339	assertLeafPaneID(t, root.Right(), "pane-b")
340	assertPaneTreeParents(t, root, nil)
341}
342
343func assertEjectedThreePaneSplitTarget(t *testing.T, app *App, paneID entity.PaneID) {
344	t.Helper()
345	targetWindow := app.browserWindows["target-window"]
346	if targetWindow == nil || targetWindow.tabs.Count() != 1 {
347		t.Fatalf("target window/tabs not created correctly: %#v", targetWindow)
348	}
349	newTab := targetWindow.tabs.Tabs[0]
350	if got := newTab.Workspace.ActivePaneID; got != paneID {
351		t.Fatalf("target active pane = %q, want %q", got, paneID)
352	}
353	if got := newTab.Workspace.Root.Parent; got != nil {
354		t.Fatalf("target root parent = %#v, want nil", got)
355	}
356	if got := app.windowForTab[newTab.ID]; got != targetWindow {
357		t.Fatalf("target owner = %p, want target window %p", got, targetWindow)
358	}
359}
360
361func assertLeafPaneID(t *testing.T, node *entity.PaneNode, paneID entity.PaneID) {
362	t.Helper()
363	if node == nil || node.Pane == nil || node.Pane.ID != paneID {
364		t.Fatalf("leaf = %#v, want %s", node, paneID)
365	}
366}
367
368func splitTreeTab(tabID entity.TabID, workspaceID entity.WorkspaceID, paneAID, paneBID, paneCID entity.PaneID) *entity.Tab {
369	paneA := entity.NewPane(paneAID)
370	paneB := entity.NewPane(paneBID)
371	paneC := entity.NewPane(paneCID)
372
373	leftSplit := &entity.PaneNode{ID: "left-split", SplitDir: entity.SplitVertical, SplitRatio: 0.5}
374	leafA := &entity.PaneNode{ID: string(paneAID) + "-node", Pane: paneA, Parent: leftSplit}
375	leafB := &entity.PaneNode{ID: string(paneBID) + "-node", Pane: paneB, Parent: leftSplit}
376	leftSplit.Children = []*entity.PaneNode{leafA, leafB}
377
378	root := &entity.PaneNode{ID: "root-split", SplitDir: entity.SplitHorizontal, SplitRatio: 0.5}
379	leafC := &entity.PaneNode{ID: string(paneCID) + "-node", Pane: paneC, Parent: root}
380	leftSplit.Parent = root
381	root.Children = []*entity.PaneNode{leftSplit, leafC}
382
383	workspace := &entity.Workspace{ID: workspaceID, Root: root, ActivePaneID: paneAID}
384	return &entity.Tab{ID: tabID, Workspace: workspace}
385}
386
387func assertPaneTreeParents(t *testing.T, node, wantParent *entity.PaneNode) {
388	t.Helper()
389	if node == nil {
390		return
391	}
392	if node.Parent != wantParent {
393		t.Fatalf("node %q parent = %#v, want %#v", node.ID, node.Parent, wantParent)
394	}
395	for _, child := range node.Children {
396		assertPaneTreeParents(t, child, node)
397	}
398}
399
400func assertTargetSnapshotContainsPane(t *testing.T, window entity.WindowTabListState, paneID entity.PaneID) {
401	t.Helper()
402	if got := window.WindowID; got != entity.WindowID("target-window") {
403		t.Fatalf("target snapshot window ID = %q, want target-window", got)
404	}
405	if got := window.Tabs.Count(); got != 1 {
406		t.Fatalf("target snapshot tab count = %d, want 1", got)
407	}
408	newTab := window.Tabs.Tabs[0]
409	if newTab.Workspace == nil || newTab.Workspace.FindPane(paneID) == nil {
410		t.Fatalf("target snapshot missing moved pane %q", paneID)
411	}
412}
413
414func (a *App) windowForTabCount() int { return len(a.windowForTab) }

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