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

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