fix(panes): preserve active pane during detach
- commit
- 54d60a948b9514932f156a47f0b5821b4e59457f
- author
- brice · · 2026-06-18 10:58 UTC
- parent
- 2970d4a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +213 -54
M internal/application/usecase/extract_pane_to_tab_list_test.go +115 -0
| @@ -130,6 +130,121 @@ func TestExtractPaneToTabList_LastPaneClosesSourceTab(t *testing.T) { | |||
| 130 | 130 | require.Same(t, pane, out.NewTab.Workspace.Root.Pane) | |
| 131 | 131 | } | |
| 132 | 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 | + | ||
| 133 | 248 | func TestExtractPaneToTabList_FromSplitSeversMovedNode(t *testing.T) { | |
| 134 | 249 | uc := NewExtractPaneToTabListUseCase(newTestIDGen()) | |
| 135 | 250 | sourceTabs := entity.NewTabList() | |
| @@ -130,6 +130,121 @@ func TestExtractPaneToTabList_LastPaneClosesSourceTab(t *testing.T) { | |||||
| 130 | require.Same(t, pane, out.NewTab.Workspace.Root.Pane) | 130 | require.Same(t, pane, out.NewTab.Workspace.Root.Pane) | ||
| 131 | } | 131 | } | ||
| 132 | 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 | + | ||||
| 133 | func TestExtractPaneToTabList_FromSplitSeversMovedNode(t *testing.T) { | 248 | func TestExtractPaneToTabList_FromSplitSeversMovedNode(t *testing.T) { | ||
| 134 | uc := NewExtractPaneToTabListUseCase(newTestIDGen()) | 249 | uc := NewExtractPaneToTabListUseCase(newTestIDGen()) | ||
| 135 | sourceTabs := entity.NewTabList() | 250 | sourceTabs := entity.NewTabList() | ||
M internal/application/usecase/move_pane_to_tab.go +66 -54
| @@ -220,14 +220,37 @@ func detachFromStackIfNeeded(ws *entity.Workspace, leaf *entity.PaneNode) (bool, | |||
| 220 | 220 | return false, nil | |
| 221 | 221 | } | |
| 222 | 222 | ||
| 223 | - | stackNode := leaf.Parent | |
| 224 | - | removedIndex := stackChildIndex(stackNode, leaf) | |
| 225 | - | if removedIndex < 0 { | |
| 226 | - | return false, fmt.Errorf("pane not found in stack") | |
| 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") | |
| 227 | 235 | } | |
| 228 | - | activeIndex, activeInStack := stackPaneIndex(stackNode, ws.ActivePaneID) | |
| 229 | - | if !activeInStack { | |
| 230 | - | activeIndex = stackNode.ActiveStackIndex | |
| 236 | + | if leaf == nil || leaf.Pane == nil { | |
| 237 | + | return fmt.Errorf("pane node is required") | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | previousActive := ws.ActivePaneID | |
| 241 | + | removedPaneID := leaf.Pane.ID | |
| 242 | + | removedIndex := -1 | |
| 243 | + | activeIndex := stackNode.ActiveStackIndex | |
| 244 | + | for i, child := range stackNode.Children { | |
| 245 | + | if child == leaf { | |
| 246 | + | removedIndex = i | |
| 247 | + | } | |
| 248 | + | if child != nil && child.Pane != nil && child.Pane.ID == previousActive { | |
| 249 | + | activeIndex = i | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | if removedIndex < 0 { | |
| 253 | + | return fmt.Errorf("pane not found in stack") | |
| 231 | 254 | } | |
| 232 | 255 | ||
| 233 | 256 | stackNode.Children = append(stackNode.Children[:removedIndex], stackNode.Children[removedIndex+1:]...) | |
| @@ -241,68 +264,57 @@ func detachFromStackIfNeeded(ws *entity.Workspace, leaf *entity.PaneNode) (bool, | |||
| 241 | 264 | stackNode.IsStacked = false | |
| 242 | 265 | stackNode.ActiveStackIndex = 0 | |
| 243 | 266 | remaining.Parent = nil | |
| 244 | - | if stackNode.Pane != nil { | |
| 245 | - | 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 | |
| 246 | 274 | } else { | |
| 247 | - | ws.ActivePaneID = "" | |
| 275 | + | activeIndex = removedIndex | |
| 248 | 276 | } | |
| 249 | - | return true, nil | |
| 277 | + | } else if activeIndex > removedIndex { | |
| 278 | + | activeIndex-- | |
| 250 | 279 | } | |
| 251 | - | ||
| 252 | - | stackNode.ActiveStackIndex = nextStackActiveIndex(activeIndex, removedIndex, len(stackNode.Children)) | |
| 253 | - | if activeChild := stackNode.ActivePane(); activeChild != nil && activeChild.Pane != nil { | |
| 254 | - | ws.ActivePaneID = activeChild.Pane.ID | |
| 280 | + | if activeIndex < 0 { | |
| 281 | + | activeIndex = 0 | |
| 255 | 282 | } | |
| 256 | - | return true, nil | |
| 257 | - | } | |
| 258 | - | ||
| 259 | - | func stackChildIndex(stackNode, child *entity.PaneNode) int { | |
| 260 | - | if stackNode == nil { | |
| 261 | - | return -1 | |
| 283 | + | if activeIndex >= len(stackNode.Children) { | |
| 284 | + | activeIndex = len(stackNode.Children) - 1 | |
| 262 | 285 | } | |
| 263 | - | for i, candidate := range stackNode.Children { | |
| 264 | - | if candidate == child { | |
| 265 | - | return i | |
| 266 | - | } | |
| 286 | + | stackNode.ActiveStackIndex = activeIndex | |
| 287 | + | ||
| 288 | + | fallbackID := entity.PaneID("") | |
| 289 | + | if activeChild := stackNode.ActivePane(); activeChild != nil { | |
| 290 | + | fallbackID = paneIDFromNode(activeChild) | |
| 267 | 291 | } | |
| 268 | - | return -1 | |
| 292 | + | setActiveAfterPaneDetach(ws, previousActive, removedPaneID, fallbackID) | |
| 293 | + | return nil | |
| 269 | 294 | } | |
| 270 | 295 | ||
| 271 | - | func stackPaneIndex(stackNode *entity.PaneNode, paneID entity.PaneID) (int, bool) { | |
| 272 | - | if stackNode == nil || paneID == "" { | |
| 273 | - | return -1, false | |
| 296 | + | func setActiveAfterPaneDetach(ws *entity.Workspace, previousActive, removedPaneID, fallbackID entity.PaneID) { | |
| 297 | + | if ws == nil { | |
| 298 | + | return | |
| 274 | 299 | } | |
| 275 | - | for i, child := range stackNode.Children { | |
| 276 | - | if child != nil && child.Pane != nil && child.Pane.ID == paneID { | |
| 277 | - | return i, true | |
| 278 | - | } | |
| 300 | + | if previousActive != "" && previousActive != removedPaneID && ws.FindPane(previousActive) != nil { | |
| 301 | + | ws.ActivePaneID = previousActive | |
| 302 | + | return | |
| 279 | 303 | } | |
| 280 | - | return -1, false | |
| 304 | + | ws.ActivePaneID = fallbackID | |
| 281 | 305 | } | |
| 282 | 306 | ||
| 283 | - | func nextStackActiveIndex(activeIndex, removedIndex, remainingCount int) int { | |
| 284 | - | if remainingCount <= 0 { | |
| 285 | - | return 0 | |
| 286 | - | } | |
| 287 | - | if activeIndex == removedIndex { | |
| 288 | - | if removedIndex >= remainingCount { | |
| 289 | - | return remainingCount - 1 | |
| 290 | - | } | |
| 291 | - | return removedIndex | |
| 292 | - | } | |
| 293 | - | if activeIndex > removedIndex { | |
| 294 | - | activeIndex-- | |
| 295 | - | } | |
| 296 | - | if activeIndex < 0 { | |
| 297 | - | return 0 | |
| 298 | - | } | |
| 299 | - | if activeIndex >= remainingCount { | |
| 300 | - | return remainingCount - 1 | |
| 307 | + | func paneIDFromNode(node *entity.PaneNode) entity.PaneID { | |
| 308 | + | if node == nil || node.Pane == nil { | |
| 309 | + | return "" | |
| 301 | 310 | } | |
| 302 | - | return activeIndex | |
| 311 | + | return node.Pane.ID | |
| 303 | 312 | } | |
| 304 | 313 | ||
| 305 | 314 | func detachLeafFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error { | |
| 315 | + | previousActive := ws.ActivePaneID | |
| 316 | + | removedPaneID := leaf.Pane.ID | |
| 317 | + | ||
| 306 | 318 | parent := leaf.Parent | |
| 307 | 319 | if parent == nil { | |
| 308 | 320 | ws.Root = nil | |
| @@ -321,7 +333,7 @@ func detachLeafFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error | |||
| 321 | 333 | promoteSibling(ws, parent, sibling) | |
| 322 | 334 | leaf.Parent = nil | |
| 323 | 335 | parent.Children = nil | |
| 324 | - | ws.ActivePaneID = findFirstLeafPaneID(sibling) | |
| 336 | + | setActiveAfterPaneDetach(ws, previousActive, removedPaneID, findFirstLeafPaneID(sibling)) | |
| 325 | 337 | return nil | |
| 326 | 338 | } | |
| 327 | 339 | ||
| @@ -220,14 +220,37 @@ 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 | - | removedIndex := stackChildIndex(stackNode, leaf) | 224 | + | return false, err |
| 225 | - | if removedIndex < 0 { | 225 | + | } |
| 226 | - | return false, fmt.Errorf("pane not found in stack") | 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") | |||
| 227 | } | 235 | } | ||
| 228 | - | activeIndex, activeInStack := stackPaneIndex(stackNode, ws.ActivePaneID) | 236 | + | if leaf == nil || leaf.Pane == nil { |
| 229 | - | if !activeInStack { | 237 | + | return fmt.Errorf("pane node is required") |
| 230 | - | activeIndex = stackNode.ActiveStackIndex | 238 | + | } |
| 239 | + | ||||
| 240 | + | previousActive := ws.ActivePaneID | |||
| 241 | + | removedPaneID := leaf.Pane.ID | |||
| 242 | + | removedIndex := -1 | |||
| 243 | + | activeIndex := stackNode.ActiveStackIndex | |||
| 244 | + | for i, child := range stackNode.Children { | |||
| 245 | + | if child == leaf { | |||
| 246 | + | removedIndex = i | |||
| 247 | + | } | |||
| 248 | + | if child != nil && child.Pane != nil && child.Pane.ID == previousActive { | |||
| 249 | + | activeIndex = i | |||
| 250 | + | } | |||
| 251 | + | } | |||
| 252 | + | if removedIndex < 0 { | |||
| 253 | + | return fmt.Errorf("pane not found in stack") | |||
| 231 | } | 254 | } | ||
| 232 | 255 | ||||
| 233 | stackNode.Children = append(stackNode.Children[:removedIndex], stackNode.Children[removedIndex+1:]...) | 256 | stackNode.Children = append(stackNode.Children[:removedIndex], stackNode.Children[removedIndex+1:]...) | ||
| @@ -241,68 +264,57 @@ func detachFromStackIfNeeded(ws *entity.Workspace, leaf *entity.PaneNode) (bool, | |||||
| 241 | stackNode.IsStacked = false | 264 | stackNode.IsStacked = false | ||
| 242 | stackNode.ActiveStackIndex = 0 | 265 | stackNode.ActiveStackIndex = 0 | ||
| 243 | remaining.Parent = nil | 266 | remaining.Parent = nil | ||
| 244 | - | if stackNode.Pane != nil { | 267 | + | setActiveAfterPaneDetach(ws, previousActive, removedPaneID, paneIDFromNode(stackNode)) |
| 245 | - | 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 | |||
| 246 | } else { | 274 | } else { | ||
| 247 | - | ws.ActivePaneID = "" | 275 | + | activeIndex = removedIndex |
| 248 | } | 276 | } | ||
| 249 | - | return true, nil | 277 | + | } else if activeIndex > removedIndex { |
| 278 | + | activeIndex-- | |||
| 250 | } | 279 | } | ||
| 251 | - | 280 | + | if activeIndex < 0 { | |
| 252 | - | stackNode.ActiveStackIndex = nextStackActiveIndex(activeIndex, removedIndex, len(stackNode.Children)) | 281 | + | activeIndex = 0 |
| 253 | - | if activeChild := stackNode.ActivePane(); activeChild != nil && activeChild.Pane != nil { | |||
| 254 | - | ws.ActivePaneID = activeChild.Pane.ID | |||
| 255 | } | 282 | } | ||
| 256 | - | return true, nil | 283 | + | if activeIndex >= len(stackNode.Children) { |
| 257 | - | } | 284 | + | activeIndex = len(stackNode.Children) - 1 |
| 258 | - | ||||
| 259 | - | func stackChildIndex(stackNode, child *entity.PaneNode) int { | |||
| 260 | - | if stackNode == nil { | |||
| 261 | - | return -1 | |||
| 262 | } | 285 | } | ||
| 263 | - | for i, candidate := range stackNode.Children { | 286 | + | stackNode.ActiveStackIndex = activeIndex |
| 264 | - | if candidate == child { | 287 | + | |
| 265 | - | return i | 288 | + | fallbackID := entity.PaneID("") |
| 266 | - | } | 289 | + | if activeChild := stackNode.ActivePane(); activeChild != nil { |
| 290 | + | fallbackID = paneIDFromNode(activeChild) | |||
| 267 | } | 291 | } | ||
| 268 | - | return -1 | 292 | + | setActiveAfterPaneDetach(ws, previousActive, removedPaneID, fallbackID) |
| 293 | + | return nil | |||
| 269 | } | 294 | } | ||
| 270 | 295 | ||||
| 271 | - | func stackPaneIndex(stackNode *entity.PaneNode, paneID entity.PaneID) (int, bool) { | 296 | + | func setActiveAfterPaneDetach(ws *entity.Workspace, previousActive, removedPaneID, fallbackID entity.PaneID) { |
| 272 | - | if stackNode == nil || paneID == "" { | 297 | + | if ws == nil { |
| 273 | - | return -1, false | 298 | + | return |
| 274 | } | 299 | } | ||
| 275 | - | for i, child := range stackNode.Children { | 300 | + | if previousActive != "" && previousActive != removedPaneID && ws.FindPane(previousActive) != nil { |
| 276 | - | if child != nil && child.Pane != nil && child.Pane.ID == paneID { | 301 | + | ws.ActivePaneID = previousActive |
| 277 | - | return i, true | 302 | + | return |
| 278 | - | } | |||
| 279 | } | 303 | } | ||
| 280 | - | return -1, false | 304 | + | ws.ActivePaneID = fallbackID |
| 281 | } | 305 | } | ||
| 282 | 306 | ||||
| 283 | - | func nextStackActiveIndex(activeIndex, removedIndex, remainingCount int) int { | 307 | + | func paneIDFromNode(node *entity.PaneNode) entity.PaneID { |
| 284 | - | if remainingCount <= 0 { | 308 | + | if node == nil || node.Pane == nil { |
| 285 | - | return 0 | 309 | + | return "" |
| 286 | - | } | |||
| 287 | - | if activeIndex == removedIndex { | |||
| 288 | - | if removedIndex >= remainingCount { | |||
| 289 | - | return remainingCount - 1 | |||
| 290 | - | } | |||
| 291 | - | return removedIndex | |||
| 292 | - | } | |||
| 293 | - | if activeIndex > removedIndex { | |||
| 294 | - | activeIndex-- | |||
| 295 | - | } | |||
| 296 | - | if activeIndex < 0 { | |||
| 297 | - | return 0 | |||
| 298 | - | } | |||
| 299 | - | if activeIndex >= remainingCount { | |||
| 300 | - | return remainingCount - 1 | |||
| 301 | } | 310 | } | ||
| 302 | - | return activeIndex | 311 | + | return node.Pane.ID |
| 303 | } | 312 | } | ||
| 304 | 313 | ||||
| 305 | 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 | + | ||||
| 306 | parent := leaf.Parent | 318 | parent := leaf.Parent | ||
| 307 | if parent == nil { | 319 | if parent == nil { | ||
| 308 | ws.Root = nil | 320 | ws.Root = nil | ||
| @@ -321,7 +333,7 @@ func detachLeafFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error | |||||
| 321 | promoteSibling(ws, parent, sibling) | 333 | promoteSibling(ws, parent, sibling) | ||
| 322 | leaf.Parent = nil | 334 | leaf.Parent = nil | ||
| 323 | parent.Children = nil | 335 | parent.Children = nil | ||
| 324 | - | ws.ActivePaneID = findFirstLeafPaneID(sibling) | 336 | + | setActiveAfterPaneDetach(ws, previousActive, removedPaneID, findFirstLeafPaneID(sibling)) |
| 325 | return nil | 337 | return nil | ||
| 326 | } | 338 | } | ||
| 327 | 339 | ||||
M internal/ui/app_eject_pane_to_window_test.go +32 -0
| @@ -154,6 +154,22 @@ func TestApp_EjectActivePaneToWindowHandlesThreePaneSplitTree(t *testing.T) { | |||
| 154 | 154 | if got := sourceTab.Workspace.FindPane("pane-c"); got != nil { | |
| 155 | 155 | t.Fatal("source workspace still contains moved split pane") | |
| 156 | 156 | } | |
| 157 | + | if got := sourceTab.Workspace.ActivePaneID; got != entity.PaneID("pane-b") { | |
| 158 | + | t.Fatalf("source active pane = %q, want pane-b", got) | |
| 159 | + | } | |
| 160 | + | if sourceTab.Workspace.Root == nil || sourceTab.Workspace.Root.ID != "left-split" || !sourceTab.Workspace.Root.IsSplit() { | |
| 161 | + | t.Fatalf("source root = %#v, want promoted left split", sourceTab.Workspace.Root) | |
| 162 | + | } | |
| 163 | + | if got := sourceTab.Workspace.Root.Parent; got != nil { | |
| 164 | + | t.Fatalf("source root parent = %#v, want nil", got) | |
| 165 | + | } | |
| 166 | + | if got := sourceTab.Workspace.Root.Left(); got == nil || got.Pane == nil || got.Pane.ID != entity.PaneID("pane-a") { | |
| 167 | + | t.Fatalf("source left leaf = %#v, want pane-a", got) | |
| 168 | + | } | |
| 169 | + | if got := sourceTab.Workspace.Root.Right(); got == nil || got.Pane == nil || got.Pane.ID != entity.PaneID("pane-b") { | |
| 170 | + | t.Fatalf("source right leaf = %#v, want pane-b", got) | |
| 171 | + | } | |
| 172 | + | assertPaneTreeParents(t, sourceTab.Workspace.Root, nil) | |
| 157 | 173 | targetWindow := app.browserWindows["target-window"] | |
| 158 | 174 | if targetWindow == nil || targetWindow.tabs.Count() != 1 { | |
| 159 | 175 | t.Fatalf("target window/tabs not created correctly: %#v", targetWindow) | |
| @@ -162,6 +178,9 @@ func TestApp_EjectActivePaneToWindowHandlesThreePaneSplitTree(t *testing.T) { | |||
| 162 | 178 | if got := newTab.Workspace.ActivePaneID; got != entity.PaneID("pane-c") { | |
| 163 | 179 | t.Fatalf("target active pane = %q, want pane-c", got) | |
| 164 | 180 | } | |
| 181 | + | if got := newTab.Workspace.Root.Parent; got != nil { | |
| 182 | + | t.Fatalf("target root parent = %#v, want nil", got) | |
| 183 | + | } | |
| 165 | 184 | if got := app.windowForTab[newTab.ID]; got != targetWindow { | |
| 166 | 185 | t.Fatalf("target owner = %p, want target window %p", got, targetWindow) | |
| 167 | 186 | } | |
| @@ -342,6 +361,19 @@ func splitTreeTab(tabID entity.TabID, workspaceID entity.WorkspaceID, paneAID, p | |||
| 342 | 361 | return &entity.Tab{ID: tabID, Workspace: workspace} | |
| 343 | 362 | } | |
| 344 | 363 | ||
| 364 | + | func assertPaneTreeParents(t *testing.T, node, wantParent *entity.PaneNode) { | |
| 365 | + | t.Helper() | |
| 366 | + | if node == nil { | |
| 367 | + | return | |
| 368 | + | } | |
| 369 | + | if node.Parent != wantParent { | |
| 370 | + | t.Fatalf("node %q parent = %#v, want %#v", node.ID, node.Parent, wantParent) | |
| 371 | + | } | |
| 372 | + | for _, child := range node.Children { | |
| 373 | + | assertPaneTreeParents(t, child, node) | |
| 374 | + | } | |
| 375 | + | } | |
| 376 | + | ||
| 345 | 377 | func assertTargetSnapshotContainsPane(t *testing.T, window entity.WindowTabListState, paneID entity.PaneID) { | |
| 346 | 378 | t.Helper() | |
| 347 | 379 | if got := window.WindowID; got != entity.WindowID("target-window") { | |
| @@ -154,6 +154,22 @@ func TestApp_EjectActivePaneToWindowHandlesThreePaneSplitTree(t *testing.T) { | |||||
| 154 | if got := sourceTab.Workspace.FindPane("pane-c"); got != nil { | 154 | if got := sourceTab.Workspace.FindPane("pane-c"); got != nil { | ||
| 155 | t.Fatal("source workspace still contains moved split pane") | 155 | t.Fatal("source workspace still contains moved split pane") | ||
| 156 | } | 156 | } | ||
| 157 | + | if got := sourceTab.Workspace.ActivePaneID; got != entity.PaneID("pane-b") { | |||
| 158 | + | t.Fatalf("source active pane = %q, want pane-b", got) | |||
| 159 | + | } | |||
| 160 | + | if sourceTab.Workspace.Root == nil || sourceTab.Workspace.Root.ID != "left-split" || !sourceTab.Workspace.Root.IsSplit() { | |||
| 161 | + | t.Fatalf("source root = %#v, want promoted left split", sourceTab.Workspace.Root) | |||
| 162 | + | } | |||
| 163 | + | if got := sourceTab.Workspace.Root.Parent; got != nil { | |||
| 164 | + | t.Fatalf("source root parent = %#v, want nil", got) | |||
| 165 | + | } | |||
| 166 | + | if got := sourceTab.Workspace.Root.Left(); got == nil || got.Pane == nil || got.Pane.ID != entity.PaneID("pane-a") { | |||
| 167 | + | t.Fatalf("source left leaf = %#v, want pane-a", got) | |||
| 168 | + | } | |||
| 169 | + | if got := sourceTab.Workspace.Root.Right(); got == nil || got.Pane == nil || got.Pane.ID != entity.PaneID("pane-b") { | |||
| 170 | + | t.Fatalf("source right leaf = %#v, want pane-b", got) | |||
| 171 | + | } | |||
| 172 | + | assertPaneTreeParents(t, sourceTab.Workspace.Root, nil) | |||
| 157 | targetWindow := app.browserWindows["target-window"] | 173 | targetWindow := app.browserWindows["target-window"] | ||
| 158 | if targetWindow == nil || targetWindow.tabs.Count() != 1 { | 174 | if targetWindow == nil || targetWindow.tabs.Count() != 1 { | ||
| 159 | t.Fatalf("target window/tabs not created correctly: %#v", targetWindow) | 175 | t.Fatalf("target window/tabs not created correctly: %#v", targetWindow) | ||
| @@ -162,6 +178,9 @@ func TestApp_EjectActivePaneToWindowHandlesThreePaneSplitTree(t *testing.T) { | |||||
| 162 | if got := newTab.Workspace.ActivePaneID; got != entity.PaneID("pane-c") { | 178 | if got := newTab.Workspace.ActivePaneID; got != entity.PaneID("pane-c") { | ||
| 163 | t.Fatalf("target active pane = %q, want pane-c", got) | 179 | t.Fatalf("target active pane = %q, want pane-c", got) | ||
| 164 | } | 180 | } | ||
| 181 | + | if got := newTab.Workspace.Root.Parent; got != nil { | |||
| 182 | + | t.Fatalf("target root parent = %#v, want nil", got) | |||
| 183 | + | } | |||
| 165 | if got := app.windowForTab[newTab.ID]; got != targetWindow { | 184 | if got := app.windowForTab[newTab.ID]; got != targetWindow { | ||
| 166 | t.Fatalf("target owner = %p, want target window %p", got, targetWindow) | 185 | t.Fatalf("target owner = %p, want target window %p", got, targetWindow) | ||
| 167 | } | 186 | } | ||
| @@ -342,6 +361,19 @@ func splitTreeTab(tabID entity.TabID, workspaceID entity.WorkspaceID, paneAID, p | |||||
| 342 | return &entity.Tab{ID: tabID, Workspace: workspace} | 361 | return &entity.Tab{ID: tabID, Workspace: workspace} | ||
| 343 | } | 362 | } | ||
| 344 | 363 | ||||
| 364 | + | func assertPaneTreeParents(t *testing.T, node, wantParent *entity.PaneNode) { | |||
| 365 | + | t.Helper() | |||
| 366 | + | if node == nil { | |||
| 367 | + | return | |||
| 368 | + | } | |||
| 369 | + | if node.Parent != wantParent { | |||
| 370 | + | t.Fatalf("node %q parent = %#v, want %#v", node.ID, node.Parent, wantParent) | |||
| 371 | + | } | |||
| 372 | + | for _, child := range node.Children { | |||
| 373 | + | assertPaneTreeParents(t, child, node) | |||
| 374 | + | } | |||
| 375 | + | } | |||
| 376 | + | ||||
| 345 | func assertTargetSnapshotContainsPane(t *testing.T, window entity.WindowTabListState, paneID entity.PaneID) { | 377 | func assertTargetSnapshotContainsPane(t *testing.T, window entity.WindowTabListState, paneID entity.PaneID) { | ||
| 346 | t.Helper() | 378 | t.Helper() | ||
| 347 | if got := window.WindowID; got != entity.WindowID("target-window") { | 379 | if got := window.WindowID; got != entity.WindowID("target-window") { | ||