attachment_input.go RawHistoryFolder

1package client
2
3import (
4	"context"
5	"fmt"
6	"log/slog"
7	"sync"
8	"time"
9
10	renderer "github.com/bnema/vev-vt"
11	"github.com/bnema/vev/internal/ports"
12	"github.com/bnema/vev/internal/protocol"
13	"github.com/bnema/vev/internal/usecase/theme"
14)
15
16// attachmentInput is the attached loop's terminal input path, ported from the
17// old client's stdin pump. Each physical read is demultiplexed before anything
18// reaches the session:
19//
20//   - theme.Scanner strips OSC 10/11/4 color replies and CSI ?997 scheme
21//     notifications;
22//   - paletteMarkerScanner strips DECRQM 2031 boundary replies;
23//   - the remaining ordinary bytes go to an overlay when one owns input, or
24//     through the paste coalescer, which keeps a bracketed paste in one Input.
25//
26// Color replies and markers drive the palette generation, whose queries are
27// written through the foreground and whose results are sent as
28// protocol.Theme. Only the attached loop calls these methods. The coalescer's
29// timers run on their own goroutines, but they only queue bytes and wake the
30// loop, so the loop stays the single sender.
31type attachmentInput struct {
32	worker *sessionAttachmentWorker
33	fg     AttachmentForeground
34	stream ports.BrokerLogicalConnection
35	picker *attachmentMovePicker
36	clock  ports.Clock
37	seq    uint64
38
39	scanner theme.Scanner
40	markers paletteMarkerScanner
41	// markerTimer bounds how long an undecided DECRQM prefix (a lone Escape
42	// included) is withheld before it is forwarded as ordinary input.
43	markerTimer ports.Timer
44
45	paste     *pasteCoalescer
46	clipboard *clipboardIntercept
47	mu        sync.Mutex
48	queued    []protocol.ClientMessage
49	wake      chan struct{}
50
51	// palette is nil when detection is disabled: no retained theme or no
52	// query seam on the foreground. Replies are stripped either way.
53	palette    *paletteGenerationCoordinator
54	themes     *terminalThemeState
55	query      attachmentQueryForeground
56	drain      paletteDeadline
57	completion paletteDeadline
58}
59
60// paletteDeadline is the one armed timer of a deadline kind. The coordinator
61// cancels a replaced generation's deadlines before arming the next, so one
62// slot per kind is enough.
63type paletteDeadline struct {
64	id    paletteGenerationID
65	timer ports.Timer
66}
67
68func (d *paletteDeadline) arm(clock ports.Clock, id paletteGenerationID, delay time.Duration) {
69	d.stop()
70	d.id = id
71	d.timer = clock.NewTimer(delay)
72}
73
74func (d *paletteDeadline) cancel(id paletteGenerationID) {
75	if d.timer != nil && d.id == id {
76		d.stop()
77	}
78}
79
80func (d *paletteDeadline) stop() {
81	if d.timer != nil {
82		d.timer.Stop()
83	}
84	*d = paletteDeadline{}
85}
86
87func (d *paletteDeadline) c() <-chan time.Time {
88	if d.timer == nil {
89		return nil
90	}
91	return d.timer.C()
92}
93
94// fired consumes the slot after its timer delivered, returning the event the
95// coordinator expects.
96func (d *paletteDeadline) fired(kind paletteGenerationEventKind) paletteGenerationEvent {
97	event := paletteGenerationEvent{id: d.id, kind: kind}
98	*d = paletteDeadline{}
99	return event
100}
101
102func newAttachmentInput(ctx context.Context, w *sessionAttachmentWorker, fg AttachmentForeground, stream ports.BrokerLogicalConnection, picker *attachmentMovePicker) *attachmentInput {
103	in := &attachmentInput{
104		worker: w,
105		fg:     fg,
106		stream: stream,
107		picker: picker,
108		clock:  w.cfg.Clock,
109		wake:   make(chan struct{}, 1),
110		themes: w.cfg.Theme,
111	}
112	in.paste = newPasteCoalescer(in.clock, in.enqueue)
113	if !w.cfg.Request.Local && w.cfg.Clipboard != nil {
114		in.clipboard = &clipboardIntercept{
115			ctx: ctx, coalescer: in.paste, reader: w.cfg.Clipboard,
116			log: slog.Default(), next: in.paste.Scan,
117			sendImage: func(mime string, data []byte) {
118				in.enqueueImage(mime, data)
119			},
120			sendNotice: func(action uint8) { in.enqueueNotice(action) },
121		}
122	}
123	if query, ok := fg.(attachmentQueryForeground); ok && in.themes != nil {
124		in.query = query
125		in.palette = newPaletteGenerationCoordinator()
126	}
127	return in
128}
129
130// enqueue is the coalescer's emit callback. It may run on a timer goroutine,
131// so it only queues and wakes the attached loop.
132func (in *attachmentInput) enqueue(data []byte) {
133	if len(data) == 0 {
134		return
135	}
136	in.queue(protocol.Input{Data: append([]byte(nil), data...)})
137}
138
139func (in *attachmentInput) enqueueImage(mime string, data []byte) {
140	in.queue(protocol.ImagePush{Mime: mime, Data: append([]byte(nil), data...)})
141}
142
143func (in *attachmentInput) enqueueNotice(action uint8) {
144	in.queue(protocol.ClientNotice{Action: action})
145}
146
147func (in *attachmentInput) queue(message protocol.ClientMessage) {
148	in.mu.Lock()
149	in.queued = append(in.queued, message)
150	in.mu.Unlock()
151	select {
152	case in.wake <- struct{}{}:
153	default:
154	}
155}
156
157// start publishes the cleared theme and writes the first palette query. The
158// retained definitive colors avoid a neutral flash on a replacement
159// attachment.
160func (in *attachmentInput) start(ctx context.Context) error {
161	if in.palette == nil {
162		return nil
163	}
164	retained := in.themes.update(func(current *protocol.Theme) { current.TrueColor = in.worker.cfg.TrueColor })
165	return in.apply(ctx, in.palette.start(retained, false))
166}
167
168// close stops every timer. Undecided bytes are dropped with the attachment,
169// as the owner boundary drops every other attachment-held byte.
170func (in *attachmentInput) close() {
171	in.paste.Close()
172	if in.markerTimer != nil {
173		in.markerTimer.Stop()
174		in.markerTimer = nil
175	}
176	in.drain.stop()
177	in.completion.stop()
178}
179
180// read demultiplexes one physical terminal read.
181func (in *attachmentInput) read(ctx context.Context, state outputApplyState, data []byte) error {
182	var ordinary []byte
183	if err := in.disarmMarker(ctx, state); err != nil {
184		return err
185	}
186	// Every callback of one read keeps the generation it started with: a
187	// scheme notification early in the read starts a replacement, and an old
188	// marker later in the same read must not advance it.
189	var id paletteGenerationID
190	if in.palette != nil {
191		id = in.palette.current.id
192	}
193	var events []paletteGenerationEvent
194	in.scanner.Scan(data, func(kind int, rgb renderer.RGB) {
195		event := paletteEventForeground
196		if kind == 11 {
197			event = paletteEventBackground
198		}
199		events = append(events, paletteGenerationEvent{id: id, kind: event, rgb: rgb})
200	}, func(slot int, rgb renderer.RGB) {
201		if slot8, ok := paletteSlot(slot); ok {
202			events = append(events, paletteGenerationEvent{id: id, kind: paletteEventPalette, slot: slot8, rgb: rgb})
203		}
204	}, func(light bool) {
205		events = append(events, paletteGenerationEvent{id: id, kind: paletteEventScheme, light: light})
206	}, func(bytes []byte) {
207		in.markers.scan(bytes, func(b []byte) { ordinary = append(ordinary, b...) }, func() {
208			events = append(events, paletteGenerationEvent{id: id, kind: paletteEventMarker})
209		})
210	})
211	for _, event := range events {
212		if err := in.handle(ctx, event); err != nil {
213			return err
214		}
215	}
216	if in.markers.hasPendingPrefix() {
217		in.markerTimer = in.clock.NewTimer(paletteMarkerAmbiguityDeadline)
218	}
219	return in.route(ctx, state, ordinary)
220}
221
222// flushHeld forwards every undecided ordinary byte before an automation batch,
223// so driver input never overtakes a keypress still being disambiguated. An
224// open bracketed paste is left alone.
225func (in *attachmentInput) flushHeld(ctx context.Context, state outputApplyState) error {
226	if in.markerTimer != nil {
227		in.markerTimer.Stop()
228		in.markerTimer = nil
229	}
230	var ordinary []byte
231	collect := func(b []byte) { ordinary = append(ordinary, b...) }
232	in.scanner.EndBatch(func(b []byte) {
233		in.markers.scan(b, collect, func() {})
234	})
235	in.markers.flush(collect)
236	if err := in.route(ctx, state, ordinary); err != nil {
237		return err
238	}
239	in.paste.EndBatch()
240	return in.flush(ctx)
241}
242
243// disarmMarker stops the ambiguity deadline before a later read. A deadline
244// that already fired wins, so bytes due as ordinary input are never
245// retroactively consumed as a marker.
246func (in *attachmentInput) disarmMarker(ctx context.Context, state outputApplyState) error {
247	if in.markerTimer == nil {
248		return nil
249	}
250	timer := in.markerTimer
251	select {
252	case <-timer.C():
253		return in.markerExpired(ctx, state)
254	default:
255	}
256	if !timer.Stop() {
257		return in.markerExpired(ctx, state)
258	}
259	in.markerTimer = nil
260	return nil
261}
262
263func (in *attachmentInput) markerC() <-chan time.Time {
264	if in.markerTimer == nil {
265		return nil
266	}
267	return in.markerTimer.C()
268}
269
270// markerExpired forwards a withheld DECRQM prefix as ordinary input.
271func (in *attachmentInput) markerExpired(ctx context.Context, state outputApplyState) error {
272	in.markerTimer = nil
273	var ordinary []byte
274	in.markers.flush(func(b []byte) { ordinary = append(ordinary, b...) })
275	return in.route(ctx, state, ordinary)
276}
277
278// route hands ordinary bytes to the overlay that owns input, or to the paste
279// coalescer on their way to the session.
280func (in *attachmentInput) route(ctx context.Context, state outputApplyState, data []byte) error {
281	if len(data) == 0 {
282		return nil
283	}
284	consumed, _, err := in.picker.consumeInput(ctx, state, AttachmentInputEvent{Data: data})
285	if err != nil || consumed {
286		return err
287	}
288	if in.clipboard != nil {
289		in.clipboard.Scan(data)
290	} else {
291		in.paste.Scan(data)
292	}
293	return in.flush(ctx)
294}
295
296// flush sends every queued coalescer emission as one session Input each.
297func (in *attachmentInput) flush(ctx context.Context) error {
298	in.mu.Lock()
299	queued := in.queued
300	in.queued = nil
301	in.mu.Unlock()
302	for _, message := range queued {
303		switch m := message.(type) {
304		case protocol.Input:
305			m.InputSeq = in.nextSeq()
306			message = m
307		case protocol.ImagePush:
308			m.InputSeq = in.nextSeq()
309			message = m
310		}
311		if err := in.send(ctx, message); err != nil {
312			return err
313		}
314	}
315	return nil
316}
317
318func (in *attachmentInput) nextSeq() uint64 {
319	in.seq++
320	return in.seq
321}
322
323func (in *attachmentInput) send(ctx context.Context, message protocol.ClientMessage) error {
324	return in.worker.send(ctx, in.fg, in.stream, message)
325}
326
327// handle applies one palette event. A scheme notification starts a
328// replacement generation from the retained colors.
329func (in *attachmentInput) handle(ctx context.Context, event paletteGenerationEvent) error {
330	if in.palette == nil {
331		return nil
332	}
333	if event.kind == paletteEventScheme {
334		retained := in.themes.update(func(current *protocol.Theme) {
335			current.SchemeKnown = true
336			current.Light = event.light
337		})
338		return in.apply(ctx, in.palette.start(retained, true))
339	}
340	return in.apply(ctx, in.palette.handle(event))
341}
342
343// drainFired and completionFired feed an expired palette deadline back to the
344// coordinator.
345func (in *attachmentInput) drainFired(ctx context.Context) error {
346	return in.handle(ctx, in.drain.fired(paletteEventDrainDeadline))
347}
348
349func (in *attachmentInput) completionFired(ctx context.Context) error {
350	return in.handle(ctx, in.completion.fired(paletteEventCompletionDeadline))
351}
352
353func (in *attachmentInput) apply(ctx context.Context, actions []paletteGenerationAction) error {
354	for _, action := range actions {
355		switch action.kind {
356		case paletteActionPublishCleared:
357			if err := in.send(ctx, action.theme); err != nil {
358				return fmt.Errorf("vev: publishing theme: %w", err)
359			}
360		case paletteActionPublishFinal:
361			in.themes.update(func(current *protocol.Theme) { *current = action.theme })
362			if err := in.send(ctx, action.theme); err != nil {
363				return fmt.Errorf("vev: publishing theme: %w", err)
364			}
365		case paletteActionWriteDrain, paletteActionWriteBatch:
366			if err := in.query.writeTerminalQuery([]byte(action.bytes)); err != nil {
367				return fmt.Errorf("vev: writing palette query: %w", err)
368			}
369		case paletteActionArmDrainDeadline:
370			in.drain.arm(in.clock, action.id, action.deadline)
371		case paletteActionArmCompletionDeadline:
372			in.completion.arm(in.clock, action.id, action.deadline)
373		case paletteActionCancelDrainDeadline:
374			in.drain.cancel(action.id)
375		case paletteActionCancelCompletionDeadline:
376			in.completion.cancel(action.id)
377		}
378	}
379	return nil
380}

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