detached 54d60a9

fix(panes): preserve active pane during detach

commit
54d60a948b9514932f156a47f0b5821b4e59457f
author
brice · · 2026-06-18 10:58 UTC
parent
2970d4a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
130130 require.Same(t, pane, out.NewTab.Workspace.Root.Pane)
131131 }
132132
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+
133248 func TestExtractPaneToTabList_FromSplitSeversMovedNode(t *testing.T) {
134249 uc := NewExtractPaneToTabListUseCase(newTestIDGen())
135250 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,
220220 return false, nil
221221 }
222222
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")
227235 }
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")
231254 }
232255
233256 stackNode.Children = append(stackNode.Children[:removedIndex], stackNode.Children[removedIndex+1:]...)
@@ -241,68 +264,57 @@ func detachFromStackIfNeeded(ws *entity.Workspace, leaf *entity.PaneNode) (bool,
241264 stackNode.IsStacked = false
242265 stackNode.ActiveStackIndex = 0
243266 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
246274 } else {
247- ws.ActivePaneID = ""
275+ activeIndex = removedIndex
248276 }
249- return true, nil
277+ } else if activeIndex > removedIndex {
278+ activeIndex--
250279 }
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
255282 }
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
262285 }
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)
267291 }
268- return -1
292+ setActiveAfterPaneDetach(ws, previousActive, removedPaneID, fallbackID)
293+ return nil
269294 }
270295
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
274299 }
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
279303 }
280- return -1, false
304+ ws.ActivePaneID = fallbackID
281305 }
282306
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 ""
301310 }
302- return activeIndex
311+ return node.Pane.ID
303312 }
304313
305314 func detachLeafFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error {
315+ previousActive := ws.ActivePaneID
316+ removedPaneID := leaf.Pane.ID
317+
306318 parent := leaf.Parent
307319 if parent == nil {
308320 ws.Root = nil
@@ -321,7 +333,7 @@ func detachLeafFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error
321333 promoteSibling(ws, parent, sibling)
322334 leaf.Parent = nil
323335 parent.Children = nil
324- ws.ActivePaneID = findFirstLeafPaneID(sibling)
336+ setActiveAfterPaneDetach(ws, previousActive, removedPaneID, findFirstLeafPaneID(sibling))
325337 return nil
326338 }
327339
@@ -220,14 +220,37 @@ func detachFromStackIfNeeded(ws *entity.Workspace, leaf *entity.PaneNode) (bool,
220 return false, nil220 return false, nil
221 }221 }
222 222
223- stackNode := leaf.Parent223+ if err := removeLeafFromStackPreservingActive(ws, leaf.Parent, leaf); err != nil {
224- 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.ActiveStackIndex238+ }
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 = false264 stackNode.IsStacked = false
242 stackNode.ActiveStackIndex = 0265 stackNode.ActiveStackIndex = 0
243 remaining.Parent = nil266 remaining.Parent = nil
244- if stackNode.Pane != nil {267+ setActiveAfterPaneDetach(ws, previousActive, removedPaneID, paneIDFromNode(stackNode))
245- ws.ActivePaneID = stackNode.Pane.ID268+ return nil
269+ }
270+
271+ if activeIndex == removedIndex {
272+ if removedIndex >= len(stackNode.Children) {
273+ activeIndex = len(stackNode.Children) - 1
246 } else {274 } else {
247- ws.ActivePaneID = ""275+ activeIndex = removedIndex
248 }276 }
249- return true, nil277+ } 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, nil283+ 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 i288+ fallbackID := entity.PaneID("")
266- }289+ if activeChild := stackNode.ActivePane(); activeChild != nil {
290+ fallbackID = paneIDFromNode(activeChild)
267 }291 }
268- return -1292+ 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, false298+ 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, true302+ return
278- }
279 }303 }
280- return -1, false304+ 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 0309+ 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 activeIndex311+ 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.Parent318 parent := leaf.Parent
307 if parent == nil {319 if parent == nil {
308 ws.Root = nil320 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 = nil334 leaf.Parent = nil
323 parent.Children = nil335 parent.Children = nil
324- ws.ActivePaneID = findFirstLeafPaneID(sibling)336+ setActiveAfterPaneDetach(ws, previousActive, removedPaneID, findFirstLeafPaneID(sibling))
325 return nil337 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) {
154154 if got := sourceTab.Workspace.FindPane("pane-c"); got != nil {
155155 t.Fatal("source workspace still contains moved split pane")
156156 }
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)
157173 targetWindow := app.browserWindows["target-window"]
158174 if targetWindow == nil || targetWindow.tabs.Count() != 1 {
159175 t.Fatalf("target window/tabs not created correctly: %#v", targetWindow)
@@ -162,6 +178,9 @@ func TestApp_EjectActivePaneToWindowHandlesThreePaneSplitTree(t *testing.T) {
162178 if got := newTab.Workspace.ActivePaneID; got != entity.PaneID("pane-c") {
163179 t.Fatalf("target active pane = %q, want pane-c", got)
164180 }
181+ if got := newTab.Workspace.Root.Parent; got != nil {
182+ t.Fatalf("target root parent = %#v, want nil", got)
183+ }
165184 if got := app.windowForTab[newTab.ID]; got != targetWindow {
166185 t.Fatalf("target owner = %p, want target window %p", got, targetWindow)
167186 }
@@ -342,6 +361,19 @@ func splitTreeTab(tabID entity.TabID, workspaceID entity.WorkspaceID, paneAID, p
342361 return &entity.Tab{ID: tabID, Workspace: workspace}
343362 }
344363
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+
345377 func assertTargetSnapshotContainsPane(t *testing.T, window entity.WindowTabListState, paneID entity.PaneID) {
346378 t.Helper()
347379 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") {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close