detached 8141b0b

fix(ui): hide active border when only one visible pane area

commit
8141b0b1f7690b5c96fdd88072db3191c690c10e
author
bnema · · 2026-01-06 19:01 UTC
committer
bnema · · 2026-01-06 19:04 UTC
parent
eac150d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +180 -2
M internal/domain/entity/pane.go +34 -0
@@ -156,3 +156,37 @@ func (n *PaneNode) LeafCount() int {
156156 })
157157 return count
158158 }
159+
160+// VisibleAreaCount returns the number of visible pane areas in the tree.
161+// A stacked node counts as 1 visible area (only one pane visible at a time).
162+// A leaf node counts as 1 visible area.
163+// A split node's visible areas are the sum of its children's visible areas.
164+func (n *PaneNode) VisibleAreaCount() int {
165+ switch {
166+ case n.IsLeaf():
167+ return 1
168+ case n.IsStacked:
169+ // Stacked panes: only one is visible at a time
170+ return 1
171+ case n.IsSplit():
172+ // Split: sum of both children's visible areas
173+ count := 0
174+ if left := n.Left(); left != nil {
175+ count += left.VisibleAreaCount()
176+ }
177+ if right := n.Right(); right != nil {
178+ count += right.VisibleAreaCount()
179+ }
180+ return count
181+ default:
182+ // Fallback for other container types
183+ if len(n.Children) == 0 {
184+ return 1
185+ }
186+ count := 0
187+ for _, child := range n.Children {
188+ count += child.VisibleAreaCount()
189+ }
190+ return count
191+ }
192+}
@@ -156,3 +156,37 @@ func (n *PaneNode) LeafCount() int {
156 })156 })
157 return count157 return count
158 }158 }
159+
160+// VisibleAreaCount returns the number of visible pane areas in the tree.
161+// A stacked node counts as 1 visible area (only one pane visible at a time).
162+// A leaf node counts as 1 visible area.
163+// A split node's visible areas are the sum of its children's visible areas.
164+func (n *PaneNode) VisibleAreaCount() int {
165+ switch {
166+ case n.IsLeaf():
167+ return 1
168+ case n.IsStacked:
169+ // Stacked panes: only one is visible at a time
170+ return 1
171+ case n.IsSplit():
172+ // Split: sum of both children's visible areas
173+ count := 0
174+ if left := n.Left(); left != nil {
175+ count += left.VisibleAreaCount()
176+ }
177+ if right := n.Right(); right != nil {
178+ count += right.VisibleAreaCount()
179+ }
180+ return count
181+ default:
182+ // Fallback for other container types
183+ if len(n.Children) == 0 {
184+ return 1
185+ }
186+ count := 0
187+ for _, child := range n.Children {
188+ count += child.VisibleAreaCount()
189+ }
190+ return count
191+ }
192+}
A internal/domain/entity/pane_test.go +129 -0
@@ -0,0 +1,129 @@
1+package entity
2+
3+import "testing"
4+
5+func TestPaneNode_VisibleAreaCount(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ node *PaneNode
9+ expected int
10+ }{
11+ {
12+ name: "single leaf pane",
13+ node: &PaneNode{
14+ ID: "pane1",
15+ Pane: NewPane("pane1"),
16+ },
17+ expected: 1,
18+ },
19+ {
20+ name: "horizontal split with two leaves",
21+ node: &PaneNode{
22+ ID: "split1",
23+ SplitDir: SplitHorizontal,
24+ Children: []*PaneNode{
25+ {ID: "pane1", Pane: NewPane("pane1")},
26+ {ID: "pane2", Pane: NewPane("pane2")},
27+ },
28+ },
29+ expected: 2,
30+ },
31+ {
32+ name: "stacked panes count as one",
33+ node: &PaneNode{
34+ ID: "stack1",
35+ IsStacked: true,
36+ ActiveStackIndex: 0,
37+ Children: []*PaneNode{
38+ {ID: "pane1", Pane: NewPane("pane1")},
39+ {ID: "pane2", Pane: NewPane("pane2")},
40+ {ID: "pane3", Pane: NewPane("pane3")},
41+ },
42+ },
43+ expected: 1,
44+ },
45+ {
46+ name: "split with one stacked side",
47+ node: &PaneNode{
48+ ID: "split1",
49+ SplitDir: SplitHorizontal,
50+ Children: []*PaneNode{
51+ {ID: "pane1", Pane: NewPane("pane1")},
52+ {
53+ ID: "stack1",
54+ IsStacked: true,
55+ ActiveStackIndex: 0,
56+ Children: []*PaneNode{
57+ {ID: "pane2", Pane: NewPane("pane2")},
58+ {ID: "pane3", Pane: NewPane("pane3")},
59+ },
60+ },
61+ },
62+ },
63+ expected: 2,
64+ },
65+ {
66+ name: "nested splits",
67+ node: &PaneNode{
68+ ID: "split1",
69+ SplitDir: SplitHorizontal,
70+ Children: []*PaneNode{
71+ {ID: "pane1", Pane: NewPane("pane1")},
72+ {
73+ ID: "split2",
74+ SplitDir: SplitVertical,
75+ Children: []*PaneNode{
76+ {ID: "pane2", Pane: NewPane("pane2")},
77+ {ID: "pane3", Pane: NewPane("pane3")},
78+ },
79+ },
80+ },
81+ },
82+ expected: 3,
83+ },
84+ {
85+ name: "complex tree with stacks and splits",
86+ node: &PaneNode{
87+ ID: "split1",
88+ SplitDir: SplitHorizontal,
89+ Children: []*PaneNode{
90+ {
91+ ID: "stack1",
92+ IsStacked: true,
93+ ActiveStackIndex: 0,
94+ Children: []*PaneNode{
95+ {ID: "pane1", Pane: NewPane("pane1")},
96+ {ID: "pane2", Pane: NewPane("pane2")},
97+ },
98+ },
99+ {
100+ ID: "split2",
101+ SplitDir: SplitVertical,
102+ Children: []*PaneNode{
103+ {ID: "pane3", Pane: NewPane("pane3")},
104+ {
105+ ID: "stack2",
106+ IsStacked: true,
107+ ActiveStackIndex: 1,
108+ Children: []*PaneNode{
109+ {ID: "pane4", Pane: NewPane("pane4")},
110+ {ID: "pane5", Pane: NewPane("pane5")},
111+ },
112+ },
113+ },
114+ },
115+ },
116+ },
117+ expected: 3, // stack1(1) + pane3(1) + stack2(1)
118+ },
119+ }
120+
121+ for _, tt := range tests {
122+ t.Run(tt.name, func(t *testing.T) {
123+ got := tt.node.VisibleAreaCount()
124+ if got != tt.expected {
125+ t.Errorf("VisibleAreaCount() = %d, want %d", got, tt.expected)
126+ }
127+ })
128+ }
129+}
@@ -0,0 +1,129 @@
1+package entity
2+
3+import "testing"
4+
5+func TestPaneNode_VisibleAreaCount(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ node *PaneNode
9+ expected int
10+ }{
11+ {
12+ name: "single leaf pane",
13+ node: &PaneNode{
14+ ID: "pane1",
15+ Pane: NewPane("pane1"),
16+ },
17+ expected: 1,
18+ },
19+ {
20+ name: "horizontal split with two leaves",
21+ node: &PaneNode{
22+ ID: "split1",
23+ SplitDir: SplitHorizontal,
24+ Children: []*PaneNode{
25+ {ID: "pane1", Pane: NewPane("pane1")},
26+ {ID: "pane2", Pane: NewPane("pane2")},
27+ },
28+ },
29+ expected: 2,
30+ },
31+ {
32+ name: "stacked panes count as one",
33+ node: &PaneNode{
34+ ID: "stack1",
35+ IsStacked: true,
36+ ActiveStackIndex: 0,
37+ Children: []*PaneNode{
38+ {ID: "pane1", Pane: NewPane("pane1")},
39+ {ID: "pane2", Pane: NewPane("pane2")},
40+ {ID: "pane3", Pane: NewPane("pane3")},
41+ },
42+ },
43+ expected: 1,
44+ },
45+ {
46+ name: "split with one stacked side",
47+ node: &PaneNode{
48+ ID: "split1",
49+ SplitDir: SplitHorizontal,
50+ Children: []*PaneNode{
51+ {ID: "pane1", Pane: NewPane("pane1")},
52+ {
53+ ID: "stack1",
54+ IsStacked: true,
55+ ActiveStackIndex: 0,
56+ Children: []*PaneNode{
57+ {ID: "pane2", Pane: NewPane("pane2")},
58+ {ID: "pane3", Pane: NewPane("pane3")},
59+ },
60+ },
61+ },
62+ },
63+ expected: 2,
64+ },
65+ {
66+ name: "nested splits",
67+ node: &PaneNode{
68+ ID: "split1",
69+ SplitDir: SplitHorizontal,
70+ Children: []*PaneNode{
71+ {ID: "pane1", Pane: NewPane("pane1")},
72+ {
73+ ID: "split2",
74+ SplitDir: SplitVertical,
75+ Children: []*PaneNode{
76+ {ID: "pane2", Pane: NewPane("pane2")},
77+ {ID: "pane3", Pane: NewPane("pane3")},
78+ },
79+ },
80+ },
81+ },
82+ expected: 3,
83+ },
84+ {
85+ name: "complex tree with stacks and splits",
86+ node: &PaneNode{
87+ ID: "split1",
88+ SplitDir: SplitHorizontal,
89+ Children: []*PaneNode{
90+ {
91+ ID: "stack1",
92+ IsStacked: true,
93+ ActiveStackIndex: 0,
94+ Children: []*PaneNode{
95+ {ID: "pane1", Pane: NewPane("pane1")},
96+ {ID: "pane2", Pane: NewPane("pane2")},
97+ },
98+ },
99+ {
100+ ID: "split2",
101+ SplitDir: SplitVertical,
102+ Children: []*PaneNode{
103+ {ID: "pane3", Pane: NewPane("pane3")},
104+ {
105+ ID: "stack2",
106+ IsStacked: true,
107+ ActiveStackIndex: 1,
108+ Children: []*PaneNode{
109+ {ID: "pane4", Pane: NewPane("pane4")},
110+ {ID: "pane5", Pane: NewPane("pane5")},
111+ },
112+ },
113+ },
114+ },
115+ },
116+ },
117+ expected: 3, // stack1(1) + pane3(1) + stack2(1)
118+ },
119+ }
120+
121+ for _, tt := range tests {
122+ t.Run(tt.name, func(t *testing.T) {
123+ got := tt.node.VisibleAreaCount()
124+ if got != tt.expected {
125+ t.Errorf("VisibleAreaCount() = %d, want %d", got, tt.expected)
126+ }
127+ })
128+ }
129+}
M internal/domain/entity/workspace.go +9 -0
@@ -64,3 +64,12 @@ func (w *Workspace) AllPanes() []*Pane {
6464 })
6565 return panes
6666 }
67+
68+// VisibleAreaCount returns the number of visible pane areas.
69+// Stacked panes count as 1 (only one visible at a time).
70+func (w *Workspace) VisibleAreaCount() int {
71+ if w.Root == nil {
72+ return 0
73+ }
74+ return w.Root.VisibleAreaCount()
75+}
@@ -64,3 +64,12 @@ func (w *Workspace) AllPanes() []*Pane {
64 })64 })
65 return panes65 return panes
66 }66 }
67+
68+// VisibleAreaCount returns the number of visible pane areas.
69+// Stacked panes count as 1 (only one visible at a time).
70+func (w *Workspace) VisibleAreaCount() int {
71+ if w.Root == nil {
72+ return 0
73+ }
74+ return w.Root.VisibleAreaCount()
75+}
M internal/ui/component/workspace_view.go +8 -2
@@ -297,17 +297,23 @@ func (wv *WorkspaceView) PaneCount() int {
297297 return len(wv.paneViews)
298298 }
299299
300-// singlePaneClass is used to hide the active pane border when only one pane exists.
300+// singlePaneClass is used to hide the active pane border when only one visible area exists.
301301 const singlePaneClass = "single-pane"
302302
303303 // updateSinglePaneModeInternal adds/removes the single-pane CSS class on the container.
304+// Uses VisibleAreaCount from the domain model: stacked panes count as 1 visible area.
304305 // Must be called with the lock held.
305306 func (wv *WorkspaceView) updateSinglePaneModeInternal() {
306307 if wv.container == nil {
307308 return
308309 }
309310
310- if len(wv.paneViews) <= 1 {
311+ visibleAreas := 0
312+ if wv.workspace != nil {
313+ visibleAreas = wv.workspace.VisibleAreaCount()
314+ }
315+
316+ if visibleAreas <= 1 {
311317 wv.container.AddCssClass(singlePaneClass)
312318 } else {
313319 wv.container.RemoveCssClass(singlePaneClass)
@@ -297,17 +297,23 @@ func (wv *WorkspaceView) PaneCount() int {
297 return len(wv.paneViews)297 return len(wv.paneViews)
298 }298 }
299 299
300-// singlePaneClass is used to hide the active pane border when only one pane exists.300+// singlePaneClass is used to hide the active pane border when only one visible area exists.
301 const singlePaneClass = "single-pane"301 const singlePaneClass = "single-pane"
302 302
303 // updateSinglePaneModeInternal adds/removes the single-pane CSS class on the container.303 // updateSinglePaneModeInternal adds/removes the single-pane CSS class on the container.
304+// Uses VisibleAreaCount from the domain model: stacked panes count as 1 visible area.
304 // Must be called with the lock held.305 // Must be called with the lock held.
305 func (wv *WorkspaceView) updateSinglePaneModeInternal() {306 func (wv *WorkspaceView) updateSinglePaneModeInternal() {
306 if wv.container == nil {307 if wv.container == nil {
307 return308 return
308 }309 }
309 310
310- if len(wv.paneViews) <= 1 {311+ visibleAreas := 0
312+ if wv.workspace != nil {
313+ visibleAreas = wv.workspace.VisibleAreaCount()
314+ }
315+
316+ if visibleAreas <= 1 {
311 wv.container.AddCssClass(singlePaneClass)317 wv.container.AddCssClass(singlePaneClass)
312 } else {318 } else {
313 wv.container.RemoveCssClass(singlePaneClass)319 wv.container.RemoveCssClass(singlePaneClass)

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