Merge branch 'main' into feat/thresholded-touchpad-navigation
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) { | |||
| 15 | 15 | paneA := entity.NewPane(entity.PaneID("pA")) | |
| 16 | 16 | paneB := entity.NewPane(entity.PaneID("pB")) | |
| 17 | 17 | paneC := entity.NewPane(entity.PaneID("pC")) | |
| 18 | - | sourceTab := newStackedTab("tA", "wA", paneA, paneB, paneC) | |
| 18 | + | sourceTab := newStackedTab(paneA, paneB, paneC) | |
| 19 | 19 | sourceTab.Workspace.ActivePaneID = paneB.ID | |
| 20 | 20 | sourceTabs.Add(sourceTab) | |
| 21 | 21 | ||
| @@ -45,6 +45,39 @@ func TestExtractPaneToTabList_FromThreePaneStack(t *testing.T) { | |||
| 45 | 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 | 81 | func TestExtractPaneToTabList_FromTwoPaneStackDissolvesSource(t *testing.T) { | |
| 49 | 82 | uc := NewExtractPaneToTabListUseCase(newTestIDGen()) | |
| 50 | 83 | sourceTabs := entity.NewTabList() | |
| @@ -52,7 +85,7 @@ func TestExtractPaneToTabList_FromTwoPaneStackDissolvesSource(t *testing.T) { | |||
| 52 | 85 | ||
| 53 | 86 | paneA := entity.NewPane(entity.PaneID("pA")) | |
| 54 | 87 | paneB := entity.NewPane(entity.PaneID("pB")) | |
| 55 | - | sourceTab := newStackedTab("tA", "wA", paneA, paneB) | |
| 88 | + | sourceTab := newStackedTab(paneA, paneB) | |
| 56 | 89 | sourceTab.Workspace.ActivePaneID = paneB.ID | |
| 57 | 90 | sourceTabs.Add(sourceTab) | |
| 58 | 91 | ||
| @@ -97,6 +130,121 @@ func TestExtractPaneToTabList_LastPaneClosesSourceTab(t *testing.T) { | |||
| 97 | 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 | 248 | func TestExtractPaneToTabList_FromSplitSeversMovedNode(t *testing.T) { | |
| 101 | 249 | uc := NewExtractPaneToTabListUseCase(newTestIDGen()) | |
| 102 | 250 | sourceTabs := entity.NewTabList() | |
| @@ -185,7 +333,7 @@ func TestExtractPaneToTabList_ParentPointerTreeIntegrity(t *testing.T) { | |||
| 185 | 333 | paneA := entity.NewPane(entity.PaneID("pA")) | |
| 186 | 334 | paneB := entity.NewPane(entity.PaneID("pB")) | |
| 187 | 335 | paneC := entity.NewPane(entity.PaneID("pC")) | |
| 188 | - | sourceTab := newStackedTab("tA", "wA", paneA, paneB, paneC) | |
| 336 | + | sourceTab := newStackedTab(paneA, paneB, paneC) | |
| 189 | 337 | sourceTabs.Add(sourceTab) | |
| 190 | 338 | ||
| 191 | 339 | out, err := uc.Execute(ExtractPaneToTabListInput{ | |
| @@ -201,8 +349,8 @@ func TestExtractPaneToTabList_ParentPointerTreeIntegrity(t *testing.T) { | |||
| 201 | 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 { | |
| 205 | - | tab := entity.NewTab(tabID, workspaceID, panes[0]) | |
| 352 | + | func newStackedTab(panes ...*entity.Pane) *entity.Tab { | |
| 353 | + | tab := entity.NewTab("tA", "wA", panes[0]) | |
| 206 | 354 | stack := &entity.PaneNode{ID: "stack", IsStacked: true, ActiveStackIndex: 0} | |
| 207 | 355 | for _, pane := range panes { | |
| 208 | 356 | 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.ID | 19 | 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.ID | 89 | 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, | |||
| 220 | 220 | return false, nil | |
| 221 | 221 | } | |
| 222 | 222 | ||
| 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 | + | } | |
| 224 | 239 | ||
| 225 | - | idx := -1 | |
| 240 | + | previousActive := ws.ActivePaneID | |
| 241 | + | removedPaneID := leaf.Pane.ID | |
| 242 | + | removedIndex := -1 | |
| 243 | + | activeIndex := stackNode.ActiveStackIndex | |
| 226 | 244 | for i, child := range stackNode.Children { | |
| 227 | 245 | 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 | |
| 230 | 250 | } | |
| 231 | 251 | } | |
| 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") | |
| 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 | 257 | 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 | 259 | // If only one pane remains in stack, dissolve it into a leaf node. | |
| 246 | 260 | if len(stackNode.Children) == 1 { | |
| @@ -250,22 +264,57 @@ func detachFromStackIfNeeded(ws *entity.Workspace, leaf *entity.PaneNode) (bool, | |||
| 250 | 264 | stackNode.IsStacked = false | |
| 251 | 265 | stackNode.ActiveStackIndex = 0 | |
| 252 | 266 | 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 | |
| 255 | 274 | } else { | |
| 256 | - | ws.ActivePaneID = "" | |
| 275 | + | activeIndex = removedIndex | |
| 257 | 276 | } | |
| 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 | |
| 259 | 285 | } | |
| 286 | + | stackNode.ActiveStackIndex = activeIndex | |
| 260 | 287 | ||
| 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) | |
| 264 | 291 | } | |
| 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 | |
| 266 | 312 | } | |
| 267 | 313 | ||
| 268 | 314 | func detachLeafFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error { | |
| 315 | + | previousActive := ws.ActivePaneID | |
| 316 | + | removedPaneID := leaf.Pane.ID | |
| 317 | + | ||
| 269 | 318 | parent := leaf.Parent | |
| 270 | 319 | if parent == nil { | |
| 271 | 320 | ws.Root = nil | |
| @@ -284,7 +333,7 @@ func detachLeafFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error | |||
| 284 | 333 | promoteSibling(ws, parent, sibling) | |
| 285 | 334 | leaf.Parent = nil | |
| 286 | 335 | parent.Children = nil | |
| 287 | - | ws.ActivePaneID = findFirstLeafPaneID(sibling) | |
| 336 | + | setActiveAfterPaneDetach(ws, previousActive, removedPaneID, findFirstLeafPaneID(sibling)) | |
| 288 | 337 | return nil | |
| 289 | 338 | } | |
| 290 | 339 | ||
| @@ -220,27 +220,41 @@ func detachFromStackIfNeeded(ws *entity.Workspace, leaf *entity.PaneNode) (bool, | |||||
| 220 | return false, nil | 220 | return false, nil | ||
| 221 | } | 221 | } | ||
| 222 | 222 | ||||
| 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 | + | } | |||
| 224 | 239 | ||||
| 225 | - | idx := -1 | 240 | + | 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 = i | 246 | + | removedIndex = i |
| 229 | - | break | 247 | + | } |
| 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 = nil | 257 | 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 = false | 264 | stackNode.IsStacked = false | ||
| 251 | stackNode.ActiveStackIndex = 0 | 265 | stackNode.ActiveStackIndex = 0 | ||
| 252 | remaining.Parent = nil | 266 | remaining.Parent = nil | ||
| 253 | - | if stackNode.Pane != nil { | 267 | + | setActiveAfterPaneDetach(ws, previousActive, removedPaneID, paneIDFromNode(stackNode)) |
| 254 | - | ws.ActivePaneID = stackNode.Pane.ID | 268 | + | 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, 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 | |||
| 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.ID | 290 | + | fallbackID = paneIDFromNode(activeChild) |
| 264 | } | 291 | } | ||
| 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 | |||
| 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.Parent | 318 | parent := leaf.Parent | ||
| 270 | if parent == nil { | 319 | if parent == nil { | ||
| 271 | ws.Root = nil | 320 | 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 = nil | 334 | leaf.Parent = nil | ||
| 286 | parent.Children = nil | 335 | parent.Children = nil | ||
| 287 | - | ws.ActivePaneID = findFirstLeafPaneID(sibling) | 336 | + | setActiveAfterPaneDetach(ws, previousActive, removedPaneID, findFirstLeafPaneID(sibling)) |
| 288 | return nil | 337 | return nil | ||
| 289 | } | 338 | } | ||
| 290 | 339 | ||||
M internal/ui/app_eject_pane_to_window_test.go +119 -0
| @@ -11,7 +11,10 @@ import ( | |||
| 11 | 11 | ||
| 12 | 12 | func TestApp_EjectActivePaneToWindowRegistersTargetOwnership(t *testing.T) { | |
| 13 | 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 | 16 | sourceTab.Workspace.ActivePaneID = "pane-b" | |
| 17 | + | sourceTab.Workspace.Root.ActiveStackIndex = 0 | |
| 15 | 18 | ||
| 16 | 19 | if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil { | |
| 17 | 20 | t.Fatalf("EjectActivePaneToWindow returned error: %v", err) | |
| @@ -40,6 +43,12 @@ func TestApp_EjectActivePaneToWindowRegistersTargetOwnership(t *testing.T) { | |||
| 40 | 43 | if got := sourceTab.Workspace.FindPane("pane-b"); got != nil { | |
| 41 | 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 | 52 | if got := app.lastFocusedWindowID; got != targetWindow.id { | |
| 44 | 53 | t.Fatalf("lastFocusedWindowID = %q, want %q", got, targetWindow.id) | |
| 45 | 54 | } | |
| @@ -122,6 +131,19 @@ func TestApp_EjectActivePaneToWindowSnapshotRetainsSourceWindowWhenTabsRemain(t | |||
| 122 | 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 | 147 | func TestApp_EjectActivePaneToWindowSnapshotReplacesEmptySourceWindow(t *testing.T) { | |
| 126 | 148 | sourceTab := entity.NewTab("source-tab", "source-ws", entity.NewPane("pane-a")) | |
| 127 | 149 | app, _, _ := newEjectTestApp(t, sourceTab) | |
| @@ -278,6 +300,103 @@ func stackedTab(tabID entity.TabID, workspaceID entity.WorkspaceID, paneIDs ...e | |||
| 278 | 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 | 400 | func assertTargetSnapshotContainsPane(t *testing.T, window entity.WindowTabListState, paneID entity.PaneID) { | |
| 282 | 401 | t.Helper() | |
| 283 | 402 | 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") { | ||