detached 77a7a6d

refactor(focus): extract stack navigation helpers with tests

Refactor navigateWithinStack into focused helper methods: findCurrentStackIndex,
calculateNewStackIndex, and getPaneIDFromStackChild. This improves code clarity
and testability. Add comprehensive unit tests for focus manager navigation logic
including boundary conditions and invalid indices.
commit
77a7a6d310cc6d51e9ca24f4056a28ca3f94c7f5
author
brice · · 2025-12-17 21:07 UTC
committer
brice · · 2025-12-17 22:25 UTC
parent
6f9d17f
signature
unsigned
2 files · +278 -25
M internal/ui/focus/manager.go +44 -25
@@ -156,51 +156,71 @@ func (m *Manager) NavigateGeometric(
156156 // navigateWithinStack tries to navigate within a stack.
157157 // Returns (canNavigate, newPaneID). If canNavigate is false, the pane is at a
158158 // boundary and navigation should escape the stack.
159-func (m *Manager) navigateWithinStack(stackNode, currentNode *entity.PaneNode, direction usecase.NavigateDirection) (bool, entity.PaneID) {
159+func (m *Manager) navigateWithinStack(
160+ stackNode, currentNode *entity.PaneNode, direction usecase.NavigateDirection,
161+) (bool, entity.PaneID) {
160162 if !stackNode.IsStacked || len(stackNode.Children) == 0 {
161163 return false, ""
162164 }
163165
164- // Find current index in stack
165- currentIdx := -1
166+ currentIdx := m.findCurrentStackIndex(stackNode, currentNode)
167+ if currentIdx < 0 {
168+ return false, ""
169+ }
170+
171+ newIdx, ok := m.calculateNewStackIndex(currentIdx, len(stackNode.Children), direction)
172+ if !ok {
173+ return false, ""
174+ }
175+
176+ stackNode.ActiveStackIndex = newIdx
177+ return m.getPaneIDFromStackChild(stackNode.Children[newIdx])
178+}
179+
180+// findCurrentStackIndex returns the current pane's index in the stack.
181+// Uses ActiveStackIndex from domain model, with fallback to pointer search.
182+func (*Manager) findCurrentStackIndex(stackNode, currentNode *entity.PaneNode) int {
183+ idx := stackNode.ActiveStackIndex
184+ if idx >= 0 && idx < len(stackNode.Children) {
185+ return idx
186+ }
187+ // Fallback to pointer search if ActiveStackIndex is invalid
166188 for i, child := range stackNode.Children {
167189 if child == currentNode {
168- currentIdx = i
169- break
190+ return i
170191 }
171192 }
172- if currentIdx < 0 {
173- return false, ""
174- }
193+ return -1
194+}
175195
176- var newIdx int
196+// calculateNewStackIndex computes the new index after navigation.
197+// Returns (newIndex, ok) where ok is false if at a boundary.
198+func (*Manager) calculateNewStackIndex(
199+ currentIdx, childCount int, direction usecase.NavigateDirection,
200+) (int, bool) {
177201 switch direction {
178202 case usecase.NavUp:
179- newIdx = currentIdx - 1
180- if newIdx < 0 {
181- // At top boundary
182- return false, ""
203+ if currentIdx <= 0 {
204+ return 0, false
183205 }
206+ return currentIdx - 1, true
184207 case usecase.NavDown:
185- newIdx = currentIdx + 1
186- if newIdx >= len(stackNode.Children) {
187- // At bottom boundary
188- return false, ""
208+ if currentIdx >= childCount-1 {
209+ return 0, false
189210 }
211+ return currentIdx + 1, true
190212 default:
191- return false, ""
213+ return 0, false
192214 }
215+}
193216
194- // Update stack active index
195- stackNode.ActiveStackIndex = newIdx
196-
197- // Get the new pane
198- targetNode := stackNode.Children[newIdx]
217+// getPaneIDFromStackChild extracts the pane ID from a stack child node.
218+// If the child is not a leaf, finds the first leaf pane.
219+func (*Manager) getPaneIDFromStackChild(targetNode *entity.PaneNode) (bool, entity.PaneID) {
199220 if targetNode.Pane != nil {
200221 return true, targetNode.Pane.ID
201222 }
202223
203- // If the child is not a leaf, find the first leaf
204224 var leafPaneID entity.PaneID
205225 targetNode.Walk(func(n *entity.PaneNode) bool {
206226 if n.IsLeaf() && n.Pane != nil {
@@ -213,6 +233,5 @@ func (m *Manager) navigateWithinStack(stackNode, currentNode *entity.PaneNode, d
213233 if leafPaneID != "" {
214234 return true, leafPaneID
215235 }
216-
217236 return false, ""
218237 }
@@ -156,51 +156,71 @@ func (m *Manager) NavigateGeometric(
156 // navigateWithinStack tries to navigate within a stack.156 // navigateWithinStack tries to navigate within a stack.
157 // Returns (canNavigate, newPaneID). If canNavigate is false, the pane is at a157 // Returns (canNavigate, newPaneID). If canNavigate is false, the pane is at a
158 // boundary and navigation should escape the stack.158 // boundary and navigation should escape the stack.
159-func (m *Manager) navigateWithinStack(stackNode, currentNode *entity.PaneNode, direction usecase.NavigateDirection) (bool, entity.PaneID) {159+func (m *Manager) navigateWithinStack(
160+ stackNode, currentNode *entity.PaneNode, direction usecase.NavigateDirection,
161+) (bool, entity.PaneID) {
160 if !stackNode.IsStacked || len(stackNode.Children) == 0 {162 if !stackNode.IsStacked || len(stackNode.Children) == 0 {
161 return false, ""163 return false, ""
162 }164 }
163 165
164- // Find current index in stack166+ currentIdx := m.findCurrentStackIndex(stackNode, currentNode)
165- currentIdx := -1167+ if currentIdx < 0 {
168+ return false, ""
169+ }
170+
171+ newIdx, ok := m.calculateNewStackIndex(currentIdx, len(stackNode.Children), direction)
172+ if !ok {
173+ return false, ""
174+ }
175+
176+ stackNode.ActiveStackIndex = newIdx
177+ return m.getPaneIDFromStackChild(stackNode.Children[newIdx])
178+}
179+
180+// findCurrentStackIndex returns the current pane's index in the stack.
181+// Uses ActiveStackIndex from domain model, with fallback to pointer search.
182+func (*Manager) findCurrentStackIndex(stackNode, currentNode *entity.PaneNode) int {
183+ idx := stackNode.ActiveStackIndex
184+ if idx >= 0 && idx < len(stackNode.Children) {
185+ return idx
186+ }
187+ // Fallback to pointer search if ActiveStackIndex is invalid
166 for i, child := range stackNode.Children {188 for i, child := range stackNode.Children {
167 if child == currentNode {189 if child == currentNode {
168- currentIdx = i190+ return i
169- break
170 }191 }
171 }192 }
172- if currentIdx < 0 {193+ return -1
173- return false, ""194+}
174- }
175 195
176- var newIdx int196+// calculateNewStackIndex computes the new index after navigation.
197+// Returns (newIndex, ok) where ok is false if at a boundary.
198+func (*Manager) calculateNewStackIndex(
199+ currentIdx, childCount int, direction usecase.NavigateDirection,
200+) (int, bool) {
177 switch direction {201 switch direction {
178 case usecase.NavUp:202 case usecase.NavUp:
179- newIdx = currentIdx - 1203+ if currentIdx <= 0 {
180- if newIdx < 0 {204+ return 0, false
181- // At top boundary
182- return false, ""
183 }205 }
206+ return currentIdx - 1, true
184 case usecase.NavDown:207 case usecase.NavDown:
185- newIdx = currentIdx + 1208+ if currentIdx >= childCount-1 {
186- if newIdx >= len(stackNode.Children) {209+ return 0, false
187- // At bottom boundary
188- return false, ""
189 }210 }
211+ return currentIdx + 1, true
190 default:212 default:
191- return false, ""213+ return 0, false
192 }214 }
215+}
193 216
194- // Update stack active index217+// getPaneIDFromStackChild extracts the pane ID from a stack child node.
195- stackNode.ActiveStackIndex = newIdx218+// If the child is not a leaf, finds the first leaf pane.
196-219+func (*Manager) getPaneIDFromStackChild(targetNode *entity.PaneNode) (bool, entity.PaneID) {
197- // Get the new pane
198- targetNode := stackNode.Children[newIdx]
199 if targetNode.Pane != nil {220 if targetNode.Pane != nil {
200 return true, targetNode.Pane.ID221 return true, targetNode.Pane.ID
201 }222 }
202 223
203- // If the child is not a leaf, find the first leaf
204 var leafPaneID entity.PaneID224 var leafPaneID entity.PaneID
205 targetNode.Walk(func(n *entity.PaneNode) bool {225 targetNode.Walk(func(n *entity.PaneNode) bool {
206 if n.IsLeaf() && n.Pane != nil {226 if n.IsLeaf() && n.Pane != nil {
@@ -213,6 +233,5 @@ func (m *Manager) navigateWithinStack(stackNode, currentNode *entity.PaneNode, d
213 if leafPaneID != "" {233 if leafPaneID != "" {
214 return true, leafPaneID234 return true, leafPaneID
215 }235 }
216-
217 return false, ""236 return false, ""
218 }237 }
A internal/ui/focus/manager_test.go +234 -0
@@ -0,0 +1,234 @@
1+package focus
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+)
11+
12+// TestNavigateWithinStack_UsesActiveStackIndex tests that navigation uses
13+// ActiveStackIndex from the domain model instead of pointer comparison.
14+func TestNavigateWithinStack_UsesActiveStackIndex(t *testing.T) {
15+ // Create a stack with 3 children
16+ pane0 := &entity.Pane{ID: "pane0"}
17+ pane1 := &entity.Pane{ID: "pane1"}
18+ pane2 := &entity.Pane{ID: "pane2"}
19+
20+ child0 := &entity.PaneNode{ID: "child0", Pane: pane0}
21+ child1 := &entity.PaneNode{ID: "child1", Pane: pane1}
22+ child2 := &entity.PaneNode{ID: "child2", Pane: pane2}
23+
24+ stackNode := &entity.PaneNode{
25+ ID: "stack",
26+ IsStacked: true,
27+ Children: []*entity.PaneNode{child0, child1, child2},
28+ ActiveStackIndex: 1, // Currently at middle pane
29+ }
30+
31+ // Set parents
32+ child0.Parent = stackNode
33+ child1.Parent = stackNode
34+ child2.Parent = stackNode
35+
36+ m := NewManager(nil)
37+
38+ tests := []struct {
39+ name string
40+ activeIndex int
41+ direction usecase.NavigateDirection
42+ wantCanNav bool
43+ wantPaneID entity.PaneID
44+ wantNewIndex int
45+ }{
46+ {
47+ name: "navigate down from middle",
48+ activeIndex: 1,
49+ direction: usecase.NavDown,
50+ wantCanNav: true,
51+ wantPaneID: "pane2",
52+ wantNewIndex: 2,
53+ },
54+ {
55+ name: "navigate up from middle",
56+ activeIndex: 1,
57+ direction: usecase.NavUp,
58+ wantCanNav: true,
59+ wantPaneID: "pane0",
60+ wantNewIndex: 0,
61+ },
62+ {
63+ name: "navigate down from last - boundary",
64+ activeIndex: 2,
65+ direction: usecase.NavDown,
66+ wantCanNav: false,
67+ wantPaneID: "",
68+ wantNewIndex: 2, // unchanged
69+ },
70+ {
71+ name: "navigate up from first - boundary",
72+ activeIndex: 0,
73+ direction: usecase.NavUp,
74+ wantCanNav: false,
75+ wantPaneID: "",
76+ wantNewIndex: 0, // unchanged
77+ },
78+ {
79+ name: "navigate down from first",
80+ activeIndex: 0,
81+ direction: usecase.NavDown,
82+ wantCanNav: true,
83+ wantPaneID: "pane1",
84+ wantNewIndex: 1,
85+ },
86+ {
87+ name: "navigate up from last",
88+ activeIndex: 2,
89+ direction: usecase.NavUp,
90+ wantCanNav: true,
91+ wantPaneID: "pane1",
92+ wantNewIndex: 1,
93+ },
94+ }
95+
96+ for _, tt := range tests {
97+ t.Run(tt.name, func(t *testing.T) {
98+ // Set up the active index
99+ stackNode.ActiveStackIndex = tt.activeIndex
100+
101+ // Call the private function through reflection or test it indirectly
102+ // Since navigateWithinStack is private, we test its behavior
103+ canNav, paneID := m.navigateWithinStack(stackNode, stackNode.Children[tt.activeIndex], tt.direction)
104+
105+ assert.Equal(t, tt.wantCanNav, canNav)
106+ assert.Equal(t, tt.wantPaneID, paneID)
107+
108+ if canNav {
109+ assert.Equal(t, tt.wantNewIndex, stackNode.ActiveStackIndex)
110+ }
111+ })
112+ }
113+}
114+
115+// TestNavigateWithinStack_EmptyStack tests navigation in empty stack returns false.
116+func TestNavigateWithinStack_EmptyStack(t *testing.T) {
117+ stackNode := &entity.PaneNode{
118+ ID: "stack",
119+ IsStacked: true,
120+ Children: []*entity.PaneNode{},
121+ }
122+
123+ m := NewManager(nil)
124+
125+ canNav, paneID := m.navigateWithinStack(stackNode, nil, usecase.NavDown)
126+
127+ assert.False(t, canNav)
128+ assert.Empty(t, paneID)
129+}
130+
131+// TestNavigateWithinStack_NotStacked tests navigation on non-stacked node returns false.
132+func TestNavigateWithinStack_NotStacked(t *testing.T) {
133+ node := &entity.PaneNode{
134+ ID: "leaf",
135+ IsStacked: false,
136+ }
137+
138+ m := NewManager(nil)
139+
140+ canNav, paneID := m.navigateWithinStack(node, nil, usecase.NavDown)
141+
142+ assert.False(t, canNav)
143+ assert.Empty(t, paneID)
144+}
145+
146+// TestNavigateWithinStack_InvalidActiveIndex tests navigation with invalid ActiveStackIndex
147+// falls back to pointer search.
148+func TestNavigateWithinStack_InvalidActiveIndexFallback(t *testing.T) {
149+ pane0 := &entity.Pane{ID: "pane0"}
150+ pane1 := &entity.Pane{ID: "pane1"}
151+
152+ child0 := &entity.PaneNode{ID: "child0", Pane: pane0}
153+ child1 := &entity.PaneNode{ID: "child1", Pane: pane1}
154+
155+ stackNode := &entity.PaneNode{
156+ ID: "stack",
157+ IsStacked: true,
158+ Children: []*entity.PaneNode{child0, child1},
159+ ActiveStackIndex: -1, // Invalid index
160+ }
161+
162+ child0.Parent = stackNode
163+ child1.Parent = stackNode
164+
165+ m := NewManager(nil)
166+
167+ // With invalid ActiveStackIndex, should fall back to pointer search
168+ // Pass child0 as current node
169+ canNav, paneID := m.navigateWithinStack(stackNode, child0, usecase.NavDown)
170+
171+ assert.True(t, canNav)
172+ assert.Equal(t, entity.PaneID("pane1"), paneID)
173+}
174+
175+// TestNavigateWithinStack_InvalidDirection tests navigation with invalid direction returns false.
176+func TestNavigateWithinStack_InvalidDirection(t *testing.T) {
177+ pane0 := &entity.Pane{ID: "pane0"}
178+ child0 := &entity.PaneNode{ID: "child0", Pane: pane0}
179+
180+ stackNode := &entity.PaneNode{
181+ ID: "stack",
182+ IsStacked: true,
183+ Children: []*entity.PaneNode{child0},
184+ ActiveStackIndex: 0,
185+ }
186+
187+ m := NewManager(nil)
188+
189+ // NavLeft and NavRight should not work for stack navigation
190+ canNav, paneID := m.navigateWithinStack(stackNode, child0, usecase.NavLeft)
191+
192+ assert.False(t, canNav)
193+ assert.Empty(t, paneID)
194+}
195+
196+// TestNavigateWithinStack_UpdatesActiveStackIndex tests that navigation properly
197+// updates the ActiveStackIndex on the stack node.
198+func TestNavigateWithinStack_UpdatesActiveStackIndex(t *testing.T) {
199+ pane0 := &entity.Pane{ID: "pane0"}
200+ pane1 := &entity.Pane{ID: "pane1"}
201+ pane2 := &entity.Pane{ID: "pane2"}
202+
203+ child0 := &entity.PaneNode{ID: "child0", Pane: pane0}
204+ child1 := &entity.PaneNode{ID: "child1", Pane: pane1}
205+ child2 := &entity.PaneNode{ID: "child2", Pane: pane2}
206+
207+ stackNode := &entity.PaneNode{
208+ ID: "stack",
209+ IsStacked: true,
210+ Children: []*entity.PaneNode{child0, child1, child2},
211+ ActiveStackIndex: 0,
212+ }
213+
214+ m := NewManager(nil)
215+
216+ // Navigate down twice
217+ stackNode.ActiveStackIndex = 0
218+ canNav, _ := m.navigateWithinStack(stackNode, child0, usecase.NavDown)
219+ assert.True(t, canNav)
220+ assert.Equal(t, 1, stackNode.ActiveStackIndex)
221+
222+ canNav, _ = m.navigateWithinStack(stackNode, child1, usecase.NavDown)
223+ assert.True(t, canNav)
224+ assert.Equal(t, 2, stackNode.ActiveStackIndex)
225+
226+ // Navigate up twice
227+ canNav, _ = m.navigateWithinStack(stackNode, child2, usecase.NavUp)
228+ assert.True(t, canNav)
229+ assert.Equal(t, 1, stackNode.ActiveStackIndex)
230+
231+ canNav, _ = m.navigateWithinStack(stackNode, child1, usecase.NavUp)
232+ assert.True(t, canNav)
233+ assert.Equal(t, 0, stackNode.ActiveStackIndex)
234+}
@@ -0,0 +1,234 @@
1+package focus
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+)
11+
12+// TestNavigateWithinStack_UsesActiveStackIndex tests that navigation uses
13+// ActiveStackIndex from the domain model instead of pointer comparison.
14+func TestNavigateWithinStack_UsesActiveStackIndex(t *testing.T) {
15+ // Create a stack with 3 children
16+ pane0 := &entity.Pane{ID: "pane0"}
17+ pane1 := &entity.Pane{ID: "pane1"}
18+ pane2 := &entity.Pane{ID: "pane2"}
19+
20+ child0 := &entity.PaneNode{ID: "child0", Pane: pane0}
21+ child1 := &entity.PaneNode{ID: "child1", Pane: pane1}
22+ child2 := &entity.PaneNode{ID: "child2", Pane: pane2}
23+
24+ stackNode := &entity.PaneNode{
25+ ID: "stack",
26+ IsStacked: true,
27+ Children: []*entity.PaneNode{child0, child1, child2},
28+ ActiveStackIndex: 1, // Currently at middle pane
29+ }
30+
31+ // Set parents
32+ child0.Parent = stackNode
33+ child1.Parent = stackNode
34+ child2.Parent = stackNode
35+
36+ m := NewManager(nil)
37+
38+ tests := []struct {
39+ name string
40+ activeIndex int
41+ direction usecase.NavigateDirection
42+ wantCanNav bool
43+ wantPaneID entity.PaneID
44+ wantNewIndex int
45+ }{
46+ {
47+ name: "navigate down from middle",
48+ activeIndex: 1,
49+ direction: usecase.NavDown,
50+ wantCanNav: true,
51+ wantPaneID: "pane2",
52+ wantNewIndex: 2,
53+ },
54+ {
55+ name: "navigate up from middle",
56+ activeIndex: 1,
57+ direction: usecase.NavUp,
58+ wantCanNav: true,
59+ wantPaneID: "pane0",
60+ wantNewIndex: 0,
61+ },
62+ {
63+ name: "navigate down from last - boundary",
64+ activeIndex: 2,
65+ direction: usecase.NavDown,
66+ wantCanNav: false,
67+ wantPaneID: "",
68+ wantNewIndex: 2, // unchanged
69+ },
70+ {
71+ name: "navigate up from first - boundary",
72+ activeIndex: 0,
73+ direction: usecase.NavUp,
74+ wantCanNav: false,
75+ wantPaneID: "",
76+ wantNewIndex: 0, // unchanged
77+ },
78+ {
79+ name: "navigate down from first",
80+ activeIndex: 0,
81+ direction: usecase.NavDown,
82+ wantCanNav: true,
83+ wantPaneID: "pane1",
84+ wantNewIndex: 1,
85+ },
86+ {
87+ name: "navigate up from last",
88+ activeIndex: 2,
89+ direction: usecase.NavUp,
90+ wantCanNav: true,
91+ wantPaneID: "pane1",
92+ wantNewIndex: 1,
93+ },
94+ }
95+
96+ for _, tt := range tests {
97+ t.Run(tt.name, func(t *testing.T) {
98+ // Set up the active index
99+ stackNode.ActiveStackIndex = tt.activeIndex
100+
101+ // Call the private function through reflection or test it indirectly
102+ // Since navigateWithinStack is private, we test its behavior
103+ canNav, paneID := m.navigateWithinStack(stackNode, stackNode.Children[tt.activeIndex], tt.direction)
104+
105+ assert.Equal(t, tt.wantCanNav, canNav)
106+ assert.Equal(t, tt.wantPaneID, paneID)
107+
108+ if canNav {
109+ assert.Equal(t, tt.wantNewIndex, stackNode.ActiveStackIndex)
110+ }
111+ })
112+ }
113+}
114+
115+// TestNavigateWithinStack_EmptyStack tests navigation in empty stack returns false.
116+func TestNavigateWithinStack_EmptyStack(t *testing.T) {
117+ stackNode := &entity.PaneNode{
118+ ID: "stack",
119+ IsStacked: true,
120+ Children: []*entity.PaneNode{},
121+ }
122+
123+ m := NewManager(nil)
124+
125+ canNav, paneID := m.navigateWithinStack(stackNode, nil, usecase.NavDown)
126+
127+ assert.False(t, canNav)
128+ assert.Empty(t, paneID)
129+}
130+
131+// TestNavigateWithinStack_NotStacked tests navigation on non-stacked node returns false.
132+func TestNavigateWithinStack_NotStacked(t *testing.T) {
133+ node := &entity.PaneNode{
134+ ID: "leaf",
135+ IsStacked: false,
136+ }
137+
138+ m := NewManager(nil)
139+
140+ canNav, paneID := m.navigateWithinStack(node, nil, usecase.NavDown)
141+
142+ assert.False(t, canNav)
143+ assert.Empty(t, paneID)
144+}
145+
146+// TestNavigateWithinStack_InvalidActiveIndex tests navigation with invalid ActiveStackIndex
147+// falls back to pointer search.
148+func TestNavigateWithinStack_InvalidActiveIndexFallback(t *testing.T) {
149+ pane0 := &entity.Pane{ID: "pane0"}
150+ pane1 := &entity.Pane{ID: "pane1"}
151+
152+ child0 := &entity.PaneNode{ID: "child0", Pane: pane0}
153+ child1 := &entity.PaneNode{ID: "child1", Pane: pane1}
154+
155+ stackNode := &entity.PaneNode{
156+ ID: "stack",
157+ IsStacked: true,
158+ Children: []*entity.PaneNode{child0, child1},
159+ ActiveStackIndex: -1, // Invalid index
160+ }
161+
162+ child0.Parent = stackNode
163+ child1.Parent = stackNode
164+
165+ m := NewManager(nil)
166+
167+ // With invalid ActiveStackIndex, should fall back to pointer search
168+ // Pass child0 as current node
169+ canNav, paneID := m.navigateWithinStack(stackNode, child0, usecase.NavDown)
170+
171+ assert.True(t, canNav)
172+ assert.Equal(t, entity.PaneID("pane1"), paneID)
173+}
174+
175+// TestNavigateWithinStack_InvalidDirection tests navigation with invalid direction returns false.
176+func TestNavigateWithinStack_InvalidDirection(t *testing.T) {
177+ pane0 := &entity.Pane{ID: "pane0"}
178+ child0 := &entity.PaneNode{ID: "child0", Pane: pane0}
179+
180+ stackNode := &entity.PaneNode{
181+ ID: "stack",
182+ IsStacked: true,
183+ Children: []*entity.PaneNode{child0},
184+ ActiveStackIndex: 0,
185+ }
186+
187+ m := NewManager(nil)
188+
189+ // NavLeft and NavRight should not work for stack navigation
190+ canNav, paneID := m.navigateWithinStack(stackNode, child0, usecase.NavLeft)
191+
192+ assert.False(t, canNav)
193+ assert.Empty(t, paneID)
194+}
195+
196+// TestNavigateWithinStack_UpdatesActiveStackIndex tests that navigation properly
197+// updates the ActiveStackIndex on the stack node.
198+func TestNavigateWithinStack_UpdatesActiveStackIndex(t *testing.T) {
199+ pane0 := &entity.Pane{ID: "pane0"}
200+ pane1 := &entity.Pane{ID: "pane1"}
201+ pane2 := &entity.Pane{ID: "pane2"}
202+
203+ child0 := &entity.PaneNode{ID: "child0", Pane: pane0}
204+ child1 := &entity.PaneNode{ID: "child1", Pane: pane1}
205+ child2 := &entity.PaneNode{ID: "child2", Pane: pane2}
206+
207+ stackNode := &entity.PaneNode{
208+ ID: "stack",
209+ IsStacked: true,
210+ Children: []*entity.PaneNode{child0, child1, child2},
211+ ActiveStackIndex: 0,
212+ }
213+
214+ m := NewManager(nil)
215+
216+ // Navigate down twice
217+ stackNode.ActiveStackIndex = 0
218+ canNav, _ := m.navigateWithinStack(stackNode, child0, usecase.NavDown)
219+ assert.True(t, canNav)
220+ assert.Equal(t, 1, stackNode.ActiveStackIndex)
221+
222+ canNav, _ = m.navigateWithinStack(stackNode, child1, usecase.NavDown)
223+ assert.True(t, canNav)
224+ assert.Equal(t, 2, stackNode.ActiveStackIndex)
225+
226+ // Navigate up twice
227+ canNav, _ = m.navigateWithinStack(stackNode, child2, usecase.NavUp)
228+ assert.True(t, canNav)
229+ assert.Equal(t, 1, stackNode.ActiveStackIndex)
230+
231+ canNav, _ = m.navigateWithinStack(stackNode, child1, usecase.NavUp)
232+ assert.True(t, canNav)
233+ assert.Equal(t, 0, stackNode.ActiveStackIndex)
234+}

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