v0.3.0 99dc266

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)
11
12// ctrlV is the byte a Ctrl+V keypress sends on the wire.
13const ctrlV = 0x16
14
15// maxClipboardImagePush caps the image the client will send in one
16// MsgImagePush, mirroring the daemon's independent cap. The 1 MiB cap fits
17// both sshstdio and the datagram transport's fragmented payload ceiling.
18const maxClipboardImagePush = 1 << 20 // 1 MiB
19
20// clipboardIntercept sits between the terminal's theme scanner and the paste
21// coalescer on a remote attach. It splits pass-through stdin bytes on Ctrl+V
22// (0x16): each occurrence triggers a clipboard image read, sent as one
23// MsgImagePush frame instead of the keystroke. Everything else — ordinary
24// bytes, and any 0x16 that arrives while the coalescer is mid-paste (pasted
25// text may legitimately contain it) — is forwarded to next unchanged, so it
26// still gets the coalescer's marker-splitting treatment.
27type clipboardIntercept struct {
28	coalescer  *pasteCoalescer
29	reader     ports.ClipboardReader
30	log        *slog.Logger
31	sendImage  func(mime string, data []byte)
32	sendNotice func(action uint8)
33	next       func([]byte)
34}
35
36// Scan implements the same func([]byte) shape as pasteCoalescer.Scan, so it
37// can be used interchangeably as the scanner sink.
38//
39// It must never scan for Ctrl+V past the point where a bracketed paste
40// starts (or might start): pasted text can legitimately contain 0x16, and
41// the paste's opening marker can itself be split across two reads. So:
42//
43//  1. If the coalescer is already Buffering (mid-paste, or holding a
44//     trailing strict prefix of the opening marker from a previous call),
45//     the whole chunk is handed to next untouched.
46//  2. Otherwise, this chunk is scanned only up to the first byte that either
47//     starts the opening marker in full or could be the start of a marker
48//     split across this chunk's end (pasteMarkerBoundary); everything from
49//     that point onward is handed to next as one piece, unscanned, letting
50//     the coalescer's own state decide what happens to it (and to any
51//     bytes trailing an in-chunk paste — see the package-level note below).
52func (c *clipboardIntercept) Scan(data []byte) {
53	if c.coalescer.Buffering() {
54		c.next(data)
55		return
56	}
57
58	boundary := pasteMarkerBoundary(data)
59	segment := data[:boundary]
60	for len(segment) > 0 {
61		idx := bytes.IndexByte(segment, ctrlV)
62		if idx < 0 {
63			c.next(segment)
64			break
65		}
66		if idx > 0 {
67			c.next(segment[:idx])
68		}
69		segment = segment[idx+1:]
70		c.handleCtrlV()
71	}
72
73	// Bytes at/after boundary are (the possible start of) a bracketed
74	// paste: handed off whole, so any Ctrl+V trailing a paste that closes
75	// within this same chunk is not intercepted either. In practice a real
76	// keypress never arrives concatenated with paste bytes in one read, so
77	// this is a deliberate, narrow trade-off in favor of never breaking
78	// paste content.
79	if tail := data[boundary:]; len(tail) > 0 {
80		c.next(tail)
81	}
82}
83
84// pasteMarkerBoundary returns the index in data at or after which bytes must
85// not be scanned for Ctrl+V because they start (or, at the very end of data,
86// might be a split-across-reads prefix of) the bracketed-paste opening
87// marker. It returns len(data) when neither is present.
88func pasteMarkerBoundary(data []byte) int {
89	if idx := bytes.Index(data, ports.BracketedPasteOpenMarker); idx >= 0 {
90		return idx
91	}
92	if p := trailingOpenPrefixLen(data); p > 0 {
93		return len(data) - p
94	}
95	return len(data)
96}
97
98func (c *clipboardIntercept) handleCtrlV() {
99	mime, data, err := c.reader.ReadImage(context.Background())
100	if err != nil {
101		if !errors.Is(err, ports.ErrNoClipboardImage) {
102			c.log.Warn("clipboard image read failed", "err", err)
103			if c.sendNotice != nil {
104				c.sendNotice(ports.ClientNoticeClipboardFallback)
105			}
106		}
107		c.next([]byte{ctrlV})
108		return
109	}
110	if len(data) > maxClipboardImagePush {
111		c.log.Warn("clipboard image too large to send, forwarding Ctrl+V instead", "size", len(data), "cap", maxClipboardImagePush)
112		if c.sendNotice != nil {
113			c.sendNotice(ports.ClientNoticeClipboardTooLarge)
114		}
115		c.next([]byte{ctrlV})
116		return
117	}
118	c.sendImage(mime, data)
119}

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