model.go RawHistoryFolder

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

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