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// EndBatch flushes only ordinary undecided bytes after a validated automated
162// batch. An open paste is never terminated here; admission and input validation
163// must prevent automation from entering or inheriting one.
164func (c *pasteCoalescer) EndBatch() bool {
165	c.mu.Lock()
166	defer c.mu.Unlock()
167	if c.closed || c.buffering {
168		return false
169	}
170	c.stopFlushTimer()
171	pending := c.pending
172	c.pending = nil
173	if len(pending) != 0 {
174		c.emit(pending)
175	}
176	return true
177}
178
179// Close stops any pending timers and their goroutines. Callers which hand input
180// to a replacement must first use CloseAndTakeHeld so undecided bytes survive.
181func (c *pasteCoalescer) Close() {
182	_ = c.CloseAndTakeHeld()
183}
184
185// CloseAndTakeHeld stops the coalescer and returns ordinary bytes withheld while
186// deciding whether an opening marker or complete bracketed paste would arrive.
187// The returned bytes retain their original order and are removed from c.
188func (c *pasteCoalescer) CloseAndTakeHeld() []byte {
189	c.mu.Lock()
190	defer c.mu.Unlock()
191	c.closed = true
192	c.stopFlushTimer()
193	c.stopSafetyTimer()
194	held := append([]byte(nil), c.pending...)
195	held = append(held, c.buf...)
196	c.pending = nil
197	c.buf = nil
198	c.buffering = false
199	return held
200}
201
202func (c *pasteCoalescer) startFlushTimer() {
203	c.stopFlushTimer()
204	if c.closed {
205		return
206	}
207	timer := c.clock.NewTimer(pasteFlushDelay)
208	done := make(chan struct{})
209	c.flushTimer = timer
210	c.flushDone = done
211	go func() {
212		select {
213		case <-timer.C():
214		case <-done:
215			return
216		}
217		c.mu.Lock()
218		defer c.mu.Unlock()
219		if c.flushTimer != timer || len(c.pending) == 0 {
220			return
221		}
222		c.flushTimer = nil
223		c.flushDone = nil
224		held := c.pending
225		c.pending = nil
226		c.emit(held)
227	}()
228}
229
230func (c *pasteCoalescer) stopFlushTimer() {
231	if c.flushTimer != nil {
232		c.flushTimer.Stop()
233		c.flushTimer = nil
234	}
235	if c.flushDone != nil {
236		close(c.flushDone)
237		c.flushDone = nil
238	}
239}
240
241func (c *pasteCoalescer) startSafetyTimer() {
242	c.stopSafetyTimer()
243	if c.closed {
244		return
245	}
246	timer := c.clock.NewTimer(pasteSafetyDelay)
247	done := make(chan struct{})
248	c.safetyTimer = timer
249	c.safetyDone = done
250	go func() {
251		select {
252		case <-timer.C():
253		case <-done:
254			return
255		}
256		c.mu.Lock()
257		defer c.mu.Unlock()
258		if c.safetyTimer != timer || !c.buffering {
259			return
260		}
261		buffered := c.buf
262		c.buffering = false
263		c.buf = nil
264		c.safetyTimer = nil
265		c.safetyDone = nil
266		if len(buffered) > 0 {
267			c.emit(buffered)
268		}
269	}()
270}
271
272func (c *pasteCoalescer) stopSafetyTimer() {
273	if c.safetyTimer != nil {
274		c.safetyTimer.Stop()
275		c.safetyTimer = nil
276	}
277	if c.safetyDone != nil {
278		close(c.safetyDone)
279		c.safetyDone = nil
280	}
281}
282
283// trailingOpenPrefixLen returns the length of the longest suffix of data that
284// is a non-empty strict prefix of the opening marker (e.g. a trailing "\x1b" or
285// "\x1b[20"). A full marker match is handled by the caller before this runs.
286func trailingOpenPrefixLen(data []byte) int {
287	matchLimit := len(ports.BracketedPasteOpenMarker) - 1
288	if matchLimit > len(data) {
289		matchLimit = len(data)
290	}
291	for n := matchLimit; n >= 1; n-- {
292		if bytes.Equal(data[len(data)-n:], ports.BracketedPasteOpenMarker[:n]) {
293			return n
294		}
295	}
296	return 0
297}

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