v0.2.0 80c61e3

visual_probe.go RawHistoryFolder

1package main
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"fmt"
8	"os"
9	"path/filepath"
10	"strings"
11
12	vt "github.com/bnema/vev-vt"
13	"github.com/bnema/vev/internal/domain"
14	"github.com/bnema/vev/internal/ports"
15)
16
17const (
18	defaultProbeCols = 80
19	defaultProbeRows = 24
20
21	maxProbeEvents      = 128
22	maxProbeCheckpoints = 16
23	maxArtifactBytes    = 64 << 10
24
25	probeEventOutput            = "output"
26	probeEventWelcome           = "welcome"
27	probeEventOutputReset       = "output_reset_request"
28	probeEventUnexpectedHandoff = "unexpected_attach_target"
29)
30
31// visualOutputState mirrors the client's output dependency chain. A probe may
32// inspect output only after the frame's epoch, base, and view revision continue
33// the last accepted state.
34type visualOutputState struct {
35	epoch        uint64
36	state        uint64
37	viewRevision uint64
38	initialized  bool
39}
40
41func (s visualOutputState) next(output ports.Output) (visualOutputState, bool) {
42	if output.Epoch == 0 {
43		return visualOutputState{}, false
44	}
45	if !s.initialized {
46		if output.New == 0 {
47			if output.Base != 0 || output.Full {
48				return visualOutputState{}, false
49			}
50			return s, true
51		}
52		if output.Base != 0 || !output.Full {
53			return visualOutputState{}, false
54		}
55		return visualOutputState{epoch: output.Epoch, state: output.New, viewRevision: output.ViewRevision, initialized: true}, true
56	}
57	if output.Epoch < s.epoch {
58		return visualOutputState{}, false
59	}
60	if output.Epoch == s.epoch {
61		if output.ViewRevision != s.viewRevision {
62			return visualOutputState{}, false
63		}
64		if output.New == 0 {
65			if output.Base != 0 || output.Full {
66				return visualOutputState{}, false
67			}
68			return s, true
69		}
70		if output.Full || output.Base != s.state || output.New != output.Base+1 {
71			return visualOutputState{}, false
72		}
73		return visualOutputState{epoch: s.epoch, state: output.New, viewRevision: s.viewRevision, initialized: true}, true
74	}
75	if !output.Full || output.Base != 0 || output.New == 0 {
76		return visualOutputState{}, false
77	}
78	return visualOutputState{epoch: output.Epoch, state: output.New, viewRevision: output.ViewRevision, initialized: true}, true
79}
80
81type probeEvent struct {
82	Sequence     uint64
83	Transport    string
84	Kind         string
85	Accepted     bool
86	StateBearing bool
87	Acked        bool
88	Epoch        uint64
89	Base         uint64
90	New          uint64
91	ViewRevision uint64
92	Full         bool
93	DataBytes    int
94}
95
96type visualCheckpoint struct {
97	event    *probeEvent
98	state    visualOutputState
99	snapshot vt.ScreenSnapshot
100}
101
102type visualOutputResult struct {
103	Accepted     bool
104	StateBearing bool
105	Ack          ports.Ack
106	event        *probeEvent
107}
108
109// visualProbe is deliberately transport-local. A new physical transport gets
110// one screen and one output state; waiting for a particular marker never
111// creates a replacement screen and therefore cannot lose prior terminal state.
112type visualProbe struct {
113	label string
114
115	screen *vt.Screen
116	state  visualOutputState
117
118	events      []*probeEvent
119	checkpoints []visualCheckpoint
120	nextEvent   uint64
121
122	outputFrames int
123	outputBytes  int
124	resetPending bool
125
126	unexpectedHandoffs int
127}
128
129type harnessArtifactContextKey struct{}
130
131func withHarnessArtifact(ctx context.Context, artifact *harnessArtifact) context.Context {
132	return context.WithValue(ctx, harnessArtifactContextKey{}, artifact)
133}
134
135func harnessArtifactFromContext(ctx context.Context) *harnessArtifact {
136	if ctx == nil {
137		return nil
138	}
139	artifact, _ := ctx.Value(harnessArtifactContextKey{}).(*harnessArtifact)
140	return artifact
141}
142
143func newVisualProbe(size domain.Size) *visualProbe {
144	width, height := size.Cols, size.Rows
145	if width <= 0 {
146		width = defaultProbeCols
147	}
148	if height <= 0 {
149		height = defaultProbeRows
150	}
151	return &visualProbe{screen: vt.NewScreen(width, height)}
152}
153
154func (p *visualProbe) configure(label string) {
155	if p == nil {
156		return
157	}
158	p.label = label
159}
160
161func (p *visualProbe) record(event probeEvent) *probeEvent {
162	if p == nil {
163		return nil
164	}
165	p.nextEvent++
166	event.Sequence = p.nextEvent
167	event.Transport = p.label
168	owned := &event
169	if len(p.events) == maxProbeEvents {
170		p.events = p.events[1:]
171	}
172	p.events = append(p.events, owned)
173	return owned
174}
175
176func (p *visualProbe) recordControl(kind string) {
177	if p == nil {
178		return
179	}
180	p.record(probeEvent{Kind: kind, Accepted: true})
181}
182
183func (p *visualProbe) apply(output ports.Output) visualOutputResult {
184	if p == nil {
185		return visualOutputResult{}
186	}
187	p.outputFrames++
188	p.outputBytes += len(output.Data)
189	accepted := ports.ValidateOutput(output) == nil
190	next := visualOutputState{}
191	if accepted {
192		next, accepted = p.state.next(output)
193	}
194	event := p.record(probeEvent{
195		Kind:         probeEventOutput,
196		Accepted:     accepted,
197		StateBearing: output.New != 0,
198		Epoch:        output.Epoch,
199		Base:         output.Base,
200		New:          output.New,
201		ViewRevision: output.ViewRevision,
202		Full:         output.Full,
203		DataBytes:    len(output.Data),
204	})
205	if !accepted {
206		return visualOutputResult{event: event}
207	}
208
209	// The client writes side effects as well as state-bearing frames, but only
210	// state-bearing frames advance the dependency chain and receive an ACK.
211	if p.screen.Frame.Width != output.Size.Cols || p.screen.Frame.Height != output.Size.Rows {
212		p.screen.Resize(output.Size.Cols, output.Size.Rows)
213	}
214	p.screen.Write(output.Data)
215	p.state = next
216	checkpoint := visualCheckpoint{event: event, state: p.state, snapshot: p.screen.Snapshot()}
217	if len(p.checkpoints) == maxProbeCheckpoints {
218		p.checkpoints = p.checkpoints[1:]
219	}
220	p.checkpoints = append(p.checkpoints, checkpoint)
221	result := visualOutputResult{Accepted: true, StateBearing: output.New != 0, event: event}
222	if result.StateBearing {
223		result.Ack = ports.Ack{Epoch: output.Epoch, State: output.New}
224	}
225	return result
226}
227
228func (p *visualProbe) text() string {
229	if p == nil || p.screen == nil {
230		return ""
231	}
232	return visualScreenText(p.screen)
233}
234
235func (p *visualProbe) contains(want string) bool {
236	return strings.Contains(p.text(), want)
237}
238
239func (p *visualProbe) recordIncoming(frame ports.Frame) {
240	if p == nil || frame.Type != ports.MsgAttachTarget {
241		return
242	}
243	p.unexpectedHandoffs++
244	p.record(probeEvent{Kind: probeEventUnexpectedHandoff})
245}
246
247func (p *visualProbe) markAcked(result visualOutputResult) {
248	if result.event != nil && result.StateBearing && result.Accepted {
249		result.event.Acked = true
250	}
251}
252
253func visualScreenLines(screen *vt.Screen) []string {
254	if screen == nil {
255		return nil
256	}
257	lines := make([]string, screen.Frame.Height)
258	for y := range screen.Frame.Height {
259		var line strings.Builder
260		for x := range screen.Frame.Width {
261			r := screen.Frame.At(x, y).Rune
262			if r == 0 {
263				r = ' '
264			}
265			line.WriteRune(r)
266		}
267		lines[y] = line.String()
268	}
269	return lines
270}
271
272func visualScreenText(screen *vt.Screen) string {
273	lines := visualScreenLines(screen)
274	if len(lines) == 0 {
275		return ""
276	}
277	return strings.Join(lines, "\n") + "\n"
278}
279
280func capturePickerRows(probe *visualProbe) []string {
281	if probe == nil {
282		return nil
283	}
284	var rows []string
285	for _, line := range visualScreenLines(probe.screen) {
286		if line = strings.TrimSpace(line); line != "" {
287			rows = append(rows, line)
288		}
289	}
290	return rows
291}
292
293func assertUnifiedPickerRows(rows []string, localSession, remoteSession string) error {
294	if len(rows) == 0 {
295		return errors.New("picker screen had no visible rows")
296	}
297	contains := func(want string) bool {
298		for _, row := range rows {
299			if strings.Contains(row, want) {
300				return true
301			}
302		}
303		return false
304	}
305	if !contains(localSession) {
306		return fmt.Errorf("unified picker rows omitted local session %q", localSession)
307	}
308	remoteRow := remoteSession + "@remote"
309	if !contains(remoteRow) {
310		return fmt.Errorf("unified picker rows omitted remote session %q", remoteRow)
311	}
312	return nil
313}
314
315func assertNoRemoteChrome(probe *visualProbe, remoteSession string) error {
316	if probe == nil {
317		return errors.New("remote view probe was not created")
318	}
319	lines := visualScreenLines(probe.screen)
320	if len(lines) < 3 {
321		return errors.New("remote view did not expose a content region")
322	}
323	// The first and last rows belong to the local attachment's bars. Remote
324	// daemon chrome must not be replayed into the content rows.
325	for _, line := range lines[1 : len(lines)-1] {
326		if strings.Contains(line, remoteSession+"@remote") || strings.Contains(line, " Sessions · ") {
327			return fmt.Errorf("remote chrome leaked into content row %q", strings.TrimSpace(line))
328		}
329	}
330	return nil
331}
332
333type harnessArtifact struct {
334	dir    string
335	probes []*visualProbe
336}
337
338func newHarnessArtifact(dir string) *harnessArtifact {
339	if dir == "" {
340		return nil
341	}
342	return &harnessArtifact{dir: dir}
343}
344
345func (a *harnessArtifact) registerProbe(probe *visualProbe) {
346	if a == nil || probe == nil {
347		return
348	}
349	a.probes = append(a.probes, probe)
350}
351
352type artifactEvent struct {
353	Sequence     uint64 `json:"sequence"`
354	Transport    string `json:"transport"`
355	Kind         string `json:"kind"`
356	Accepted     bool   `json:"accepted"`
357	StateBearing bool   `json:"state_bearing"`
358	Acked        bool   `json:"acked"`
359	Epoch        uint64 `json:"epoch,omitempty"`
360	Base         uint64 `json:"base,omitempty"`
361	New          uint64 `json:"new,omitempty"`
362	ViewRevision uint64 `json:"view_revision,omitempty"`
363	Full         bool   `json:"full,omitempty"`
364	DataBytes    int    `json:"data_bytes,omitempty"`
365}
366
367type artifactCheckpoint struct {
368	Sequence           uint64 `json:"sequence"`
369	Epoch              uint64 `json:"epoch,omitempty"`
370	State              uint64 `json:"state,omitempty"`
371	ViewRevision       uint64 `json:"view_revision,omitempty"`
372	Columns            int    `json:"columns"`
373	Rows               int    `json:"rows"`
374	CursorRow          int    `json:"cursor_row"`
375	CursorColumn       int    `json:"cursor_column"`
376	AlternateScreen    bool   `json:"alternate_screen"`
377	BracketedPaste     bool   `json:"bracketed_paste"`
378	SynchronizedUpdate bool   `json:"synchronized_update"`
379}
380
381type artifactProbe struct {
382	Transport   string               `json:"transport"`
383	Events      []artifactEvent      `json:"events"`
384	Checkpoints []artifactCheckpoint `json:"checkpoints"`
385}
386
387type artifactDocument struct {
388	Version int             `json:"version"`
389	Passed  bool            `json:"passed"`
390	Probes  []artifactProbe `json:"probes"`
391}
392
393func (a *harnessArtifact) document(passed bool) artifactDocument {
394	document := artifactDocument{Version: 1, Passed: passed}
395	for _, probe := range a.probes {
396		if probe == nil {
397			continue
398		}
399		item := artifactProbe{Transport: probe.label}
400		for _, event := range probe.events {
401			if event == nil {
402				continue
403			}
404			item.Events = append(item.Events, artifactEventFrom(event))
405		}
406		for _, checkpoint := range probe.checkpoints {
407			snapshot := checkpoint.snapshot
408			cursor := snapshot.Cursor()
409			modes := snapshot.Modes()
410			item.Checkpoints = append(item.Checkpoints, artifactCheckpoint{
411				Sequence:           checkpoint.event.Sequence,
412				Epoch:              checkpoint.state.epoch,
413				State:              checkpoint.state.state,
414				ViewRevision:       checkpoint.state.viewRevision,
415				Columns:            snapshot.Columns(),
416				Rows:               snapshot.Rows(),
417				CursorRow:          cursor.Row,
418				CursorColumn:       cursor.Col,
419				AlternateScreen:    modes.AlternateScreen,
420				BracketedPaste:     modes.BracketedPaste,
421				SynchronizedUpdate: modes.SynchronizedUpdate,
422			})
423		}
424		document.Probes = append(document.Probes, item)
425	}
426	return document
427}
428
429func artifactEventFrom(event *probeEvent) artifactEvent {
430	return artifactEvent{
431		Sequence:     event.Sequence,
432		Transport:    event.Transport,
433		Kind:         event.Kind,
434		Accepted:     event.Accepted,
435		StateBearing: event.StateBearing,
436		Acked:        event.Acked,
437		Epoch:        event.Epoch,
438		Base:         event.Base,
439		New:          event.New,
440		ViewRevision: event.ViewRevision,
441		Full:         event.Full,
442		DataBytes:    event.DataBytes,
443	}
444}
445
446func (a *harnessArtifact) write(passed bool) error {
447	if a == nil || a.dir == "" {
448		return nil
449	}
450	data, err := json.MarshalIndent(a.document(passed), "", "  ")
451	if err != nil {
452		return fmt.Errorf("marshal harness artifact: %w", err)
453	}
454	if len(data) > maxArtifactBytes {
455		return fmt.Errorf("harness artifact exceeds %d bytes", maxArtifactBytes)
456	}
457	if err := os.MkdirAll(a.dir, 0o700); err != nil {
458		return fmt.Errorf("create harness artifact directory: %w", err)
459	}
460	tmp, err := os.CreateTemp(a.dir, ".remote-picker-harness-*.tmp")
461	if err != nil {
462		return fmt.Errorf("create harness artifact: %w", err)
463	}
464	tmpName := tmp.Name()
465	defer func() { _ = os.Remove(tmpName) }()
466	if err := tmp.Chmod(0o600); err != nil {
467		_ = tmp.Close()
468		return fmt.Errorf("protect harness artifact: %w", err)
469	}
470	if _, err := tmp.Write(data); err != nil {
471		_ = tmp.Close()
472		return fmt.Errorf("write harness artifact: %w", err)
473	}
474	if err := tmp.Close(); err != nil {
475		return fmt.Errorf("close harness artifact: %w", err)
476	}
477	if err := os.Rename(tmpName, filepath.Join(a.dir, "remote-picker-harness.json")); err != nil {
478		return fmt.Errorf("publish harness artifact: %w", err)
479	}
480	return nil
481}

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