v0.3.0 99dc266

paste_coalescer.go RawHistoryFolder

1package client
2
3import (
4	"bytes"
5	"sync"
6	"time"
7
8	"github.com/bnema/vev/internal/ports"
9)
10
11const (
12	// pasteFlushDelay bounds how long a trailing strict prefix of the opening
13	// marker is held before it is emitted as ordinary bytes. It only has to
14	// bridge a local kernel read split: the rest of the marker is already in
15	// the tty buffer and arrives on the next Read microseconds later. It is NOT
16	// meant to bridge a network-scale gap — once the opening marker completes,
17	// buffering mode holds the whole paste (up to the 2s safety timer), so a
18	// close-marker split at any realistic latency is covered by whole-paste
19	// framing rather than this timer. Kept well under the daemon router's
20	// ESCDelay so a real lone Esc keypress still reaches the pane promptly.
21	pasteFlushDelay = 20 * time.Millisecond
22	// pasteSafetyDelay caps how long the coalescer waits for a closing marker
23	// before flushing a partial paste, so a paste with a lost closing marker
24	// degrades to today's per-frame behavior instead of stalling input.
25	pasteSafetyDelay = 2 * time.Second
26	// maxPasteBuffer caps buffered paste bytes. Beyond it the buffer is flushed
27	// verbatim and normal scanning resumes; input is never dropped.
28	maxPasteBuffer = 2 << 20 // 2 MiB
29)
30
31// pasteCoalescer reframes a bracketed paste that arrives split across input
32// reads into a single emit call. Over a remote attach, stdin is read in fixed
33// chunks and forwarded frame by frame; an input-frame boundary can land inside
34// a paste marker (e.g. between the marker's ESC and its '['). When the daemon's
35// keys router then sees a lone trailing ESC it retains it for ESCDelay and, if
36// the next frame is late, forwards it as a standalone Esc keypress — breaking
37// the opening marker so the pane's TUI never enters paste mode. Coalescing the
38// whole paste into one emit keeps both markers contiguous on the wire.
39//
40// It is safe for concurrent use: Scan runs on the stdin pump goroutine while a
41// flush/safety timer may fire on its own goroutine; both take the mutex, so
42// emit calls are serialised.
43type pasteCoalescer struct {
44	clock ports.Clock
45	emit  func([]byte)
46
47	mu        sync.Mutex
48	buffering bool   // inside a paste: accumulating until the closing marker
49	buf       []byte // paste bytes accumulated in buffering mode
50	pending   []byte // held trailing strict prefix of the opening marker
51
52	flushTimer  ports.Timer
53	flushDone   chan struct{}
54	safetyTimer ports.Timer
55	safetyDone  chan struct{}
56	closed      bool
57}
58
59func newPasteCoalescer(clock ports.Clock, emit func([]byte)) *pasteCoalescer {
60	return &pasteCoalescer{clock: clock, emit: emit}
61}
62
63// Scan consumes one run of ordinary input bytes, emitting either pass-through
64// bytes immediately or a whole bracketed paste as a single emit call once its
65// closing marker is seen.
66func (c *pasteCoalescer) Scan(data []byte) {
67	if len(data) == 0 {
68		return
69	}
70	c.mu.Lock()
71	defer c.mu.Unlock()
72	if len(c.pending) > 0 {
73		// New bytes arrived before the flush timer fired: the held prefix was
74		// the start of this data, not a lone Esc. Recombine and rescan.
75		c.stopFlushTimer()
76		data = append(c.pending, data...)
77		c.pending = nil
78	}
79	c.process(data)
80}
81
82// process runs the scan/buffer state machine with the mutex held.
83func (c *pasteCoalescer) process(data []byte) {
84	for {
85		if c.buffering {
86			searchFrom := len(c.buf)
87			if overlap := len(ports.BracketedPasteCloseMarker) - 1; searchFrom > overlap {
88				searchFrom -= overlap
89			} else {
90				searchFrom = 0
91			}
92			c.buf = append(c.buf, data...)
93			idx := bytes.Index(c.buf[searchFrom:], ports.BracketedPasteCloseMarker)
94			if idx >= 0 {
95				idx += searchFrom
96			}
97			if idx < 0 {
98				if len(c.buf) > maxPasteBuffer {
99					c.emit(c.buf)
100					c.resetBuffering()
101				}
102				return
103			}
104			end := idx + len(ports.BracketedPasteCloseMarker)
105			c.emit(c.buf[:end])
106			rest := append([]byte(nil), c.buf[end:]...)
107			c.resetBuffering()
108			data = rest
109			// Fall through to scan any bytes trailing the paste.
110		}
111
112		if len(data) == 0 {
113			return
114		}
115
116		if idx := bytes.Index(data, ports.BracketedPasteOpenMarker); idx >= 0 {
117			if idx > 0 {
118				c.emit(data[:idx])
119			}
120			c.buffering = true
121			c.startSafetyTimer()
122			data = data[idx:] // marker onward; re-enter the buffering branch
123			continue
124		}
125
126		if p := trailingOpenPrefixLen(data); p > 0 {
127			if len(data) > p {
128				c.emit(data[:len(data)-p])
129			}
130			c.pending = append([]byte(nil), data[len(data)-p:]...)
131			c.startFlushTimer()
132			return
133		}
134
135		c.emit(data)
136		return
137	}
138}
139
140// resetBuffering leaves paste mode and stops the safety timer.
141func (c *pasteCoalescer) resetBuffering() {
142	c.buffering = false
143	c.buf = nil
144	c.stopSafetyTimer()
145}
146
147// Buffering reports whether it is currently unsafe to reinterpret incoming
148// bytes as ordinary keystrokes: either a bracketed paste is being
149// accumulated, or a trailing strict prefix of the opening marker is being
150// held across a read boundary (pending, awaiting either completion into a
151// real paste or the flush timer proving it was not one). The clipboard
152// Ctrl+V interceptor uses this to avoid treating bytes that are, or might
153// turn out to be, part of an in-flight paste as a standalone keystroke —
154// pasted text may legitimately contain 0x16.
155func (c *pasteCoalescer) Buffering() bool {
156	c.mu.Lock()
157	defer c.mu.Unlock()
158	return c.buffering || len(c.pending) > 0
159}
160
161// Close stops any pending timers and their goroutines. Callers which hand input
162// to a replacement must first use CloseAndTakeHeld so undecided bytes survive.
163func (c *pasteCoalescer) Close() {
164	_ = c.CloseAndTakeHeld()
165}
166
167// CloseAndTakeHeld stops the coalescer and returns ordinary bytes withheld while
168// deciding whether an opening marker or complete bracketed paste would arrive.
169// The returned bytes retain their original order and are removed from c.
170func (c *pasteCoalescer) CloseAndTakeHeld() []byte {
171	c.mu.Lock()
172	defer c.mu.Unlock()
173	c.closed = true
174	c.stopFlushTimer()
175	c.stopSafetyTimer()
176	held := append([]byte(nil), c.pending...)
177	held = append(held, c.buf...)
178	c.pending = nil
179	c.buf = nil
180	c.buffering = false
181	return held
182}
183
184func (c *pasteCoalescer) startFlushTimer() {
185	c.stopFlushTimer()
186	if c.closed {
187		return
188	}
189	timer := c.clock.NewTimer(pasteFlushDelay)
190	done := make(chan struct{})
191	c.flushTimer = timer
192	c.flushDone = done
193	go func() {
194		select {
195		case <-timer.C():
196		case <-done:
197			return
198		}
199		c.mu.Lock()
200		defer c.mu.Unlock()
201		if c.flushTimer != timer || len(c.pending) == 0 {
202			return
203		}
204		c.flushTimer = nil
205		c.flushDone = nil
206		held := c.pending
207		c.pending = nil
208		c.emit(held)
209	}()
210}
211
212func (c *pasteCoalescer) stopFlushTimer() {
213	if c.flushTimer != nil {
214		c.flushTimer.Stop()
215		c.flushTimer = nil
216	}
217	if c.flushDone != nil {
218		close(c.flushDone)
219		c.flushDone = nil
220	}
221}
222
223func (c *pasteCoalescer) startSafetyTimer() {
224	c.stopSafetyTimer()
225	if c.closed {
226		return
227	}
228	timer := c.clock.NewTimer(pasteSafetyDelay)
229	done := make(chan struct{})
230	c.safetyTimer = timer
231	c.safetyDone = done
232	go func() {
233		select {
234		case <-timer.C():
235		case <-done:
236			return
237		}
238		c.mu.Lock()
239		defer c.mu.Unlock()
240		if c.safetyTimer != timer || !c.buffering {
241			return
242		}
243		buffered := c.buf
244		c.buffering = false
245		c.buf = nil
246		c.safetyTimer = nil
247		c.safetyDone = nil
248		if len(buffered) > 0 {
249			c.emit(buffered)
250		}
251	}()
252}
253
254func (c *pasteCoalescer) stopSafetyTimer() {
255	if c.safetyTimer != nil {
256		c.safetyTimer.Stop()
257		c.safetyTimer = nil
258	}
259	if c.safetyDone != nil {
260		close(c.safetyDone)
261		c.safetyDone = nil
262	}
263}
264
265// trailingOpenPrefixLen returns the length of the longest suffix of data that
266// is a non-empty strict prefix of the opening marker (e.g. a trailing "\x1b" or
267// "\x1b[20"). A full marker match is handled by the caller before this runs.
268func trailingOpenPrefixLen(data []byte) int {
269	matchLimit := len(ports.BracketedPasteOpenMarker) - 1
270	if matchLimit > len(data) {
271		matchLimit = len(data)
272	}
273	for n := matchLimit; n >= 1; n-- {
274		if bytes.Equal(data[len(data)-n:], ports.BracketedPasteOpenMarker[:n]) {
275			return n
276		}
277	}
278	return 0
279}

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