detached 7065e4e

Merge pull request #310 from bnema/fix/pane-eject-multipane

fix(panes): stabilize pane eject with stacked layouts
commit
7065e4e8a802f5332dfc47b02d5c923ccacbf751
author
bnema · · 2026-06-18 11:18 UTC
committer
GitHub · · 2026-06-18 11:18 UTC
merge of
a450a83 + 84bb9d4 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
3 files · +343 -27
M internal/application/usecase/extract_pane_to_tab_list_test.go +153 -5
@@ -15,7 +15,7 @@ func TestExtractPaneToTabList_FromThreePaneStack(t *testing.T) {
1515 paneA := entity.NewPane(entity.PaneID("pA"))
1616 paneB := entity.NewPane(entity.PaneID("pB"))
1717 paneC := entity.NewPane(entity.PaneID("pC"))
18- sourceTab := newStackedTab("tA", "wA", paneA, paneB, paneC)
18+ sourceTab := newStackedTab(paneA, paneB, paneC)
1919 sourceTab.Workspace.ActivePaneID = paneB.ID
2020 sourceTabs.Add(sourceTab)
2121
@@ -45,6 +45,39 @@ func TestExtractPaneToTabList_FromThreePaneStack(t *testing.T) {
4545 require.Nil(t, out.MovedPaneNode.Parent)
4646 }
4747
48+func TestExtractPaneToTabList_FromThreePaneStackUsesWorkspaceActivePaneWhenStackIndexIsStale(t *testing.T) {
49+ uc := NewExtractPaneToTabListUseCase(newTestIDGen())
50+ sourceTabs := entity.NewTabList()
51+ targetTabs := entity.NewTabList()
52+
53+ paneA := entity.NewPane(entity.PaneID("pA"))
54+ paneB := entity.NewPane(entity.PaneID("pB"))
55+ paneC := entity.NewPane(entity.PaneID("pC"))
56+ sourceTab := newStackedTab(paneA, paneB, paneC)
57+ // ActivePaneID is the workspace source of truth, but stacked UI state can lag.
58+ // Eject should remove pB deterministically and activate the pane that takes its slot.
59+ sourceTab.Workspace.ActivePaneID = paneB.ID
60+ sourceTab.Workspace.Root.ActiveStackIndex = 0
61+ sourceTabs.Add(sourceTab)
62+
63+ out, err := uc.Execute(ExtractPaneToTabListInput{
64+ SourceTabs: sourceTabs,
65+ SourceTabID: sourceTab.ID,
66+ SourcePaneID: paneB.ID,
67+ TargetTabs: targetTabs,
68+ })
69+ require.NoError(t, err)
70+ require.False(t, out.SourceTabClosed)
71+
72+ require.True(t, sourceTab.Workspace.Root.IsStacked)
73+ require.Len(t, sourceTab.Workspace.Root.Children, 2)
74+ require.Equal(t, paneA.ID, sourceTab.Workspace.Root.Children[0].Pane.ID)
75+ require.Equal(t, paneC.ID, sourceTab.Workspace.Root.Children[1].Pane.ID)
76+ require.Equal(t, 1, sourceTab.Workspace.Root.ActiveStackIndex)
77+ require.Equal(t, paneC.ID, sourceTab.Workspace.ActivePaneID)
78+ require.Same(t, paneB, out.NewTab.Workspace.Root.Pane)
79+}
80+
4881 func TestExtractPaneToTabList_FromTwoPaneStackDissolvesSource(t *testing.T) {
4982 uc := NewExtractPaneToTabListUseCase(newTestIDGen())
5083 sourceTabs := entity.NewTabList()
@@ -52,7 +85,7 @@ func TestExtractPaneToTabList_FromTwoPaneStackDissolvesSource(t *testing.T) {
5285
5386 paneA := entity.NewPane(entity.PaneID("pA"))
5487 paneB := entity.NewPane(entity.PaneID("pB"))
55- sourceTab := newStackedTab("tA", "wA", paneA, paneB)
88+ sourceTab := newStackedTab(paneA, paneB)
5689 sourceTab.Workspace.ActivePaneID = paneB.ID
5790 sourceTabs.Add(sourceTab)
5891
@@ -97,6 +130,121 @@ func TestExtractPaneToTabList_LastPaneClosesSourceTab(t *testing.T) {
97130 require.Same(t, pane, out.NewTab.Workspace.Root.Pane)
98131 }
99132
133+func TestExtractPaneToTabList_FromStackPreservesActivePaneOutsideStack(t *testing.T) {
134+ uc := NewExtractPaneToTabListUseCase(newTestIDGen())
135+ sourceTabs := entity.NewTabList()
136+ targetTabs := entity.NewTabList()
137+
138+ paneA := entity.NewPane(entity.PaneID("pA"))
139+ paneB := entity.NewPane(entity.PaneID("pB"))
140+ paneC := entity.NewPane(entity.PaneID("pC"))
141+ paneD := entity.NewPane(entity.PaneID("pD"))
142+ stack := &entity.PaneNode{ID: "stack", IsStacked: true, ActiveStackIndex: 2}
143+ stack.Children = []*entity.PaneNode{
144+ {ID: string(paneA.ID), Pane: paneA, Parent: stack},
145+ {ID: string(paneB.ID), Pane: paneB, Parent: stack},
146+ {ID: string(paneC.ID), Pane: paneC, Parent: stack},
147+ }
148+ root := &entity.PaneNode{ID: "root", SplitDir: entity.SplitHorizontal, SplitRatio: 0.5}
149+ paneDNode := &entity.PaneNode{ID: string(paneD.ID), Pane: paneD, Parent: root}
150+ stack.Parent = root
151+ root.Children = []*entity.PaneNode{stack, paneDNode}
152+ sourceTab := &entity.Tab{ID: "tA", Workspace: &entity.Workspace{ID: "wA", Root: root, ActivePaneID: paneD.ID}}
153+ sourceTabs.Add(sourceTab)
154+
155+ out, err := uc.Execute(ExtractPaneToTabListInput{
156+ SourceTabs: sourceTabs,
157+ SourceTabID: sourceTab.ID,
158+ SourcePaneID: paneB.ID,
159+ TargetTabs: targetTabs,
160+ })
161+ require.NoError(t, err)
162+ require.False(t, out.SourceTabClosed)
163+
164+ require.Equal(t, paneD.ID, sourceTab.Workspace.ActivePaneID)
165+ require.True(t, stack.IsStacked)
166+ require.Len(t, stack.Children, 2)
167+ require.Equal(t, 1, stack.ActiveStackIndex)
168+ require.Same(t, paneB, out.NewTab.Workspace.Root.Pane)
169+ assertParentPointers(t, sourceTab.Workspace.Root, nil)
170+ assertParentPointers(t, out.NewTab.Workspace.Root, nil)
171+}
172+
173+func TestExtractPaneToTabList_FromTwoPaneStackDissolvePreservesActivePaneOutsideStack(t *testing.T) {
174+ uc := NewExtractPaneToTabListUseCase(newTestIDGen())
175+ sourceTabs := entity.NewTabList()
176+ targetTabs := entity.NewTabList()
177+
178+ paneA := entity.NewPane(entity.PaneID("pA"))
179+ paneB := entity.NewPane(entity.PaneID("pB"))
180+ paneC := entity.NewPane(entity.PaneID("pC"))
181+ stack := &entity.PaneNode{ID: "stack", IsStacked: true, ActiveStackIndex: 1}
182+ stack.Children = []*entity.PaneNode{
183+ {ID: string(paneA.ID), Pane: paneA, Parent: stack},
184+ {ID: string(paneB.ID), Pane: paneB, Parent: stack},
185+ }
186+ root := &entity.PaneNode{ID: "root", SplitDir: entity.SplitHorizontal, SplitRatio: 0.5}
187+ paneCNode := &entity.PaneNode{ID: string(paneC.ID), Pane: paneC, Parent: root}
188+ stack.Parent = root
189+ root.Children = []*entity.PaneNode{stack, paneCNode}
190+ sourceTab := &entity.Tab{ID: "tA", Workspace: &entity.Workspace{ID: "wA", Root: root, ActivePaneID: paneC.ID}}
191+ sourceTabs.Add(sourceTab)
192+
193+ out, err := uc.Execute(ExtractPaneToTabListInput{
194+ SourceTabs: sourceTabs,
195+ SourceTabID: sourceTab.ID,
196+ SourcePaneID: paneB.ID,
197+ TargetTabs: targetTabs,
198+ })
199+ require.NoError(t, err)
200+ require.False(t, out.SourceTabClosed)
201+
202+ require.Equal(t, paneC.ID, sourceTab.Workspace.ActivePaneID)
203+ require.True(t, stack.IsLeaf())
204+ require.Same(t, paneA, stack.Pane)
205+ require.Same(t, paneB, out.NewTab.Workspace.Root.Pane)
206+ assertParentPointers(t, sourceTab.Workspace.Root, nil)
207+ assertParentPointers(t, out.NewTab.Workspace.Root, nil)
208+}
209+
210+func TestExtractPaneToTabList_FromNestedSplitPreservesActivePaneOutsidePromotedSibling(t *testing.T) {
211+ uc := NewExtractPaneToTabListUseCase(newTestIDGen())
212+ sourceTabs := entity.NewTabList()
213+ targetTabs := entity.NewTabList()
214+
215+ paneA := entity.NewPane(entity.PaneID("pA"))
216+ paneB := entity.NewPane(entity.PaneID("pB"))
217+ paneC := entity.NewPane(entity.PaneID("pC"))
218+ root := &entity.PaneNode{ID: "root", SplitDir: entity.SplitHorizontal, SplitRatio: 0.5}
219+ leftLeaf := &entity.PaneNode{ID: string(paneA.ID), Pane: paneA, Parent: root}
220+ rightSplit := &entity.PaneNode{ID: "right-split", SplitDir: entity.SplitVertical, SplitRatio: 0.5, Parent: root}
221+ paneBNode := &entity.PaneNode{ID: string(paneB.ID), Pane: paneB, Parent: rightSplit}
222+ paneCNode := &entity.PaneNode{ID: string(paneC.ID), Pane: paneC, Parent: rightSplit}
223+ rightSplit.Children = []*entity.PaneNode{paneBNode, paneCNode}
224+ root.Children = []*entity.PaneNode{leftLeaf, rightSplit}
225+ sourceTab := &entity.Tab{ID: "tA", Workspace: &entity.Workspace{ID: "wA", Root: root, ActivePaneID: paneA.ID}}
226+ sourceTabs.Add(sourceTab)
227+
228+ out, err := uc.Execute(ExtractPaneToTabListInput{
229+ SourceTabs: sourceTabs,
230+ SourceTabID: sourceTab.ID,
231+ SourcePaneID: paneC.ID,
232+ TargetTabs: targetTabs,
233+ })
234+ require.NoError(t, err)
235+ require.False(t, out.SourceTabClosed)
236+
237+ require.Equal(t, paneA.ID, sourceTab.Workspace.ActivePaneID)
238+ require.Same(t, root, sourceTab.Workspace.Root)
239+ require.Same(t, leftLeaf, sourceTab.Workspace.Root.Left())
240+ require.Same(t, paneBNode, sourceTab.Workspace.Root.Right())
241+ require.Nil(t, paneCNode.Parent)
242+ require.Empty(t, rightSplit.Children)
243+ require.Same(t, paneC, out.NewTab.Workspace.Root.Pane)
244+ assertParentPointers(t, sourceTab.Workspace.Root, nil)
245+ assertParentPointers(t, out.NewTab.Workspace.Root, nil)
246+}
247+
100248 func TestExtractPaneToTabList_FromSplitSeversMovedNode(t *testing.T) {
101249 uc := NewExtractPaneToTabListUseCase(newTestIDGen())
102250 sourceTabs := entity.NewTabList()
@@ -185,7 +333,7 @@ func TestExtractPaneToTabList_ParentPointerTreeIntegrity(t *testing.T) {
185333 paneA := entity.NewPane(entity.PaneID("pA"))
186334 paneB := entity.NewPane(entity.PaneID("pB"))
187335 paneC := entity.NewPane(entity.PaneID("pC"))
188- sourceTab := newStackedTab("tA", "wA", paneA, paneB, paneC)
336+ sourceTab := newStackedTab(paneA, paneB, paneC)
189337 sourceTabs.Add(sourceTab)
190338
191339 out, err := uc.Execute(ExtractPaneToTabListInput{
@@ -201,8 +349,8 @@ func TestExtractPaneToTabList_ParentPointerTreeIntegrity(t *testing.T) {
201349 require.Nil(t, out.MovedPaneNode.Parent)
202350 }
203351
204-func newStackedTab(tabID entity.TabID, workspaceID entity.WorkspaceID, panes ...*entity.Pane) *entity.Tab {
205- tab := entity.NewTab(tabID, workspaceID, panes[0])
352+func newStackedTab(panes ...*entity.Pane) *entity.Tab {
353+ tab := entity.NewTab("tA", "wA", panes[0])
206354 stack := &entity.PaneNode{ID: "stack", IsStacked: true, ActiveStackIndex: 0}
207355 for _, pane := range panes {
208356 child := &entity.PaneNode{ID: string(pane.ID), Pane: pane, Parent: stack}
@@ -15,7 +15,7 @@ func TestExtractPaneToTabList_FromThreePaneStack(t *testing.T) {
15 paneA := entity.NewPane(entity.PaneID("pA"))15 paneA := entity.NewPane(entity.PaneID("pA"))
16 paneB := entity.NewPane(entity.PaneID("pB"))16 paneB := entity.NewPane(entity.PaneID("pB"))
17 paneC := entity.NewPane(entity.PaneID("pC"))17 paneC := entity.NewPane(entity.PaneID("pC"))
18- sourceTab := newStackedTab("tA", "wA", paneA, paneB, paneC)18+ sourceTab := newStackedTab(paneA, paneB, paneC)
19 sourceTab.Workspace.ActivePaneID = paneB.ID19 sourceTab.Workspace.ActivePaneID = paneB.ID
20 sourceTabs.Add(sourceTab)20 sourceTabs.Add(sourceTab)
21 21
@@ -45,6 +45,39 @@ func TestExtractPaneToTabList_FromThreePaneStack(t *testing.T) {
45 require.Nil(t, out.MovedPaneNode.Parent)45 require.Nil(t, out.MovedPaneNode.Parent)
46 }46 }
47 47
48+func TestExtractPaneToTabList_FromThreePaneStackUsesWorkspaceActivePaneWhenStackIndexIsStale(t *testing.T) {
49+ uc := NewExtractPaneToTabListUseCase(newTestIDGen())
50+ sourceTabs := entity.NewTabList()
51+ targetTabs := entity.NewTabList()
52+
53+ paneA := entity.NewPane(entity.PaneID("pA"))
54+ paneB := entity.NewPane(entity.PaneID("pB"))
55+ paneC := entity.NewPane(entity.PaneID("pC"))
56+ sourceTab := newStackedTab(paneA, paneB, paneC)
57+ // ActivePaneID is the workspace source of truth, but stacked UI state can lag.
58+ // Eject should remove pB deterministically and activate the pane that takes its slot.
59+ sourceTab.Workspace.ActivePaneID = paneB.ID
60+ sourceTab.Workspace.Root.ActiveStackIndex = 0
61+ sourceTabs.Add(sourceTab)
62+
63+ out, err := uc.Execute(ExtractPaneToTabListInput{
64+ SourceTabs: sourceTabs,
65+ SourceTabID: sourceTab.ID,
66+ SourcePaneID: paneB.ID,
67+ TargetTabs: targetTabs,
68+ })
69+ require.NoError(t, err)
70+ require.False(t, out.SourceTabClosed)
71+
72+ require.True(t, sourceTab.Workspace.Root.IsStacked)
73+ require.Len(t, sourceTab.Workspace.Root.Children, 2)
74+ require.Equal(t, paneA.ID, sourceTab.Workspace.Root.Children[0].Pane.ID)
75+ require.Equal(t, paneC.ID, sourceTab.Workspace.Root.Children[1].Pane.ID)
76+ require.Equal(t, 1, sourceTab.Workspace.Root.ActiveStackIndex)
77+ require.Equal(t, paneC.ID, sourceTab.Workspace.ActivePaneID)
78+ require.Same(t, paneB, out.NewTab.Workspace.Root.Pane)
79+}
80+
48 func TestExtractPaneToTabList_FromTwoPaneStackDissolvesSource(t *testing.T) {81 func TestExtractPaneToTabList_FromTwoPaneStackDissolvesSource(t *testing.T) {
49 uc := NewExtractPaneToTabListUseCase(newTestIDGen())82 uc := NewExtractPaneToTabListUseCase(newTestIDGen())
50 sourceTabs := entity.NewTabList()83 sourceTabs := entity.NewTabList()
@@ -52,7 +85,7 @@ func TestExtractPaneToTabList_FromTwoPaneStackDissolvesSource(t *testing.T) {
52 85
53 paneA := entity.NewPane(entity.PaneID("pA"))86 paneA := entity.NewPane(entity.PaneID("pA"))
54 paneB := entity.NewPane(entity.PaneID("pB"))87 paneB := entity.NewPane(entity.PaneID("pB"))
55- sourceTab := newStackedTab("tA", "wA", paneA, paneB)88+ sourceTab := newStackedTab(paneA, paneB)
56 sourceTab.Workspace.ActivePaneID = paneB.ID89 sourceTab.Workspace.ActivePaneID = paneB.ID
57 sourceTabs.Add(sourceTab)90 sourceTabs.Add(sourceTab)
58 91
@@ -97,6 +130,121 @@ func TestExtractPaneToTabList_LastPaneClosesSourceTab(t *testing.T) {
97 require.Same(t, pane, out.NewTab.Workspace.Root.Pane)130 require.Same(t, pane, out.NewTab.Workspace.Root.Pane)
98 }131 }
99 132
133+func TestExtractPaneToTabList_FromStackPreservesActivePaneOutsideStack(t *testing.T) {
134+ uc := NewExtractPaneToTabListUseCase(newTestIDGen())
135+ sourceTabs := entity.NewTabList()
136+ targetTabs := entity.NewTabList()
137+
138+ paneA := entity.NewPane(entity.PaneID("pA"))
139+ paneB := entity.NewPane(entity.PaneID("pB"))
140+ paneC := entity.NewPane(entity.PaneID("pC"))
141+ paneD := entity.NewPane(entity.PaneID("pD"))
142+ stack := &entity.PaneNode{ID: "stack", IsStacked: true, ActiveStackIndex: 2}
143+ stack.Children = []*entity.PaneNode{
144+ {ID: string(paneA.ID), Pane: paneA, Parent: stack},
145+ {ID: string(paneB.ID), Pane: paneB, Parent: stack},
146+ {ID: string(paneC.ID), Pane: paneC, Parent: stack},
147+ }
148+ root := &entity.PaneNode{ID: "root", SplitDir: entity.SplitHorizontal, SplitRatio: 0.5}
149+ paneDNode := &entity.PaneNode{ID: string(paneD.ID), Pane: paneD, Parent: root}
150+ stack.Parent = root
151+ root.Children = []*entity.PaneNode{stack, paneDNode}
152+ sourceTab := &entity.Tab{ID: "tA", Workspace: &entity.Workspace{ID: "wA", Root: root, ActivePaneID: paneD.ID}}
153+ sourceTabs.Add(sourceTab)
154+
155+ out, err := uc.Execute(ExtractPaneToTabListInput{
156+ SourceTabs: sourceTabs,
157+ SourceTabID: sourceTab.ID,
158+ SourcePaneID: paneB.ID,
159+ TargetTabs: targetTabs,
160+ })
161+ require.NoError(t, err)
162+ require.False(t, out.SourceTabClosed)
163+
164+ require.Equal(t, paneD.ID, sourceTab.Workspace.ActivePaneID)
165+ require.True(t, stack.IsStacked)
166+ require.Len(t, stack.Children, 2)
167+ require.Equal(t, 1, stack.ActiveStackIndex)
168+ require.Same(t, paneB, out.NewTab.Workspace.Root.Pane)
169+ assertParentPointers(t, sourceTab.Workspace.Root, nil)
170+ assertParentPointers(t, out.NewTab.Workspace.Root, nil)
171+}
172+
173+func TestExtractPaneToTabList_FromTwoPaneStackDissolvePreservesActivePaneOutsideStack(t *testing.T) {
174+ uc := NewExtractPaneToTabListUseCase(newTestIDGen())
175+ sourceTabs := entity.NewTabList()
176+ targetTabs := entity.NewTabList()
177+
178+ paneA := entity.NewPane(entity.PaneID("pA"))
179+ paneB := entity.NewPane(entity.PaneID("pB"))
180+ paneC := entity.NewPane(entity.PaneID("pC"))
181+ stack := &entity.PaneNode{ID: "stack", IsStacked: true, ActiveStackIndex: 1}
182+ stack.Children = []*entity.PaneNode{
183+ {ID: string(paneA.ID), Pane: paneA, Parent: stack},
184+ {ID: string(paneB.ID), Pane: paneB, Parent: stack},
185+ }
186+ root := &entity.PaneNode{ID: "root", SplitDir: entity.SplitHorizontal, SplitRatio: 0.5}
187+ paneCNode := &entity.PaneNode{ID: string(paneC.ID), Pane: paneC, Parent: root}
188+ stack.Parent = root
189+ root.Children = []*entity.PaneNode{stack, paneCNode}
190+ sourceTab := &entity.Tab{ID: "tA", Workspace: &entity.Workspace{ID: "wA", Root: root, ActivePaneID: paneC.ID}}
191+ sourceTabs.Add(sourceTab)
192+
193+ out, err := uc.Execute(ExtractPaneToTabListInput{
194+ SourceTabs: sourceTabs,
195+ SourceTabID: sourceTab.ID,
196+ SourcePaneID: paneB.ID,
197+ TargetTabs: targetTabs,
198+ })
199+ require.NoError(t, err)
200+ require.False(t, out.SourceTabClosed)
201+
202+ require.Equal(t, paneC.ID, sourceTab.Workspace.ActivePaneID)
203+ require.True(t, stack.IsLeaf())
204+ require.Same(t, paneA, stack.Pane)
205+ require.Same(t, paneB, out.NewTab.Workspace.Root.Pane)
206+ assertParentPointers(t, sourceTab.Workspace.Root, nil)
207+ assertParentPointers(t, out.NewTab.Workspace.Root, nil)
208+}
209+
210+func TestExtractPaneToTabList_FromNestedSplitPreservesActivePaneOutsidePromotedSibling(t *testing.T) {
211+ uc := NewExtractPaneToTabListUseCase(newTestIDGen())
212+ sourceTabs := entity.NewTabList()
213+ targetTabs := entity.NewTabList()
214+
215+ paneA := entity.NewPane(entity.PaneID("pA"))
216+ paneB := entity.NewPane(entity.PaneID("pB"))
217+ paneC := entity.NewPane(entity.PaneID("pC"))
218+ root := &entity.PaneNode{ID: "root", SplitDir: entity.SplitHorizontal, SplitRatio: 0.5}
219+ leftLeaf := &entity.PaneNode{ID: string(paneA.ID), Pane: paneA, Parent: root}
220+ rightSplit := &entity.PaneNode{ID: "right-split", SplitDir: entity.SplitVertical, SplitRatio: 0.5, Parent: root}
221+ paneBNode := &entity.PaneNode{ID: string(paneB.ID), Pane: paneB, Parent: rightSplit}
222+ paneCNode := &entity.PaneNode{ID: string(paneC.ID), Pane: paneC, Parent: rightSplit}
223+ rightSplit.Children = []*entity.PaneNode{paneBNode, paneCNode}
224+ root.Children = []*entity.PaneNode{leftLeaf, rightSplit}
225+ sourceTab := &entity.Tab{ID: "tA", Workspace: &entity.Workspace{ID: "wA", Root: root, ActivePaneID: paneA.ID}}
226+ sourceTabs.Add(sourceTab)
227+
228+ out, err := uc.Execute(ExtractPaneToTabListInput{
229+ SourceTabs: sourceTabs,
230+ SourceTabID: sourceTab.ID,
231+ SourcePaneID: paneC.ID,
232+ TargetTabs: targetTabs,
233+ })
234+ require.NoError(t, err)
235+ require.False(t, out.SourceTabClosed)
236+
237+ require.Equal(t, paneA.ID, sourceTab.Workspace.ActivePaneID)
238+ require.Same(t, root, sourceTab.Workspace.Root)
239+ require.Same(t, leftLeaf, sourceTab.Workspace.Root.Left())
240+ require.Same(t, paneBNode, sourceTab.Workspace.Root.Right())
241+ require.Nil(t, paneCNode.Parent)
242+ require.Empty(t, rightSplit.Children)
243+ require.Same(t, paneC, out.NewTab.Workspace.Root.Pane)
244+ assertParentPointers(t, sourceTab.Workspace.Root, nil)
245+ assertParentPointers(t, out.NewTab.Workspace.Root, nil)
246+}
247+
100 func TestExtractPaneToTabList_FromSplitSeversMovedNode(t *testing.T) {248 func TestExtractPaneToTabList_FromSplitSeversMovedNode(t *testing.T) {
101 uc := NewExtractPaneToTabListUseCase(newTestIDGen())249 uc := NewExtractPaneToTabListUseCase(newTestIDGen())
102 sourceTabs := entity.NewTabList()250 sourceTabs := entity.NewTabList()
@@ -185,7 +333,7 @@ func TestExtractPaneToTabList_ParentPointerTreeIntegrity(t *testing.T) {
185 paneA := entity.NewPane(entity.PaneID("pA"))333 paneA := entity.NewPane(entity.PaneID("pA"))
186 paneB := entity.NewPane(entity.PaneID("pB"))334 paneB := entity.NewPane(entity.PaneID("pB"))
187 paneC := entity.NewPane(entity.PaneID("pC"))335 paneC := entity.NewPane(entity.PaneID("pC"))
188- sourceTab := newStackedTab("tA", "wA", paneA, paneB, paneC)336+ sourceTab := newStackedTab(paneA, paneB, paneC)
189 sourceTabs.Add(sourceTab)337 sourceTabs.Add(sourceTab)
190 338
191 out, err := uc.Execute(ExtractPaneToTabListInput{339 out, err := uc.Execute(ExtractPaneToTabListInput{
@@ -201,8 +349,8 @@ func TestExtractPaneToTabList_ParentPointerTreeIntegrity(t *testing.T) {
201 require.Nil(t, out.MovedPaneNode.Parent)349 require.Nil(t, out.MovedPaneNode.Parent)
202 }350 }
203 351
204-func newStackedTab(tabID entity.TabID, workspaceID entity.WorkspaceID, panes ...*entity.Pane) *entity.Tab {352+func newStackedTab(panes ...*entity.Pane) *entity.Tab {
205- tab := entity.NewTab(tabID, workspaceID, panes[0])353+ tab := entity.NewTab("tA", "wA", panes[0])
206 stack := &entity.PaneNode{ID: "stack", IsStacked: true, ActiveStackIndex: 0}354 stack := &entity.PaneNode{ID: "stack", IsStacked: true, ActiveStackIndex: 0}
207 for _, pane := range panes {355 for _, pane := range panes {
208 child := &entity.PaneNode{ID: string(pane.ID), Pane: pane, Parent: stack}356 child := &entity.PaneNode{ID: string(pane.ID), Pane: pane, Parent: stack}
M internal/application/usecase/move_pane_to_tab.go +71 -22
@@ -220,27 +220,41 @@ func detachFromStackIfNeeded(ws *entity.Workspace, leaf *entity.PaneNode) (bool,
220220 return false, nil
221221 }
222222
223- stackNode := leaf.Parent
223+ if err := removeLeafFromStackPreservingActive(ws, leaf.Parent, leaf); err != nil {
224+ return false, err
225+ }
226+ return true, nil
227+}
228+
229+func removeLeafFromStackPreservingActive(ws *entity.Workspace, stackNode, leaf *entity.PaneNode) error {
230+ if ws == nil {
231+ return fmt.Errorf("workspace is required")
232+ }
233+ if stackNode == nil || !stackNode.IsStacked {
234+ return fmt.Errorf("node is not a stack")
235+ }
236+ if leaf == nil || leaf.Pane == nil {
237+ return fmt.Errorf("pane node is required")
238+ }
224239
225- idx := -1
240+ previousActive := ws.ActivePaneID
241+ removedPaneID := leaf.Pane.ID
242+ removedIndex := -1
243+ activeIndex := stackNode.ActiveStackIndex
226244 for i, child := range stackNode.Children {
227245 if child == leaf {
228- idx = i
229- break
246+ removedIndex = i
247+ }
248+ if child != nil && child.Pane != nil && child.Pane.ID == previousActive {
249+ activeIndex = i
230250 }
231251 }
232- if idx < 0 {
233- return false, fmt.Errorf("pane not found in stack")
252+ if removedIndex < 0 {
253+ return fmt.Errorf("pane not found in stack")
234254 }
235255
236- stackNode.Children = append(stackNode.Children[:idx], stackNode.Children[idx+1:]...)
256+ stackNode.Children = append(stackNode.Children[:removedIndex], stackNode.Children[removedIndex+1:]...)
237257 leaf.Parent = nil
238- if stackNode.ActiveStackIndex >= len(stackNode.Children) {
239- stackNode.ActiveStackIndex = len(stackNode.Children) - 1
240- }
241- if stackNode.ActiveStackIndex < 0 {
242- stackNode.ActiveStackIndex = 0
243- }
244258
245259 // If only one pane remains in stack, dissolve it into a leaf node.
246260 if len(stackNode.Children) == 1 {
@@ -250,22 +264,57 @@ func detachFromStackIfNeeded(ws *entity.Workspace, leaf *entity.PaneNode) (bool,
250264 stackNode.IsStacked = false
251265 stackNode.ActiveStackIndex = 0
252266 remaining.Parent = nil
253- if stackNode.Pane != nil {
254- ws.ActivePaneID = stackNode.Pane.ID
267+ setActiveAfterPaneDetach(ws, previousActive, removedPaneID, paneIDFromNode(stackNode))
268+ return nil
269+ }
270+
271+ if activeIndex == removedIndex {
272+ if removedIndex >= len(stackNode.Children) {
273+ activeIndex = len(stackNode.Children) - 1
255274 } else {
256- ws.ActivePaneID = ""
275+ activeIndex = removedIndex
257276 }
258- return true, nil
277+ } else if activeIndex > removedIndex {
278+ activeIndex--
279+ }
280+ if activeIndex < 0 {
281+ activeIndex = 0
282+ }
283+ if activeIndex >= len(stackNode.Children) {
284+ activeIndex = len(stackNode.Children) - 1
259285 }
286+ stackNode.ActiveStackIndex = activeIndex
260287
261- // Otherwise, set workspace active pane to the stack's current active.
262- if stackNode.ActivePane() != nil && stackNode.ActivePane().Pane != nil {
263- ws.ActivePaneID = stackNode.ActivePane().Pane.ID
288+ fallbackID := entity.PaneID("")
289+ if activeChild := stackNode.ActivePane(); activeChild != nil {
290+ fallbackID = paneIDFromNode(activeChild)
264291 }
265- return true, nil
292+ setActiveAfterPaneDetach(ws, previousActive, removedPaneID, fallbackID)
293+ return nil
294+}
295+
296+func setActiveAfterPaneDetach(ws *entity.Workspace, previousActive, removedPaneID, fallbackID entity.PaneID) {
297+ if ws == nil {
298+ return
299+ }
300+ if previousActive != "" && previousActive != removedPaneID && ws.FindPane(previousActive) != nil {
301+ ws.ActivePaneID = previousActive
302+ return
303+ }
304+ ws.ActivePaneID = fallbackID
305+}
306+
307+func paneIDFromNode(node *entity.PaneNode) entity.PaneID {
308+ if node == nil || node.Pane == nil {
309+ return ""
310+ }
311+ return node.Pane.ID
266312 }
267313
268314 func detachLeafFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error {
315+ previousActive := ws.ActivePaneID
316+ removedPaneID := leaf.Pane.ID
317+
269318 parent := leaf.Parent
270319 if parent == nil {
271320 ws.Root = nil
@@ -284,7 +333,7 @@ func detachLeafFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error
284333 promoteSibling(ws, parent, sibling)
285334 leaf.Parent = nil
286335 parent.Children = nil
287- ws.ActivePaneID = findFirstLeafPaneID(sibling)
336+ setActiveAfterPaneDetach(ws, previousActive, removedPaneID, findFirstLeafPaneID(sibling))
288337 return nil
289338 }
290339
@@ -220,27 +220,41 @@ func detachFromStackIfNeeded(ws *entity.Workspace, leaf *entity.PaneNode) (bool,
220 return false, nil220 return false, nil
221 }221 }
222 222
223- stackNode := leaf.Parent223+ if err := removeLeafFromStackPreservingActive(ws, leaf.Parent, leaf); err != nil {
224+ return false, err
225+ }
226+ return true, nil
227+}
228+
229+func removeLeafFromStackPreservingActive(ws *entity.Workspace, stackNode, leaf *entity.PaneNode) error {
230+ if ws == nil {
231+ return fmt.Errorf("workspace is required")
232+ }
233+ if stackNode == nil || !stackNode.IsStacked {
234+ return fmt.Errorf("node is not a stack")
235+ }
236+ if leaf == nil || leaf.Pane == nil {
237+ return fmt.Errorf("pane node is required")
238+ }
224 239
225- idx := -1240+ previousActive := ws.ActivePaneID
241+ removedPaneID := leaf.Pane.ID
242+ removedIndex := -1
243+ activeIndex := stackNode.ActiveStackIndex
226 for i, child := range stackNode.Children {244 for i, child := range stackNode.Children {
227 if child == leaf {245 if child == leaf {
228- idx = i246+ removedIndex = i
229- break247+ }
248+ if child != nil && child.Pane != nil && child.Pane.ID == previousActive {
249+ activeIndex = i
230 }250 }
231 }251 }
232- if idx < 0 {252+ if removedIndex < 0 {
233- return false, fmt.Errorf("pane not found in stack")253+ return fmt.Errorf("pane not found in stack")
234 }254 }
235 255
236- stackNode.Children = append(stackNode.Children[:idx], stackNode.Children[idx+1:]...)256+ stackNode.Children = append(stackNode.Children[:removedIndex], stackNode.Children[removedIndex+1:]...)
237 leaf.Parent = nil257 leaf.Parent = nil
238- if stackNode.ActiveStackIndex >= len(stackNode.Children) {
239- stackNode.ActiveStackIndex = len(stackNode.Children) - 1
240- }
241- if stackNode.ActiveStackIndex < 0 {
242- stackNode.ActiveStackIndex = 0
243- }
244 258
245 // If only one pane remains in stack, dissolve it into a leaf node.259 // If only one pane remains in stack, dissolve it into a leaf node.
246 if len(stackNode.Children) == 1 {260 if len(stackNode.Children) == 1 {
@@ -250,22 +264,57 @@ func detachFromStackIfNeeded(ws *entity.Workspace, leaf *entity.PaneNode) (bool,
250 stackNode.IsStacked = false264 stackNode.IsStacked = false
251 stackNode.ActiveStackIndex = 0265 stackNode.ActiveStackIndex = 0
252 remaining.Parent = nil266 remaining.Parent = nil
253- if stackNode.Pane != nil {267+ setActiveAfterPaneDetach(ws, previousActive, removedPaneID, paneIDFromNode(stackNode))
254- ws.ActivePaneID = stackNode.Pane.ID268+ return nil
269+ }
270+
271+ if activeIndex == removedIndex {
272+ if removedIndex >= len(stackNode.Children) {
273+ activeIndex = len(stackNode.Children) - 1
255 } else {274 } else {
256- ws.ActivePaneID = ""275+ activeIndex = removedIndex
257 }276 }
258- return true, nil277+ } else if activeIndex > removedIndex {
278+ activeIndex--
279+ }
280+ if activeIndex < 0 {
281+ activeIndex = 0
282+ }
283+ if activeIndex >= len(stackNode.Children) {
284+ activeIndex = len(stackNode.Children) - 1
259 }285 }
286+ stackNode.ActiveStackIndex = activeIndex
260 287
261- // Otherwise, set workspace active pane to the stack's current active.288+ fallbackID := entity.PaneID("")
262- if stackNode.ActivePane() != nil && stackNode.ActivePane().Pane != nil {289+ if activeChild := stackNode.ActivePane(); activeChild != nil {
263- ws.ActivePaneID = stackNode.ActivePane().Pane.ID290+ fallbackID = paneIDFromNode(activeChild)
264 }291 }
265- return true, nil292+ setActiveAfterPaneDetach(ws, previousActive, removedPaneID, fallbackID)
293+ return nil
294+}
295+
296+func setActiveAfterPaneDetach(ws *entity.Workspace, previousActive, removedPaneID, fallbackID entity.PaneID) {
297+ if ws == nil {
298+ return
299+ }
300+ if previousActive != "" && previousActive != removedPaneID && ws.FindPane(previousActive) != nil {
301+ ws.ActivePaneID = previousActive
302+ return
303+ }
304+ ws.ActivePaneID = fallbackID
305+}
306+
307+func paneIDFromNode(node *entity.PaneNode) entity.PaneID {
308+ if node == nil || node.Pane == nil {
309+ return ""
310+ }
311+ return node.Pane.ID
266 }312 }
267 313
268 func detachLeafFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error {314 func detachLeafFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error {
315+ previousActive := ws.ActivePaneID
316+ removedPaneID := leaf.Pane.ID
317+
269 parent := leaf.Parent318 parent := leaf.Parent
270 if parent == nil {319 if parent == nil {
271 ws.Root = nil320 ws.Root = nil
@@ -284,7 +333,7 @@ func detachLeafFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error
284 promoteSibling(ws, parent, sibling)333 promoteSibling(ws, parent, sibling)
285 leaf.Parent = nil334 leaf.Parent = nil
286 parent.Children = nil335 parent.Children = nil
287- ws.ActivePaneID = findFirstLeafPaneID(sibling)336+ setActiveAfterPaneDetach(ws, previousActive, removedPaneID, findFirstLeafPaneID(sibling))
288 return nil337 return nil
289 }338 }
290 339
M internal/ui/app_eject_pane_to_window_test.go +119 -0
@@ -11,7 +11,10 @@ import (
1111
1212 func TestApp_EjectActivePaneToWindowRegistersTargetOwnership(t *testing.T) {
1313 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.
1416 sourceTab.Workspace.ActivePaneID = "pane-b"
17+ sourceTab.Workspace.Root.ActiveStackIndex = 0
1518
1619 if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {
1720 t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
@@ -40,6 +43,12 @@ func TestApp_EjectActivePaneToWindowRegistersTargetOwnership(t *testing.T) {
4043 if got := sourceTab.Workspace.FindPane("pane-b"); got != nil {
4144 t.Fatal("source workspace still contains moved pane")
4245 }
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+ }
4352 if got := app.lastFocusedWindowID; got != targetWindow.id {
4453 t.Fatalf("lastFocusedWindowID = %q, want %q", got, targetWindow.id)
4554 }
@@ -122,6 +131,19 @@ func TestApp_EjectActivePaneToWindowSnapshotRetainsSourceWindowWhenTabsRemain(t
122131 assertTargetSnapshotContainsPane(t, windows[1], "pane-b")
123132 }
124133
134+func 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+
125147 func TestApp_EjectActivePaneToWindowSnapshotReplacesEmptySourceWindow(t *testing.T) {
126148 sourceTab := entity.NewTab("source-tab", "source-ws", entity.NewPane("pane-a"))
127149 app, _, _ := newEjectTestApp(t, sourceTab)
@@ -278,6 +300,103 @@ func stackedTab(tabID entity.TabID, workspaceID entity.WorkspaceID, paneIDs ...e
278300 return &entity.Tab{ID: tabID, Workspace: workspace}
279301 }
280302
303+func 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+
313+func 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+
330+func 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+
343+func 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+
361+func 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+
368+func 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+
387+func 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+
281400 func assertTargetSnapshotContainsPane(t *testing.T, window entity.WindowTabListState, paneID entity.PaneID) {
282401 t.Helper()
283402 if got := window.WindowID; got != entity.WindowID("target-window") {
@@ -11,7 +11,10 @@ import (
11 11
12 func TestApp_EjectActivePaneToWindowRegistersTargetOwnership(t *testing.T) {12 func TestApp_EjectActivePaneToWindowRegistersTargetOwnership(t *testing.T) {
13 app, sourceWindow, sourceTab := newEjectTestApp(t, stackedTab("source-tab-a", "source-ws-a", "pane-a", "pane-b", "pane-c"))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.
14 sourceTab.Workspace.ActivePaneID = "pane-b"16 sourceTab.Workspace.ActivePaneID = "pane-b"
17+ sourceTab.Workspace.Root.ActiveStackIndex = 0
15 18
16 if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {19 if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {
17 t.Fatalf("EjectActivePaneToWindow returned error: %v", err)20 t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
@@ -40,6 +43,12 @@ func TestApp_EjectActivePaneToWindowRegistersTargetOwnership(t *testing.T) {
40 if got := sourceTab.Workspace.FindPane("pane-b"); got != nil {43 if got := sourceTab.Workspace.FindPane("pane-b"); got != nil {
41 t.Fatal("source workspace still contains moved pane")44 t.Fatal("source workspace still contains moved pane")
42 }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+ }
43 if got := app.lastFocusedWindowID; got != targetWindow.id {52 if got := app.lastFocusedWindowID; got != targetWindow.id {
44 t.Fatalf("lastFocusedWindowID = %q, want %q", got, targetWindow.id)53 t.Fatalf("lastFocusedWindowID = %q, want %q", got, targetWindow.id)
45 }54 }
@@ -122,6 +131,19 @@ func TestApp_EjectActivePaneToWindowSnapshotRetainsSourceWindowWhenTabsRemain(t
122 assertTargetSnapshotContainsPane(t, windows[1], "pane-b")131 assertTargetSnapshotContainsPane(t, windows[1], "pane-b")
123 }132 }
124 133
134+func 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+
125 func TestApp_EjectActivePaneToWindowSnapshotReplacesEmptySourceWindow(t *testing.T) {147 func TestApp_EjectActivePaneToWindowSnapshotReplacesEmptySourceWindow(t *testing.T) {
126 sourceTab := entity.NewTab("source-tab", "source-ws", entity.NewPane("pane-a"))148 sourceTab := entity.NewTab("source-tab", "source-ws", entity.NewPane("pane-a"))
127 app, _, _ := newEjectTestApp(t, sourceTab)149 app, _, _ := newEjectTestApp(t, sourceTab)
@@ -278,6 +300,103 @@ func stackedTab(tabID entity.TabID, workspaceID entity.WorkspaceID, paneIDs ...e
278 return &entity.Tab{ID: tabID, Workspace: workspace}300 return &entity.Tab{ID: tabID, Workspace: workspace}
279 }301 }
280 302
303+func 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+
313+func 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+
330+func 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+
343+func 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+
361+func 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+
368+func 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+
387+func 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+
281 func assertTargetSnapshotContainsPane(t *testing.T, window entity.WindowTabListState, paneID entity.PaneID) {400 func assertTargetSnapshotContainsPane(t *testing.T, window entity.WindowTabListState, paneID entity.PaneID) {
282 t.Helper()401 t.Helper()
283 if got := window.WindowID; got != entity.WindowID("target-window") {402 if got := window.WindowID; got != entity.WindowID("target-window") {

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