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

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