v0.33.1 9577fee

move_pane_to_tab_test.go RawHistoryFolder

1package usecase
2
3import (
4	"fmt"
5	"testing"
6
7	"github.com/bnema/dumber/internal/domain/entity"
8	"github.com/stretchr/testify/require"
9)
10
11func TestMovePaneToTab_MoveToExistingTab(t *testing.T) {
12	id := newTestIDGen()
13	uc := NewMovePaneToTabUseCase(id)
14
15	tabs := entity.NewTabList()
16
17	paneA := entity.NewPane(entity.PaneID("pA"))
18	tabA := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA)
19	tabs.Add(tabA)
20
21	paneB := entity.NewPane(entity.PaneID("pB"))
22	tabB := entity.NewTab(entity.TabID("tB"), entity.WorkspaceID("wB"), paneB)
23	tabs.Add(tabB)
24	// Ensure tabB active pane is paneB.
25	tabB.Workspace.ActivePaneID = paneB.ID
26
27	out, err := uc.Execute(MovePaneToTabInput{
28		TabList:      tabs,
29		SourceTabID:  tabA.ID,
30		SourcePaneID: paneA.ID,
31		TargetTabID:  tabB.ID,
32	})
33	require.NoError(t, err)
34	require.True(t, out.SourceTabClosed)
35	require.False(t, out.NewTabCreated)
36	require.Equal(t, tabB.ID, out.TargetTab.ID)
37	require.NotNil(t, out.MovedPaneNode)
38
39	require.Nil(t, tabs.Find(tabA.ID))
40	// Inserted as split in tabB.
41	require.NotNil(t, tabB.Workspace.Root)
42	require.True(t, tabB.Workspace.Root.IsSplit())
43	require.Equal(t, paneB.ID, tabB.Workspace.Root.Left().Pane.ID)
44	require.Equal(t, paneA.ID, tabB.Workspace.Root.Right().Pane.ID)
45	// Active pane becomes moved pane.
46	require.Equal(t, paneA.ID, tabB.Workspace.ActivePaneID)
47}
48
49func TestMovePaneToTab_MoveCreatesNewTabWhenOnlyOneTab(t *testing.T) {
50	id := newTestIDGen()
51	uc := NewMovePaneToTabUseCase(id)
52
53	tabs := entity.NewTabList()
54	paneA := entity.NewPane(entity.PaneID("pA"))
55	tabA := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA)
56	tabs.Add(tabA)
57
58	out, err := uc.Execute(MovePaneToTabInput{
59		TabList:      tabs,
60		SourceTabID:  tabA.ID,
61		SourcePaneID: paneA.ID,
62		TargetTabID:  "",
63	})
64	require.NoError(t, err)
65	require.True(t, out.NewTabCreated)
66	require.True(t, out.SourceTabClosed)
67	require.Equal(t, 1, tabs.Count())
68	require.Equal(t, paneA.ID, out.TargetTab.Workspace.ActivePaneID)
69	require.NotNil(t, out.TargetTab.Workspace.Root)
70	require.True(t, out.TargetTab.Workspace.Root.IsLeaf())
71	require.Equal(t, paneA.ID, out.TargetTab.Workspace.Root.Pane.ID)
72}
73
74func TestMovePaneToTab_CannotMoveToSameTab(t *testing.T) {
75	uc := NewMovePaneToTabUseCase(newTestIDGen())
76	abs := entity.NewTabList()
77	pane := entity.NewPane(entity.PaneID("pA"))
78	tab := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), pane)
79	abs.Add(tab)
80
81	_, err := uc.Execute(MovePaneToTabInput{
82		TabList:      abs,
83		SourceTabID:  tab.ID,
84		SourcePaneID: pane.ID,
85		TargetTabID:  tab.ID,
86	})
87	require.Error(t, err)
88}
89
90func TestMovePaneToTab_SourcePaneNotFound(t *testing.T) {
91	uc := NewMovePaneToTabUseCase(newTestIDGen())
92	tabs := entity.NewTabList()
93	pane := entity.NewPane(entity.PaneID("pA"))
94	tab := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), pane)
95	tabs.Add(tab)
96
97	_, err := uc.Execute(MovePaneToTabInput{
98		TabList:      tabs,
99		SourceTabID:  tab.ID,
100		SourcePaneID: entity.PaneID("missing"),
101		TargetTabID:  entity.TabID("tB"),
102	})
103	require.Error(t, err)
104}
105
106func TestMovePaneToTab_MoveFromSplitClosesSourceTabIfLastPane(t *testing.T) {
107	id := newTestIDGen()
108	uc := NewMovePaneToTabUseCase(id)
109
110	tabs := entity.NewTabList()
111
112	// Source workspace: split with two panes; move one, leaving one.
113	paneLeft := entity.NewPane(entity.PaneID("pL"))
114	paneRight := entity.NewPane(entity.PaneID("pR"))
115	source := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneLeft)
116	// Build a split root manually.
117	source.Workspace.Root = &entity.PaneNode{
118		ID:         "root",
119		SplitDir:   entity.SplitHorizontal,
120		SplitRatio: 0.5,
121		Children: []*entity.PaneNode{
122			{ID: string(paneLeft.ID), Pane: paneLeft},
123			{ID: string(paneRight.ID), Pane: paneRight},
124		},
125	}
126	source.Workspace.Root.Children[0].Parent = source.Workspace.Root
127	source.Workspace.Root.Children[1].Parent = source.Workspace.Root
128	source.Workspace.ActivePaneID = paneRight.ID
129	tabs.Add(source)
130
131	targetPane := entity.NewPane(entity.PaneID("pT"))
132	target := entity.NewTab(entity.TabID("tB"), entity.WorkspaceID("wB"), targetPane)
133	tabs.Add(target)
134
135	out, err := uc.Execute(MovePaneToTabInput{
136		TabList:      tabs,
137		SourceTabID:  source.ID,
138		SourcePaneID: paneRight.ID,
139		TargetTabID:  target.ID,
140	})
141	require.NoError(t, err)
142	require.False(t, out.SourceTabClosed)
143	// Source should now be single leaf.
144	require.Equal(t, 1, source.Workspace.PaneCount())
145	require.NotNil(t, source.Workspace.Root)
146	require.True(t, source.Workspace.Root.IsLeaf())
147	require.Equal(t, paneLeft.ID, source.Workspace.Root.Pane.ID)
148}
149
150func TestMovePaneToTab_InsertsRightOfActiveEvenWhenActiveInStack(t *testing.T) {
151	id := newTestIDGen()
152	uc := NewMovePaneToTabUseCase(id)
153
154	tabs := entity.NewTabList()
155
156	paneA := entity.NewPane(entity.PaneID("pA"))
157	source := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA)
158	tabs.Add(source)
159
160	// Target workspace: stacked container with two panes; active is second.
161	pane1 := entity.NewPane(entity.PaneID("p1"))
162	pane2 := entity.NewPane(entity.PaneID("p2"))
163	target := entity.NewTab(entity.TabID("tB"), entity.WorkspaceID("wB"), pane1)
164	stack := &entity.PaneNode{
165		ID:               "stack",
166		IsStacked:        true,
167		ActiveStackIndex: 1,
168		Children: []*entity.PaneNode{
169			{ID: string(pane1.ID), Pane: pane1},
170			{ID: string(pane2.ID), Pane: pane2},
171		},
172	}
173	stack.Children[0].Parent = stack
174	stack.Children[1].Parent = stack
175	target.Workspace.Root = stack
176	target.Workspace.ActivePaneID = pane2.ID
177	tabs.Add(target)
178
179	out, err := uc.Execute(MovePaneToTabInput{
180		TabList:      tabs,
181		SourceTabID:  source.ID,
182		SourcePaneID: paneA.ID,
183		TargetTabID:  target.ID,
184	})
185	require.NoError(t, err)
186	require.NotNil(t, out.TargetTab.Workspace.Root)
187	require.True(t, out.TargetTab.Workspace.Root.IsSplit())
188	// Left child should be the stack container.
189	require.True(t, out.TargetTab.Workspace.Root.Left().IsStacked)
190	// Right child is moved pane.
191	require.Equal(t, paneA.ID, out.TargetTab.Workspace.Root.Right().Pane.ID)
192}
193
194// CORRECTNESS-02: Resolve move targets before detaching panes.
195//
196// When a pane move fails after source detach (e.g., nil idGenerator prevents
197// insertion into an existing workspace), the source tab must retain its pane.
198// Previously, detachPaneFromWorkspace was called before resolveTargetTab and
199// insertIntoTargetWorkspace, so a failure in either would leave the pane
200// detached from its original location while returning an error.
201func TestMovePaneToTab_FailedMovePreservesSourcePane(t *testing.T) {
202	// Source tab with a single pane.
203	tabs := entity.NewTabList()
204	paneA := entity.NewPane(entity.PaneID("pA"))
205	tabA := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA)
206	tabs.Add(tabA)
207
208	// Target tab with an existing pane (non-nil Root), so insertion needs idGenerator.
209	paneB := entity.NewPane(entity.PaneID("pB"))
210	tabB := entity.NewTab(entity.TabID("tB"), entity.WorkspaceID("wB"), paneB)
211	tabs.Add(tabB)
212	tabB.Workspace.ActivePaneID = paneB.ID
213
214	// Use nil idGenerator — insertion into existing workspace will fail.
215	uc := NewMovePaneToTabUseCase(nil)
216
217	_, err := uc.Execute(MovePaneToTabInput{
218		TabList:      tabs,
219		SourceTabID:  tabA.ID,
220		SourcePaneID: paneA.ID,
221		TargetTabID:  tabB.ID,
222	})
223	require.Error(t, err)
224	require.Contains(t, err.Error(), "id generator is required")
225
226	// Source must be preserved: tab still exists and pane is still inside it.
227	require.NotNil(t, tabs.Find(tabA.ID), "source tab must not be removed on failed move")
228	require.NotNil(t, tabA.Workspace.FindPane(paneA.ID), "source pane must not be detached on failed move")
229}
230
231// CORRECTNESS-02: Pre-validate that target workspace has a valid active pane
232// before detaching source. When a target workspace has Root != nil but
233// ActivePane() returns nil (e.g., ActivePaneID points to a non-existent pane),
234// insertIntoTargetWorkspace would fail after detach. Pre-validation catches
235// this early.
236func TestMovePaneToTab_FailsOnTargetWithNoActivePane(t *testing.T) {
237	id := newTestIDGen()
238	uc := NewMovePaneToTabUseCase(id)
239
240	tabs := entity.NewTabList()
241
242	// Source tab.
243	paneA := entity.NewPane(entity.PaneID("pA"))
244	tabA := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA)
245	tabs.Add(tabA)
246
247	// Target tab with Root != nil but ActivePaneID pointing to a non-existent pane.
248	paneB := entity.NewPane(entity.PaneID("pB"))
249	tabB := entity.NewTab(entity.TabID("tB"), entity.WorkspaceID("wB"), paneB)
250	tabB.Workspace.ActivePaneID = "nonexistent"
251	tabs.Add(tabB)
252
253	_, err := uc.Execute(MovePaneToTabInput{
254		TabList:      tabs,
255		SourceTabID:  tabA.ID,
256		SourcePaneID: paneA.ID,
257		TargetTabID:  tabB.ID,
258	})
259	require.Error(t, err)
260	require.Contains(t, err.Error(), "no active pane")
261
262	// Source must be preserved.
263	require.NotNil(t, tabs.Find(tabA.ID), "source tab must not be removed on failed move")
264	require.NotNil(t, tabA.Workspace.FindPane(paneA.ID), "source pane must not be detached on failed move")
265}
266
267// CORRECTNESS-02: New tab must not be added to TabList when detach fails.
268// When create-new-tab is requested (empty TargetTabID), resolveTargetTab
269// generates IDs and creates the tab but must NOT add it to TabList until
270// after successful detach from source. This test verifies that a detach
271// failure (e.g., pane's parent is not a valid split) does not leave a
272// stale/duplicate tab in the list.
273func TestMovePaneToTab_NewTabNotAddedOnDetachFailure(t *testing.T) {
274	id := newTestIDGen()
275	uc := NewMovePaneToTabUseCase(id)
276
277	tabs := entity.NewTabList()
278
279	paneA := entity.NewPane(entity.PaneID("pA"))
280	tabA := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA)
281	tabs.Add(tabA)
282
283	// Construct an invalid parent structure so detach fails:
284	// Make paneA's PaneNode a child of a non-split container node.
285	// detachLeafFromWorkspace will reject because parent.IsSplit() is false.
286	invalidParent := &entity.PaneNode{
287		ID:       "invalid-parent",
288		Children: []*entity.PaneNode{tabA.Workspace.Root},
289		// SplitDir is deliberately SplitNone (zero value), so IsSplit() returns false.
290	}
291	tabA.Workspace.Root.Parent = invalidParent
292	tabA.Workspace.Root = invalidParent
293
294	initialCount := tabs.Count()
295
296	_, err := uc.Execute(MovePaneToTabInput{
297		TabList:      tabs,
298		SourceTabID:  tabA.ID,
299		SourcePaneID: paneA.ID,
300		TargetTabID:  "", // creates new tab
301	})
302	require.Error(t, err)
303	require.Contains(t, err.Error(), "pane parent is not a split")
304
305	// No new tab should have been added to the list.
306	require.Equal(t, initialCount, tabs.Count(), "no new tab should be added on failed move")
307	// Source tab and pane should still be in their original positions.
308	require.NotNil(t, tabs.Find(tabA.ID), "source tab must not be removed on failed move")
309	require.NotNil(t, tabA.Workspace.FindPane(paneA.ID), "source pane must not be detached on failed move")
310}
311
312func newTestIDGen() func() string {
313	counter := 0
314	return func() string {
315		counter++
316		return fmt.Sprintf("id%d", counter)
317	}
318}

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