detached 2970d4a

fix(panes): stabilize stacked pane eject

commit
2970d4a901dc0824062d1bbb3a5b83a275d80f2c
author
brice · · 2026-06-18 10:46 UTC
parent
a450a83
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
4545 require.Nil(t, out.MovedPaneNode.Parent)
4646 }
4747
48+func TestExtractPaneToTabList_FromThreePaneStackUsesWorkspaceActivePaneWhenStackIndexIsStale(t *testing.T) {
49+ uc := NewExtractPaneToTabListUseCase(newTestIDGen())
50+ sourceTabs := entity.NewTabList()
51+ targetTabs := entity.NewTabList()
52+
53+ paneA := entity.NewPane(entity.PaneID("pA"))
54+ paneB := entity.NewPane(entity.PaneID("pB"))
55+ paneC := entity.NewPane(entity.PaneID("pC"))
56+ sourceTab := newStackedTab("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+
4881 func TestExtractPaneToTabList_FromTwoPaneStackDissolvesSource(t *testing.T) {
4982 uc := NewExtractPaneToTabListUseCase(newTestIDGen())
5083 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,
221221 }
222222
223223 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 {
233226 return false, fmt.Errorf("pane not found in stack")
234227 }
228+ activeIndex, activeInStack := stackPaneIndex(stackNode, ws.ActivePaneID)
229+ if !activeInStack {
230+ activeIndex = stackNode.ActiveStackIndex
231+ }
235232
236- stackNode.Children = append(stackNode.Children[:idx], stackNode.Children[idx+1:]...)
233+ stackNode.Children = append(stackNode.Children[:removedIndex], stackNode.Children[removedIndex+1:]...)
237234 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- }
244235
245236 // If only one pane remains in stack, dissolve it into a leaf node.
246237 if len(stackNode.Children) == 1 {
@@ -258,13 +249,59 @@ func detachFromStackIfNeeded(ws *entity.Workspace, leaf *entity.PaneNode) (bool,
258249 return true, nil
259250 }
260251
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
264255 }
265256 return true, nil
266257 }
267258
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+
268305 func detachLeafFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error {
269306 parent := leaf.Parent
270307 if parent == nil {
@@ -221,26 +221,17 @@ func detachFromStackIfNeeded(ws *entity.Workspace, leaf *entity.PaneNode) (bool,
221 }221 }
222 222
223 stackNode := leaf.Parent223 stackNode := leaf.Parent
224-224+ removedIndex := stackChildIndex(stackNode, leaf)
225- idx := -1225+ 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 = nil234 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, nil249 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.ID254+ ws.ActivePaneID = activeChild.Pane.ID
264 }255 }
265 return true, nil256 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.Parent306 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 (
1111
1212 func TestApp_EjectActivePaneToWindowRegistersTargetOwnership(t *testing.T) {
1313 app, sourceWindow, sourceTab := newEjectTestApp(t, stackedTab("source-tab-a", "source-ws-a", "pane-a", "pane-b", "pane-c"))
14+ // ActivePaneID is the source of truth. Keep ActiveStackIndex stale to cover
15+ // ejection after stack/UI state drift with more than two panes.
1416 sourceTab.Workspace.ActivePaneID = "pane-b"
17+ sourceTab.Workspace.Root.ActiveStackIndex = 0
1518
1619 if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {
1720 t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
@@ -40,6 +43,12 @@ func TestApp_EjectActivePaneToWindowRegistersTargetOwnership(t *testing.T) {
4043 if got := sourceTab.Workspace.FindPane("pane-b"); got != nil {
4144 t.Fatal("source workspace still contains moved pane")
4245 }
46+ if got := sourceTab.Workspace.ActivePaneID; got != entity.PaneID("pane-c") {
47+ t.Fatalf("source active pane = %q, want pane-c", got)
48+ }
49+ if got := sourceTab.Workspace.Root.ActiveStackIndex; got != 1 {
50+ t.Fatalf("source active stack index = %d, want 1", got)
51+ }
4352 if got := app.lastFocusedWindowID; got != targetWindow.id {
4453 t.Fatalf("lastFocusedWindowID = %q, want %q", got, targetWindow.id)
4554 }
@@ -122,6 +131,42 @@ func TestApp_EjectActivePaneToWindowSnapshotRetainsSourceWindowWhenTabsRemain(t
122131 assertTargetSnapshotContainsPane(t, windows[1], "pane-b")
123132 }
124133
134+func TestApp_EjectActivePaneToWindowHandlesThreePaneSplitTree(t *testing.T) {
135+ app, sourceWindow, sourceTab := newEjectTestApp(t, splitTreeTab("source-tab", "source-ws", "pane-a", "pane-b", "pane-c"))
136+ sourceTab.Workspace.ActivePaneID = "pane-c"
137+
138+ if err := app.EjectActivePaneToWindow(context.Background(), ""); err != nil {
139+ t.Fatalf("EjectActivePaneToWindow returned error: %v", err)
140+ }
141+
142+ 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+
125170 func TestApp_EjectActivePaneToWindowSnapshotReplacesEmptySourceWindow(t *testing.T) {
126171 sourceTab := entity.NewTab("source-tab", "source-ws", entity.NewPane("pane-a"))
127172 app, _, _ := newEjectTestApp(t, sourceTab)
@@ -278,6 +323,25 @@ func stackedTab(tabID entity.TabID, workspaceID entity.WorkspaceID, paneIDs ...e
278323 return &entity.Tab{ID: tabID, Workspace: workspace}
279324 }
280325
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+
281345 func assertTargetSnapshotContainsPane(t *testing.T, window entity.WindowTabListState, paneID entity.PaneID) {
282346 t.Helper()
283347 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") {

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