detached d8255fc

fix(picker): keep selection visible on resize

commit
d8255fc228b28fd396d3fef4500174b72fb51aba
author
brice · · 2026-07-02 21:21 UTC
parent
00a245c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +64 -21
M internal/usecase/daemon/daemon.go +6 -9
@@ -2,7 +2,7 @@
22 // accept loop, the ephemeral/named session registry, the per-tab PTY reader
33 // and VT screen, and the per-client debounced render scheduler.
44 //
5-// Concurrency model (one tab per session for the MVP; multi-tab is M3):
5+// Concurrency model (sessions own one or more PTY-backed tabs):
66 //
77 // - Serve runs the accept loop. Each accepted connection is handled by its
88 // own goroutine (handleConn): it reads the first frame and routes it to a
@@ -487,7 +487,7 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
487487 return
488488 }
489489 d.firstPaint(sess, ac, h.Size)
490- d.runConnLoop(sess, ac)
490+ d.runConnLoop(ac)
491491 _ = tr.Close()
492492 }
493493
@@ -675,9 +675,9 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
675675
676676 // runConnLoop is the per-connection input router: it pumps client messages
677677 // until detach, EOF, or a transport error.
678-func (d *Daemon) runConnLoop(sess *session, ac *attachedClient) {
678+func (d *Daemon) runConnLoop(ac *attachedClient) {
679679 for {
680- sess = ac.currentSession()
680+ sess := ac.currentSession()
681681 if sess == nil {
682682 return
683683 }
@@ -1200,11 +1200,8 @@ func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker
12001200 return
12011201 }
12021202 if targetSess == sess {
1203- if sess.switchTab(target.TabIndex) {
1204- d.paint(sess, ac, true)
1205- } else {
1206- d.paint(sess, ac, true)
1207- }
1203+ sess.switchTab(target.TabIndex)
1204+ d.paint(sess, ac, true)
12081205 return
12091206 }
12101207 if !sess.detachIfCurrent(ac) {
@@ -2,7 +2,7 @@
2 // accept loop, the ephemeral/named session registry, the per-tab PTY reader2 // accept loop, the ephemeral/named session registry, the per-tab PTY reader
3 // and VT screen, and the per-client debounced render scheduler.3 // and VT screen, and the per-client debounced render scheduler.
4 //4 //
5-// Concurrency model (one tab per session for the MVP; multi-tab is M3):5+// Concurrency model (sessions own one or more PTY-backed tabs):
6 //6 //
7 // - Serve runs the accept loop. Each accepted connection is handled by its7 // - Serve runs the accept loop. Each accepted connection is handled by its
8 // own goroutine (handleConn): it reads the first frame and routes it to a8 // own goroutine (handleConn): it reads the first frame and routes it to a
@@ -487,7 +487,7 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
487 return487 return
488 }488 }
489 d.firstPaint(sess, ac, h.Size)489 d.firstPaint(sess, ac, h.Size)
490- d.runConnLoop(sess, ac)490+ d.runConnLoop(ac)
491 _ = tr.Close()491 _ = tr.Close()
492 }492 }
493 493
@@ -675,9 +675,9 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
675 675
676 // runConnLoop is the per-connection input router: it pumps client messages676 // runConnLoop is the per-connection input router: it pumps client messages
677 // until detach, EOF, or a transport error.677 // until detach, EOF, or a transport error.
678-func (d *Daemon) runConnLoop(sess *session, ac *attachedClient) {678+func (d *Daemon) runConnLoop(ac *attachedClient) {
679 for {679 for {
680- sess = ac.currentSession()680+ sess := ac.currentSession()
681 if sess == nil {681 if sess == nil {
682 return682 return
683 }683 }
@@ -1200,11 +1200,8 @@ func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker
1200 return1200 return
1201 }1201 }
1202 if targetSess == sess {1202 if targetSess == sess {
1203- if sess.switchTab(target.TabIndex) {1203+ sess.switchTab(target.TabIndex)
1204- d.paint(sess, ac, true)1204+ d.paint(sess, ac, true)
1205- } else {
1206- d.paint(sess, ac, true)
1207- }
1208 return1205 return
1209 }1206 }
1210 if !sess.detachIfCurrent(ac) {1207 if !sess.detachIfCurrent(ac) {
M internal/usecase/picker/model.go +33 -7
@@ -8,6 +8,7 @@ import (
88 const (
99 MinPreviewWidth = 24
1010 MinListWidth = 16
11+ MaxListWidth = 32
1112 MinPaneHeight = 4
1213 MinStackHeight = 12
1314 )
@@ -83,12 +84,14 @@ func ChooseLayout(inner domain.Size) Layout {
8384 if inner.Cols <= 0 || inner.Rows <= 0 {
8485 return Layout{}
8586 }
86- if inner.Cols >= 40 && inner.Rows >= MinPaneHeight {
87- listWidth := max(MinListWidth, inner.Cols*30/100)
87+ if inner.Rows >= MinPaneHeight && inner.Cols >= MinListWidth+1+MinPreviewWidth {
88+ listWidth := clamp(inner.Cols*30/100, MinListWidth, MaxListWidth)
8889 previewWidth := inner.Cols - listWidth - 1
89- return Layout{
90- List: domain.Rect{Width: listWidth, Height: inner.Rows},
91- Preview: domain.Rect{X: listWidth + 1, Width: previewWidth, Height: inner.Rows},
90+ if previewWidth >= MinPreviewWidth {
91+ return Layout{
92+ List: domain.Rect{Width: listWidth, Height: inner.Rows},
93+ Preview: domain.Rect{X: listWidth + 1, Width: previewWidth, Height: inner.Rows},
94+ }
9295 }
9396 }
9497 if inner.Rows >= MinStackHeight && inner.Cols >= MinPreviewWidth {
@@ -158,8 +161,10 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect) {
158161 if m == nil || rect.Width <= 0 || rect.Height <= 0 {
159162 return
160163 }
161- for y := range min(rect.Height, frame.Height-rect.Y) {
162- idx := y
164+ visible := min(rect.Height, frame.Height-rect.Y)
165+ offset := m.scrollOffset(visible)
166+ for y := range visible {
167+ idx := offset + y
163168 if idx >= len(m.rows) {
164169 break
165170 }
@@ -176,6 +181,17 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect) {
176181 }
177182 }
178183
184+func (m *Model) scrollOffset(visible int) int {
185+ if visible <= 0 || len(m.rows) <= visible || m.selected < 0 {
186+ return 0
187+ }
188+ if m.selected < visible {
189+ return 0
190+ }
191+ offset := m.selected - visible + 1
192+ return min(offset, len(m.rows)-visible)
193+}
194+
179195 func drawText(frame renderer.Frame, x, y, width int, text string, style renderer.Style) {
180196 if y < 0 || y >= frame.Height || x >= frame.Width || width <= 0 {
181197 return
@@ -198,6 +214,16 @@ func drawText(frame renderer.Frame, x, y, width int, text string, style renderer
198214 }
199215 }
200216
217+func clamp(n, low, high int) int {
218+ if n < low {
219+ return low
220+ }
221+ if n > high {
222+ return high
223+ }
224+ return n
225+}
226+
201227 func blitPreview(frame renderer.Frame, rect domain.Rect, preview Preview) {
202228 if rect.Width <= 0 || rect.Height <= 0 {
203229 return
@@ -8,6 +8,7 @@ import (
8 const (8 const (
9 MinPreviewWidth = 249 MinPreviewWidth = 24
10 MinListWidth = 1610 MinListWidth = 16
11+ MaxListWidth = 32
11 MinPaneHeight = 412 MinPaneHeight = 4
12 MinStackHeight = 1213 MinStackHeight = 12
13 )14 )
@@ -83,12 +84,14 @@ func ChooseLayout(inner domain.Size) Layout {
83 if inner.Cols <= 0 || inner.Rows <= 0 {84 if inner.Cols <= 0 || inner.Rows <= 0 {
84 return Layout{}85 return Layout{}
85 }86 }
86- if inner.Cols >= 40 && inner.Rows >= MinPaneHeight {87+ if inner.Rows >= MinPaneHeight && inner.Cols >= MinListWidth+1+MinPreviewWidth {
87- listWidth := max(MinListWidth, inner.Cols*30/100)88+ listWidth := clamp(inner.Cols*30/100, MinListWidth, MaxListWidth)
88 previewWidth := inner.Cols - listWidth - 189 previewWidth := inner.Cols - listWidth - 1
89- return Layout{90+ if previewWidth >= MinPreviewWidth {
90- List: domain.Rect{Width: listWidth, Height: inner.Rows},91+ return Layout{
91- Preview: domain.Rect{X: listWidth + 1, Width: previewWidth, Height: inner.Rows},92+ List: domain.Rect{Width: listWidth, Height: inner.Rows},
93+ Preview: domain.Rect{X: listWidth + 1, Width: previewWidth, Height: inner.Rows},
94+ }
92 }95 }
93 }96 }
94 if inner.Rows >= MinStackHeight && inner.Cols >= MinPreviewWidth {97 if inner.Rows >= MinStackHeight && inner.Cols >= MinPreviewWidth {
@@ -158,8 +161,10 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect) {
158 if m == nil || rect.Width <= 0 || rect.Height <= 0 {161 if m == nil || rect.Width <= 0 || rect.Height <= 0 {
159 return162 return
160 }163 }
161- for y := range min(rect.Height, frame.Height-rect.Y) {164+ visible := min(rect.Height, frame.Height-rect.Y)
162- idx := y165+ offset := m.scrollOffset(visible)
166+ for y := range visible {
167+ idx := offset + y
163 if idx >= len(m.rows) {168 if idx >= len(m.rows) {
164 break169 break
165 }170 }
@@ -176,6 +181,17 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect) {
176 }181 }
177 }182 }
178 183
184+func (m *Model) scrollOffset(visible int) int {
185+ if visible <= 0 || len(m.rows) <= visible || m.selected < 0 {
186+ return 0
187+ }
188+ if m.selected < visible {
189+ return 0
190+ }
191+ offset := m.selected - visible + 1
192+ return min(offset, len(m.rows)-visible)
193+}
194+
179 func drawText(frame renderer.Frame, x, y, width int, text string, style renderer.Style) {195 func drawText(frame renderer.Frame, x, y, width int, text string, style renderer.Style) {
180 if y < 0 || y >= frame.Height || x >= frame.Width || width <= 0 {196 if y < 0 || y >= frame.Height || x >= frame.Width || width <= 0 {
181 return197 return
@@ -198,6 +214,16 @@ func drawText(frame renderer.Frame, x, y, width int, text string, style renderer
198 }214 }
199 }215 }
200 216
217+func clamp(n, low, high int) int {
218+ if n < low {
219+ return low
220+ }
221+ if n > high {
222+ return high
223+ }
224+ return n
225+}
226+
201 func blitPreview(frame renderer.Frame, rect domain.Rect, preview Preview) {227 func blitPreview(frame renderer.Frame, rect domain.Rect, preview Preview) {
202 if rect.Width <= 0 || rect.Height <= 0 {228 if rect.Width <= 0 || rect.Height <= 0 {
203 return229 return
M internal/usecase/picker/model_test.go +25 -5
@@ -70,8 +70,16 @@ func TestChooseLayoutBoundaries(t *testing.T) {
7070 require.Equal(t, domain.Rect{Y: 5, Width: 39, Height: 7}, layout.Preview)
7171
7272 layout = ChooseLayout(domain.Size{Cols: 40, Rows: 4})
73+ require.Equal(t, domain.Rect{Width: 40, Height: 4}, layout.List)
74+ require.Equal(t, domain.Rect{}, layout.Preview)
75+
76+ layout = ChooseLayout(domain.Size{Cols: 41, Rows: 4})
7377 require.Equal(t, domain.Rect{Width: 16, Height: 4}, layout.List)
74- require.Equal(t, domain.Rect{X: 17, Width: 23, Height: 4}, layout.Preview)
78+ require.Equal(t, domain.Rect{X: 17, Width: 24, Height: 4}, layout.Preview)
79+
80+ layout = ChooseLayout(domain.Size{Cols: 120, Rows: 4})
81+ require.Equal(t, domain.Rect{Width: MaxListWidth, Height: 4}, layout.List)
82+ require.Equal(t, domain.Rect{X: MaxListWidth + 1, Width: 87, Height: 4}, layout.Preview)
7583
7684 layout = ChooseLayout(domain.Size{Cols: 24, Rows: 11})
7785 require.Equal(t, domain.Rect{Width: 24, Height: 11}, layout.List)
@@ -101,18 +109,30 @@ func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {
101109 Rows: [][]renderer.Cell{{
102110 cell('a'), cell('b'), cell('c'), cell('d'), cell('e'), cell('f'), cell('g'), cell('h'), cell('i'), cell('j'),
103111 cell('k'), cell('l'), cell('m'), cell('n'), cell('o'), cell('p'), cell('q'), cell('r'), cell('s'), cell('t'),
104- cell('u'), cell('v'), {Rune: '界', Style: renderer.DefaultStyle()}, {Continuation: true, Style: renderer.DefaultStyle()},
112+ cell('u'), cell('v'), cell('w'), {Rune: '界', Style: renderer.DefaultStyle()}, {Continuation: true, Style: renderer.DefaultStyle()},
105113 }},
106114 }
107115
108- frame := m.Render(domain.Size{Cols: 40, Rows: 4}, preview)
116+ frame := m.Render(domain.Size{Cols: 41, Rows: 4}, preview)
109117 require.True(t, frame.At(0, 1).Style.Inverse)
110118 require.Equal(t, 'a', frame.At(17, 0).Rune)
111- require.Equal(t, 'v', frame.At(38, 0).Rune)
112- require.Equal(t, ' ', frame.At(39, 0).Rune, "wide rune crossing preview pane is dropped")
119+ require.Equal(t, 'w', frame.At(39, 0).Rune)
120+ require.Equal(t, ' ', frame.At(40, 0).Rune, "wide rune crossing preview pane is dropped")
113121 require.Equal(t, ' ', frame.At(17, 1).Rune, "preview pane is padded with blanks")
114122 }
115123
124+func TestRenderListScrollsSelectionIntoView(t *testing.T) {
125+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"a", "b", "c", "d", "e", "f"}, Active: 0}}, "s1", 0)
126+ for range 5 {
127+ m.Down()
128+ }
129+
130+ frame := m.Render(domain.Size{Cols: 23, Rows: 4}, Preview{})
131+ require.Equal(t, ' ', frame.At(0, 3).Rune)
132+ require.Equal(t, 'f', frame.At(2, 3).Rune)
133+ require.True(t, frame.At(0, 3).Style.Inverse)
134+}
135+
116136 func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) {
117137 m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0)
118138 preview := Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}}
@@ -70,8 +70,16 @@ func TestChooseLayoutBoundaries(t *testing.T) {
70 require.Equal(t, domain.Rect{Y: 5, Width: 39, Height: 7}, layout.Preview)70 require.Equal(t, domain.Rect{Y: 5, Width: 39, Height: 7}, layout.Preview)
71 71
72 layout = ChooseLayout(domain.Size{Cols: 40, Rows: 4})72 layout = ChooseLayout(domain.Size{Cols: 40, Rows: 4})
73+ require.Equal(t, domain.Rect{Width: 40, Height: 4}, layout.List)
74+ require.Equal(t, domain.Rect{}, layout.Preview)
75+
76+ layout = ChooseLayout(domain.Size{Cols: 41, Rows: 4})
73 require.Equal(t, domain.Rect{Width: 16, Height: 4}, layout.List)77 require.Equal(t, domain.Rect{Width: 16, Height: 4}, layout.List)
74- require.Equal(t, domain.Rect{X: 17, Width: 23, Height: 4}, layout.Preview)78+ require.Equal(t, domain.Rect{X: 17, Width: 24, Height: 4}, layout.Preview)
79+
80+ layout = ChooseLayout(domain.Size{Cols: 120, Rows: 4})
81+ require.Equal(t, domain.Rect{Width: MaxListWidth, Height: 4}, layout.List)
82+ require.Equal(t, domain.Rect{X: MaxListWidth + 1, Width: 87, Height: 4}, layout.Preview)
75 83
76 layout = ChooseLayout(domain.Size{Cols: 24, Rows: 11})84 layout = ChooseLayout(domain.Size{Cols: 24, Rows: 11})
77 require.Equal(t, domain.Rect{Width: 24, Height: 11}, layout.List)85 require.Equal(t, domain.Rect{Width: 24, Height: 11}, layout.List)
@@ -101,18 +109,30 @@ func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {
101 Rows: [][]renderer.Cell{{109 Rows: [][]renderer.Cell{{
102 cell('a'), cell('b'), cell('c'), cell('d'), cell('e'), cell('f'), cell('g'), cell('h'), cell('i'), cell('j'),110 cell('a'), cell('b'), cell('c'), cell('d'), cell('e'), cell('f'), cell('g'), cell('h'), cell('i'), cell('j'),
103 cell('k'), cell('l'), cell('m'), cell('n'), cell('o'), cell('p'), cell('q'), cell('r'), cell('s'), cell('t'),111 cell('k'), cell('l'), cell('m'), cell('n'), cell('o'), cell('p'), cell('q'), cell('r'), cell('s'), cell('t'),
104- cell('u'), cell('v'), {Rune: '界', Style: renderer.DefaultStyle()}, {Continuation: true, Style: renderer.DefaultStyle()},112+ cell('u'), cell('v'), cell('w'), {Rune: '界', Style: renderer.DefaultStyle()}, {Continuation: true, Style: renderer.DefaultStyle()},
105 }},113 }},
106 }114 }
107 115
108- frame := m.Render(domain.Size{Cols: 40, Rows: 4}, preview)116+ frame := m.Render(domain.Size{Cols: 41, Rows: 4}, preview)
109 require.True(t, frame.At(0, 1).Style.Inverse)117 require.True(t, frame.At(0, 1).Style.Inverse)
110 require.Equal(t, 'a', frame.At(17, 0).Rune)118 require.Equal(t, 'a', frame.At(17, 0).Rune)
111- require.Equal(t, 'v', frame.At(38, 0).Rune)119+ require.Equal(t, 'w', frame.At(39, 0).Rune)
112- require.Equal(t, ' ', frame.At(39, 0).Rune, "wide rune crossing preview pane is dropped")120+ require.Equal(t, ' ', frame.At(40, 0).Rune, "wide rune crossing preview pane is dropped")
113 require.Equal(t, ' ', frame.At(17, 1).Rune, "preview pane is padded with blanks")121 require.Equal(t, ' ', frame.At(17, 1).Rune, "preview pane is padded with blanks")
114 }122 }
115 123
124+func TestRenderListScrollsSelectionIntoView(t *testing.T) {
125+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"a", "b", "c", "d", "e", "f"}, Active: 0}}, "s1", 0)
126+ for range 5 {
127+ m.Down()
128+ }
129+
130+ frame := m.Render(domain.Size{Cols: 23, Rows: 4}, Preview{})
131+ require.Equal(t, ' ', frame.At(0, 3).Rune)
132+ require.Equal(t, 'f', frame.At(2, 3).Rune)
133+ require.True(t, frame.At(0, 3).Style.Inverse)
134+}
135+
116 func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) {136 func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) {
117 m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0)137 m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0)
118 preview := Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}}138 preview := Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}}

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 2

Tags 13

↑↓ moveenter openesc close