v0.7.0 4c16dd8

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/terminalcap"
10	"github.com/bnema/vev/internal/ports"
11	"github.com/bnema/vev/internal/usecase/layout"
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 || owner.session == nil || owner.tab == nil {
217		return
218	}
219	if effects.lease.Current() {
220		markSnapshotDirty(owner.session)
221	}
222	if effects.attention && effects.lease.Current() {
223		d.noteAttention(owner.session, owner.tab)
224	}
225	for _, b64 := range effects.clipboards {
226		if effects.lease.Current() {
227			d.forwardClipboardAsync(effects.lease, b64)
228		}
229	}
230	if len(effects.responses) > 0 && effects.lease.Current() {
231		if _, err := p.pty.Write(effects.responses); err != nil {
232			if sessionName, current := paneEffectSessionName(effects.lease); current {
233				d.log.Warn("pty response write failed", "err", err, "session", sessionName)
234			}
235		}
236	}
237	if effects.renderCoordinator == nil || !effects.lease.Current() {
238		return
239	}
240	renderable := d.paneRenderable(owner.session, owner.tab, p)
241	if !effects.lease.Current() {
242		return
243	}
244	if effects.wasSyncing != effects.isSyncing {
245		if effects.isSyncing {
246			if renderable {
247				effects.renderCoordinator.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
248			}
249		} else if effects.syncEnded && renderable {
250			effects.renderCoordinator.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
251		}
252	}
253	if effects.completeSyncRead && renderable {
254		effects.renderCoordinator.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
255	} else if renderable && !effects.isSyncing && effects.wasSyncing == effects.isSyncing {
256		effects.renderCoordinator.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
257	}
258}
259
260// paint draws the composed client frame (active tab plus status bar) and
261// sends the resulting bytes. The renderer shadow is reset on explicit invalidations
262// such as switch/create/close/rename/resize so the repaint is complete.
263func (d *Daemon) observeRuntime(kind ports.RuntimeMarkKind, bytes uint64, valid bool) {
264	if d != nil && d.runtimeObserver != nil {
265		d.runtimeObserver.ObserveRuntime(ports.NewRuntimeMark("daemon", kind, bytes, valid))
266	}
267}
268
269// runtimeMarkBatch captures immutable runtime marks during a render transaction.
270// paint owns attachment sendMu while it captures, composes, and emits; JSONL
271// observer I/O must therefore happen only after that ownership is released.
272type runtimeMarkBatch struct {
273	observer         ports.RuntimeObserver
274	marks            []ports.RuntimeMark
275	attachmentEffect *attachmentEffect
276}
277
278func (d *Daemon) newRuntimeMarkBatch() runtimeMarkBatch {
279	if d == nil {
280		return runtimeMarkBatch{}
281	}
282	return runtimeMarkBatch{observer: d.runtimeObserver}
283}
284
285func (b *runtimeMarkBatch) span(start, end ports.RuntimeMarkKind, bytes uint64) func(uint64, bool) {
286	if b == nil || b.observer == nil {
287		return func(uint64, bool) {}
288	}
289	correlation := ports.NewRuntimeCorrelation()
290	b.marks = append(b.marks, ports.NewRuntimeMarkWithCorrelation("daemon", correlation, start, bytes, true))
291	return func(endBytes uint64, valid bool) {
292		b.marks = append(b.marks, ports.NewRuntimeMarkWithCorrelation("daemon", correlation, end, endBytes, valid))
293	}
294}
295
296func (b *runtimeMarkBatch) flush() {
297	if b == nil || b.observer == nil {
298		return
299	}
300	for _, mark := range b.marks {
301		b.observer.ObserveRuntime(mark)
302	}
303}
304
305type paintResult uint8
306
307const (
308	paintRejected paintResult = iota
309	paintEmitted
310	paintBlockedCapacity
311)
312
313func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *attachmentLease) paintResult {
314	return d.paintWithActivationEffect(entry, ac, reset, lease, nil)
315}
316
317// activation owns the drained transition gate; only its explicit ticket may
318// publish while ordinary input and render admission remain disabled.
319func (d *Daemon) paintWithActivationEffect(entry *session, ac *attachedClient, reset bool, lease *attachmentLease, activation *attachmentEffect) paintResult {
320	// Session-owned PTY preparation remains local; attachment rendering is
321	// captured through session.captureRenderState.
322	sess := entry
323	local := sess != nil
324	marks := d.newRuntimeMarkBatch()
325	var paintEffect *attachmentEffect
326	if activation != nil {
327		paintEffect = activation
328		marks.attachmentEffect = activation
329	} else if ac.attachmentActivity() != attachmentActive {
330		return paintRejected
331	} else if lease != nil {
332		token := captureAttachmentCapability(entry, ac, ac.transport())
333		token.lease = lease
334		ticket, admitted := ac.beginAttachmentEffect(token)
335		if !admitted {
336			return paintRejected
337		}
338		paintEffect = ticket
339		marks.attachmentEffect = ticket
340		defer ticket.End()
341	}
342	var tb *tab
343	if local {
344		tb = sess.tabForAttachment(ac)
345		if tb == nil {
346			return paintRejected
347		}
348	}
349
350	ac.sendMu.Lock()
351	result, afterUnlock := d.paintLocked(entry, ac, tb, reset, lease, paintEffect, &marks)
352	ac.sendMu.Unlock()
353	afterUnlock()
354	return result
355}
356
357func noAfterUnlock() {}
358
359// paintLocked is the complete capture/compose/publish transaction. It runs with
360// ac.sendMu held and never releases it; paint is the sole owner of that lock.
361// The returned function runs after sendMu is released, with no attachment,
362// session, tab, or pane lock held.
363func (d *Daemon) paintLocked(entry *session, ac *attachedClient, tb *tab, reset bool, lease *attachmentLease, paintEffect *attachmentEffect, marks *runtimeMarkBatch) (paintResult, func()) {
364	sess := entry
365	local := sess != nil
366	if paintEffect != nil {
367		if !paintEffect.current() {
368			return paintRejected, noAfterUnlock
369		}
370	} else {
371		// Direct/headless rendering has no Attachment effect and retains its
372		// membership-only fixture path.
373		entry.core().mu.Lock()
374		_, owned := entry.core().attachments[ac]
375		entry.core().mu.Unlock()
376		if !owned || ac.currentAttachmentSession() != entry {
377			return paintRejected, noAfterUnlock
378		}
379	}
380	// Capacity is checked before any destructive capture. Refresh the atomic
381	// readiness snapshot while sendMu is held so direct test/setup mutations of
382	// the stream fields remain covered without making coordinator probes wait on
383	// a blocked transport send.
384	if ac.output != nil {
385		ac.output.syncCapacityLocked()
386	}
387	// The coordinator is the normal gate, but this ownership check also protects
388	// direct resize and test paint paths from consuming damage that cannot be
389	// emitted.
390	if ac.output != nil && ac.output.atCapacity() {
391		// The coordinator owns the blocked interval; this guard only protects
392		// direct test/resize paint calls from destructive capture.
393		return paintBlockedCapacity, noAfterUnlock
394	}
395	var uiFence uint64
396	if lease != nil {
397		if rc := attachmentRenderCoordinator(entry); rc != nil {
398			uiFence = rc.captureUIFence(lease)
399		}
400	}
401	// Composition owns attachment sendMu; initialize its lazy overlay state
402	// under that same ownership so concurrent fallback paints cannot observe a
403	// partially published runtime.
404	ac.initOverlays()
405	frameCapture := ac.afterFrame.beginCapture()
406	overlays := ac.overlays.SnapshotForRender()
407	repaintAttachedClients := false
408	var published bool
409	var followUp frameFollowUp
410	afterUnlock := func() {
411		// Frame follow-up (notices, repaints, send-error cleanup) runs first,
412		// then the captured sequence is emitted before any follow-up repaint can
413		// introduce another boundary.
414		if published {
415			d.settleFrame(entry, ac, marks, followUp)
416		}
417		overlays.Unlock()
418		marks.flush()
419		ac.afterFrame.runReady()
420		if repaintAttachedClients {
421			d.repaintAllAttachedClients()
422		}
423	}
424	if local {
425		d.refreshSessionFocusedTitles(sess)
426	}
427	// Contextual ranks are completely captured under paletteMu. Compose them
428	// without reading the live MRU, whose order may have changed mid-interaction.
429	statusFeedback := overlays.statusFeedback
430	if statusFeedback == "" && overlays.resizeActive {
431		statusFeedback = "resize: h/j/k/l or arrows · = equalize · q/esc/enter exit"
432	}
433	bars := d.barStateForAttachmentPaletteHintsFor(entry, ac, statusFeedback, overlays.paletteHints, overlays.paletteRouteSnapshot)
434	applied := ac.getAppliedTheme()
435	bars.theme = applied.Raw
436	if local {
437		attentionVisible := pulseVisible(bars.attentionFrame)
438		repaintAttachedClients = sess.ackAttention(tb, ac, attentionVisible)
439	}
440
441	paletteCfg := d.currentPaletteConfig()
442	floatingCfg := d.currentFloatingConfig()
443	// Title refresh may inspect process state, so it remains before the capture
444	// boundary and outside tab/pane ownership locks.
445	if local {
446		tb.mu.Lock()
447		titleIDs := ac.render.capture.titleIDs[:0]
448		if tb.tree != nil {
449			titleIDs = appendStackPaneIDs(titleIDs, tb.tree.Root)
450		}
451		ac.render.capture.titleIDs = titleIDs
452		floating := tb.floating.pane
453		hasFloating := tb.floating.state == floatingVisible && floating != nil
454		tb.mu.Unlock()
455		for _, id := range titleIDs {
456			d.refreshPaneTitle(sess, id, tb)
457		}
458		if hasFloating {
459			d.refreshPaneDisplayTitle(sess, floating, false)
460		}
461	}
462
463	capturedOverlays := capturedOverlayRenderState{
464		copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil,
465		paletteActive: overlays.paletteActive, promptActive: overlays.promptActive,
466		resizeActive: overlays.resizeActive, statusFeedback: statusFeedback,
467	}
468	endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)
469	state, ok := entry.captureRenderState(ac, renderCaptureRequest{
470		bars:            bars,
471		overlays:        capturedOverlays,
472		floatingCfg:     floatingCfg,
473		styles:          applied.Resolved.Styles,
474		styleGeneration: applied.Generation,
475		reset:           reset,
476		lease:           lease,
477		uiFence:         uiFence,
478	})
479	endCapture(0, ok)
480	if !ok {
481		return paintRejected, afterUnlock
482	}
483	if ac.renderStages.capture != nil {
484		ac.renderStages.capture()
485	}
486	state.frameCapture = frameCapture
487	captureOverlayLayers(state, overlays, paletteCfg)
488	endCompose := marks.span(ports.RuntimeComposeStart, ports.RuntimeComposeEnd, 0)
489	composed := composeFrame(*state, ac.render.cache, ac.render.spare)
490	endCompose(0, true)
491	if ac.renderStages.compose != nil {
492		ac.renderStages.compose()
493	}
494	followUp, published = d.publishFrameLocked(entry, ac, state, composed, marks)
495	return paintEmitted, afterUnlock
496}
497
498var fallbackChromeStyles = themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
499
500var (
501	fallbackANSI16Styles = themeui.ResolveForColor(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16}).Styles
502	fallbackMonoStyles   = themeui.ResolveForColor(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome}).Styles
503)
504
505// fallbackStylesFor is the static pre-theme chrome for one attachment's mode.
506func fallbackStylesFor(color terminalcap.ColorCapabilities) themeui.Styles {
507	switch color.Mode {
508	case terminalcap.ANSI16:
509		return fallbackANSI16Styles
510	case terminalcap.Monochrome:
511		return fallbackMonoStyles
512	default:
513		return fallbackChromeStyles
514	}
515}
516
517func resolveStyles(styles []themeui.Styles) themeui.Styles {
518	if len(styles) > 0 {
519		return styles[0]
520	}
521	return fallbackChromeStyles
522}
523
524type barCache struct {
525	top    []renderer.Cell
526	bottom []renderer.Cell
527}
528
529func (c *barCache) Reset() {
530	c.top = nil
531	c.bottom = nil
532}
533
534func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement {
535	pl.Content.X += dx
536	pl.Content.Y += dy
537	pl.TitleBar.X += dx
538	pl.TitleBar.Y += dy
539	return pl
540}
541
542func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, dimmer themeui.Dimmer) {
543	startX, startY := max(r.X, 0), max(r.Y, 0)
544	endX, endY := min(r.X+r.Width, dst.Width), min(r.Y+r.Height, dst.Height)
545	for y := startY; y < endY; y++ {
546		sy := y - r.Y
547		if sy < 0 || sy >= src.Height {
548			continue
549		}
550		for x := startX; x < endX; x++ {
551			sx := x - r.X
552			if sx < 0 || sx >= src.Width {
553				continue
554			}
555			cell := src.At(sx, sy)
556			if dim {
557				cell.Style = dimmer.Dim(cell.Style)
558			}
559			dst.Set(x, y, cell)
560		}
561	}
562}
563
564// drawDividers paints each precomputed divider gap onto frame, offsetting by
565// dy (composeFrame's content area starts one row below the top status bar).
566// The dividers themselves come from layout.SolveWithDividers, computed once
567// per capture alongside pane placements, rather than recomputed here.
568func drawDividers(frame renderer.Frame, dividers []layout.Divider, dy int, style renderer.Style) {
569	for _, d := range dividers {
570		glyph := rune('│')
571		if d.Dir == layout.Vertical {
572			glyph = '─'
573		}
574		y0 := max(d.Rect.Y+dy, 0)
575		y1 := min(d.Rect.Y+dy+d.Rect.Height, frame.Height)
576		x0 := max(d.Rect.X, 0)
577		x1 := min(d.Rect.X+d.Rect.Width, frame.Width)
578		for y := y0; y < y1; y++ {
579			for x := x0; x < x1; x++ {
580				frame.Set(x, y, renderer.Cell{Rune: glyph, Style: style})
581			}
582		}
583	}
584}
585
586func (c *barCache) capture(top, bottom []renderer.Cell) {
587	c.top = cloneCells(c.top, top)
588	c.bottom = cloneCells(c.bottom, bottom)
589}
590
591func cloneCells(dst, src []renderer.Cell) []renderer.Cell {
592	if cap(dst) < len(src) {
593		dst = make([]renderer.Cell, len(src))
594	} else {
595		dst = dst[:len(src)]
596	}
597	copy(dst, src)
598	return dst
599}
600
601func sameCells(a, b []renderer.Cell) bool {
602	if len(a) != len(b) {
603		return false
604	}
605	for i := range a {
606		if !a[i].Equal(b[i]) {
607			return false
608		}
609	}
610	return true
611}
612
613func offsetDamage(in []renderer.Damage) []renderer.Damage {
614	return translateDamage(in, 0, 1)
615}
616
617func translateDamage(in []renderer.Damage, dx, dy int) []renderer.Damage {
618	out := make([]renderer.Damage, len(in))
619	for i, d := range in {
620		out[i] = d
621		if d.Kind != renderer.DamageFullRedraw {
622			out[i].X += dx
623			out[i].Y += dy
624		}
625	}
626	return out
627}
628
629func translatePaneDamage(d renderer.Damage, content domain.Rect, area domain.Rect) []renderer.Damage {
630	if d.Kind == renderer.DamageFullRedraw {
631		return []renderer.Damage{d}
632	}
633	if d.Kind == renderer.DamageScrollUp && (content.X != 0 || content.Width != area.Width) {
634		return []renderer.Damage{{Kind: renderer.DamageText, X: content.X, Y: content.Y, Width: content.Width, Height: content.Height}}
635	}
636	d.X += content.X
637	d.Y += content.Y
638	return []renderer.Damage{d}
639}

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