Skip to content

Commit 751dd20

Browse files
authored
fix(ui): scroll branch tree selections (#1058)
Implement scrolling for branch selection prompts when the terminal cannot show all matching branches. Add viewport support to `fliptree` as the renderer that owns line-by-line tree output, including scroll marker rendering. Keep `branchtree` as the branch-specific adapter that passes viewport settings through, and keep `BranchTreeSelect` responsible for selection state, filter matching, and choosing the viewport offset. Extend the UI script harness to accept per-script terminal sizes, and add regression coverage for constrained-height scrolling and preserving an already selected branch when filtering still matches it.
1 parent bc636cc commit 751dd20

File tree

13 files changed

+638
-94
lines changed

13 files changed

+638
-94
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
kind: Fixed
2+
body: Branch selection prompt now scrolls when the terminal is too short to show all matching branches.
3+
time: 2026-03-11T06:07:27.684554-07:00
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
Output "20260312-branch-checkout-scroll.gif"
2+
Set Shell "bash"
3+
Set FontSize 16
4+
Set Width 480
5+
Set Height 260
6+
Set Padding 10
7+
Set CursorBlink false
8+
9+
Hide
10+
Type "cd $(mktemp -d) && git init && git commit --allow-empty -m 'Initial commit'" Enter
11+
Type "alias gs=git-spice" Enter
12+
Type "gs repo init" Enter Sleep 100ms
13+
Type "git add lion && gs branch create lion -m 'Add lion'" Enter Sleep 100ms
14+
Type "touch griffin && git add griffin && gs branch create griffin -m 'Add griffin'" Enter Sleep 100ms
15+
Type "gs branch checkout main" Enter Sleep 100ms
16+
Type "touch squid && git add squid && gs branch create squid -m 'Add squid'" Enter Sleep 100ms
17+
Type "touch kraken && git add kraken && gs branch create kraken -m 'Add kraken'" Enter Sleep 100ms
18+
Type "gs branch checkout main" Enter Sleep 100ms
19+
Type "touch snake && git add snake && gs branch create snake -m 'Add snake'" Enter Sleep 100ms
20+
Type "touch basilisk && git add basilisk && gs branch create basilisk -m 'Add basilisk'" Enter Sleep 100ms
21+
Type "gs branch checkout main" Enter Sleep 100ms
22+
Type "touch eagle && git add eagle && gs branch create eagle -m 'Add eagle'" Enter Sleep 100ms
23+
Type "touch phoenix && git add phoenix && gs branch create phoenix -m 'Add phoenix'" Enter Sleep 100ms
24+
Type "gs branch checkout main" Enter Sleep 100ms
25+
Type "touch scorpion && git add scorpion && gs branch create scorpion -m 'Add scorpion'" Enter Sleep 100ms
26+
Type "touch manticore && git add manticore && gs branch create manticore -m 'Add manticore'" Enter Sleep 100ms
27+
Type "clear" Enter Sleep 100ms
28+
Show
29+
30+
Sleep 500ms
31+
32+
Type "gs ls" Enter
33+
Sleep 2s
34+
35+
Type "gs bco" Enter
36+
Sleep 2s
37+
38+
Down
39+
Sleep 300ms
40+
Down
41+
Sleep 300ms
42+
Down
43+
Sleep 300ms
44+
Down
45+
Sleep 300ms
46+
Down
47+
Sleep 1s
48+
49+
Down
50+
Sleep 300ms
51+
Down
52+
Sleep 1s
53+
54+
Up
55+
Sleep 1s
56+
57+
Enter
58+
Sleep 2s
59+
60+
Type "gs ls" Enter
61+
Sleep 3s

internal/ui/branchtree/tree.go

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,13 @@ type GraphOptions struct {
225225
// HomeDir is used for "~" substitution in worktree paths.
226226
// If empty, no substitution is performed.
227227
HomeDir string
228+
229+
// Offset is the number of rendered lines to skip.
230+
Offset int
231+
232+
// Height is the maximum number of rendered lines to show.
233+
// Zero or negative means render all lines.
234+
Height int
228235
}
229236

230237
// PushStatusFormat controls how push status is rendered.
@@ -262,18 +269,16 @@ func Write(w io.Writer, g Graph, opts *GraphOptions) error {
262269
HomeDir: opts.HomeDir,
263270
}
264271

265-
treeStyle := &fliptree.Style[*Item]{
266-
Joint: ui.NewStyle().Faint(true),
267-
NodeMarker: func(item *Item) ui.Style {
268-
switch {
269-
case item.Disabled:
270-
return opts.Style.NodeMarkerDisabled
271-
case item.Highlighted:
272-
return opts.Style.NodeMarkerHighlighted
273-
default:
274-
return opts.Style.NodeMarker
275-
}
276-
},
272+
treeStyle := fliptree.DefaultStyle[*Item]()
273+
treeStyle.NodeMarker = func(item *Item) ui.Style {
274+
switch {
275+
case item.Disabled:
276+
return opts.Style.NodeMarkerDisabled
277+
case item.Highlighted:
278+
return opts.Style.NodeMarkerHighlighted
279+
default:
280+
return opts.Style.NodeMarker
281+
}
277282
}
278283

279284
return fliptree.Write(w, fliptree.Graph[*Item]{
@@ -282,8 +287,10 @@ func Write(w io.Writer, g Graph, opts *GraphOptions) error {
282287
Edges: func(item *Item) []int { return item.Aboves },
283288
View: renderer.RenderItem,
284289
}, fliptree.Options[*Item]{
285-
Theme: opts.Theme,
286-
Style: treeStyle,
290+
Theme: opts.Theme,
291+
Style: treeStyle,
292+
Offset: opts.Offset,
293+
Height: opts.Height,
287294
})
288295
}
289296

internal/ui/fliptree/tree.go

Lines changed: 121 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ package fliptree
1313

1414
import (
1515
"bufio"
16+
"bytes"
17+
"fmt"
1618
"io"
1719
"slices"
1820
"strings"
@@ -24,6 +26,18 @@ import (
2426
// DefaultNodeMarker is the marker used for each node in the tree.
2527
var DefaultNodeMarker = ui.NewStyle().SetString("□")
2628

29+
// DefaultScrollUpMarker is the marker shown
30+
// when content is scrolled out above the viewport.
31+
var DefaultScrollUpMarker = ui.NewStyle().
32+
Foreground(ui.Gray).
33+
SetString("▲▲▲")
34+
35+
// DefaultScrollDownMarker is the marker shown
36+
// when content is scrolled out below the viewport.
37+
var DefaultScrollDownMarker = ui.NewStyle().
38+
Foreground(ui.Gray).
39+
SetString("▼▼▼")
40+
2741
// Graph defines a directed graph.
2842
type Graph[T any] struct {
2943
// Values specifies the value for each node in the graph.
@@ -49,6 +63,15 @@ type Graph[T any] struct {
4963
type Options[T any] struct {
5064
Theme ui.Theme
5165
Style *Style[T]
66+
67+
// Offset states the number of lines to skip before rendering the tree,
68+
// and Height states the maximum number of lines to render after that.
69+
//
70+
// Use these together to render a view of a larger tree.
71+
//
72+
// A height <= 0 indicates no limit.
73+
// Scroll markers are rendered for a height > 0.
74+
Offset, Height int
5275
}
5376

5477
// Style configures the visual appearance of the tree.
@@ -61,6 +84,14 @@ type Style[T any] struct {
6184
//
6285
// By default, all nodes are marked with [DefaultNodeMarker].
6386
NodeMarker func(T) ui.Style
87+
88+
// ScrollUpMarker is shown above the viewport
89+
// when content exists above it.
90+
ScrollUpMarker ui.Style
91+
92+
// ScrollDownMarker is shown below the viewport
93+
// when content exists below it.
94+
ScrollDownMarker ui.Style
6495
}
6596

6697
// DefaultStyle returns the default style for rendering trees.
@@ -70,6 +101,8 @@ func DefaultStyle[T any]() *Style[T] {
70101
NodeMarker: func(T) ui.Style {
71102
return DefaultNodeMarker
72103
},
104+
ScrollUpMarker: DefaultScrollUpMarker,
105+
ScrollDownMarker: DefaultScrollDownMarker,
73106
}
74107
}
75108

@@ -80,15 +113,23 @@ func Write[T any](w io.Writer, g Graph[T], opts Options[T]) error {
80113
}
81114

82115
tw := treeWriter[T]{
83-
w: bufio.NewWriter(w),
84-
g: g,
85-
style: newTreeStyle(opts.Style, opts.Theme),
116+
w: bufio.NewWriter(w),
117+
g: g,
118+
style: newTreeStyle(opts.Style, opts.Theme),
119+
offset: max(0, opts.Offset),
120+
height: opts.Height,
86121
}
87122
for _, root := range g.Roots {
88123
if err := tw.writeTree(root, nil, nil); err != nil {
89124
return err
90125
}
91126
}
127+
128+
if tw.truncatedBelow {
129+
if _, err := fmt.Fprintln(tw.w, tw.style.ScrollDownMarker.String()); err != nil {
130+
return err
131+
}
132+
}
92133
return tw.w.Flush()
93134
}
94135

@@ -97,12 +138,38 @@ type treeWriter[T any] struct {
97138
g Graph[T]
98139

99140
lineNum int
100-
style treeStyle[T]
141+
142+
// Number of rendered tree lines to skip before starting the viewport.
143+
offset int
144+
// Maximum number of tree content lines to write.
145+
// Zero or negative means no viewport limit.
146+
height int
147+
148+
// Number of tree content lines written to the viewport,
149+
// excluding scroll markers.
150+
wroteLines int
151+
152+
// Whether the top scroll marker has already been emitted.
153+
// This is shown once when offset > 0 and the first viewport line is written.
154+
wroteScrollUp bool
155+
156+
// Whether tree content was cut off at the bottom.
157+
// This is true if height > 0,
158+
// and the number of lines to write exceeds the height limit.
159+
//
160+
// This informs the caller whether a bottom scroll marker
161+
// should be emitted after the tree is fully rendered.
162+
truncatedBelow bool
163+
// TODO: maybe we can fold this into the render loop
164+
165+
style treeStyle[T]
101166
}
102167

103168
type treeStyle[T any] struct {
104-
Joint lipgloss.Style
105-
NodeMarker func(T) lipgloss.Style
169+
Joint lipgloss.Style
170+
NodeMarker func(T) lipgloss.Style
171+
ScrollUpMarker lipgloss.Style
172+
ScrollDownMarker lipgloss.Style
106173
}
107174

108175
func newTreeStyle[T any](s *Style[T], theme ui.Theme) treeStyle[T] {
@@ -111,6 +178,8 @@ func newTreeStyle[T any](s *Style[T], theme ui.Theme) treeStyle[T] {
111178
NodeMarker: func(v T) lipgloss.Style {
112179
return s.NodeMarker(v).Resolve(theme)
113180
},
181+
ScrollUpMarker: s.ScrollUpMarker.Resolve(theme),
182+
ScrollDownMarker: s.ScrollDownMarker.Resolve(theme),
114183
}
115184
}
116185

@@ -225,25 +294,58 @@ func (tw *treeWriter[T]) writeTree(nodeIdx int, path []int, pathNodeIxes []int)
225294
lastJoint = string(_verticalRight) + string(_horizontal)
226295
}
227296

228-
lines := strings.Split(tw.g.View(nodeValue), "\n")
229-
for idx, line := range lines {
297+
var lineBuffer bytes.Buffer
298+
firstLine := true
299+
for line := range strings.SplitSeq(tw.g.View(nodeValue), "\n") {
300+
lineBuffer.Reset()
301+
230302
// The text may be multi-line.
231303
// Only the first line has a title marker.
232-
if idx == 0 {
233-
tw.pipes(path, lastJoint, titlePrefix)
304+
if firstLine {
305+
firstLine = false
306+
tw.pipes(&lineBuffer, path, lastJoint, titlePrefix)
234307
} else {
235-
tw.pipes(path, string(_vertical)+" ", bodyPrefix)
308+
tw.pipes(&lineBuffer, path, string(_vertical)+" ", bodyPrefix)
236309
}
237310

238-
_, _ = tw.w.WriteString(line)
239-
_, _ = tw.w.WriteString("\n")
240-
tw.lineNum++
311+
lineBuffer.WriteString(line)
312+
tw.writeLine(lineBuffer.Bytes())
241313
}
242314

243315
return nil
244316
}
245317

246-
func (tw *treeWriter[T]) pipes(path []int, joint string, marker string) {
318+
// writeLine writes a line of the tree to the output,
319+
// respecting the scroll offset and height limits.
320+
//
321+
// This performs necessary bookkeeping internally.
322+
func (tw *treeWriter[T]) writeLine(line []byte) {
323+
lineNum := tw.lineNum
324+
tw.lineNum++
325+
326+
if lineNum < tw.offset {
327+
return
328+
}
329+
330+
if tw.height > 0 && tw.wroteLines >= tw.height {
331+
tw.truncatedBelow = true
332+
return
333+
}
334+
335+
// If content above the viewport is getting cut off,
336+
// we need to add a scroll marker.
337+
if !tw.wroteScrollUp && tw.offset > 0 {
338+
_, _ = fmt.Fprintln(tw.w, tw.style.ScrollUpMarker.String())
339+
tw.wroteScrollUp = true
340+
}
341+
342+
_, _ = tw.w.Write(line)
343+
_ = tw.w.WriteByte('\n')
344+
345+
tw.wroteLines++
346+
}
347+
348+
func (tw *treeWriter[T]) pipes(buf *bytes.Buffer, path []int, joint string, marker string) {
247349
if len(path) == 0 {
248350
return
249351
}
@@ -254,15 +356,16 @@ func (tw *treeWriter[T]) pipes(path []int, joint string, marker string) {
254356
// needs just connecting pipes.
255357
for _, pos := range path[:len(path)-1] {
256358
if pos > 0 {
257-
_, _ = tw.w.WriteString(
359+
buf.WriteString(
258360
style.Render(string(_vertical) + " "),
259361
)
260362
} else {
261-
_, _ = tw.w.WriteString(" ")
363+
buf.WriteString(" ")
262364
}
263365
}
264366

265-
_, _ = tw.w.WriteString(style.Render(joint) + marker)
367+
buf.WriteString(style.Render(joint))
368+
buf.WriteString(marker)
266369
}
267370

268371
// CycleError is returned when a cycle is detected in the tree.

0 commit comments

Comments
 (0)