render.go RawHistoryFolder

1// Package daemon holds vev's server-side session multiplexer use case.
2package daemon
3
4import (
5	"bytes"
6
7	renderer "github.com/bnema/vev-vt/ansi"
8	"github.com/bnema/vev/internal/domain"
9	"github.com/bnema/vev/internal/ports"
10	"github.com/bnema/vev/internal/usecase/layout"
11	themeui "github.com/bnema/vev/internal/usecase/theme"
12)
13
14// completedSynchronizedUpdate reports a full DEC 2026 batch contained in one
15// PTY read. The VT's final state is inactive in this case, so it cannot be
16// inferred from before/after SyncUpdateActive alone.
17func completedSynchronizedUpdate(data []byte) bool {
18	_, tail, found := bytes.Cut(data, []byte(renderer.SyncStartCSI))
19	return found && bytes.Contains(tail, []byte(renderer.SyncEndCSI))
20}
21
22// paneRenderable reports dynamic composition visibility. Callers must not
23// hold coordinator locks: this acquires daemon/session/tab/pane-adjacent locks.
24func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool {
25	if sess == nil || tb == nil || p == nil {
26		return false
27	}
28	sess.mu.Lock()
29	active := sess.attachmentViewsTabLocked(tb)
30	attached := len(sess.attachments) != 0
31	sess.mu.Unlock()
32
33	if !active || !attached {
34		// A headless tab is still renderable while a preview subscriber
35		// watches it: the preview service composes its frames from this tab.
36		rc := sess.renderCoordinator()
37		if rc == nil || !rc.hasPreviewSubscribers() {
38			return false
39		}
40	}
41	tb.mu.Lock()
42	defer tb.mu.Unlock()
43	if tb.floating.pane == p {
44		return active && attached && tb.floating.state == floatingVisible
45	}
46	placements, ok := solvedPlacementsLocked(tb)
47	if !ok {
48		return false
49	}
50	for _, placement := range placements {
51		if placement.ID == p.id && !placement.Collapsed && placement.Content.Width > 0 && placement.Content.Height > 0 {
52			return true
53		}
54	}
55	return false
56}
57
58// ptyReader is retained as a compatibility entry point for focused tests. The
59// production goroutine calls readPanePTY, whose closure owns only the pane.
60func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
61	if p != nil && p.ownerSnapshot() == nil && sess != nil && tb != nil {
62		publishPaneOwner(p, sess, tb, 0)
63	}
64	d.readPanePTY(p)
65}
66
67func (d *Daemon) readPanePTY(p *pane) {
68	defer d.sessWg.Done()
69	if p == nil {
70		return
71	}
72	buf := make([]byte, ptyReadBufSize)
73	p.mu.Lock()
74	p.screen.OnResponse = func(b []byte) { p.ptyResponses = append(p.ptyResponses, b...) }
75	p.screen.OnBell = func() { p.ptyAttention = true }
76	p.screen.OnNotify = func(string, string) { p.ptyAttention = true }
77	p.screen.OnProgress = func(bool) { p.ptyAttention = true }
78	p.screen.OnClipboard = func(b64 string) { p.ptyClipboards = append(p.ptyClipboards, b64) }
79	p.mu.Unlock()
80	for {
81		n, err := p.pty.Read(buf)
82		if n > 0 {
83			d.processPanePTYData(p, buf[:n], true)
84		}
85		if err != nil {
86			lease := p.effectLease()
87			if sessionName, current := paneEffectSessionName(lease); current {
88				d.log.Info("pane pty closed", "err", err, "session", sessionName)
89			} else {
90				d.log.Info("pane pty closed", "err", err)
91			}
92			if p.onExit != nil {
93				p.onExit()
94			} else {
95				d.reapPaneOwner(p)
96			}
97			return
98		}
99	}
100}
101
102type panePTYEffects struct {
103	lease             paneEffectLease
104	renderCoordinator *renderCoordinator
105	wasSyncing        bool
106	isSyncing         bool
107	completeSyncRead  bool
108	syncEnded         bool
109	attention         bool
110	responses         []byte
111	clipboards        []string
112	syncCleanup       syncTimerCleanup
113}
114
115// processPTYData retains the old call shape for resize replay and focused tests,
116// but routing is always derived from the pane owner captured under pane.mu.
117func (d *Daemon) processPTYData(_ *session, _ *tab, p *pane, data []byte, bufferDuringApply bool) {
118	d.processPanePTYData(p, data, bufferDuringApply)
119}
120
121// processPanePTYData is the sole VT parsing path. While a resize apply owns the
122// pane, reader calls append exact bytes and returns immediately; it never waits
123// for PTY.Resize or loses a short read.
124func (d *Daemon) processPanePTYData(p *pane, data []byte, bufferDuringApply bool) {
125	if p == nil {
126		return
127	}
128	p.mu.Lock()
129	if bufferDuringApply && p.resizeApplying {
130		p.resizePending = append(p.resizePending, data...)
131		p.mu.Unlock()
132		return
133	}
134	effects := panePTYEffects{lease: p.effectLeaseLocked()}
135	owner := effects.lease.owner
136	if owner != nil && owner.session != nil {
137		effects.renderCoordinator = owner.session.renderCoordinator()
138	}
139	effects.wasSyncing = p.screen.SyncUpdateActive()
140	writePaneScreenLocked(p, data)
141	p.refreshTerminalTitleLocked()
142	effects.isSyncing = p.screen.SyncUpdateActive()
143	effects.completeSyncRead = !effects.wasSyncing && !effects.isSyncing && completedSynchronizedUpdate(data)
144	if effects.wasSyncing != effects.isSyncing && owner != nil && owner.session != nil {
145		if effects.isSyncing {
146			syncGen := owner.session.syncGen.Add(1)
147			p.syncGen = syncGen
148			if effects.renderCoordinator != nil {
149				lease := effects.lease
150				effects.syncCleanup = effects.renderCoordinator.beginSyncBatchWithRenderability(p, syncGen, func() bool {
151					return lease.Current() && d.paneRenderable(owner.session, owner.tab, p)
152				}, func() {
153					p.mu.Lock()
154					if p.owner.Load() == lease.owner && p.syncGen == syncGen && p.screen.SyncUpdateActive() {
155						p.screen.ForceSyncEnd()
156					}
157					p.mu.Unlock()
158				})
159			}
160		} else {
161			effects.syncEnded = effects.renderCoordinator != nil && effects.renderCoordinator.removeSyncEnd(p, p.syncGen, true)
162		}
163	}
164	effects.attention = p.ptyAttention
165	p.ptyAttention = false
166	effects.responses = append([]byte(nil), p.ptyResponses...)
167	effects.responses = filterUnsupportedKittyAnimationResponses(effects.responses)
168	p.ptyResponses = p.ptyResponses[:0]
169	effects.clipboards = append([]string(nil), p.ptyClipboards...)
170	p.ptyClipboards = p.ptyClipboards[:0]
171	p.mu.Unlock()
172	effects.syncCleanup.finish()
173	d.applyPanePTYEffects(p, effects)
174}
175
176// filterUnsupportedKittyAnimationResponses keeps older icat releases from
177// leaving vev's static-animation fallback replies in the shared PTY input
178// queue. Current Kitty sends animation controls with q=2, but older releases
179// requested error replies and then exited without consuming them. Transfer
180// capability probes use ordinary image IDs and retain their ENOTSUP replies.
181func filterUnsupportedKittyAnimationResponses(data []byte) []byte {
182	if !bytes.Contains(data, []byte("i=0,I=")) || !bytes.Contains(data, []byte(";ENOTSUP")) {
183		return data
184	}
185	out := make([]byte, 0, len(data))
186	for len(data) != 0 {
187		start := bytes.Index(data, []byte("\x1b_G"))
188		if start < 0 {
189			out = append(out, data...)
190			break
191		}
192		out = append(out, data[:start]...)
193		endOffset := bytes.Index(data[start+3:], []byte("\x1b\\"))
194		if endOffset < 0 {
195			out = append(out, data[start:]...)
196			break
197		}
198		end := start + 3 + endOffset + 2
199		record := data[start:end]
200		body := record[3 : len(record)-2]
201		if !bytes.Contains(body, []byte("i=0,I=")) || !bytes.HasSuffix(body, []byte(";ENOTSUP")) {
202			out = append(out, record...)
203		}
204		data = data[end:]
205	}
206	return out
207}
208
209// applyPanePTYEffects emits only effects still bound to the owner generation
210// that parsed them. Each owner-sensitive boundary revalidates independently so
211// ownership publication can retire the remaining effects without rerouting
212// bytes that belonged to the old owner.
213func (d *Daemon) applyPanePTYEffects(p *pane, effects panePTYEffects) {
214	owner := effects.lease.owner
215	if owner == nil || owner.session == nil || owner.tab == nil {
216		return
217	}
218	if effects.lease.Current() {
219		markSnapshotDirty(owner.session)
220	}
221	if effects.attention && effects.lease.Current() {
222		d.noteAttention(owner.session, owner.tab)
223	}
224	for _, b64 := range effects.clipboards {
225		if effects.lease.Current() {
226			d.forwardClipboardAsync(effects.lease, b64)
227		}
228	}
229	if len(effects.responses) > 0 && effects.lease.Current() {
230		if _, err := p.pty.Write(effects.responses); err != nil {
231			if sessionName, current := paneEffectSessionName(effects.lease); current {
232				d.log.Warn("pty response write failed", "err", err, "session", sessionName)
233			}
234		}
235	}
236	if effects.renderCoordinator == nil || !effects.lease.Current() {
237		return
238	}
239	renderable := d.paneRenderable(owner.session, owner.tab, p)
240	if !effects.lease.Current() {
241		return
242	}
243	if effects.wasSyncing != effects.isSyncing {
244		if effects.isSyncing {
245			if renderable {
246				effects.renderCoordinator.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
247			}
248		} else if effects.syncEnded && renderable {
249			effects.renderCoordinator.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
250		}
251	}
252	if effects.completeSyncRead && renderable {
253		effects.renderCoordinator.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
254	} else if renderable && !effects.isSyncing && effects.wasSyncing == effects.isSyncing {
255		effects.renderCoordinator.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
256	}
257}
258
259// paint draws the composed client frame (active tab plus status bar) and
260// sends the resulting bytes. The renderer shadow is reset on explicit invalidations
261// such as switch/create/close/rename/resize so the repaint is complete.
262func (d *Daemon) observeRuntime(kind ports.RuntimeMarkKind, bytes uint64, valid bool) {
263	if d != nil && d.runtimeObserver != nil {
264		d.runtimeObserver.ObserveRuntime(ports.NewRuntimeMark("daemon", kind, bytes, valid))
265	}
266}
267
268// runtimeMarkBatch captures immutable runtime marks during a render transaction.
269// paint owns attachment sendMu while it captures, composes, and emits; JSONL
270// observer I/O must therefore happen only after that ownership is released.
271type runtimeMarkBatch struct {
272	observer         ports.RuntimeObserver
273	marks            []ports.RuntimeMark
274	attachmentEffect *attachmentEffect
275}
276
277func (d *Daemon) newRuntimeMarkBatch() runtimeMarkBatch {
278	if d == nil {
279		return runtimeMarkBatch{}
280	}
281	return runtimeMarkBatch{observer: d.runtimeObserver}
282}
283
284func (b *runtimeMarkBatch) span(start, end ports.RuntimeMarkKind, bytes uint64) func(uint64, bool) {
285	if b == nil || b.observer == nil {
286		return func(uint64, bool) {}
287	}
288	correlation := ports.NewRuntimeCorrelation()
289	b.marks = append(b.marks, ports.NewRuntimeMarkWithCorrelation("daemon", correlation, start, bytes, true))
290	return func(endBytes uint64, valid bool) {
291		b.marks = append(b.marks, ports.NewRuntimeMarkWithCorrelation("daemon", correlation, end, endBytes, valid))
292	}
293}
294
295func (b *runtimeMarkBatch) flush() {
296	if b == nil || b.observer == nil {
297		return
298	}
299	for _, mark := range b.marks {
300		b.observer.ObserveRuntime(mark)
301	}
302}
303
304type paintResult uint8
305
306const (
307	paintRejected paintResult = iota
308	paintEmitted
309	paintBlockedCapacity
310)
311
312func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *attachmentLease) paintResult {
313	return d.paintWithActivationEffect(entry, ac, reset, lease, nil)
314}
315
316// activation owns the drained transition gate; only its explicit ticket may
317// publish while ordinary input and render admission remain disabled.
318func (d *Daemon) paintWithActivationEffect(entry *session, ac *attachedClient, reset bool, lease *attachmentLease, activation *attachmentEffect) paintResult {
319	// Session-owned PTY preparation remains local; attachment rendering is
320	// captured through session.captureRenderState.
321	sess := entry
322	local := sess != nil
323	marks := d.newRuntimeMarkBatch()
324	var paintEffect *attachmentEffect
325	if activation != nil {
326		paintEffect = activation
327		marks.attachmentEffect = activation
328	} else if ac.attachmentActivity() != attachmentActive {
329		return paintRejected
330	} else if lease != nil {
331		token := captureAttachmentCapability(entry, ac, ac.transport())
332		token.lease = lease
333		ticket, admitted := ac.beginAttachmentEffect(token)
334		if !admitted {
335			return paintRejected
336		}
337		paintEffect = ticket
338		marks.attachmentEffect = ticket
339		defer ticket.End()
340	}
341	var tb *tab
342	if local {
343		tb = sess.tabForAttachment(ac)
344		if tb == nil {
345			return paintRejected
346		}
347	}
348
349	ac.sendMu.Lock()
350	result, afterUnlock := d.paintLocked(entry, ac, tb, reset, lease, paintEffect, &marks)
351	ac.sendMu.Unlock()
352	afterUnlock()
353	return result
354}
355
356func noAfterUnlock() {}
357
358// paintLocked is the complete capture/compose/publish transaction. It runs with
359// ac.sendMu held and never releases it; paint is the sole owner of that lock.
360// The returned function runs after sendMu is released, with no attachment,
361// session, tab, or pane lock held.
362func (d *Daemon) paintLocked(entry *session, ac *attachedClient, tb *tab, reset bool, lease *attachmentLease, paintEffect *attachmentEffect, marks *runtimeMarkBatch) (paintResult, func()) {
363	sess := entry
364	local := sess != nil
365	if paintEffect != nil {
366		if !paintEffect.current() {
367			return paintRejected, noAfterUnlock
368		}
369	} else {
370		// Direct/headless rendering has no Attachment effect and retains its
371		// membership-only fixture path.
372		entry.core().mu.Lock()
373		_, owned := entry.core().attachments[ac]
374		entry.core().mu.Unlock()
375		if !owned || ac.currentAttachmentSession() != entry {
376			return paintRejected, noAfterUnlock
377		}
378	}
379	// Capacity is checked before any destructive capture. Refresh the atomic
380	// readiness snapshot while sendMu is held so direct test/setup mutations of
381	// the stream fields remain covered without making coordinator probes wait on
382	// a blocked transport send.
383	if ac.output != nil {
384		ac.output.syncCapacityLocked()
385	}
386	// The coordinator is the normal gate, but this ownership check also protects
387	// direct resize and test paint paths from consuming damage that cannot be
388	// emitted.
389	if ac.output != nil && ac.output.atCapacity() {
390		// The coordinator owns the blocked interval; this guard only protects
391		// direct test/resize paint calls from destructive capture.
392		return paintBlockedCapacity, noAfterUnlock
393	}
394	var uiFence uint64
395	if lease != nil {
396		if rc := attachmentRenderCoordinator(entry); rc != nil {
397			uiFence = rc.captureUIFence(lease)
398		}
399	}
400	// Composition owns attachment sendMu; initialize its lazy overlay state
401	// under that same ownership so concurrent fallback paints cannot observe a
402	// partially published runtime.
403	ac.initOverlays()
404	frameCapture := ac.afterFrame.beginCapture()
405	overlays := ac.overlays.SnapshotForRender()
406	repaintAttachedClients := false
407	var published bool
408	var followUp frameFollowUp
409	afterUnlock := func() {
410		// Frame follow-up (notices, repaints, send-error cleanup) runs first,
411		// then the captured sequence is emitted before any follow-up repaint can
412		// introduce another boundary.
413		if published {
414			d.settleFrame(entry, ac, marks, followUp)
415		}
416		overlays.Unlock()
417		marks.flush()
418		ac.afterFrame.runReady()
419		if repaintAttachedClients {
420			d.repaintAllAttachedClients()
421		}
422	}
423	if local {
424		d.refreshSessionFocusedTitles(sess)
425	}
426	// Contextual ranks are completely captured under paletteMu. Compose them
427	// without reading the live MRU, whose order may have changed mid-interaction.
428	statusFeedback := overlays.statusFeedback
429	if statusFeedback == "" && overlays.resizeActive {
430		statusFeedback = "resize: h/j/k/l or arrows · = equalize · q/esc/enter exit"
431	}
432	bars := d.barStateForAttachmentPaletteHintsFor(entry, ac, statusFeedback, overlays.paletteHints, overlays.paletteRouteSnapshot)
433	applied := ac.getAppliedTheme()
434	bars.theme = applied.Raw
435	if local {
436		attentionVisible := pulseVisible(bars.attentionFrame)
437		repaintAttachedClients = sess.ackAttention(tb, ac, attentionVisible)
438	}
439
440	paletteCfg := d.currentPaletteConfig()
441	floatingCfg := d.currentFloatingConfig()
442	// Title refresh may inspect process state, so it remains before the capture
443	// boundary and outside tab/pane ownership locks.
444	if local {
445		tb.mu.Lock()
446		titleIDs := ac.render.capture.titleIDs[:0]
447		if tb.tree != nil {
448			titleIDs = appendStackPaneIDs(titleIDs, tb.tree.Root)
449		}
450		ac.render.capture.titleIDs = titleIDs
451		floating := tb.floating.pane
452		hasFloating := tb.floating.state == floatingVisible && floating != nil
453		tb.mu.Unlock()
454		for _, id := range titleIDs {
455			d.refreshPaneTitle(sess, id, tb)
456		}
457		if hasFloating {
458			d.refreshPaneDisplayTitle(sess, floating, false)
459		}
460	}
461
462	capturedOverlays := capturedOverlayRenderState{
463		copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil,
464		paletteActive: overlays.paletteActive, promptActive: overlays.promptActive,
465		resizeActive: overlays.resizeActive, statusFeedback: statusFeedback,
466	}
467	endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)
468	state, ok := entry.captureRenderState(ac, renderCaptureRequest{
469		bars:            bars,
470		overlays:        capturedOverlays,
471		floatingCfg:     floatingCfg,
472		styles:          applied.Resolved.Styles,
473		styleGeneration: applied.Generation,
474		reset:           reset,
475		lease:           lease,
476		uiFence:         uiFence,
477	})
478	endCapture(0, ok)
479	if !ok {
480		return paintRejected, afterUnlock
481	}
482	if ac.renderStages.capture != nil {
483		ac.renderStages.capture()
484	}
485	state.frameCapture = frameCapture
486	captureOverlayLayers(state, overlays, paletteCfg)
487	endCompose := marks.span(ports.RuntimeComposeStart, ports.RuntimeComposeEnd, 0)
488	composed := composeFrame(*state, ac.render.cache, ac.render.spare)
489	endCompose(0, true)
490	if ac.renderStages.compose != nil {
491		ac.renderStages.compose()
492	}
493	followUp, published = d.publishFrameLocked(entry, ac, state, composed, marks)
494	return paintEmitted, afterUnlock
495}
496
497var fallbackChromeStyles = themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
498
499func resolveStyles(styles []themeui.Styles) themeui.Styles {
500	if len(styles) > 0 {
501		return styles[0]
502	}
503	return fallbackChromeStyles
504}
505
506type barCache struct {
507	top    []renderer.Cell
508	bottom []renderer.Cell
509}
510
511func (c *barCache) Reset() {
512	c.top = nil
513	c.bottom = nil
514}
515
516func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement {
517	pl.Content.X += dx
518	pl.Content.Y += dy
519	pl.TitleBar.X += dx
520	pl.TitleBar.Y += dy
521	return pl
522}
523
524func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, dimmer themeui.Dimmer) {
525	startX, startY := max(r.X, 0), max(r.Y, 0)
526	endX, endY := min(r.X+r.Width, dst.Width), min(r.Y+r.Height, dst.Height)
527	for y := startY; y < endY; y++ {
528		sy := y - r.Y
529		if sy < 0 || sy >= src.Height {
530			continue
531		}
532		for x := startX; x < endX; x++ {
533			sx := x - r.X
534			if sx < 0 || sx >= src.Width {
535				continue
536			}
537			cell := src.At(sx, sy)
538			if dim {
539				cell.Style = dimmer.Dim(cell.Style)
540			}
541			dst.Set(x, y, cell)
542		}
543	}
544}
545
546// drawDividers paints each precomputed divider gap onto frame, offsetting by
547// dy (composeFrame's content area starts one row below the top status bar).
548// The dividers themselves come from layout.SolveWithDividers, computed once
549// per capture alongside pane placements, rather than recomputed here.
550func drawDividers(frame renderer.Frame, dividers []layout.Divider, dy int, style renderer.Style) {
551	for _, d := range dividers {
552		glyph := rune('│')
553		if d.Dir == layout.Vertical {
554			glyph = '─'
555		}
556		y0 := max(d.Rect.Y+dy, 0)
557		y1 := min(d.Rect.Y+dy+d.Rect.Height, frame.Height)
558		x0 := max(d.Rect.X, 0)
559		x1 := min(d.Rect.X+d.Rect.Width, frame.Width)
560		for y := y0; y < y1; y++ {
561			for x := x0; x < x1; x++ {
562				frame.Set(x, y, renderer.Cell{Rune: glyph, Style: style})
563			}
564		}
565	}
566}
567
568func (c *barCache) capture(top, bottom []renderer.Cell) {
569	c.top = cloneCells(c.top, top)
570	c.bottom = cloneCells(c.bottom, bottom)
571}
572
573func cloneCells(dst, src []renderer.Cell) []renderer.Cell {
574	if cap(dst) < len(src) {
575		dst = make([]renderer.Cell, len(src))
576	} else {
577		dst = dst[:len(src)]
578	}
579	copy(dst, src)
580	return dst
581}
582
583func sameCells(a, b []renderer.Cell) bool {
584	if len(a) != len(b) {
585		return false
586	}
587	for i := range a {
588		if !a[i].Equal(b[i]) {
589			return false
590		}
591	}
592	return true
593}
594
595func offsetDamage(in []renderer.Damage) []renderer.Damage {
596	return translateDamage(in, 0, 1)
597}
598
599func translateDamage(in []renderer.Damage, dx, dy int) []renderer.Damage {
600	out := make([]renderer.Damage, len(in))
601	for i, d := range in {
602		out[i] = d
603		if d.Kind != renderer.DamageFullRedraw {
604			out[i].X += dx
605			out[i].Y += dy
606		}
607	}
608	return out
609}
610
611func translatePaneDamage(d renderer.Damage, content domain.Rect, area domain.Rect) []renderer.Damage {
612	if d.Kind == renderer.DamageFullRedraw {
613		return []renderer.Damage{d}
614	}
615	if d.Kind == renderer.DamageScrollUp && (content.X != 0 || content.Width != area.Width) {
616		return []renderer.Damage{{Kind: renderer.DamageText, X: content.X, Y: content.Y, Width: content.Width, Height: content.Height}}
617	}
618	d.X += content.X
619	d.Y += content.Y
620	return []renderer.Damage{d}
621}

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