fix(panes): stabilize stacked pane eject
- commit
- 2970d4a901dc0824062d1bbb3a5b83a275d80f2c
- author
- brice · · 2026-06-18 10:46 UTC
- parent
- a450a83
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +153 -19
M internal/application/usecase/extract_pane_to_tab_list_test.go +33 -0
| @@ -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("tA", "wA", 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() | |
| @@ -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("tA", "wA", 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() | ||
M internal/application/usecase/move_pane_to_tab.go +56 -19
| @@ -221,26 +221,17 @@ func detachFromStackIfNeeded(ws *entity.Workspace, leaf *entity.PaneNode) (bool, | |||
| 221 | 221 | } | |
| 222 | 222 | ||
| 223 | 223 | stackNode := leaf.Parent | |
| 224 | - | ||
| 225 | - | idx := -1 | |
| 226 | - | for i, child := range stackNode.Children { | |
| 227 | - | if child == leaf { | |
| 228 | - | idx = i | |
| 229 | - | break | |
| 230 | - | } | |
| 231 | - | } | |
| 232 | - | if idx < 0 { | |
| 224 | + | removedIndex := stackChildIndex(stackNode, leaf) | |
| 225 | + | if removedIndex < 0 { | |
| 233 | 226 | return false, fmt.Errorf("pane not found in stack") | |
| 234 | 227 | } | |
| 228 | + | activeIndex, activeInStack := stackPaneIndex(stackNode, ws.ActivePaneID) | |
| 229 | + | if !activeInStack { | |
| 230 | + | activeIndex = stackNode.ActiveStackIndex | |
| 231 | + | } | |
| 235 | 232 | ||
| 236 | - | stackNode.Children = append(stackNode.Children[:idx], stackNode.Children[idx+1:]...) | |
| 233 | + | stackNode.Children = append(stackNode.Children[:removedIndex], stackNode.Children[removedIndex+1:]...) | |
| 237 | 234 | 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 | 235 | ||
| 245 | 236 | // If only one pane remains in stack, dissolve it into a leaf node. | |
| 246 | 237 | if len(stackNode.Children) == 1 { | |
| @@ -258,13 +249,59 @@ func detachFromStackIfNeeded(ws *entity.Workspace, leaf *entity.PaneNode) (bool, | |||
| 258 | 249 | return true, nil | |
| 259 | 250 | } | |
| 260 | 251 | ||
| 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 | |
| 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 | |
| 264 | 255 | } | |
| 265 | 256 | return true, nil | |
| 266 | 257 | } | |
| 267 | 258 | ||
| 259 | + | func stackChildIndex(stackNode, child *entity.PaneNode) int { | |
| 260 | + | if stackNode == nil { | |
| 261 | + | return -1 | |
| 262 | + | } | |
| 263 | + | for i, candidate := range stackNode.Children { | |
| 264 | + | if candidate == child { | |
| 265 | + | return i | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | return -1 | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | func stackPaneIndex(stackNode *entity.PaneNode, paneID entity.PaneID) (int, bool) { | |
| 272 | + | if stackNode == nil || paneID == "" { | |
| 273 | + | return -1, false | |
| 274 | + | } | |
| 275 | + | for i, child := range stackNode.Children { | |
| 276 | + | if child != nil && child.Pane != nil && child.Pane.ID == paneID { | |
| 277 | + | return i, true | |
| 278 | + | } | |
| 279 | + | } | |
| 280 | + | return -1, false | |
| 281 | + | } | |
| 282 | + | ||
| 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 | |
| 301 | + | } | |
| 302 | + | return activeIndex | |
| 303 | + | } | |
| 304 | + | ||
| 268 | 305 | func detachLeafFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error { | |
| 269 | 306 | parent := leaf.Parent | |
| 270 | 307 | if parent == nil { | |
| @@ -221,26 +221,17 @@ func detachFromStackIfNeeded(ws *entity.Workspace, leaf *entity.PaneNode) (bool, | |||||
| 221 | } | 221 | } | ||
| 222 | 222 | ||||
| 223 | stackNode := leaf.Parent | 223 | stackNode := leaf.Parent | ||
| 224 | - | 224 | + | removedIndex := stackChildIndex(stackNode, leaf) | |
| 225 | - | idx := -1 | 225 | + | if removedIndex < 0 { |
| 226 | - | for i, child := range stackNode.Children { | |||
| 227 | - | if child == leaf { | |||
| 228 | - | idx = i | |||
| 229 | - | break | |||
| 230 | - | } | |||
| 231 | - | } | |||
| 232 | - | if idx < 0 { | |||
| 233 | return false, fmt.Errorf("pane not found in stack") | 226 | return false, fmt.Errorf("pane not found in stack") | ||
| 234 | } | 227 | } | ||
| 228 | + | activeIndex, activeInStack := stackPaneIndex(stackNode, ws.ActivePaneID) | |||
| 229 | + | if !activeInStack { | |||
| 230 | + | activeIndex = stackNode.ActiveStackIndex | |||
| 231 | + | } | |||
| 235 | 232 | ||||
| 236 | - | stackNode.Children = append(stackNode.Children[:idx], stackNode.Children[idx+1:]...) | 233 | + | stackNode.Children = append(stackNode.Children[:removedIndex], stackNode.Children[removedIndex+1:]...) |
| 237 | leaf.Parent = nil | 234 | 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 | 235 | ||||
| 245 | // If only one pane remains in stack, dissolve it into a leaf node. | 236 | // If only one pane remains in stack, dissolve it into a leaf node. | ||
| 246 | if len(stackNode.Children) == 1 { | 237 | if len(stackNode.Children) == 1 { | ||
| @@ -258,13 +249,59 @@ func detachFromStackIfNeeded(ws *entity.Workspace, leaf *entity.PaneNode) (bool, | |||||
| 258 | return true, nil | 249 | return true, nil | ||
| 259 | } | 250 | } | ||
| 260 | 251 | ||||
| 261 | - | // Otherwise, set workspace active pane to the stack's current active. | 252 | + | stackNode.ActiveStackIndex = nextStackActiveIndex(activeIndex, removedIndex, len(stackNode.Children)) |
| 262 | - | if stackNode.ActivePane() != nil && stackNode.ActivePane().Pane != nil { | 253 | + | if activeChild := stackNode.ActivePane(); activeChild != nil && activeChild.Pane != nil { |
| 263 | - | ws.ActivePaneID = stackNode.ActivePane().Pane.ID | 254 | + | ws.ActivePaneID = activeChild.Pane.ID |
| 264 | } | 255 | } | ||
| 265 | return true, nil | 256 | return true, nil | ||
| 266 | } | 257 | } | ||
| 267 | 258 | ||||
| 259 | + | func stackChildIndex(stackNode, child *entity.PaneNode) int { | |||
| 260 | + | if stackNode == nil { | |||
| 261 | + | return -1 | |||
| 262 | + | } | |||
| 263 | + | for i, candidate := range stackNode.Children { | |||
| 264 | + | if candidate == child { | |||
| 265 | + | return i | |||
| 266 | + | } | |||
| 267 | + | } | |||
| 268 | + | return -1 | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | func stackPaneIndex(stackNode *entity.PaneNode, paneID entity.PaneID) (int, bool) { | |||
| 272 | + | if stackNode == nil || paneID == "" { | |||
| 273 | + | return -1, false | |||
| 274 | + | } | |||
| 275 | + | for i, child := range stackNode.Children { | |||
| 276 | + | if child != nil && child.Pane != nil && child.Pane.ID == paneID { | |||
| 277 | + | return i, true | |||
| 278 | + | } | |||
| 279 | + | } | |||
| 280 | + | return -1, false | |||
| 281 | + | } | |||
| 282 | + | ||||
| 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 | |||
| 301 | + | } | |||
| 302 | + | return activeIndex | |||
| 303 | + | } | |||
| 304 | + | ||||
| 268 | func detachLeafFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error { | 305 | func detachLeafFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error { | ||
| 269 | parent := leaf.Parent | 306 | parent := leaf.Parent | ||
| 270 | if parent == nil { | 307 | if parent == nil { | ||
M internal/ui/app_eject_pane_to_window_test.go +64 -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,42 @@ 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 | + | if got := len(app.browserWindows); got != 2 { | |
| 143 | + | t.Fatalf("browserWindows length = %d, want source and target", got) | |
| 144 | + | } | |
| 145 | + | if app.browserWindows[sourceWindow.id] == nil { | |
| 146 | + | t.Fatal("source window should remain registered") | |
| 147 | + | } | |
| 148 | + | if got := sourceTab.Workspace.PaneCount(); got != 2 { | |
| 149 | + | t.Fatalf("source pane count = %d, want 2", got) | |
| 150 | + | } | |
| 151 | + | if sourceTab.Workspace.FindPane("pane-a") == nil || sourceTab.Workspace.FindPane("pane-b") == nil { | |
| 152 | + | t.Fatal("source workspace missing remaining split panes") | |
| 153 | + | } | |
| 154 | + | if got := sourceTab.Workspace.FindPane("pane-c"); got != nil { | |
| 155 | + | t.Fatal("source workspace still contains moved split pane") | |
| 156 | + | } | |
| 157 | + | targetWindow := app.browserWindows["target-window"] | |
| 158 | + | if targetWindow == nil || targetWindow.tabs.Count() != 1 { | |
| 159 | + | t.Fatalf("target window/tabs not created correctly: %#v", targetWindow) | |
| 160 | + | } | |
| 161 | + | newTab := targetWindow.tabs.Tabs[0] | |
| 162 | + | if got := newTab.Workspace.ActivePaneID; got != entity.PaneID("pane-c") { | |
| 163 | + | t.Fatalf("target active pane = %q, want pane-c", got) | |
| 164 | + | } | |
| 165 | + | if got := app.windowForTab[newTab.ID]; got != targetWindow { | |
| 166 | + | t.Fatalf("target owner = %p, want target window %p", got, targetWindow) | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 125 | 170 | func TestApp_EjectActivePaneToWindowSnapshotReplacesEmptySourceWindow(t *testing.T) { | |
| 126 | 171 | sourceTab := entity.NewTab("source-tab", "source-ws", entity.NewPane("pane-a")) | |
| 127 | 172 | app, _, _ := newEjectTestApp(t, sourceTab) | |
| @@ -278,6 +323,25 @@ func stackedTab(tabID entity.TabID, workspaceID entity.WorkspaceID, paneIDs ...e | |||
| 278 | 323 | return &entity.Tab{ID: tabID, Workspace: workspace} | |
| 279 | 324 | } | |
| 280 | 325 | ||
| 326 | + | func splitTreeTab(tabID entity.TabID, workspaceID entity.WorkspaceID, paneAID, paneBID, paneCID entity.PaneID) *entity.Tab { | |
| 327 | + | paneA := entity.NewPane(paneAID) | |
| 328 | + | paneB := entity.NewPane(paneBID) | |
| 329 | + | paneC := entity.NewPane(paneCID) | |
| 330 | + | ||
| 331 | + | leftSplit := &entity.PaneNode{ID: "left-split", SplitDir: entity.SplitVertical, SplitRatio: 0.5} | |
| 332 | + | leafA := &entity.PaneNode{ID: string(paneAID) + "-node", Pane: paneA, Parent: leftSplit} | |
| 333 | + | leafB := &entity.PaneNode{ID: string(paneBID) + "-node", Pane: paneB, Parent: leftSplit} | |
| 334 | + | leftSplit.Children = []*entity.PaneNode{leafA, leafB} | |
| 335 | + | ||
| 336 | + | root := &entity.PaneNode{ID: "root-split", SplitDir: entity.SplitHorizontal, SplitRatio: 0.5} | |
| 337 | + | leafC := &entity.PaneNode{ID: string(paneCID) + "-node", Pane: paneC, Parent: root} | |
| 338 | + | leftSplit.Parent = root | |
| 339 | + | root.Children = []*entity.PaneNode{leftSplit, leafC} | |
| 340 | + | ||
| 341 | + | workspace := &entity.Workspace{ID: workspaceID, Root: root, ActivePaneID: paneAID} | |
| 342 | + | return &entity.Tab{ID: tabID, Workspace: workspace} | |
| 343 | + | } | |
| 344 | + | ||
| 281 | 345 | func assertTargetSnapshotContainsPane(t *testing.T, window entity.WindowTabListState, paneID entity.PaneID) { | |
| 282 | 346 | t.Helper() | |
| 283 | 347 | 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,42 @@ 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 | + | if got := len(app.browserWindows); got != 2 { | |||
| 143 | + | t.Fatalf("browserWindows length = %d, want source and target", got) | |||
| 144 | + | } | |||
| 145 | + | if app.browserWindows[sourceWindow.id] == nil { | |||
| 146 | + | t.Fatal("source window should remain registered") | |||
| 147 | + | } | |||
| 148 | + | if got := sourceTab.Workspace.PaneCount(); got != 2 { | |||
| 149 | + | t.Fatalf("source pane count = %d, want 2", got) | |||
| 150 | + | } | |||
| 151 | + | if sourceTab.Workspace.FindPane("pane-a") == nil || sourceTab.Workspace.FindPane("pane-b") == nil { | |||
| 152 | + | t.Fatal("source workspace missing remaining split panes") | |||
| 153 | + | } | |||
| 154 | + | if got := sourceTab.Workspace.FindPane("pane-c"); got != nil { | |||
| 155 | + | t.Fatal("source workspace still contains moved split pane") | |||
| 156 | + | } | |||
| 157 | + | targetWindow := app.browserWindows["target-window"] | |||
| 158 | + | if targetWindow == nil || targetWindow.tabs.Count() != 1 { | |||
| 159 | + | t.Fatalf("target window/tabs not created correctly: %#v", targetWindow) | |||
| 160 | + | } | |||
| 161 | + | newTab := targetWindow.tabs.Tabs[0] | |||
| 162 | + | if got := newTab.Workspace.ActivePaneID; got != entity.PaneID("pane-c") { | |||
| 163 | + | t.Fatalf("target active pane = %q, want pane-c", got) | |||
| 164 | + | } | |||
| 165 | + | if got := app.windowForTab[newTab.ID]; got != targetWindow { | |||
| 166 | + | t.Fatalf("target owner = %p, want target window %p", got, targetWindow) | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | ||||
| 125 | func TestApp_EjectActivePaneToWindowSnapshotReplacesEmptySourceWindow(t *testing.T) { | 170 | func TestApp_EjectActivePaneToWindowSnapshotReplacesEmptySourceWindow(t *testing.T) { | ||
| 126 | sourceTab := entity.NewTab("source-tab", "source-ws", entity.NewPane("pane-a")) | 171 | sourceTab := entity.NewTab("source-tab", "source-ws", entity.NewPane("pane-a")) | ||
| 127 | app, _, _ := newEjectTestApp(t, sourceTab) | 172 | app, _, _ := newEjectTestApp(t, sourceTab) | ||
| @@ -278,6 +323,25 @@ func stackedTab(tabID entity.TabID, workspaceID entity.WorkspaceID, paneIDs ...e | |||||
| 278 | return &entity.Tab{ID: tabID, Workspace: workspace} | 323 | return &entity.Tab{ID: tabID, Workspace: workspace} | ||
| 279 | } | 324 | } | ||
| 280 | 325 | ||||
| 326 | + | func splitTreeTab(tabID entity.TabID, workspaceID entity.WorkspaceID, paneAID, paneBID, paneCID entity.PaneID) *entity.Tab { | |||
| 327 | + | paneA := entity.NewPane(paneAID) | |||
| 328 | + | paneB := entity.NewPane(paneBID) | |||
| 329 | + | paneC := entity.NewPane(paneCID) | |||
| 330 | + | ||||
| 331 | + | leftSplit := &entity.PaneNode{ID: "left-split", SplitDir: entity.SplitVertical, SplitRatio: 0.5} | |||
| 332 | + | leafA := &entity.PaneNode{ID: string(paneAID) + "-node", Pane: paneA, Parent: leftSplit} | |||
| 333 | + | leafB := &entity.PaneNode{ID: string(paneBID) + "-node", Pane: paneB, Parent: leftSplit} | |||
| 334 | + | leftSplit.Children = []*entity.PaneNode{leafA, leafB} | |||
| 335 | + | ||||
| 336 | + | root := &entity.PaneNode{ID: "root-split", SplitDir: entity.SplitHorizontal, SplitRatio: 0.5} | |||
| 337 | + | leafC := &entity.PaneNode{ID: string(paneCID) + "-node", Pane: paneC, Parent: root} | |||
| 338 | + | leftSplit.Parent = root | |||
| 339 | + | root.Children = []*entity.PaneNode{leftSplit, leafC} | |||
| 340 | + | ||||
| 341 | + | workspace := &entity.Workspace{ID: workspaceID, Root: root, ActivePaneID: paneAID} | |||
| 342 | + | return &entity.Tab{ID: tabID, Workspace: workspace} | |||
| 343 | + | } | |||
| 344 | + | ||||
| 281 | func assertTargetSnapshotContainsPane(t *testing.T, window entity.WindowTabListState, paneID entity.PaneID) { | 345 | func assertTargetSnapshotContainsPane(t *testing.T, window entity.WindowTabListState, paneID entity.PaneID) { | ||
| 282 | t.Helper() | 346 | t.Helper() | ||
| 283 | if got := window.WindowID; got != entity.WindowID("target-window") { | 347 | if got := window.WindowID; got != entity.WindowID("target-window") { | ||