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

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