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

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