v0.8.1 1f54f61

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

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