detached 363b093

feat(picker): draw the session list as a dimmed tree

commit
363b093251c542313268d2a9463f8f6567edd105
author
brice · · 2026-09-23 20:00 UTC
parent
417e024
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +88 -22
M internal/usecase/client/picker_catalogue.go +4 -1
@@ -929,7 +929,10 @@ func pickerSessionDetail(session catalogue.RemoteCatalogSession) string {
929929 if session.Attached {
930930 parts = append(parts, "attached")
931931 }
932- if count := len(session.Tabs); count > 0 {
932+ switch count := len(session.Tabs); {
933+ case count == 1:
934+ parts = append(parts, "1 tab")
935+ case count > 1:
933936 parts = append(parts, fmt.Sprintf("%d tabs", count))
934937 }
935938 return strings.Join(parts, " · ")
@@ -929,7 +929,10 @@ func pickerSessionDetail(session catalogue.RemoteCatalogSession) string {
929 if session.Attached {929 if session.Attached {
930 parts = append(parts, "attached")930 parts = append(parts, "attached")
931 }931 }
932- if count := len(session.Tabs); count > 0 {932+ switch count := len(session.Tabs); {
933+ case count == 1:
934+ parts = append(parts, "1 tab")
935+ case count > 1:
933 parts = append(parts, fmt.Sprintf("%d tabs", count))936 parts = append(parts, fmt.Sprintf("%d tabs", count))
934 }937 }
935 return strings.Join(parts, " · ")938 return strings.Join(parts, " · ")
M internal/usecase/picker/model.go +58 -21
@@ -131,6 +131,8 @@ type row struct {
131131 // foldedContext is the owning session's name for a tab row, so searching a
132132 // session name reaches the tab rows that are its destinations.
133133 foldedContext string
134+ // tree is the dimmed branch prefix drawn before the label ("├─ ", "│ └─ "…).
135+ tree string
134136 }
135137
136138 func (r row) key() string { return r.line.Key }
@@ -288,6 +290,7 @@ func (m *Model) rebuild(key string, hadKey bool, fallbackIndex int) {
288290 session = ""
289291 }
290292 }
293+ m.assignTreePrefixes()
291294 m.searchMatches = nil
292295 m.matchRows = nil
293296 if m.searchActive {
@@ -296,6 +299,47 @@ func (m *Model) rebuild(key string, hadKey bool, fallbackIndex int) {
296299 m.restoreSelection(key, hadKey, fallbackIndex)
297300 }
298301
302+// assignTreePrefixes gives each row its tree branch: sessions and hosts hang
303+// off their section, tabs off their session. The last sibling closes its
304+// branch with └─ and its children no longer continue the parent's │.
305+func (m *Model) assignTreePrefixes() {
306+ isHead := func(r row) bool { return r.rendersAsHeader() }
307+ parentLast := true
308+ for i := range m.rows {
309+ r := &m.rows[i]
310+ switch {
311+ case r.section():
312+ r.tree = ""
313+ case isHead(*r):
314+ last := true
315+ for j := i + 1; j < len(m.rows) && !m.rows[j].section(); j++ {
316+ if isHead(m.rows[j]) {
317+ last = false
318+ break
319+ }
320+ }
321+ parentLast = last
322+ r.tree = treeBranch(last)
323+ case r.kind() == protocol.PickerLineTab:
324+ last := i+1 >= len(m.rows) || m.rows[i+1].kind() != protocol.PickerLineTab
325+ stem := "│ "
326+ if parentLast {
327+ stem = " "
328+ }
329+ r.tree = stem + treeBranch(last)
330+ default:
331+ r.tree = ""
332+ }
333+ }
334+}
335+
336+func treeBranch(last bool) string {
337+ if last {
338+ return "└─ "
339+ }
340+ return "├─ "
341+}
342+
299343 // sortRun orders one section run locally. Recency keeps the source's
300344 // published order; grouped moves ephemeral sessions behind named ones without
301345 // disturbing the relative order inside each group.
@@ -552,18 +596,26 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
552596 ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base})
553597
554598 name := r.line.Label
555- if r.kind() == protocol.PickerLineTab {
556- name = " " + name
599+ if r.section() {
600+ nameStyle.Bold = true
601+ } else if r.rendersAsHeader() && idx != m.selected && !r.line.Stopped {
602+ nameStyle.Bold = true
603+ }
604+ treeX := rect.X
605+ if r.tree != "" {
606+ treeStyle := base
607+ treeStyle.Attrs |= renderer.AttrDim
608+ treeX = ui.DrawText(frame, rect.X, rect.Y+y, clipX, r.tree, treeStyle)
557609 }
558610 badge := ""
559611 if r.rendersAsHeader() && !(r.kind() == protocol.PickerLineSession && r.line.Status == protocol.PickerLineStatusUp) {
560612 badge = lineStatusBadge(r.line.Status)
561613 }
562614 contentClipX := clipX
563- nameWidth := rect.Width
615+ nameWidth := max(clipX-treeX, 0)
564616 badgeWidth := textCellWidth(badge)
565617 if badge != "" {
566- nameWidth = max(rect.Width-badgeWidth-1, 0)
618+ nameWidth = max(clipX-treeX-badgeWidth-1, 0)
567619 contentClipX = max(rect.X, clipX-badgeWidth-1)
568620 }
569621 originalName := name
@@ -572,12 +624,8 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
572624 if idx == m.selected {
573625 nameMatchStyle = styles.SelectionMatch
574626 }
575- namePositions := m.matchPositions(idx, matchName)
576- if r.kind() == protocol.PickerLineTab {
577- namePositions = shiftPositions(namePositions, 2)
578- }
579- namePositions = visibleMatchPositions(namePositions, name, name != originalName)
580- x := drawMatchedText(frame, rect.X, rect.Y+y, contentClipX, name, nameStyle, nameMatchStyle, namePositions)
627+ namePositions := visibleMatchPositions(m.matchPositions(idx, matchName), name, name != originalName)
628+ x := drawMatchedText(frame, treeX, rect.Y+y, contentClipX, name, nameStyle, nameMatchStyle, namePositions)
581629
582630 if r.section() {
583631 continue
@@ -634,17 +682,6 @@ func visibleMatchPositions(positions []int, rendered string, truncated bool) []i
634682 return visible
635683 }
636684
637-func shiftPositions(positions []int, delta int) []int {
638- if len(positions) == 0 || delta == 0 {
639- return positions
640- }
641- shifted := make([]int, len(positions))
642- for i, position := range positions {
643- shifted[i] = position + delta
644- }
645- return shifted
646-}
647-
648685 func drawMatchedText(frame renderer.Frame, x, y, clipX int, text string, base, match renderer.Style, positions []int) int {
649686 if len(positions) == 0 {
650687 return ui.DrawText(frame, x, y, clipX, text, base)
@@ -131,6 +131,8 @@ type row struct {
131 // foldedContext is the owning session's name for a tab row, so searching a131 // foldedContext is the owning session's name for a tab row, so searching a
132 // session name reaches the tab rows that are its destinations.132 // session name reaches the tab rows that are its destinations.
133 foldedContext string133 foldedContext string
134+ // tree is the dimmed branch prefix drawn before the label ("├─ ", "│ └─ "…).
135+ tree string
134 }136 }
135 137
136 func (r row) key() string { return r.line.Key }138 func (r row) key() string { return r.line.Key }
@@ -288,6 +290,7 @@ func (m *Model) rebuild(key string, hadKey bool, fallbackIndex int) {
288 session = ""290 session = ""
289 }291 }
290 }292 }
293+ m.assignTreePrefixes()
291 m.searchMatches = nil294 m.searchMatches = nil
292 m.matchRows = nil295 m.matchRows = nil
293 if m.searchActive {296 if m.searchActive {
@@ -296,6 +299,47 @@ func (m *Model) rebuild(key string, hadKey bool, fallbackIndex int) {
296 m.restoreSelection(key, hadKey, fallbackIndex)299 m.restoreSelection(key, hadKey, fallbackIndex)
297 }300 }
298 301
302+// assignTreePrefixes gives each row its tree branch: sessions and hosts hang
303+// off their section, tabs off their session. The last sibling closes its
304+// branch with └─ and its children no longer continue the parent's │.
305+func (m *Model) assignTreePrefixes() {
306+ isHead := func(r row) bool { return r.rendersAsHeader() }
307+ parentLast := true
308+ for i := range m.rows {
309+ r := &m.rows[i]
310+ switch {
311+ case r.section():
312+ r.tree = ""
313+ case isHead(*r):
314+ last := true
315+ for j := i + 1; j < len(m.rows) && !m.rows[j].section(); j++ {
316+ if isHead(m.rows[j]) {
317+ last = false
318+ break
319+ }
320+ }
321+ parentLast = last
322+ r.tree = treeBranch(last)
323+ case r.kind() == protocol.PickerLineTab:
324+ last := i+1 >= len(m.rows) || m.rows[i+1].kind() != protocol.PickerLineTab
325+ stem := "│ "
326+ if parentLast {
327+ stem = " "
328+ }
329+ r.tree = stem + treeBranch(last)
330+ default:
331+ r.tree = ""
332+ }
333+ }
334+}
335+
336+func treeBranch(last bool) string {
337+ if last {
338+ return "└─ "
339+ }
340+ return "├─ "
341+}
342+
299 // sortRun orders one section run locally. Recency keeps the source's343 // sortRun orders one section run locally. Recency keeps the source's
300 // published order; grouped moves ephemeral sessions behind named ones without344 // published order; grouped moves ephemeral sessions behind named ones without
301 // disturbing the relative order inside each group.345 // disturbing the relative order inside each group.
@@ -552,18 +596,26 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
552 ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base})596 ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base})
553 597
554 name := r.line.Label598 name := r.line.Label
555- if r.kind() == protocol.PickerLineTab {599+ if r.section() {
556- name = " " + name600+ nameStyle.Bold = true
601+ } else if r.rendersAsHeader() && idx != m.selected && !r.line.Stopped {
602+ nameStyle.Bold = true
603+ }
604+ treeX := rect.X
605+ if r.tree != "" {
606+ treeStyle := base
607+ treeStyle.Attrs |= renderer.AttrDim
608+ treeX = ui.DrawText(frame, rect.X, rect.Y+y, clipX, r.tree, treeStyle)
557 }609 }
558 badge := ""610 badge := ""
559 if r.rendersAsHeader() && !(r.kind() == protocol.PickerLineSession && r.line.Status == protocol.PickerLineStatusUp) {611 if r.rendersAsHeader() && !(r.kind() == protocol.PickerLineSession && r.line.Status == protocol.PickerLineStatusUp) {
560 badge = lineStatusBadge(r.line.Status)612 badge = lineStatusBadge(r.line.Status)
561 }613 }
562 contentClipX := clipX614 contentClipX := clipX
563- nameWidth := rect.Width615+ nameWidth := max(clipX-treeX, 0)
564 badgeWidth := textCellWidth(badge)616 badgeWidth := textCellWidth(badge)
565 if badge != "" {617 if badge != "" {
566- nameWidth = max(rect.Width-badgeWidth-1, 0)618+ nameWidth = max(clipX-treeX-badgeWidth-1, 0)
567 contentClipX = max(rect.X, clipX-badgeWidth-1)619 contentClipX = max(rect.X, clipX-badgeWidth-1)
568 }620 }
569 originalName := name621 originalName := name
@@ -572,12 +624,8 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
572 if idx == m.selected {624 if idx == m.selected {
573 nameMatchStyle = styles.SelectionMatch625 nameMatchStyle = styles.SelectionMatch
574 }626 }
575- namePositions := m.matchPositions(idx, matchName)627+ namePositions := visibleMatchPositions(m.matchPositions(idx, matchName), name, name != originalName)
576- if r.kind() == protocol.PickerLineTab {628+ x := drawMatchedText(frame, treeX, rect.Y+y, contentClipX, name, nameStyle, nameMatchStyle, namePositions)
577- namePositions = shiftPositions(namePositions, 2)
578- }
579- namePositions = visibleMatchPositions(namePositions, name, name != originalName)
580- x := drawMatchedText(frame, rect.X, rect.Y+y, contentClipX, name, nameStyle, nameMatchStyle, namePositions)
581 629
582 if r.section() {630 if r.section() {
583 continue631 continue
@@ -634,17 +682,6 @@ func visibleMatchPositions(positions []int, rendered string, truncated bool) []i
634 return visible682 return visible
635 }683 }
636 684
637-func shiftPositions(positions []int, delta int) []int {
638- if len(positions) == 0 || delta == 0 {
639- return positions
640- }
641- shifted := make([]int, len(positions))
642- for i, position := range positions {
643- shifted[i] = position + delta
644- }
645- return shifted
646-}
647-
648 func drawMatchedText(frame renderer.Frame, x, y, clipX int, text string, base, match renderer.Style, positions []int) int {685 func drawMatchedText(frame renderer.Frame, x, y, clipX int, text string, base, match renderer.Style, positions []int) int {
649 if len(positions) == 0 {686 if len(positions) == 0 {
650 return ui.DrawText(frame, x, y, clipX, text, base)687 return ui.DrawText(frame, x, y, clipX, text, base)
M internal/usecase/picker/model_test.go +26 -0
@@ -459,3 +459,29 @@ func TestReplaceLinesSelectsThePreviousRowWhenTheCursorRowLeaves(t *testing.T) {
459459 })
460460 }
461461 }
462+
463+func TestTreePrefixesBranchSessionsAndTabs(t *testing.T) {
464+ m := New([]protocol.PickerLine{
465+ section("local"),
466+ navLine("a", "alpha"), tabLine("a1", "one", protocol.PickerCanNavigate), tabLine("a2", "two", protocol.PickerCanNavigate),
467+ navLine("b", "beta"), tabLine("b1", "one", protocol.PickerCanNavigate),
468+ section("remote"),
469+ navLine("c", "gamma"),
470+ }, Config{})
471+ got := make([]string, 0, len(m.rows))
472+ for _, r := range m.rows {
473+ got = append(got, r.tree+r.line.Label)
474+ }
475+ require.Equal(t, []string{
476+ "local",
477+ "├─ alpha", "│ ├─ one", "│ └─ two",
478+ "└─ beta", " └─ one",
479+ "remote",
480+ "└─ gamma",
481+ }, got)
482+
483+ frame := m.Render(domain.Size{Cols: 40, Rows: 30}, Preview{})
484+ cell := frame.At(0, 1)
485+ require.Equal(t, '├', cell.Rune)
486+ require.NotZero(t, cell.Style.Attrs&renderer.AttrDim, "tree lines are dimmed")
487+}
@@ -459,3 +459,29 @@ func TestReplaceLinesSelectsThePreviousRowWhenTheCursorRowLeaves(t *testing.T) {
459 })459 })
460 }460 }
461 }461 }
462+
463+func TestTreePrefixesBranchSessionsAndTabs(t *testing.T) {
464+ m := New([]protocol.PickerLine{
465+ section("local"),
466+ navLine("a", "alpha"), tabLine("a1", "one", protocol.PickerCanNavigate), tabLine("a2", "two", protocol.PickerCanNavigate),
467+ navLine("b", "beta"), tabLine("b1", "one", protocol.PickerCanNavigate),
468+ section("remote"),
469+ navLine("c", "gamma"),
470+ }, Config{})
471+ got := make([]string, 0, len(m.rows))
472+ for _, r := range m.rows {
473+ got = append(got, r.tree+r.line.Label)
474+ }
475+ require.Equal(t, []string{
476+ "local",
477+ "├─ alpha", "│ ├─ one", "│ └─ two",
478+ "└─ beta", " └─ one",
479+ "remote",
480+ "└─ gamma",
481+ }, got)
482+
483+ frame := m.Render(domain.Size{Cols: 40, Rows: 30}, Preview{})
484+ cell := frame.At(0, 1)
485+ require.Equal(t, '├', cell.Rune)
486+ require.NotZero(t, cell.Style.Attrs&renderer.AttrDim, "tree lines are dimmed")
487+}

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 12

Tags 11

↑↓ moveenter openesc close