v0.2.0 80c61e3

render_pipeline.go RawHistoryFolder

1package daemon
2
3import (
4	"errors"
5	"strconv"
6
7	renderer "github.com/bnema/vev-vt"
8	"github.com/bnema/vev/internal/domain"
9	"github.com/bnema/vev/internal/ports"
10	"github.com/bnema/vev/internal/usecase/layout"
11	"github.com/bnema/vev/internal/usecase/notices"
12	"github.com/bnema/vev/internal/usecase/palette"
13	"github.com/bnema/vev/internal/usecase/picker"
14	"github.com/bnema/vev/internal/usecase/prompt"
15	themeui "github.com/bnema/vev/internal/usecase/theme"
16	"github.com/bnema/vev/internal/usecase/ui"
17	"github.com/bnema/vev/internal/usecase/visualsearch"
18)
19
20// composeCacheInput is an attachment-local value snapshot. composeFrame never
21// retains or accesses the attachment that owns it.
22// renderStageHooks are optional observability hooks. capture and compose run
23// after their result is available, and emit after prepare plus transport success
24// (including a successful no-byte emission); those hooks run while sendMu is
25// held. Failed attempts count for capture/compose but never for emit.
26// handoffRebase marks the boundary immediately before a handoff acquires sendMu
27// to rebase attachment-owned output state, so it intentionally runs unlocked.
28type renderStageHooks struct {
29	capture       func()
30	compose       func()
31	emit          func()
32	handoffRebase func()
33}
34
35type composeCacheInput struct {
36	valid                   bool
37	frame                   renderer.Frame
38	layoutFingerprint       string
39	titleGenerations        map[layout.PaneID]uint64
40	damage                  []renderer.Damage
41	floatingVisible         bool
42	floatingFocused         bool
43	floatingGeneration      uint64
44	floatingGeometry        floatingGeometry
45	floatingTitleGeneration uint64
46	bars                    barCache
47	toastFootprints         []domain.Rect
48	theme                   themeui.Theme
49	styleGeneration         uint64
50}
51
52type composedRenderFrame struct {
53	frame  renderer.Frame
54	damage []renderer.Damage
55	cursor cursorOut
56	cache  composeCacheInput
57	reset  bool
58}
59
60// composeFrame is pure with respect to daemon ownership: it consumes only the
61// capture, the last committed cache, and an independent attachment-owned
62// scratch cache. It returns a new cache without mutating committed.
63const inactivePaneForegroundDimming = 55
64
65func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...composeCacheInput) composedRenderFrame {
66	scratch := composeCacheInput{}
67	if len(scratchIn) > 0 {
68		scratch = scratchIn[0]
69	}
70	width, rows := state.layout.area.Width, state.layout.area.Height
71	if state.window.Valid() {
72		window := contentSize(state.window)
73		width, rows = window.Cols, window.Rows
74	}
75	if width <= 0 || rows < 0 {
76		return composedRenderFrame{frame: renderer.NewFrame(0, 0), cursor: cursorOut{hidden: true}, reset: state.reset}
77	}
78	// Compose into the alternate attachment-owned buffer. The committed frame is
79	// copied before incremental drawing so prepare/send failures cannot change it.
80	contentY := 1
81	frameHeight := rows + tabChromeRows
82	canReuseFrame := scratch.frame.Width == width && scratch.frame.Height == frameHeight
83	frame := scratch.frame
84	if !canReuseFrame {
85		frame = renderer.NewFrame(width, frameHeight)
86	}
87	if in.frame.Width == width && in.frame.Height == frameHeight {
88		for y := 0; y < frame.Height; y++ {
89			copy(frame.Row(y), in.frame.Row(y))
90		}
91	}
92	styles := state.styles
93	if styles == (themeui.Styles{}) {
94		// Direct/test-only composition has no applied snapshot; production
95		// capture always supplies immutable styles.
96		styles = fallbackChromeStyles
97	}
98	state.styles = styles
99	defaultDimmer := themeui.NewDimmer(state.theme)
100	neutralBorder := styles.NeutralBorder
101	inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming))
102	drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)
103	drawStatusBarState(frame.Row(rows+1), state.bars, styles)
104	content := domain.Rect{Y: contentY, Width: width, Height: rows}
105	if state.layout.valid {
106		drawDividers(frame, state.layout.dividers, content.Y, defaultDimmer.Dim(neutralBorder))
107	}
108
109	full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != frameHeight || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.styleGeneration != state.styleGeneration || in.floatingVisible != state.floating.visible
110	titles := scratch.titleGenerations
111	if titles == nil {
112		titles = make(map[layout.PaneID]uint64, len(state.panes))
113	}
114	clear(titles)
115	damage := scratch.damage[:0]
116	for _, pane := range state.panes {
117		pl := offsetPlacement(pane.placement, 0, contentY)
118		if pl.TitleBar.Height > 0 {
119			drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, styles, neutralBorder, defaultDimmer)
120			if !full && in.titleGenerations[pane.id] != pane.titleGeneration {
121				damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: pl.TitleBar.X, Y: pl.TitleBar.Y, Width: pl.TitleBar.Width, Height: pl.TitleBar.Height})
122			}
123			titles[pane.id] = pane.titleGeneration
124		}
125		if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 && (full || len(pane.damage) > 0) {
126			blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused, inactivePaneDimmer)
127		}
128		for _, d := range pane.damage {
129			if d.Kind != renderer.DamageFullRedraw {
130				d.Y += contentY
131			}
132			damage = append(damage, d)
133		}
134	}
135	// Keep the committed cache unadorned. Toasts are composed first so floating
136	// terminals and scoped modals dim them with the rest of the complete frame.
137	baseFrame := frame
138	overlaysActive := state.overlays.active()
139	toastsVisible := len(state.overlays.notices) > 0 || state.overlays.noticeOverflow > 0
140	var toastFootprints []domain.Rect
141	if toastsVisible {
142		frame = baseFrame.Clone()
143		toastFootprints = composeCapturedNotices(state.overlays, frame, state.styles)
144	}
145	if state.floating.visible {
146		var floatingDamage []renderer.Damage
147		frame, floatingDamage = composeCapturedFloatingFrame(floatingComposeInput{
148			baseFrame:    frame,
149			baseDamage:   damage,
150			floating:     state.floating,
151			content:      content,
152			theme:        state.theme,
153			borderMuted:  styles.BorderMuted,
154			borderActive: styles.BorderActive,
155			cache:        in,
156			full:         full || overlaysActive,
157		})
158		damage = floatingDamage
159	}
160	if overlaysActive {
161		if !toastsVisible && !state.floating.visible {
162			frame = baseFrame.Clone()
163		}
164		frame, damage = composeCapturedCopyMode(state, frame, damage, content)
165		frame, damage = composeCapturedOverlays(state, frame, damage)
166	}
167	if !full {
168		if !sameCells(in.bars.top, baseFrame.Row(0)) {
169			damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})
170		}
171		if !sameCells(in.bars.bottom, baseFrame.Row(rows+1)) {
172			damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1})
173		}
174	}
175	// The cache stays toast-free. The terminal shadow, in contrast, includes
176	// toasts, so every render damages both the last and current toast coverage.
177	// This restores cells exposed by dismissal and redraws a stable toast over
178	// any underlying pane update without promoting either case to a full frame.
179	if full || overlaysActive {
180		damage = []renderer.Damage{renderer.FullRedraw()}
181	} else {
182		damage = appendToastDamage(damage, in.toastFootprints)
183		damage = appendToastDamage(damage, toastFootprints)
184	}
185	cursorInputs := state.cursor
186	cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive
187	cursor := desiredCapturedCursor(cursorInputs, contentY)
188	outCache := composeCacheInput{valid: !overlaysActive, frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, toastFootprints: append(scratch.toastFootprints[:0], toastFootprints...), floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars}
189	outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1))
190	return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}
191}
192
193func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, neutralBorder renderer.Style, dimmer themeui.Dimmer) {
194	if pl.TitleBar.Y < 0 || pl.TitleBar.Y >= frame.Height {
195		return
196	}
197	style := styles.StatusBar
198	if !focused {
199		style = dimmer.Dim(neutralBorder)
200	}
201	for x := max(pl.TitleBar.X, 0); x < min(pl.TitleBar.X+pl.TitleBar.Width, frame.Width); x++ {
202		frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
203	}
204	ui.DrawText(frame, max(pl.TitleBar.X, 0), pl.TitleBar.Y, min(pl.TitleBar.X+pl.TitleBar.Width, frame.Width), title, style)
205}
206
207func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut {
208	if !c.renderable || c.hiddenByOverlay || !c.visible {
209		return cursorOut{hidden: true}
210	}
211	style := c.style
212	if !c.hasStyle {
213		style = 1
214	}
215	return cursorOut{valid: true, row: c.content.Y + contentY + c.row, col: c.content.X + c.col, style: style, hasStyle: true}
216}
217
218func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapshot, paletteCfg domain.PaletteConfig) {
219	if state == nil || snap == nil {
220		return
221	}
222	o := &state.overlays
223	o.copyActive, o.copySearchActive, o.pickerActive, o.paletteActive, o.promptActive = snap.copyActive, snap.copySearchModel != nil, snap.pickerActive, snap.paletteActive, snap.promptActive
224	o.noticesOverlayActive = snap.noticesOverlayActive
225	o.copyMode = snap.copyMode
226	o.notices, o.noticeOverflow = snap.notices, snap.noticeOverflow
227	if snap.copyPane != nil {
228		o.copyPaneID = snap.copyPane.id
229	}
230	styles := state.styles
231	if styles == (themeui.Styles{}) {
232		styles = fallbackChromeStyles
233	}
234	rows := state.layout.area.Height
235	rows += tabChromeRows
236	size := domain.Size{Cols: state.layout.area.Width, Rows: rows}
237	if snap.copySearchModel != nil {
238		presentation := copySearchModal.Resolve(size)
239		o.copySearch = capturedModal{active: true, title: copySearchModal.Title, presentation: presentation, focused: true}
240		o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(presentation.Inner), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection})
241	}
242	if snap.pickerActive && snap.pickerModel != nil {
243		presentation := pickerModal.Resolve(size)
244		title := snap.pickerTitle
245		if title == "" {
246			title = pickerModal.Title
247		}
248		o.picker = capturedModal{active: true, title: title, presentation: presentation, focused: true}
249		stoppedStyle := styles.PickerName
250		stoppedStyle.Attrs |= renderer.AttrDim
251		stoppedStyle.Italic = true
252		renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PickerDescription, Base: styles.PickerBase, Separator: styles.PickerSeparator, Stopped: stoppedStyle}
253		o.picker.inner = snap.pickerModel.Render(rectSize(presentation.Inner), state.preview, renderStyles)
254	}
255	if snap.noticesOverlayActive && snap.noticesOverlayModel != nil {
256		presentation := noticesModal.Resolve(size)
257		o.noticesOverlay = capturedModal{active: true, title: noticesModal.Title, presentation: presentation, focused: true}
258		renderStyles := notices.RenderStyles{Background: styles.PickerBase, Base: styles.PickerBase, Selection: styles.PickerSelection, Text: styles.PickerBase, SelectionText: styles.PickerSelectionName, Muted: styles.PickerDescription, SelectionMuted: styles.PickerSelectionMuted}
259		o.noticesOverlay.inner = snap.noticesOverlayModel.Render(rectSize(presentation.Inner), renderStyles)
260	}
261	if snap.paletteActive && snap.paletteModel != nil {
262		modal := paletteModalFor(size, paletteCfg)
263		presentation := modal.Resolve(size)
264		o.palette = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true}
265		guidance := ""
266		if snap.paletteHints != nil {
267			guidance = snap.paletteHints.Feedback
268		}
269		o.paletteGuidance = snap.paletteFeedback
270		o.palette.inner = snap.paletteModel.Render(rectSize(presentation.Inner), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.PickerBase, Selection: styles.PickerSelection, Description: styles.PickerDescription, SelectionDescription: styles.PickerSelectionMuted}, Guidance: guidance, Feedback: snap.paletteFeedback})
271	}
272	if snap.promptActive && snap.promptModel != nil {
273		modal := promptModalFor(snap.promptModel.Title())
274		presentation := modal.Resolve(size)
275		o.prompt = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true}
276		o.prompt.inner = snap.promptModel.RenderStyled(rectSize(presentation.Inner), prompt.RenderStyles{Base: styles.PromptBase, Selection: styles.SurfaceActive})
277	}
278	state.cursor.hiddenByOverlay = o.active()
279}
280
281func composeCapturedCopyMode(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage, content domain.Rect) (renderer.Frame, []renderer.Damage) {
282	o := state.overlays
283	if !o.copyActive {
284		return frame, damage
285	}
286	target := domain.Rect{}
287	if state.floating.visible && (o.copyPaneID == "" || o.copyPaneID == state.floating.pane.id) {
288		target = state.floating.geometry.translate(content.X, content.Y).Inner
289	}
290	if target.Width == 0 {
291		id := o.copyPaneID
292		if id == "" {
293			id = state.layout.focus
294		}
295		for _, p := range state.panes {
296			if p.id == id && !p.placement.Collapsed {
297				target = p.placement.Content
298				target.Y += content.Y
299				break
300			}
301		}
302	}
303	return composeCopyClientFrame(o.copyMode, target, frame, state.styles)
304}
305
306func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage) (renderer.Frame, []renderer.Damage) {
307	o := state.overlays
308	// Paint in reverse keyboard priority so the same layer that owns input is
309	// visually topmost: prompt > palette > picker > notices > copy search.
310	for _, modal := range []capturedModal{o.copySearch, o.noticesOverlay, o.picker, o.palette, o.prompt} {
311		if !modal.active {
312			continue
313		}
314		applyOverlayBackdrop(frame, state.theme)
315		border := state.styles.BorderMuted
316		if modal.focused {
317			border = state.styles.BorderActive
318		}
319		(ui.Modal{Title: modal.title}).CompositePresentation(frame, modal.presentation, border, state.styles.PickerBase)
320		copyModalInner(frame, modal.presentation.Inner, modal.inner)
321		damage = []renderer.Damage{renderer.FullRedraw()}
322	}
323	return frame, damage
324}
325
326// copyModalInner copies a captured model frame into its resolved destination.
327// Both source and destination are clipped so degenerate presentations remain
328// safe immutable composition inputs.
329func copyModalInner(dst renderer.Frame, target domain.Rect, src renderer.Frame) {
330	left := max(target.X, 0)
331	top := max(target.Y, 0)
332	right := min(target.X+target.Width, dst.Width)
333	bottom := min(target.Y+target.Height, dst.Height)
334	if left >= right || top >= bottom {
335		return
336	}
337
338	sourceX := left - target.X
339	sourceY := top - target.Y
340	width := min(right-left, src.Width-sourceX)
341	height := min(bottom-top, src.Height-sourceY)
342	if width <= 0 || height <= 0 {
343		return
344	}
345	for y := range height {
346		copy(dst.Row(top + y)[left:left+width], src.Row(sourceY + y)[sourceX:sourceX+width])
347	}
348}
349
350func composeCapturedNotices(overlays capturedOverlayRenderState, frame renderer.Frame, styles themeui.Styles) []domain.Rect {
351	if len(overlays.notices) == 0 && overlays.noticeOverflow == 0 {
352		return nil
353	}
354	views := make([]ui.NoticeView, len(overlays.notices))
355	for i, n := range overlays.notices {
356		views[i] = ui.NoticeView{Severity: n.Severity, Title: n.Code.String(), Message: n.Message, Count: n.Count}
357	}
358	return ui.ComposeNotices(frame, views, overlays.noticeOverflow, noticeStylesFrom(styles))
359}
360
361func appendToastDamage(damage []renderer.Damage, footprints []domain.Rect) []renderer.Damage {
362	for _, footprint := range footprints {
363		if footprint.Width > 0 && footprint.Height > 0 {
364			damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: footprint.X, Y: footprint.Y, Width: footprint.Width, Height: footprint.Height})
365		}
366	}
367	return damage
368}
369
370// noticeStylesFrom picks the toast box color per severity from the theme's
371// chrome roles. Warn uses the dedicated BorderWarn role so it reads as
372// distinct from Info instead of sharing BorderMuted with it.
373func noticeStylesFrom(styles themeui.Styles) ui.NoticeStyles {
374	return ui.NoticeStyles{
375		Text:     styles.PickerBase,
376		BoxError: styles.BorderActive,
377		BoxWarn:  styles.BorderWarn,
378		BoxInfo:  styles.BorderMuted,
379	}
380}
381
382type cursorCandidate struct {
383	data []byte
384	next cursorOut
385}
386
387func (ac *attachedClient) prepareCursorTail(desired cursorOut, force bool) cursorCandidate {
388	candidate := cursorCandidate{next: desired}
389	candidate.next.valid = true
390	prev := ac.lastCursor
391	changed := force || !prev.valid || prev.hidden != desired.hidden || prev.row != desired.row || prev.col != desired.col || prev.style != desired.style || prev.hasStyle != desired.hasStyle
392	if !changed {
393		return candidate
394	}
395	if desired.hidden {
396		candidate.data = []byte("\x1b[?25l")
397		return candidate
398	}
399	candidate.data = append(candidate.data, "\x1b["...)
400	candidate.data = strconv.AppendInt(candidate.data, int64(desired.row+1), 10)
401	candidate.data = append(candidate.data, ';')
402	candidate.data = strconv.AppendInt(candidate.data, int64(desired.col+1), 10)
403	candidate.data = append(candidate.data, 'H')
404	if !prev.valid || prev.hidden || prev.style != desired.style || prev.hasStyle != desired.hasStyle {
405		candidate.data = append(candidate.data, "\x1b["...)
406		candidate.data = strconv.AppendInt(candidate.data, int64(desired.style), 10)
407		candidate.data = append(candidate.data, " q"...)
408	}
409	candidate.data = append(candidate.data, "\x1b[?25h"...)
410	return candidate
411}
412
413// commitDamageReceipts acknowledges only snapshots that reached the client.
414// sendMu serializes attachment transactions; panes are locked individually so
415// no pane lock is held while another is acquired. A stale generation is
416// intentionally acknowledged too: Screen conservatively leaves FullRedraw for
417// the next transaction.
418func commitDamageReceipts(receipts []damageReceipt) {
419	for _, receipt := range receipts {
420		if receipt.pane != nil {
421			receipt.pane.mu.Lock()
422			receipt.pane.screen.AcknowledgeDamage(receipt.generation)
423			receipt.pane.mu.Unlock()
424			continue
425		}
426	}
427}
428
429// emitFrame is the sole side-effecting half of the pipeline. The caller holds
430// sendMu for the complete capture/compose/emit transaction.
431func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame, batches ...*runtimeMarkBatch) bool {
432	var ownedMarks runtimeMarkBatch
433	var marks *runtimeMarkBatch
434	if len(batches) != 0 {
435		marks = batches[0]
436	} else {
437		ownedMarks = d.newRuntimeMarkBatch()
438		marks = &ownedMarks
439		// Direct test callers retain the same guarantee as paint: observer I/O
440		// follows emitFrame's release of attachment ownership.
441		defer marks.flush()
442	}
443	if entry == nil || entry.core() == nil || ac == nil || state == nil {
444		if ac != nil {
445			ac.sendMu.Unlock()
446		}
447		return false
448	}
449	entry.core().mu.Lock()
450	_, owned := entry.core().attachments[ac]
451	position := ports.RoutePosition{
452		Target: ports.ExactSessionTarget{
453			LifecycleID: entry.core().incarnation,
454			SessionName: entry.core().name,
455		},
456		ActiveTabID: state.view.tabID,
457	}
458	entry.core().mu.Unlock()
459	if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry {
460		ac.sendMu.Unlock()
461		return false
462	}
463	if state.lease != nil {
464		rc := attachmentRenderCoordinator(entry)
465		if rc == nil || state.lease.attachment != ac || !rc.leaseCurrent(state.lease, true) {
466			ac.sendMu.Unlock()
467			return false
468		}
469	}
470	endDiff := marks.span(ports.RuntimeDiffStart, ports.RuntimeDiffEnd, 0)
471	var (
472		preparedANSI *preparedOutput
473		err          error
474		cursor       cursorCandidate
475		data         []byte
476	)
477	preparedANSI, err = ac.output.prepare(composed.frame, composed.damage, composed.reset)
478	if err == nil {
479		cursor = ac.prepareCursorTail(composed.cursor, len(preparedANSI.data) > 0)
480		data = append([]byte(nil), preparedANSI.data...)
481		data = append(data, cursor.data...)
482	}
483	endDiff(0, err == nil)
484	if err != nil {
485		ac.sendMu.Unlock()
486		core := entry.core()
487		core.mu.Lock()
488		sessionName := core.name
489		core.mu.Unlock()
490		d.log.Error("render draw failed", "err", err, "session", sessionName)
491		// Without a coordinator reportError repaints synchronously. Suppress only
492		// that nested notice repaint; leave the guard before returning so a later,
493		// independent failed transaction can still notify the user.
494		if entry.renderCoordinator() == nil {
495			if !ac.prepareFailureFallback.CompareAndSwap(false, true) {
496				return true
497			}
498			d.reportError(entry, domain.UserErr(domain.NoticeInternal, "display update failed", err))
499			ac.prepareFailureFallback.Store(false)
500			return true
501		}
502		d.reportError(entry, domain.UserErr(domain.NoticeInternal, "display update failed", err))
503		// Notices are session-scoped, while the attachment repaint still needs
504		// to redraw its chrome after a failed transaction.
505		d.invalidateRender(entry, ac, true, "render_pipeline.go:prepare-failed")
506		return true
507	}
508	var sendTr ports.Transport
509	var sendErr error
510	if len(data) > 0 {
511		sendTransport := ac.transportSnapshot()
512		sendTr = sendTransport.transport
513		endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data)))
514		if sendTr == nil {
515			sendErr = errors.New("client transport is nil")
516		} else {
517			send := sendTr.Send
518			if async, ok := sendTr.(ports.AsyncTransport); ok {
519				send = async.SendAsync
520			}
521			interruptible := false
522			if marks.attachmentEffect != nil {
523				interruptible = marks.attachmentEffect.beginTransportSend(sendTransport)
524				if !interruptible {
525					sendErr = errAttachmentTransition
526				}
527			}
528			if sendErr == nil {
529				sendErr = preparedANSI.send(data, ac.echoAck.Load(), send)
530			}
531			if marks.attachmentEffect != nil && interruptible {
532				if sendErr != nil {
533					marks.attachmentEffect.reportTransportFailure(sendTransport)
534				}
535				marks.attachmentEffect.endTransportSend()
536			}
537		}
538		emitted := sendErr == nil && preparedANSI.sent
539		endEmit(uint64(len(data)), emitted)
540		if sendErr == nil && !emitted {
541			ac.sendMu.Unlock()
542			return true
543		}
544	}
545	if sendErr == nil {
546		if len(data) == 0 {
547			preparedANSI.commitNoSend()
548			if !preparedANSI.sent {
549				ac.sendMu.Unlock()
550				return true
551			}
552		}
553		// Publish only after output preparation and transport emission both
554		// succeed. A cross-session transition may publish concurrently, but its
555		// mandatory first-paint rebase waits for sendMu and therefore follows this
556		// completed output transaction.
557		ac.lastCursor = cursor.next
558		ac.pipelineScratch = ac.pipelineCache
559		ac.pipelineCache = composed.cache
560
561		// A successful no-byte emission also commits: its renderer shadow still
562		// represents the captured frame. Lock panes only under sendMu and with no
563		// session guard held.
564		commitDamageReceipts(state.receipts)
565		if ac.renderStages.emit != nil {
566			ac.renderStages.emit()
567		}
568
569		if position.ActiveTabID != "" && position != ac.lastRoutePosition {
570			payload, positionMarshalErr := ports.MarshalRoutePosition(position)
571			if positionMarshalErr != nil {
572				d.log.Error("marshal route position", "err", positionMarshalErr)
573			} else {
574				sendTransport := ac.transportSnapshot()
575				sendTr = sendTransport.transport
576				interruptible := false
577				if marks.attachmentEffect != nil {
578					interruptible = marks.attachmentEffect.beginTransportSend(sendTransport)
579					if !interruptible {
580						sendErr = errAttachmentTransition
581					}
582				}
583				if sendErr == nil {
584					if sendTr == nil {
585						sendErr = errors.New("client transport is nil")
586					} else {
587						send := sendTr.Send
588						if async, ok := sendTr.(ports.AsyncTransport); ok {
589							send = async.SendAsync
590						}
591						sendErr = send(ports.Frame{Type: ports.MsgRoutePosition, Payload: payload})
592					}
593				}
594				if marks.attachmentEffect != nil && interruptible {
595					if sendErr != nil {
596						marks.attachmentEffect.reportTransportFailure(sendTransport)
597					}
598					marks.attachmentEffect.endTransportSend()
599				}
600				if sendErr == nil {
601					ac.lastRoutePosition = position
602				}
603			}
604		}
605	}
606	ac.sendMu.Unlock()
607	if sendErr != nil {
608		// A transport failure may invalidate the role gate. Release this render's
609		// admission first. Detachment freezes the gate and therefore cannot mutate
610		// ownership until any enclosing admitted operation has also ended.
611		if marks.attachmentEffect == nil {
612			d.detachOnSendError(entry, ac, sendTr)
613		} else {
614			// Capture the exact admitted capability, including its coordinator
615			// lease, before End permits a new attachment publication. Reserve
616			// cleanup accounting before End so terminal Wait cannot race a later Add.
617			token := marks.attachmentEffect.connectionToken()
618			launchCleanup := d.reserveAttachmentSendErrorCleanup(token, sendTr)
619			marks.attachmentEffect.End()
620			launchCleanup()
621		}
622	}
623	return true
624}

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