v0.8.1 1f54f61

model.go RawHistoryFolder

1package picker
2
3import (
4	"fmt"
5	"slices"
6	"strings"
7
8	renderer "github.com/bnema/vev-vt"
9	"github.com/bnema/vev/internal/domain"
10	"github.com/bnema/vev/internal/protocol"
11	"github.com/bnema/vev/internal/usecase/ui"
12)
13
14const (
15	MinPreviewWidth           = 24
16	MinHorizontalPreviewWidth = 48
17	MinListWidth              = 16
18	MinHorizontalListWidth    = 20
19	MaxListWidth              = 44
20	MinPaneHeight             = 4
21	MinStackHeight            = 12
22)
23
24// SortMode orders the rows of one source locally. The source publishes its
25// lines with rank and ephemeral markers; the presenting client decides the
26// order, so two attachments of the same daemon never share a sort mode.
27type SortMode uint8
28
29const (
30	// SortRecent orders every run by the source's recency rank.
31	SortRecent SortMode = iota
32	// SortGrouped puts named sessions before ephemeral ones inside each run,
33	// each keeping recency order.
34	SortGrouped
35)
36
37// Config describes one presented model: which intent produced the lines, the
38// source's cursor hint, and the local sort order.
39type Config struct {
40	Intent protocol.PickerIntent
41	Cursor protocol.PickerCursor
42	Sort   SortMode
43}
44
45// RenderStyles are the styles Render uses to draw list rows. The zero value
46// (all renderer.Style{}) is never used directly; Render falls back to
47// defaultRenderStyles when no RenderStyles is supplied, mirroring the styling
48// of a non-truecolor client.
49type RenderStyles struct {
50	Selection      renderer.Style // selected row fill + suffixes
51	SelectionName  renderer.Style // selected row name segment
52	SelectionMuted renderer.Style // selected row detail segment
53	Name           renderer.Style // non-selected name segment
54	Detail         renderer.Style // non-selected detail segment
55	// Background fills otherwise unused interior cells. Base owns ordinary
56	// rows, preserving a distinct inactive surface from modal chrome.
57	Background     renderer.Style
58	Base           renderer.Style // non-selected row fill + suffixes
59	Stopped        renderer.Style // full row style for non-selected stopped rows (fill, name, detail, suffix)
60	Separator      renderer.Style // preview separator
61	Tree           renderer.Style // foreground of tree prefixes on non-selected rows; zero keeps them dimmed
62	Status         renderer.Style // one-row picker-local contextual help
63	SearchMatch    renderer.Style // matched runes on ordinary rows
64	SelectionMatch renderer.Style // matched runes on the selected row
65	// ProblemBold draws error and warning dots bold. A monochrome terminal
66	// drops the fixed problem colors, so weight is what sets them apart.
67	ProblemBold bool
68}
69
70// DefaultRenderStyles returns the picker's built-in styles. monochrome selects
71// the attribute-only variant: tree lines are dimmed instead of grey and
72// problem dots are bold, since no color survives on such a terminal.
73func DefaultRenderStyles(monochrome bool) RenderStyles {
74	styles := defaultRenderStyles()
75	if monochrome {
76		styles.Tree = renderer.Style{} // zero Tree keeps the dimmed row style
77		styles.ProblemBold = true
78	}
79	return styles
80}
81
82func defaultRenderStyles() RenderStyles {
83	selection := renderer.DefaultStyle()
84	selection.Inverse = true
85	base := renderer.DefaultStyle()
86	separator := renderer.DefaultStyle()
87	separator.Attrs = renderer.AttrDim
88	stopped := renderer.DefaultStyle()
89	stopped.Attrs = renderer.AttrDim
90	stopped.Italic = true
91	searchMatch := base
92	searchMatch.Bold = true
93	selectionMatch := selection
94	selectionMatch.Bold = true
95	// Tree lines use the palette's bright black (the theme's grey) rather
96	// than SGR dim: terminals such as kitty draw box-drawing glyphs
97	// themselves and barely dim them, so dim lines stayed near full white.
98	tree := renderer.DefaultStyle()
99	tree.Foreground = 244 // fixed mid grey: readable on dark and light themes
100	return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Background: base, Base: base, Separator: separator, Tree: tree, Stopped: stopped, Status: separator, SearchMatch: searchMatch, SelectionMatch: selectionMatch}
101}
102
103// Preview is a bounded view of the selected pane's visible frame.
104type Preview = ui.FrameView
105
106type LayoutMode uint8
107
108const (
109	LayoutListOnly LayoutMode = iota
110	LayoutStacked
111	LayoutHorizontal
112)
113
114type Layout struct {
115	Mode      LayoutMode
116	List      domain.Rect
117	Separator domain.Rect
118	Preview   domain.Rect
119}
120
121// Geometry is the single picker layout contract consumed by rendering and
122// preview sizing. Status always reserves the final available inner row.
123type Geometry struct {
124	Content   domain.Rect
125	Status    domain.Rect
126	Mode      LayoutMode
127	List      domain.Rect
128	Separator domain.Rect
129	Preview   domain.Rect
130}
131
132// Model is the pure presentation state of one picker: the published lines,
133// the local cursor, the search editor, and the local sort order. It owns no
134// target resolution and no mutation authority: the source that published a
135// line resolves its opaque key when the client commits it.
136type Model struct {
137	intent        protocol.PickerIntent
138	sort          SortMode
139	lines         []protocol.PickerLine
140	rows          []row
141	selected      int
142	searchActive  bool
143	query         ui.TextInput
144	searchMatches map[int]searchMatch
145	matchRows     []int
146	// folded names the section headers the user collapsed. It is local
147	// presentation state, kept across publications by section label.
148	folded map[string]bool
149	// hidden and view describe which rows are drawn: a collapsed section hides
150	// its rows, and a non-empty query shows only matches and their ancestors.
151	// Both are replaced, never mutated, so clones may share them.
152	hidden []bool
153	view   []int
154}
155
156// row is one rendered line plus its case-folded search fields.
157type row struct {
158	line         protocol.PickerLine
159	foldedLabel  string
160	foldedDetail string
161	// foldedContext is the owning session's name for a tab row, so searching a
162	// session name reaches the tab rows that are its destinations.
163	foldedContext string
164	// tree is the dimmed branch prefix drawn before the label ("├─ ", "│  └─ "…).
165	tree string
166	// sectionID names a section header locally: its label plus its occurrence
167	// among same-label sections, so two hosts sharing a label fold apart.
168	sectionID string
169}
170
171func (r row) kind() protocol.PickerLineKind { return r.line.Kind }
172
173func (r row) section() bool { return r.line.Kind == protocol.PickerLineSection }
174
175func (r row) selectable() bool { return r.line.Actions != 0 }
176
177// focusable reports the source's authorisation to rest the cursor on this row.
178// A section header is never a destination; every other shape follows the
179// published flag, so a row the source kept for inspection stays reachable while
180// a row it skipped is passed over. The model lets the cursor rest on a section
181// header on its own, only to fold it (see eligible).
182func (r row) focusable() bool {
183	return r.line.Kind != protocol.PickerLineSection && r.line.Focusable
184}
185
186// id names the row for cursor restoration. A section has no source key, so
187// it is named by its sectionID, which no source key can collide with.
188func (r row) id() string {
189	if r.section() {
190		return r.sectionID
191	}
192	return r.line.Key
193}
194
195func (r row) rendersAsHeader() bool {
196	return r.line.Kind == protocol.PickerLineSession || r.line.Kind == protocol.PickerLineHost
197}
198
199// New builds a model from one source's published lines. Order is applied
200// locally: recency or grouped, inside each section run.
201func New(lines []protocol.PickerLine, config Config) *Model {
202	m := &Model{intent: config.Intent, sort: config.Sort}
203	m.setLines(lines, config.Cursor)
204	return m
205}
206
207// Intent reports which picker produced this model.
208func (m *Model) Intent() protocol.PickerIntent {
209	if m == nil {
210		return 0
211	}
212	return m.intent
213}
214
215// SortMode reports the local ordering mode.
216func (m *Model) SortMode() SortMode {
217	if m == nil {
218		return SortGrouped
219	}
220	return m.sort
221}
222
223// SetSort re-orders the rows locally, preserving the selected key.
224func (m *Model) SetSort(mode SortMode) {
225	if m == nil {
226		return
227	}
228	m.sort = mode
229	key, hadKey := m.cursorKey()
230	m.rebuild(key, hadKey, m.selected)
231}
232
233// setLines replaces the published lines and restores the cursor by key,
234// falling back to the source hint and then to the nearest focusable row.
235func (m *Model) setLines(lines []protocol.PickerLine, cursor protocol.PickerCursor) {
236	if m == nil {
237		return
238	}
239	m.lines = append(m.lines[:0], lines...)
240	m.rebuild(cursor.Key, cursor.Key != "", cursor.Index)
241}
242
243// ReplaceLines applies a fresh publication of the same source while retaining
244// the local search editor and the selected key when it still exists.
245func (m *Model) ReplaceLines(lines []protocol.PickerLine, cursor protocol.PickerCursor) {
246	if m == nil {
247		return
248	}
249	key, hadKey := m.cursorKey()
250	above := m.keysAboveCursor()
251	m.lines = append(m.lines[:0], lines...)
252	if !hadKey {
253		key, hadKey = cursor.Key, cursor.Key != ""
254	}
255	m.rebuild(key, hadKey, cursor.Index)
256	if hadKey && !m.cursorRestsOn(key) {
257		// The selected row left the publication (for example it was killed)
258		// or stopped matching the query: rest on the nearest surviving row
259		// above it instead of jumping elsewhere.
260		m.selectFirstKey(above)
261	}
262}
263
264// cursorRestsOn reports whether the cursor is on the row with this id, or on
265// the collapsed section that hides it.
266func (m *Model) cursorRestsOn(id string) bool {
267	current, ok := m.cursorKey()
268	if !ok {
269		return false
270	}
271	if current == id {
272		return true
273	}
274	if !m.rows[m.selected].section() {
275		return false
276	}
277	for idx, r := range m.rows {
278		if r.id() == id {
279			return m.sectionOf(idx) == m.selected
280		}
281	}
282	return false
283}
284
285// keysAboveCursor lists the row keys above the cursor, nearest first. Section
286// headers are left out: a removed row never hands the cursor to a header.
287func (m *Model) keysAboveCursor() []string {
288	var keys []string
289	for i := m.selected - 1; i >= 0; i-- {
290		if m.target(i) {
291			keys = append(keys, m.rows[i].id())
292		}
293	}
294	return keys
295}
296
297// selectFirstKey moves the cursor to the first key still present, if any.
298func (m *Model) selectFirstKey(keys []string) {
299	index := make(map[string]int, len(m.rows))
300	for idx, candidate := range m.rows {
301		if m.eligible(idx) {
302			index[candidate.id()] = idx
303		}
304	}
305	for _, key := range keys {
306		if idx, ok := index[key]; ok {
307			m.selected = idx
308			return
309		}
310	}
311}
312
313// ReplaceProjection atomically changes the daemon-authored projection and its
314// local title mode while retaining search and selection state.
315func (m *Model) ReplaceProjection(lines []protocol.PickerLine, cursor protocol.PickerCursor, mode SortMode) {
316	if m == nil {
317		return
318	}
319	m.sort = mode
320	m.ReplaceLines(lines, cursor)
321}
322
323// rebuild recomputes the row list from the published lines. Search matches are
324// row-index keyed, so they are recomputed here, before the cursor is restored:
325// restoration consults them to keep the cursor on a row the query shows.
326func (m *Model) rebuild(key string, hadKey bool, fallbackIndex int) {
327	m.rows = m.rows[:0]
328	run := make([]protocol.PickerLine, 0, len(m.lines))
329	flush := func() {
330		if len(run) == 0 {
331			return
332		}
333		for _, line := range m.sortRun(run) {
334			// The picker draws its own gap before a detail. A source such as a
335			// tab title (" (fish)") may already lead with a space, which would
336			// double it; every source, local or remote, is trimmed here.
337			line.Detail = strings.TrimSpace(line.Detail)
338			m.rows = append(m.rows, row{line: line, foldedLabel: strings.ToLower(line.Label), foldedDetail: strings.ToLower(line.Detail)})
339		}
340		run = run[:0]
341	}
342	sections := make(map[string]int)
343	for _, line := range m.lines {
344		if line.Kind == protocol.PickerLineSection {
345			flush()
346			sectionID := fmt.Sprintf("\x00section:%d:%s", sections[line.Label], line.Label)
347			sections[line.Label]++
348			m.rows = append(m.rows, row{line: line, foldedLabel: strings.ToLower(line.Label), sectionID: sectionID})
349			continue
350		}
351		run = append(run, line)
352	}
353	flush()
354	session := ""
355	for i := range m.rows {
356		switch m.rows[i].line.Kind {
357		case protocol.PickerLineSession:
358			session = m.rows[i].foldedLabel
359		case protocol.PickerLineTab:
360			m.rows[i].foldedContext = session
361		default:
362			session = ""
363		}
364	}
365	m.searchMatches = nil
366	m.matchRows = nil
367	if m.searchActive {
368		m.refreshSearch(false)
369	} else {
370		m.refreshView()
371	}
372	m.restoreSelection(key, hadKey, fallbackIndex)
373}
374
375// refreshView recomputes which rows are drawn. A non-empty query shows only
376// the matching rows plus their section and session headers, in tree order, and
377// ignores folds. Otherwise every row is drawn except those under a collapsed
378// section.
379func (m *Model) refreshView() {
380	hidden := make([]bool, len(m.rows))
381	if m.searchRestricted() {
382		section, session := -1, -1
383		for i, r := range m.rows {
384			hidden[i] = true
385			switch r.kind() {
386			case protocol.PickerLineSection:
387				section, session = i, -1
388				continue
389			case protocol.PickerLineSession:
390				session = i
391			case protocol.PickerLineTab:
392			default:
393				session = -1
394			}
395			if _, ok := m.searchMatches[i]; !ok {
396				continue
397			}
398			hidden[i] = false
399			if section >= 0 {
400				hidden[section] = false
401			}
402			if r.kind() == protocol.PickerLineTab && session >= 0 {
403				hidden[session] = false
404			}
405		}
406	} else {
407		folded := false
408		for i, r := range m.rows {
409			if r.section() {
410				folded = m.folded[r.sectionID]
411				continue
412			}
413			hidden[i] = folded
414		}
415	}
416	view := make([]int, 0, len(m.rows))
417	for i := range m.rows {
418		if !hidden[i] {
419			view = append(view, i)
420		}
421	}
422	m.hidden, m.view = hidden, view
423	m.assignTreePrefixes()
424}
425
426// sectionFolded reports whether section row i is drawn collapsed. A query
427// shows matches regardless of folds, so it never draws a section collapsed.
428func (m *Model) sectionFolded(i int) bool {
429	return !m.searchRestricted() && m.rows[i].section() && m.folded[m.rows[i].sectionID]
430}
431
432// sectionOf reports the section header row i belongs to, or -1.
433func (m *Model) sectionOf(i int) int {
434	for j := i; j >= 0; j-- {
435		if m.rows[j].section() {
436			return j
437		}
438	}
439	return -1
440}
441
442// setFold collapses or expands the section under the cursor and reports
443// whether anything changed. It never applies while a query filters the list.
444func (m *Model) setFold(fold bool) bool {
445	if m == nil || m.searchRestricted() || m.selected < 0 || m.selected >= len(m.rows) || !m.rows[m.selected].section() {
446		return false
447	}
448	label := m.rows[m.selected].sectionID
449	if m.folded[label] == fold {
450		return false
451	}
452	folded := make(map[string]bool, len(m.folded)+1)
453	for k, v := range m.folded {
454		folded[k] = v
455	}
456	if fold {
457		folded[label] = true
458	} else {
459		delete(folded, label)
460	}
461	m.folded = folded
462	m.refreshView()
463	return true
464}
465
466// CursorOnSection reports whether the cursor rests on a section header.
467func (m *Model) CursorOnSection() bool {
468	return m != nil && m.selected >= 0 && m.selected < len(m.rows) && m.rows[m.selected].section()
469}
470
471// ToggleSection collapses or expands the section header under the cursor and
472// reports whether it did.
473func (m *Model) ToggleSection() bool {
474	if !m.CursorOnSection() {
475		return false
476	}
477	return m.setFold(!m.folded[m.rows[m.selected].sectionID])
478}
479
480// Left collapses the section under the cursor, or moves the cursor from a row
481// up to its section header. It reports whether anything changed.
482func (m *Model) Left() bool {
483	if m == nil || m.selected < 0 || m.selected >= len(m.rows) {
484		return false
485	}
486	if m.rows[m.selected].section() {
487		return m.setFold(true)
488	}
489	if section := m.sectionOf(m.selected); m.eligible(section) {
490		m.selected = section
491		return true
492	}
493	return false
494}
495
496// Right expands the section under the cursor and reports whether it did.
497func (m *Model) Right() bool {
498	return m.setFold(false)
499}
500
501// assignTreePrefixes gives each drawn row its tree branch: sessions and hosts
502// hang off their section, tabs off their session. The last drawn sibling
503// closes its branch with └─ and its children no longer continue the parent's │.
504func (m *Model) assignTreePrefixes() {
505	parentLast := true
506	for p, i := range m.view {
507		r := &m.rows[i]
508		switch {
509		case r.section():
510			r.tree = ""
511		case r.rendersAsHeader():
512			last := true
513			for _, j := range m.view[p+1:] {
514				if m.rows[j].section() {
515					break
516				}
517				if m.rows[j].rendersAsHeader() {
518					last = false
519					break
520				}
521			}
522			parentLast = last
523			r.tree = treeBranch(last)
524		case r.kind() == protocol.PickerLineTab:
525			last := p+1 >= len(m.view) || m.rows[m.view[p+1]].kind() != protocol.PickerLineTab
526			stem := "│  "
527			if parentLast {
528				stem = "   "
529			}
530			r.tree = stem + treeBranch(last)
531		default:
532			r.tree = ""
533		}
534	}
535}
536
537func treeBranch(last bool) string {
538	if last {
539		return "└─ "
540	}
541	return "├─ "
542}
543
544// sortRun orders one section run locally. Recency keeps the source's
545// published order; grouped moves ephemeral sessions behind named ones without
546// disturbing the relative order inside each group.
547func (m *Model) sortRun(lines []protocol.PickerLine) []protocol.PickerLine {
548	if len(lines) < 2 || m.sort != SortGrouped {
549		return lines
550	}
551	named := make([]protocol.PickerLine, 0, len(lines))
552	ephemeral := make([]protocol.PickerLine, 0, len(lines))
553	for _, line := range lines {
554		if line.Ephemeral {
555			ephemeral = append(ephemeral, line)
556			continue
557		}
558		named = append(named, line)
559	}
560	return append(named, ephemeral...)
561}
562
563func (m *Model) restoreSelection(key string, hadKey bool, fallbackIndex int) {
564	if hadKey {
565		for idx, candidate := range m.rows {
566			if m.eligible(idx) && candidate.id() == key {
567				m.selected = idx
568				return
569			}
570		}
571		// A row inside a collapsed section keeps the cursor on that section.
572		for idx, candidate := range m.rows {
573			if candidate.id() == key && idx < len(m.hidden) && m.hidden[idx] {
574				if section := m.sectionOf(idx); m.eligible(section) {
575					m.selected = section
576					return
577				}
578			}
579		}
580	}
581	m.selected = -1
582	m.SelectNearestRow(fallbackIndex)
583	if m.selected < 0 {
584		m.selected = m.firstEligible()
585	}
586}
587
588// searchRestricted reports whether the active query narrows eligibility. An
589// empty query never hides a row.
590func (m *Model) searchRestricted() bool {
591	return m != nil && m.searchActive && m.query.Value() != ""
592}
593
594// eligible reports whether the cursor may rest on row i: the row is drawn,
595// and either the source authorised it as a destination and the typed query,
596// when there is one, matches it, or it is a section header outside a query.
597func (m *Model) eligible(i int) bool {
598	if m == nil || i < 0 || i >= len(m.rows) || i < len(m.hidden) && m.hidden[i] {
599		return false
600	}
601	if m.rows[i].section() {
602		return !m.searchRestricted()
603	}
604	if !m.rows[i].focusable() {
605		return false
606	}
607	return !m.searchRestricted() || m.rowMatches(i)
608}
609
610// target reports whether the cursor may fall back to row i on its own: an
611// eligible row that is not a section header.
612func (m *Model) target(i int) bool {
613	return m.eligible(i) && !m.rows[i].section()
614}
615
616// restable is target, or eligible when section headers are allowed.
617func (m *Model) restable(i int, sections bool) bool {
618	if sections {
619		return m.eligible(i)
620	}
621	return m.target(i)
622}
623
624// committable reports whether row i may be committed: it admits an action and
625// the typed query, when there is one, does not exclude it.
626func (m *Model) committable(i int) bool {
627	if m == nil || i < 0 || i >= len(m.rows) || !m.rows[i].selectable() {
628		return false
629	}
630	return !m.searchRestricted() || m.rowMatches(i)
631}
632
633func (m *Model) cursorKey() (string, bool) {
634	if m == nil || m.selected < 0 || m.selected >= len(m.rows) {
635		return "", false
636	}
637	return m.rows[m.selected].id(), true
638}
639
640func (m *Model) Up() { m.move(-1) }
641
642func (m *Model) Down() { m.move(1) }
643
644// Cursor reports the line under the picker cursor independently from whether
645// that row may be activated, so a refresh can preserve an anchor row.
646func (m *Model) Cursor() (protocol.PickerLine, bool) {
647	if m == nil || m.selected < 0 || m.selected >= len(m.rows) {
648		return protocol.PickerLine{}, false
649	}
650	return m.rows[m.selected].line, true
651}
652
653// Selected reports the line under the cursor when it admits an action and the
654// active query does not exclude it.
655func (m *Model) Selected() (protocol.PickerLine, bool) {
656	if !m.committable(m.selected) {
657		return protocol.PickerLine{}, false
658	}
659	return m.rows[m.selected].line, true
660}
661
662// SelectedIndex reports the raw selected row index. It is -1 when the model has
663// no rows or when nothing in them qualifies as a cursor destination (see
664// Selected); otherwise it is a real row index even when that row is not
665// selectable.
666func (m *Model) SelectedIndex() int {
667	if m == nil {
668		return -1
669	}
670	return m.selected
671}
672
673// SelectNearestRow selects the nearest eligible row at or after idx, falling
674// back to the last eligible row before it. Section headers are taken only when
675// no other row qualifies.
676func (m *Model) SelectNearestRow(idx int) {
677	if m == nil || len(m.rows) == 0 {
678		return
679	}
680	idx = clamp(idx, 0, len(m.rows)-1)
681	for _, sections := range [2]bool{false, true} {
682		for i := idx; i < len(m.rows); i++ {
683			if m.restable(i, sections) {
684				m.selected = i
685				return
686			}
687		}
688		for i := idx - 1; i >= 0; i-- {
689			if m.restable(i, sections) {
690				m.selected = i
691				return
692			}
693		}
694	}
695}
696
697func (m *Model) Clone() *Model {
698	if m == nil {
699		return nil
700	}
701	clone := *m
702	clone.lines = append([]protocol.PickerLine(nil), m.lines...)
703	clone.rows = append([]row(nil), m.rows...)
704	clone.query.SetValue(m.query.Value())
705	// Search results are copy-on-write: every query mutation publishes a new
706	// map and row slice, so render snapshots can safely share the old values.
707	clone.matchRows = m.matchRows
708	clone.searchMatches = m.searchMatches
709	return &clone
710}
711
712func (m *Model) Render(inner domain.Size, preview Preview, styles ...RenderStyles) renderer.Frame {
713	frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
714	geometry := ChooseGeometry(inner)
715	styleSet := defaultRenderStyles()
716	if len(styles) > 0 {
717		styleSet = styles[0]
718	}
719	ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: styleSet.Background})
720	m.renderList(frame, geometry.List, styleSet)
721	switch geometry.Mode {
722	case LayoutHorizontal:
723		ui.DrawSeparator(frame, geometry.Separator, ui.SeparatorVertical, styleSet.Separator)
724	case LayoutStacked:
725		ui.DrawSeparator(frame, geometry.Separator, ui.SeparatorHorizontal, styleSet.Separator)
726	}
727	ui.BlitFrame(frame, geometry.Preview, preview, ui.VerticalAnchorBottom)
728	m.renderStatus(frame, geometry.Status, styleSet.Status)
729	return frame
730}
731
732func (m *Model) move(delta int) {
733	if m == nil || len(m.rows) == 0 {
734		return
735	}
736	if !m.eligible(m.selected) {
737		m.selected = m.firstEligible()
738		return
739	}
740	for i := m.selected + delta; i >= 0 && i < len(m.rows); i += delta {
741		if m.eligible(i) {
742			m.selected = i
743			return
744		}
745	}
746}
747
748// firstEligible reports the first row the cursor may rest on, preferring any
749// row over a section header.
750func (m *Model) firstEligible() int {
751	for _, sections := range [2]bool{false, true} {
752		for i := range m.rows {
753			if m.restable(i, sections) {
754				return i
755			}
756		}
757	}
758	return -1
759}
760
761func (s SortMode) Title() string {
762	if s == SortGrouped {
763		return " Sessions · grouped "
764	}
765	return " Sessions · recent "
766}
767
768// statusDot is the one-cell problem marker. It is drawn only when something
769// is wrong, in a fixed xterm-256 color so it reads the same in any terminal
770// and theme: red down or error, yellow stale, magenta version mismatch, blue
771// no daemon. Healthy and stopped rows carry no dot: a stopped row is already
772// muted, and the toast explains each failure in words.
773const statusDot = "●"
774
775// Fixed xterm-256 problem colors, shared with client toast borders so one
776// color always means one thing.
777const (
778	ColorProblemError = 167 // red
779	ColorProblemWarn  = 179 // yellow
780)
781
782// lineStatusColor is the xterm-256 index for a problem status, or -1 for none.
783func lineStatusColor(status protocol.PickerLineStatus) int {
784	switch status {
785	case protocol.PickerLineStatusDown, protocol.PickerLineStatusError:
786		return ColorProblemError
787	case protocol.PickerLineStatusStale:
788		return ColorProblemWarn
789	case protocol.PickerLineStatusVersion:
790		return 170 // magenta
791	case protocol.PickerLineStatusNoDaemon:
792		return 67 // blue
793	default:
794		return -1
795	}
796}
797
798// drawStatusBadge draws a problem dot right-aligned on row y. The dot keeps
799// its own color even on the inverse selected row.
800func drawStatusBadge(frame renderer.Frame, rect domain.Rect, y, clipX int, badge string, base renderer.Style, status protocol.PickerLineStatus, problemBold bool) {
801	if badge == "" {
802		return
803	}
804	style := base
805	style.Inverse = false
806	style.Foreground = lineStatusColor(status)
807	style.HasForegroundRGB = false
808	if color := lineStatusColor(status); problemBold && (color == ColorProblemError || color == ColorProblemWarn) {
809		style.Bold = true
810	}
811	ui.DrawText(frame, max(rect.X, clipX-textCellWidth(badge)), rect.Y+y, clipX, badge, style)
812}
813
814func lineStatusBadge(status protocol.PickerLineStatus) string {
815	if lineStatusColor(status) < 0 {
816		return ""
817	}
818	return statusDot
819}
820
821// renderList draws each visible row: a dimmed tree prefix, the name (bold on
822// unselected live session and host headers), the attention marker, then for
823// headers a right-aligned muted detail (tab count, "*" when attached) and a
824// problem dot in a fixed xterm-256 color. Section headers carry their host's
825// problem dot. A tight width truncates the detail before the name.
826func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles RenderStyles) {
827	if m == nil || rect.Width <= 0 || rect.Height <= 0 {
828		return
829	}
830	visible := min(rect.Height, frame.Height-rect.Y)
831	offset := m.scrollOffset(visible)
832	clipX := rect.X + rect.Width
833	for y := range visible {
834		if offset+y >= len(m.view) {
835			break
836		}
837		idx := m.view[offset+y]
838		r := m.rows[idx]
839		base, nameStyle, detailStyle := styles.Base, styles.Name, styles.Detail
840		if r.section() {
841			nameStyle = styles.Detail
842		}
843		if idx == m.selected {
844			base, nameStyle, detailStyle = styles.Selection, styles.SelectionName, styles.SelectionMuted
845		}
846		if r.line.Stopped && idx != m.selected {
847			base, nameStyle, detailStyle = styles.Stopped, styles.Stopped, styles.Stopped
848		}
849		if r.line.Dim && !(r.section() && idx == m.selected) {
850			base.Attrs |= renderer.AttrDim
851			nameStyle.Attrs |= renderer.AttrDim
852			detailStyle.Attrs |= renderer.AttrDim
853		}
854		ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base})
855
856		name := r.line.Label
857		if r.section() {
858			nameStyle.Bold = true
859			marker := "▾ "
860			if m.sectionFolded(idx) {
861				marker = "▸ "
862				name += fmt.Sprintf(" (%d)", m.sectionSessions(idx))
863			}
864			name = marker + name
865		} else if r.rendersAsHeader() && idx != m.selected && !r.line.Stopped {
866			nameStyle.Bold = true
867		}
868		treeX := rect.X
869		if r.tree != "" {
870			treeStyle := base
871			// A caller style set without its own Tree style keeps the row's
872			// colors dimmed; a zero Tree style would be black on black.
873			if idx == m.selected || r.line.Stopped || styles.Tree == (renderer.Style{}) {
874				treeStyle.Attrs |= renderer.AttrDim
875			} else {
876				// Only the foreground comes from Tree: the row's fill and its
877				// dimming (a Dim line) stay as they are.
878				treeStyle.Foreground = styles.Tree.Foreground
879				treeStyle.HasForegroundRGB = styles.Tree.HasForegroundRGB
880				treeStyle.ForegroundRGB = styles.Tree.ForegroundRGB
881			}
882			treeX = ui.DrawText(frame, rect.X, rect.Y+y, max(rect.X, clipX-2), r.tree, treeStyle)
883		}
884		badge := ""
885		if r.rendersAsHeader() || r.section() {
886			badge = lineStatusBadge(r.line.Status)
887		}
888		contentClipX := clipX
889		nameWidth := max(clipX-treeX, 0)
890		badgeWidth := textCellWidth(badge)
891		if badge != "" {
892			nameWidth = max(clipX-treeX-badgeWidth-1, 0)
893			contentClipX = max(rect.X, clipX-badgeWidth-1)
894		}
895		originalName := name
896		name = ui.TruncateText(name, nameWidth)
897		nameMatchStyle := styles.SearchMatch
898		if idx == m.selected {
899			nameMatchStyle = styles.SelectionMatch
900		}
901		namePositions := visibleMatchPositions(m.matchPositions(idx, matchName), name, name != originalName)
902		x := drawMatchedText(frame, treeX, rect.Y+y, contentClipX, name, nameStyle, nameMatchStyle, namePositions)
903
904		if r.section() {
905			drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status, styles.ProblemBold)
906			continue
907		}
908		if r.rendersAsHeader() {
909			if r.line.Attention && x < contentClipX {
910				// A session header rings when any of its tabs does.
911				x = ui.DrawText(frame, x, rect.Y+y, contentClipX, " "+string(ui.AttentionGlyph), base)
912			}
913			// The short detail (tab count, attached marker) is right-aligned
914			// before the badge, muted but not dimmed so it stays readable.
915			if r.line.Detail != "" && x < contentClipX-1 {
916				detail := ui.TruncateText(r.line.Detail, contentClipX-x-1)
917				detailX := max(x+1, contentClipX-textCellWidth(detail))
918				detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail)
919				drawMatchedText(frame, detailX, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions)
920			}
921			drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status, styles.ProblemBold)
922			continue
923		}
924
925		if r.line.Attention {
926			x = ui.DrawText(frame, x, rect.Y+y, clipX, " "+string(ui.AttentionGlyph), base)
927		}
928
929		if r.line.Detail != "" && x < clipX {
930			x = ui.DrawText(frame, x, rect.Y+y, clipX, " ", detailStyle)
931		}
932		detail := ui.TruncateText(r.line.Detail, clipX-x)
933		detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail)
934		drawMatchedText(frame, x, rect.Y+y, clipX, detail, detailStyle, nameMatchStyle, detailPositions)
935	}
936}
937
938func (m *Model) matchPositions(idx int, field matchField) []int {
939	if m == nil || !m.searchActive || m.query.Value() == "" {
940		return nil
941	}
942	return m.searchMatches[idx].positions(field)
943}
944
945func visibleMatchPositions(positions []int, rendered string, truncated bool) []int {
946	if !truncated || len(positions) == 0 {
947		return positions
948	}
949	limit := max(len([]rune(rendered))-1, 0) // the final rune is the ellipsis
950	visible := make([]int, 0, len(positions))
951	for _, position := range positions {
952		if position < limit {
953			visible = append(visible, position)
954		}
955	}
956	return visible
957}
958
959func drawMatchedText(frame renderer.Frame, x, y, clipX int, text string, base, match renderer.Style, positions []int) int {
960	if len(positions) == 0 {
961		return ui.DrawText(frame, x, y, clipX, text, base)
962	}
963	positionIndex := 0
964	runeIndex := 0
965	for _, r := range text {
966		style := base
967		if positionIndex < len(positions) && positions[positionIndex] == runeIndex {
968			style = match
969			positionIndex++
970		}
971		x = ui.DrawText(frame, x, y, clipX, string(r), style)
972		if x >= clipX {
973			break
974		}
975		runeIndex++
976	}
977	return x
978}
979
980func (m *Model) renderStatus(frame renderer.Frame, rect domain.Rect, style renderer.Style) {
981	if rect.Width <= 0 || rect.Height <= 0 || rect.Y < 0 || rect.Y >= frame.Height {
982		return
983	}
984	ui.FillRect(frame, rect, renderer.Cell{Rune: ' ', Style: style})
985	// The footer describes the effective selection: a row kept for inspection,
986	// a row the query hides, and an empty picker never promise a commit.
987	action := ""
988	deletable := false
989	selected := protocol.PickerLine{}
990	hasCursorRow := m != nil && m.selected >= 0 && m.selected < len(m.rows)
991	if hasCursorRow {
992		selected = m.rows[m.selected].line
993	}
994	if m != nil && m.committable(m.selected) {
995		action = actionVerb(selected, m.intent)
996		deletable = selected.Actions&protocol.PickerCanKill != 0
997	}
998	enter := "Enter " + action
999	if action == "" {
1000		enter = "Enter unavailable"
1001	}
1002	if m != nil && m.CursorOnSection() {
1003		enter = "Enter/h/l fold"
1004		if m.searchActive {
1005			// h and l type into the query while searching.
1006			enter = "Enter fold"
1007		}
1008	}
1009	var groups []string
1010	if m != nil && m.searchActive {
1011		groups = []string{fmt.Sprintf("%d matches", len(m.matchRows)), enter, "arrows next"}
1012		escape := "Esc exit"
1013		if m.query.Value() != "" {
1014			escape = "Esc clear"
1015		}
1016		groups = append(groups, escape)
1017	} else {
1018		if hasCursorRow && selected.Dim && selected.StatusDetail != "" {
1019			groups = append(groups, selected.StatusDetail)
1020		}
1021		if rect.Width < 60 {
1022			groups = append(groups, enter, "/", "Esc", "j/k")
1023			if deletable {
1024				groups = append(groups, "x")
1025			}
1026			groups = append(groups, "s")
1027		} else {
1028			groups = append(groups, "j/k move", enter)
1029			if deletable {
1030				groups = append(groups, "x delete")
1031			}
1032			groups = append(groups, "/ search", "s sort", "Esc close")
1033		}
1034	}
1035	text := strings.Join(groups, "  ")
1036	ui.DrawText(frame, rect.X, rect.Y, rect.X+rect.Width, ui.TruncateText(text, rect.Width), style)
1037}
1038
1039// actionVerb names what Enter does on one row, so the footer never promises
1040// an action the source did not authorise.
1041func actionVerb(line protocol.PickerLine, intent protocol.PickerIntent) string {
1042	if line.Actions == 0 {
1043		return "unavailable"
1044	}
1045	if line.Actions&protocol.PickerCanMove != 0 {
1046		if intent == protocol.PickerIntentMoveTab {
1047			return "move"
1048		}
1049		return "move pane"
1050	}
1051	if line.Stopped {
1052		return "restart"
1053	}
1054	return "open"
1055}
1056
1057// scrollOffset is the first drawn position so the cursor row stays visible.
1058func (m *Model) scrollOffset(visible int) int {
1059	if visible <= 0 || len(m.view) <= visible || m.selected < 0 {
1060		return 0
1061	}
1062	position := slices.Index(m.view, m.selected)
1063	if position < visible {
1064		return 0
1065	}
1066	return min(position-visible+1, len(m.view)-visible)
1067}
1068
1069// sectionSessions counts the session and host rows under section row i.
1070func (m *Model) sectionSessions(i int) int {
1071	count := 0
1072	for j := i + 1; j < len(m.rows) && !m.rows[j].section(); j++ {
1073		if m.rows[j].rendersAsHeader() {
1074			count++
1075		}
1076	}
1077	return count
1078}
1079
1080func clamp(n, low, high int) int {
1081	if n < low {
1082		return low
1083	}
1084	if n > high {
1085		return high
1086	}
1087	return n
1088}
1089
1090// ChooseGeometry reserves the final inner row for picker-local status before
1091// solving the list and preview layout.
1092func ChooseGeometry(inner domain.Size) Geometry {
1093	if inner.Cols <= 0 || inner.Rows <= 0 {
1094		return Geometry{}
1095	}
1096	contentRows := max(inner.Rows-1, 0)
1097	layout := ChooseLayout(domain.Size{Cols: inner.Cols, Rows: contentRows})
1098	return Geometry{
1099		Content: domain.Rect{Width: inner.Cols, Height: contentRows},
1100		Status:  domain.Rect{Y: contentRows, Width: inner.Cols, Height: 1},
1101		Mode:    layout.Mode, List: layout.List, Separator: layout.Separator, Preview: layout.Preview,
1102	}
1103}
1104
1105func ChooseLayout(inner domain.Size) Layout {
1106	if inner.Cols <= 0 || inner.Rows <= 0 {
1107		return Layout{}
1108	}
1109	if inner.Rows >= MinPaneHeight {
1110		listWidth := clamp(inner.Cols*40/100, MinListWidth, MaxListWidth)
1111		listWidth = min(listWidth, inner.Cols-MinHorizontalPreviewWidth-1)
1112		previewWidth := inner.Cols - listWidth - 1
1113		if listWidth >= MinHorizontalListWidth && previewWidth >= MinHorizontalPreviewWidth {
1114			return Layout{
1115				Mode:      LayoutHorizontal,
1116				List:      domain.Rect{Width: listWidth, Height: inner.Rows},
1117				Separator: domain.Rect{X: listWidth, Width: 1, Height: inner.Rows},
1118				Preview:   domain.Rect{X: listWidth + 1, Width: previewWidth, Height: inner.Rows},
1119			}
1120		}
1121	}
1122	if inner.Rows >= MinStackHeight && inner.Cols >= MinPreviewWidth {
1123		listHeight := max(MinPaneHeight, inner.Rows*40/100)
1124		previewHeight := inner.Rows - listHeight - 1
1125		return Layout{
1126			Mode:      LayoutStacked,
1127			List:      domain.Rect{Width: inner.Cols, Height: listHeight},
1128			Separator: domain.Rect{Y: listHeight, Width: inner.Cols, Height: 1},
1129			Preview:   domain.Rect{Y: listHeight + 1, Width: inner.Cols, Height: previewHeight},
1130		}
1131	}
1132	return Layout{Mode: LayoutListOnly, List: domain.Rect{Width: inner.Cols, Height: inner.Rows}}
1133}

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