v0.3.0 99dc266

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

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