v0.3.0 99dc266

model.go RawHistoryFolder

1package picker
2
3import (
4	"math"
5
6	renderer "github.com/bnema/vev-vt"
7	"github.com/bnema/vev/internal/domain"
8	"github.com/bnema/vev/internal/usecase/ui"
9)
10
11const (
12	MinPreviewWidth           = 24
13	MinHorizontalPreviewWidth = 48
14	MinListWidth              = 16
15	MaxListWidth              = 32
16	MinPaneHeight             = 4
17	MinStackHeight            = 12
18)
19
20type RemoteAvailability uint8
21
22const (
23	RemoteNone RemoteAvailability = iota
24	RemoteCached
25	RemoteFresh
26	RemoteStale
27	RemoteVersionMismatch
28)
29
30// RemoteActivation is the picker action authorized by the current remote
31// catalog snapshot. It is presentation state only; RemoteSessionTarget remains
32// the exact route and is revalidated when the picker hands off the client.
33type RemoteActivation uint8
34
35const (
36	RemoteUnavailable RemoteActivation = iota
37	RemoteAttach
38	RemoteRestart
39)
40
41type SessionView struct {
42	ID                domain.SessionID
43	Incarnation       domain.IncarnationID
44	Name              string
45	TargetName        string
46	Tabs              []TabEntry
47	Active            int
48	Stopped           bool
49	ExpectedCreatedAt *int64
50	RemoteKey         *domain.RemoteSessionKey
51	// RemoteTarget is the structured route/lifecycle identity for picker rows.
52	// It is never reconstructed from Name or a rendered label.
53	RemoteTarget *domain.RemoteSessionTarget
54	// RemoteHost marks remote host status rows that have no session key.
55	RemoteHost         string
56	RemoteAvailability RemoteAvailability
57	RemoteDetail       string
58	RemoteReason       string
59	RemoteActivation   RemoteActivation
60	ConnectOnly        bool
61	// CannotAcceptMoves reports whether this session cannot receive a moved tab
62	// or pane. False for ordinary local (and stopped) sessions; true for
63	// restricted remote rows.
64	CannotAcceptMoves bool
65}
66
67// TabEntry is one tab row; Name is drawn emphasized, Detail muted.
68type TabEntry struct {
69	TabID     domain.TabStableID // stable tab identity; independent of its current index
70	Name      string             // tab display name
71	RawName   string             // unformatted name used by exact remote selectors
72	Detail    string             // " (paneTitle)" or "", drawn muted
73	Attention bool               // draw the attention marker right after Name, before Detail
74}
75
76type SelectionMode uint8
77
78const (
79	SelectNavigationTab SelectionMode = iota
80	SelectMovePaneTab
81	SelectMoveTabSession
82)
83
84type SourceFilter struct {
85	Session     domain.SessionID
86	Incarnation domain.IncarnationID
87	TabID       domain.TabStableID
88	RemoteKey   *domain.RemoteSessionKey
89}
90
91// SelectionConfig describes which rows are selectable, which stable target
92// should remain selected, and which source must not be offered as a move
93// destination.
94type SelectionConfig struct {
95	Mode    SelectionMode
96	Current SourceFilter
97	Source  SourceFilter
98}
99
100// RenderStyles are the styles Render uses to draw list rows. The zero value
101// (all renderer.Style{}) is never used directly; Render falls back to
102// defaultRenderStyles when no RenderStyles is supplied, mirroring the styling
103// of a non-truecolor client.
104type RenderStyles struct {
105	Selection      renderer.Style // selected row fill + suffixes
106	SelectionName  renderer.Style // selected row name segment
107	SelectionMuted renderer.Style // selected row detail segment
108	Name           renderer.Style // non-selected name segment
109	Detail         renderer.Style // non-selected detail segment
110	// Background fills otherwise unused interior cells. Base owns ordinary
111	// rows, preserving a distinct inactive surface from modal chrome.
112	Background renderer.Style
113	Base       renderer.Style // non-selected row fill + suffixes
114	Stopped    renderer.Style // full row style for non-selected stopped rows (fill, name, detail, suffix)
115	Separator  renderer.Style // preview separator
116}
117
118func defaultRenderStyles() RenderStyles {
119	selection := renderer.DefaultStyle()
120	selection.Inverse = true
121	base := renderer.DefaultStyle()
122	separator := renderer.DefaultStyle()
123	separator.Attrs = renderer.AttrDim
124	stopped := renderer.DefaultStyle()
125	stopped.Attrs = renderer.AttrDim
126	stopped.Italic = true
127	return RenderStyles{Selection: selection, SelectionName: selection, SelectionMuted: selection, Name: base, Detail: base, Background: base, Base: base, Separator: separator, Stopped: stopped}
128}
129
130type Target struct {
131	Session           domain.SessionID
132	Incarnation       domain.IncarnationID
133	Name              string
134	RemoteKey         *domain.RemoteSessionKey
135	RemoteTarget      *domain.RemoteSessionTarget
136	RemoteHost        string
137	UnavailableReason string
138	TabID             domain.TabStableID
139	TabIndex          int
140	Stopped           bool
141	// ExpectedCreatedAt optionally pins this target to a particular named
142	// session lifecycle. Callers that obtain a snapshot outside the daemon use
143	// it to reject a same-name replacement at commit time.
144	ExpectedCreatedAt *int64
145}
146
147// Preview is a bounded view of the selected pane's visible frame.
148type Preview = ui.FrameView
149
150type LayoutMode uint8
151
152const (
153	LayoutListOnly LayoutMode = iota
154	LayoutStacked
155	LayoutHorizontal
156)
157
158type Layout struct {
159	Mode      LayoutMode
160	List      domain.Rect
161	Separator domain.Rect
162	Preview   domain.Rect
163}
164
165type Model struct {
166	mode     SelectionMode
167	rows     []row
168	selected int
169}
170
171type rowKind uint8
172
173const (
174	rowSession rowKind = iota
175	rowTab
176)
177
178func (k rowKind) rendersAsHeader() bool {
179	return k == rowSession
180}
181
182func (k rowKind) selectable(mode SelectionMode) bool {
183	switch mode {
184	case SelectNavigationTab, SelectMovePaneTab:
185		return k == rowTab
186	case SelectMoveTabSession:
187		return k == rowSession
188	default:
189		return false
190	}
191}
192
193type row struct {
194	kind        rowKind
195	dispName    string // display name segment; bold on truecolor
196	detail      string // " (paneTitle)" segment, muted; "" for session rows
197	attention   bool   // draw the attention marker right after the name, before detail; tab rows only
198	session     domain.SessionID
199	incarnation domain.IncarnationID
200	// targetName is the named-session lookup name threaded into Target;
201	// distinct from dispName, which is what gets drawn.
202	targetName           string
203	tabID                domain.TabStableID
204	tabIndex             int
205	stopped              bool
206	expectedCreatedAt    int64
207	hasExpectedCreatedAt bool
208	remoteKey            domain.RemoteSessionKey
209	hasRemoteKey         bool
210	remote               bool
211	remoteTarget         domain.RemoteSessionTarget
212	hasRemoteTarget      bool
213	remoteHost           string
214	unavailableReason    string
215	selectable           bool
216	focusable            bool
217	dim                  bool
218}
219
220func New(sessions []SessionView, config SelectionConfig) *Model {
221	m := &Model{mode: config.Mode, selected: -1}
222	activeSelection := -1
223	for _, session := range sessions {
224		sessionRows := rowsForSession(session, config)
225		for _, pickerRow := range sessionRows {
226			idx := len(m.rows)
227			m.rows = append(m.rows, pickerRow)
228			if selectionMatches(pickerRow, config.Current, config.Mode) {
229				m.selected = idx
230			}
231			if config.Mode == SelectNavigationTab && activeSelection < 0 && pickerRow.focusable && pickerRow.kind == rowTab && pickerRow.tabIndex == normalizedActive(session) {
232				activeSelection = idx
233			}
234		}
235	}
236	if m.selected < 0 {
237		m.selected = activeSelection
238	}
239	if m.selected < 0 {
240		m.selected = m.firstFocusable()
241	}
242	if m.selected < 0 && len(m.rows) > 0 {
243		m.selected = 0
244	}
245	return m
246}
247
248// rowsForSession is the sole owner of mode-specific destination eligibility.
249// The daemon supplies canonical lifecycle/tab snapshots without prefiltering.
250func rowsForSession(session SessionView, config SelectionConfig) []row {
251	if config.Mode != SelectNavigationTab && session.Stopped {
252		return nil
253	}
254	if config.Mode == SelectMoveTabSession && sourceMatchesSession(config.Source, session) {
255		return nil
256	}
257	if config.Mode == SelectMoveTabSession && len(session.Tabs) == 0 && !session.CannotAcceptMoves {
258		return nil
259	}
260
261	targetName := session.TargetName
262	if config.Mode == SelectNavigationTab && !session.Stopped && session.ExpectedCreatedAt == nil {
263		targetName = ""
264	} else if targetName == "" {
265		targetName = session.Name
266	}
267	expectedCreatedAt, hasExpectedCreatedAt := int64Value(session.ExpectedCreatedAt)
268	common := row{
269		session: session.ID, incarnation: session.Incarnation, targetName: targetName,
270		stopped: session.Stopped, expectedCreatedAt: expectedCreatedAt,
271		hasExpectedCreatedAt: hasExpectedCreatedAt,
272	}
273	if session.RemoteKey != nil {
274		common.remoteKey, common.hasRemoteKey = *session.RemoteKey, true
275	}
276	if session.RemoteTarget != nil {
277		common.remoteTarget, common.hasRemoteTarget = *session.RemoteTarget, true
278	}
279	common.remoteHost = session.RemoteHost
280	common.remote = common.hasRemoteKey || common.hasRemoteTarget || session.RemoteHost != ""
281	header := common
282	header.kind, header.dispName, header.tabIndex = rowSession, session.Name, -1
283	if common.hasRemoteKey {
284		header.dispName = common.remoteKey.Name
285		display := common.remoteKey.Display()
286		header.detail = display[len(common.remoteKey.Name):]
287	}
288	header.selectable = header.kind.selectable(config.Mode)
289	header.focusable = header.selectable
290	if common.remote {
291		if session.RemoteDetail != "" {
292			if header.detail != "" {
293				header.detail += " (" + session.RemoteDetail + ")"
294			} else {
295				header.detail = session.RemoteDetail
296			}
297		}
298		if common.hasRemoteTarget {
299			header.selectable = false
300			header.focusable = config.Mode == SelectNavigationTab && len(session.Tabs) == 0
301		} else {
302			header.selectable = common.hasRemoteKey && config.Mode == SelectNavigationTab && session.ConnectOnly && remoteActivatable(session)
303			header.focusable = header.selectable
304			if !common.hasRemoteKey && !common.hasRemoteTarget {
305				header.focusable = config.Mode == SelectNavigationTab
306			}
307		}
308		header.unavailableReason = session.RemoteReason
309		header.dim = session.RemoteActivation == RemoteUnavailable
310	}
311	if config.Mode != SelectNavigationTab && session.CannotAcceptMoves {
312		header.selectable, header.focusable, header.dim = false, false, true
313	}
314	rows := []row{header}
315	if !session.ConnectOnly || common.hasRemoteTarget {
316		for i, tab := range session.Tabs {
317			if config.Mode == SelectMovePaneTab && sourceMatchesTab(config.Source, session, tab) {
318				continue
319			}
320			tabRow := common
321			tabRow.kind, tabRow.dispName, tabRow.detail, tabRow.attention = rowTab, tab.Name, tab.Detail, tab.Attention
322			tabRow.tabID, tabRow.tabIndex = tab.TabID, i
323			tabRow.selectable = tabRow.kind.selectable(config.Mode)
324			tabRow.focusable = tabRow.selectable
325			if common.remote {
326				tabRow.unavailableReason = session.RemoteReason
327				if common.hasRemoteTarget {
328					remoteTarget := common.remoteTarget
329					if session.Stopped {
330						remoteTarget.LiveTabID = ""
331						if tab.TabID != "" {
332							remoteTarget.StoppedTab = domain.NewStableTabSelector(tab.TabID)
333						} else {
334							tabCount := min(len(session.Tabs), math.MaxUint16)
335							remoteTarget.StoppedTab = domain.NewOrdinalTabSelector(uint16(i), tab.RawName, uint16(tabCount))
336						}
337					} else {
338						remoteTarget.StoppedTab = domain.TabSelector{}
339						remoteTarget.LiveTabID = tab.TabID
340					}
341					// Keep the selected tab's structured identity even when it is
342					// invalid. Focus remains possible for diagnostics, while both
343					// the readiness gate and daemon wire validation reject it.
344					tabRow.remoteTarget = remoteTarget
345					tabRow.hasRemoteTarget = true
346				}
347				if !tabRow.hasRemoteTarget {
348					legacyRemote := common.hasRemoteKey && config.Mode == SelectNavigationTab && remoteActivatable(session)
349					tabRow.focusable = legacyRemote
350					tabRow.selectable = legacyRemote
351				} else {
352					tabRow.focusable = true
353					tabRow.selectable = tabRow.remoteTarget.Validate() == nil && config.Mode == SelectNavigationTab && remoteActivatable(session)
354				}
355				tabRow.dim = session.RemoteActivation == RemoteUnavailable
356			}
357			if config.Mode != SelectNavigationTab && session.CannotAcceptMoves {
358				tabRow.selectable, tabRow.focusable, tabRow.dim = false, false, true
359			}
360			rows = append(rows, tabRow)
361		}
362	}
363	if config.Mode == SelectMovePaneTab && len(rows) == 1 && !session.CannotAcceptMoves {
364		return nil
365	}
366	return rows
367}
368
369func remoteActivatable(session SessionView) bool {
370	return session.RemoteActivation == RemoteAttach || session.RemoteActivation == RemoteRestart
371}
372
373func sourceMatchesSession(source SourceFilter, session SessionView) bool {
374	if source.Session != session.ID {
375		return false
376	}
377	return source.Incarnation == (domain.IncarnationID{}) || source.Incarnation == session.Incarnation
378}
379
380func sourceMatchesTab(source SourceFilter, session SessionView, tab TabEntry) bool {
381	return sourceMatchesSession(source, session) && source.TabID == tab.TabID
382}
383
384func normalizedActive(session SessionView) int {
385	if session.Active < 0 || session.Active >= len(session.Tabs) {
386		return 0
387	}
388	return session.Active
389}
390
391func selectionMatches(pickerRow row, current SourceFilter, mode SelectionMode) bool {
392	if pickerRow.session != current.Session || (current.Incarnation != (domain.IncarnationID{}) && pickerRow.incarnation != current.Incarnation) {
393		return false
394	}
395	if pickerRow.hasRemoteKey && pickerRow.kind == rowSession {
396		return current.RemoteKey == nil || pickerRow.remoteKey == *current.RemoteKey
397	}
398	if mode == SelectMoveTabSession {
399		return pickerRow.kind == rowSession
400	}
401	if pickerRow.kind != rowTab {
402		return false
403	}
404	// A stopped session contributes one synthetic tab row carrying no stable tab
405	// identity, so it matches on session identity alone. Its session ID is
406	// namespaced ("stopped:<name>"), and move modes drop stopped sessions
407	// entirely, so no live row can be reached this way.
408	if current.TabID == "" {
409		return pickerRow.stopped
410	}
411	return pickerRow.tabID == current.TabID
412}
413
414func int64Value(value *int64) (int64, bool) {
415	if value == nil {
416		return 0, false
417	}
418	return *value, true
419}
420
421func ChooseLayout(inner domain.Size) Layout {
422	if inner.Cols <= 0 || inner.Rows <= 0 {
423		return Layout{}
424	}
425	if inner.Rows >= MinPaneHeight {
426		listWidth := clamp(inner.Cols*30/100, MinListWidth, MaxListWidth)
427		previewWidth := inner.Cols - listWidth - 1
428		if previewWidth >= MinHorizontalPreviewWidth {
429			return Layout{
430				Mode:      LayoutHorizontal,
431				List:      domain.Rect{Width: listWidth, Height: inner.Rows},
432				Separator: domain.Rect{X: listWidth, Width: 1, Height: inner.Rows},
433				Preview:   domain.Rect{X: listWidth + 1, Width: previewWidth, Height: inner.Rows},
434			}
435		}
436	}
437	if inner.Rows >= MinStackHeight && inner.Cols >= MinPreviewWidth {
438		listHeight := max(MinPaneHeight, inner.Rows*40/100)
439		previewHeight := inner.Rows - listHeight - 1
440		return Layout{
441			Mode:      LayoutStacked,
442			List:      domain.Rect{Width: inner.Cols, Height: listHeight},
443			Separator: domain.Rect{Y: listHeight, Width: inner.Cols, Height: 1},
444			Preview:   domain.Rect{Y: listHeight + 1, Width: inner.Cols, Height: previewHeight},
445		}
446	}
447	return Layout{Mode: LayoutListOnly, List: domain.Rect{Width: inner.Cols, Height: inner.Rows}}
448}
449
450func (m *Model) Up() {
451	m.move(-1)
452}
453
454func (m *Model) Down() {
455	m.move(1)
456}
457
458// Cursor reports the identity under the picker cursor independently from
459// whether that row may be activated. Remote refreshes use it to preserve a
460// structured remote session key while Selected continues to enforce safety.
461func (m *Model) Cursor() (Target, bool) {
462	if m == nil || m.selected < 0 || m.selected >= len(m.rows) {
463		return Target{}, false
464	}
465	return m.rows[m.selected].target(), true
466}
467
468func (m *Model) Selected() (Target, bool) {
469	if m == nil || m.selected < 0 || m.selected >= len(m.rows) {
470		return Target{}, false
471	}
472	r := m.rows[m.selected]
473	if !r.selectable {
474		return Target{}, false
475	}
476	return r.target(), true
477}
478
479// SelectedIndex reports the raw selected row index. It is -1 only when the
480// model is nil or has no rows at all; otherwise it is a real row index even
481// when that row is not selectable (see Selected). Row indices are only
482// meaningful against this exact model.
483func (m *Model) SelectedIndex() int {
484	if m == nil {
485		return -1
486	}
487	return m.selected
488}
489
490// SelectNearestRow selects the first selectable row at or after idx, falling
491// back to the last selectable row before it. Callers use it to keep the
492// cursor on the row that takes a removed item's place.
493func (m *Model) SelectNearestRow(idx int) {
494	if m == nil || len(m.rows) == 0 {
495		return
496	}
497	idx = clamp(idx, 0, len(m.rows)-1)
498	for i := idx; i < len(m.rows); i++ {
499		if m.rows[i].focusable {
500			m.selected = i
501			return
502		}
503	}
504	for i := idx - 1; i >= 0; i-- {
505		if m.rows[i].focusable {
506			m.selected = i
507			return
508		}
509	}
510}
511
512func (m *Model) Clone() *Model {
513	if m == nil {
514		return nil
515	}
516	clone := *m
517	clone.rows = append([]row(nil), m.rows...)
518	return &clone
519}
520
521func (m *Model) Render(inner domain.Size, preview Preview, styles ...RenderStyles) renderer.Frame {
522	frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
523	layout := ChooseLayout(inner)
524	styleSet := defaultRenderStyles()
525	if len(styles) > 0 {
526		styleSet = styles[0]
527	}
528	ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: styleSet.Background})
529	m.renderList(frame, layout.List, styleSet)
530	switch layout.Mode {
531	case LayoutHorizontal:
532		ui.DrawSeparator(frame, layout.Separator, ui.SeparatorVertical, styleSet.Separator)
533	case LayoutStacked:
534		ui.DrawSeparator(frame, layout.Separator, ui.SeparatorHorizontal, styleSet.Separator)
535	}
536	ui.BlitFrame(frame, layout.Preview, preview, ui.VerticalAnchorBottom)
537	return frame
538}
539
540func (m *Model) move(delta int) {
541	if m == nil || len(m.rows) == 0 {
542		return
543	}
544	if m.selected < 0 || m.selected >= len(m.rows) || !m.rows[m.selected].focusable {
545		m.selected = m.firstFocusable()
546		return
547	}
548	for i := m.selected + delta; i >= 0 && i < len(m.rows); i += delta {
549		if m.rows[i].focusable {
550			m.selected = i
551			return
552		}
553	}
554}
555
556func (m *Model) firstFocusable() int {
557	for i, r := range m.rows {
558		if r.focusable {
559			return i
560		}
561	}
562	return -1
563}
564
565func (r row) target() Target {
566	var remoteTarget *domain.RemoteSessionTarget
567	if r.hasRemoteTarget {
568		copyTarget := r.remoteTarget
569		remoteTarget = &copyTarget
570	}
571	return Target{
572		Session: r.session, Incarnation: r.incarnation, Name: r.targetName, RemoteKey: r.remoteKeyPointer(), RemoteTarget: remoteTarget,
573		RemoteHost: r.remoteHost, UnavailableReason: r.unavailableReason, TabID: r.tabID, TabIndex: r.tabIndex, Stopped: r.stopped, ExpectedCreatedAt: r.expectedCreatedAtPointer(),
574	}
575}
576
577func (r row) expectedCreatedAtPointer() *int64 {
578	if !r.hasExpectedCreatedAt {
579		return nil
580	}
581	value := r.expectedCreatedAt
582	return &value
583}
584
585func (r row) remoteKeyPointer() *domain.RemoteSessionKey {
586	if !r.hasRemoteKey {
587		return nil
588	}
589	key := r.remoteKey
590	return &key
591}
592
593// renderList draws each visible row as up to three segments: a name segment
594// (bold when styles came from a truecolor theme), a base-styled attention
595// marker right after the name, and a muted detail segment (tab rows only) —
596// or a base-styled "(down)" suffix for down session headers. A tight width
597// ellipsizes the detail segment before eating into the name.
598func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles RenderStyles) {
599	if m == nil || rect.Width <= 0 || rect.Height <= 0 {
600		return
601	}
602	visible := min(rect.Height, frame.Height-rect.Y)
603	offset := m.scrollOffset(visible)
604	clipX := rect.X + rect.Width
605	for y := range visible {
606		idx := offset + y
607		if idx >= len(m.rows) {
608			break
609		}
610		r := m.rows[idx]
611		base, nameStyle, detailStyle := styles.Base, styles.Name, styles.Detail
612		if idx == m.selected {
613			base, nameStyle, detailStyle = styles.Selection, styles.SelectionName, styles.SelectionMuted
614		}
615		if r.stopped && idx != m.selected {
616			base, nameStyle, detailStyle = styles.Stopped, styles.Stopped, styles.Stopped
617		}
618		if r.dim {
619			base.Attrs |= renderer.AttrDim
620			nameStyle.Attrs |= renderer.AttrDim
621			detailStyle.Attrs |= renderer.AttrDim
622		}
623		ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: base})
624
625		name := r.dispName
626		if !r.kind.rendersAsHeader() {
627			name = "  " + name
628		}
629		suffix := ""
630		if r.kind.rendersAsHeader() && r.stopped {
631			suffix = "(down)"
632		}
633		name = ui.TruncateText(name, max(rect.Width-len(suffix), 0))
634		x := ui.DrawText(frame, rect.X, rect.Y+y, clipX, name, nameStyle)
635
636		if r.kind.rendersAsHeader() {
637			if r.stopped {
638				detail := r.detail
639				if detail == "" {
640					detail = " "
641				} else {
642					detail += " "
643				}
644				detail = ui.TruncateText(detail, max(clipX-x-len(suffix), 0))
645				x = ui.DrawText(frame, x, rect.Y+y, clipX, detail, base)
646				ui.DrawText(frame, x, rect.Y+y, clipX, suffix, base)
647			} else {
648				ui.DrawText(frame, x, rect.Y+y, clipX, ui.TruncateText(r.detail, clipX-x), detailStyle)
649			}
650			continue
651		}
652
653		if r.attention {
654			x = ui.DrawText(frame, x, rect.Y+y, clipX, " "+string(ui.AttentionGlyph), base)
655		}
656
657		detail := ui.TruncateText(r.detail, clipX-x)
658		ui.DrawText(frame, x, rect.Y+y, clipX, detail, detailStyle)
659	}
660}
661
662func (m *Model) scrollOffset(visible int) int {
663	if visible <= 0 || len(m.rows) <= visible || m.selected < 0 {
664		return 0
665	}
666	if m.selected < visible {
667		return 0
668	}
669	offset := m.selected - visible + 1
670	return min(offset, len(m.rows)-visible)
671}
672
673func clamp(n, low, high int) int {
674	if n < low {
675		return low
676	}
677	if n > high {
678		return high
679	}
680	return n
681}

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