feat(picker): draw the session list as a dimmed tree
- commit
- 363b093251c542313268d2a9463f8f6567edd105
- author
- brice · · 2026-09-23 20:00 UTC
- parent
- 417e024
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 929 | 929 | if session.Attached { | |
| 930 | 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 | 936 | parts = append(parts, fmt.Sprintf("%d tabs", count)) | |
| 934 | 937 | } | |
| 935 | 938 | 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 { | |||
| 131 | 131 | // foldedContext is the owning session's name for a tab row, so searching a | |
| 132 | 132 | // session name reaches the tab rows that are its destinations. | |
| 133 | 133 | foldedContext string | |
| 134 | + | // tree is the dimmed branch prefix drawn before the label ("├─ ", "│ └─ "…). | |
| 135 | + | tree string | |
| 134 | 136 | } | |
| 135 | 137 | ||
| 136 | 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 | 290 | session = "" | |
| 289 | 291 | } | |
| 290 | 292 | } | |
| 293 | + | m.assignTreePrefixes() | |
| 291 | 294 | m.searchMatches = nil | |
| 292 | 295 | m.matchRows = nil | |
| 293 | 296 | if m.searchActive { | |
| @@ -296,6 +299,47 @@ func (m *Model) rebuild(key string, hadKey bool, fallbackIndex int) { | |||
| 296 | 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 | 343 | // sortRun orders one section run locally. Recency keeps the source's | |
| 300 | 344 | // published order; grouped moves ephemeral sessions behind named ones without | |
| 301 | 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 | 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 | 598 | 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) | |
| 557 | 609 | } | |
| 558 | 610 | badge := "" | |
| 559 | 611 | if r.rendersAsHeader() && !(r.kind() == protocol.PickerLineSession && r.line.Status == protocol.PickerLineStatusUp) { | |
| 560 | 612 | badge = lineStatusBadge(r.line.Status) | |
| 561 | 613 | } | |
| 562 | 614 | contentClipX := clipX | |
| 563 | - | nameWidth := rect.Width | |
| 615 | + | nameWidth := max(clipX-treeX, 0) | |
| 564 | 616 | badgeWidth := textCellWidth(badge) | |
| 565 | 617 | if badge != "" { | |
| 566 | - | nameWidth = max(rect.Width-badgeWidth-1, 0) | |
| 618 | + | nameWidth = max(clipX-treeX-badgeWidth-1, 0) | |
| 567 | 619 | contentClipX = max(rect.X, clipX-badgeWidth-1) | |
| 568 | 620 | } | |
| 569 | 621 | originalName := name | |
| @@ -572,12 +624,8 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||
| 572 | 624 | if idx == m.selected { | |
| 573 | 625 | nameMatchStyle = styles.SelectionMatch | |
| 574 | 626 | } | |
| 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) | |
| 581 | 629 | ||
| 582 | 630 | if r.section() { | |
| 583 | 631 | continue | |
| @@ -634,17 +682,6 @@ func visibleMatchPositions(positions []int, rendered string, truncated bool) []i | |||
| 634 | 682 | 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 | 685 | func drawMatchedText(frame renderer.Frame, x, y, clipX int, text string, base, match renderer.Style, positions []int) int { | |
| 649 | 686 | if len(positions) == 0 { | |
| 650 | 687 | 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 a | 131 | // 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 string | 133 | 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 = nil | 294 | m.searchMatches = nil | ||
| 292 | m.matchRows = nil | 295 | 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's | 343 | // sortRun orders one section run locally. Recency keeps the source's | ||
| 300 | // published order; grouped moves ephemeral sessions behind named ones without | 344 | // 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.Label | 598 | name := r.line.Label | ||
| 555 | - | if r.kind() == protocol.PickerLineTab { | 599 | + | if r.section() { |
| 556 | - | name = " " + name | 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) | |||
| 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 := clipX | 614 | contentClipX := clipX | ||
| 563 | - | nameWidth := rect.Width | 615 | + | 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 := name | 621 | 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.SelectionMatch | 625 | 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 | continue | 631 | continue | ||
| @@ -634,17 +682,6 @@ func visibleMatchPositions(positions []int, rendered string, truncated bool) []i | |||||
| 634 | return visible | 682 | 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) { | |||
| 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 | + | } | |
| @@ -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 | + | } | |||