clipboard_intercept.go RawHistoryFolder

1package client
2
3import (
4	"bytes"
5	"context"
6	"errors"
7	"log/slog"
8
9	"github.com/bnema/vev/internal/ports"
10	"github.com/bnema/vev/internal/protocol"
11	"github.com/bnema/vev/internal/usecase/keys/kittykey"
12)
13
14// ctrlV is the byte a Ctrl+V keypress sends on the wire.
15const ctrlV = 0x16
16
17// maxClipboardImagePush caps the image the client will send in one
18// ImagePush, mirroring the daemon's independent cap. The 1 MiB cap fits
19// both sshstdio and the datagram transport's fragmented payload ceiling.
20const maxClipboardImagePush = 1 << 20 // 1 MiB
21
22// clipboardIntercept sits between the terminal's theme scanner and the paste
23// coalescer on a remote attach. It splits pass-through stdin bytes on Ctrl+V
24// (0x16): each occurrence triggers a clipboard image read, sent as one
25// ImagePush envelope instead of the keystroke. Everything else — ordinary
26// bytes, and any 0x16 that arrives while the coalescer is mid-paste (pasted
27// text may legitimately contain it) — is forwarded to next unchanged, so it
28// still gets the coalescer's marker-splitting treatment.
29type clipboardIntercept struct {
30	// ctx is the foreground lifetime owning this scanner. A slow
31	// workstation read (e.g. a hung wl-paste) must abort when the
32	// foreground is stopped for a route switch, so the pump cannot hold
33	// the switch waiting and a late result cannot reach a replacement
34	// owner. Adapters map cancellation onto ErrNoClipboardImage, which
35	// takes the existing forward path: the keystroke stays ordinary
36	// input and follows the standard undelivered-byte handoff.
37	ctx        context.Context
38	coalescer  *pasteCoalescer
39	reader     ports.ClipboardReader
40	log        *slog.Logger
41	sendImage  func(mime string, data []byte)
42	sendNotice func(action uint8)
43	next       func([]byte)
44}
45
46// Scan implements the same func([]byte) shape as pasteCoalescer.Scan, so it
47// can be used interchangeably as the scanner sink.
48//
49// It must never scan for Ctrl+V past the point where a bracketed paste
50// starts (or might start): pasted text can legitimately contain 0x16, and
51// the paste's opening marker can itself be split across two reads. So:
52//
53//  1. If the coalescer is already Buffering (mid-paste, or holding a
54//     trailing strict prefix of the opening marker from a previous call),
55//     the whole chunk is handed to next untouched.
56//  2. Otherwise, this chunk is scanned only up to the first byte that either
57//     starts the opening marker in full or could be the start of a marker
58//     split across this chunk's end (pasteMarkerBoundary); everything from
59//     that point onward is handed to next as one piece, unscanned, letting
60//     the coalescer's own state decide what happens to it (and to any
61//     bytes trailing an in-chunk paste — see the package-level note below).
62func (c *clipboardIntercept) Scan(data []byte) {
63	if c.coalescer.Buffering() {
64		c.next(data)
65		return
66	}
67
68	boundary := pasteMarkerBoundary(data)
69	segment := data[:boundary]
70	for len(segment) > 0 {
71		idx, size := indexCtrlV(segment)
72		if idx < 0 {
73			c.next(segment)
74			break
75		}
76		if idx > 0 {
77			c.next(segment[:idx])
78		}
79		raw := segment[idx : idx+size]
80		segment = segment[idx+size:]
81		c.handleCtrlV(raw)
82	}
83
84	// Bytes at/after boundary are (the possible start of) a bracketed
85	// paste: handed off whole, so any Ctrl+V trailing a paste that closes
86	// within this same chunk is not intercepted either. In practice a real
87	// keypress never arrives concatenated with paste bytes in one read, so
88	// this is a deliberate, narrow trade-off in favor of never breaking
89	// paste content.
90	if tail := data[boundary:]; len(tail) > 0 {
91		c.next(tail)
92	}
93}
94
95// pasteMarkerBoundary returns the index in data at or after which bytes must
96// not be scanned for Ctrl+V because they start (or, at the very end of data,
97// might be a split-across-reads prefix of) the bracketed-paste opening
98// marker. It returns len(data) when neither is present.
99func pasteMarkerBoundary(data []byte) int {
100	if idx := bytes.Index(data, ports.BracketedPasteOpenMarker); idx >= 0 {
101		return idx
102	}
103	if p := trailingOpenPrefixLen(data); p > 0 {
104		return len(data) - p
105	}
106	return len(data)
107}
108
109// indexCtrlV finds the first Ctrl+V keypress: the legacy 0x16 byte or a
110// kitty keyboard protocol press of Ctrl+V on any layout.
111func indexCtrlV(data []byte) (idx, size int) {
112	for i, b := range data {
113		switch b {
114		case ctrlV:
115			return i, 1
116		case 0x1b:
117			if ev, n, ok, _ := kittykey.Parse(data[i:]); ok && isKittyCtrlV(ev) {
118				return i, n
119			}
120		}
121	}
122	return -1, 0
123}
124
125func isKittyCtrlV(ev kittykey.Event) bool {
126	return !ev.Release && ev.Mods == kittykey.ModCtrl && (ev.Code == 'v' || ev.Base == 'v')
127}
128
129func (c *clipboardIntercept) handleCtrlV(raw []byte) {
130	ctx := c.ctx
131	if ctx == nil {
132		ctx = context.Background()
133	}
134	mime, data, err := c.reader.ReadImage(ctx)
135	if err != nil {
136		if !errors.Is(err, ports.ErrNoClipboardImage) {
137			c.log.Warn("clipboard image read failed", "err", err)
138			if c.sendNotice != nil {
139				c.sendNotice(protocol.ClientNoticeClipboardFallback)
140			}
141		}
142		c.next(raw)
143		return
144	}
145	if len(data) > maxClipboardImagePush {
146		c.log.Warn("clipboard image too large to send, forwarding Ctrl+V instead", "size", len(data), "cap", maxClipboardImagePush)
147		if c.sendNotice != nil {
148			c.sendNotice(protocol.ClientNoticeClipboardTooLarge)
149		}
150		c.next(raw)
151		return
152	}
153	c.sendImage(mime, data)
154}

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