v0.3.0 99dc266

clipboard.go RawHistoryFolder

1// Package daemon holds vev's server-side session multiplexer use case: the
2// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3// and VT screen, and the per-client debounced render coordinator.
4package daemon
5
6import (
7	"errors"
8	"os"
9
10	"github.com/bnema/vev/internal/ports"
11	scopy "github.com/bnema/vev/internal/usecase/copy"
12)
13
14// maxImagePushSize independently caps an accepted MsgImagePush payload,
15// defending against an old or foreign client even though the client is
16// expected to enforce the same cap before sending. Kept at 1 MiB so one
17// ImagePush fits the datagram transport's fragmented payload ceiling.
18const maxImagePushSize = 1 << 20 // 1 MiB
19
20// Bracketed-paste markers used to wrap the injected clip path when the
21// focused pane has DEC private mode 2004 enabled. Mirrors the client's
22// pasteOpenMarker/pasteCloseMarker (internal/usecase/client), kept as a
23// separate copy since the daemon has no dependency on the client package.
24var (
25	clipPasteOpenMarker  = []byte("\x1b[200~")
26	clipPasteCloseMarker = []byte("\x1b[201~")
27)
28
29func (d *Daemon) handleSequencedImagePushForAttachment(token attachmentConnectionToken, _ uint64, ip ports.ImagePush) {
30	if !token.attachmentEffectCurrent() {
31		return
32	}
33	d.handleImagePushForAttachment(token, ip)
34}
35
36func (d *Daemon) handleImagePushForAttachment(token attachmentConnectionToken, ip ports.ImagePush) {
37	if len(ip.Data) == 0 || len(ip.Data) > maxImagePushSize || !token.attachmentEffectCurrent() {
38		return
39	}
40	path, err := d.writeClipboardImageForAttachment(token, ip)
41	if err != nil {
42		d.log.Error("writing clipboard image failed", "err", err)
43		return
44	}
45	if !token.attachmentEffectCurrent() {
46		_ = os.Remove(path)
47		return
48	}
49	d.injectClipboardPathForAttachment(token, path)
50}
51
52// handleImagePush writes an ImagePush's bytes to a temp file and injects the
53// file's path into the session's focused pane, as if typed/pasted there.
54// Oversized or empty payloads are rejected without panicking. A write
55// failure is logged and the push is dropped: the client has already
56// consumed the Ctrl+V keystroke that triggered it, so there is nothing left
57// to re-trigger from here.
58func (d *Daemon) handleImagePush(sess *session, ip ports.ImagePush) {
59	if len(ip.Data) == 0 {
60		return
61	}
62	if len(ip.Data) > maxImagePushSize {
63		d.log.Warn("rejected oversized clipboard image push", "size", len(ip.Data), "cap", maxImagePushSize)
64		return
65	}
66	path, err := d.writeClipboardImage(sess, ip)
67	if err != nil {
68		d.log.Error("writing clipboard image failed", "err", err)
69		return
70	}
71	d.injectClipboardPath(sess, path)
72}
73
74// writeClipboardImage writes ip's bytes to a new 0600 temp file and records
75// the path on sess for best-effort cleanup at session end (killSession).
76func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string, error) {
77	path, err := d.createClipboardImage(ip)
78	if err != nil {
79		return "", err
80	}
81	sess.mu.Lock()
82	sess.clipFiles = append(sess.clipFiles, path)
83	sess.mu.Unlock()
84	return path, nil
85}
86
87func (d *Daemon) writeClipboardImageForAttachment(token attachmentConnectionToken, ip ports.ImagePush) (string, error) {
88	path, err := d.createClipboardImage(ip)
89	if err != nil {
90		return "", err
91	}
92	sess := token.sess
93	if sess == nil {
94		_ = os.Remove(path)
95		return "", errAttachmentTransition
96	}
97	sess.mu.Lock()
98	if !token.attachmentEffectCurrentSessionLocked() {
99		sess.mu.Unlock()
100		_ = os.Remove(path)
101		return "", errAttachmentTransition
102	}
103	sess.clipFiles = append(sess.clipFiles, path)
104	sess.mu.Unlock()
105	return path, nil
106}
107
108func (d *Daemon) createClipboardImage(ip ports.ImagePush) (string, error) {
109	dir := d.tempDir
110	if dir == "" {
111		dir = os.TempDir()
112	}
113	f, err := os.CreateTemp(dir, "vev-clip-*."+clipboardExt(ip.Mime))
114	if err != nil {
115		return "", err
116	}
117	path := f.Name()
118	if _, err := f.Write(ip.Data); err != nil {
119		_ = f.Close()
120		_ = os.Remove(path)
121		return "", err
122	}
123	if err := f.Close(); err != nil {
124		_ = os.Remove(path)
125		return "", err
126	}
127	return path, nil
128}
129
130// injectClipboardPath writes path's text into sess's focused pane, through
131// the same PTY write path as ordinary input, wrapped in bracketed-paste
132// markers iff the pane's Screen reports bracketed-paste mode enabled.
133func (d *Daemon) injectClipboardPath(sess *session, path string) {
134	d.injectClipboardPathToTarget(sess, path, nil)
135}
136
137func (d *Daemon) injectClipboardPathForAttachment(token attachmentConnectionToken, path string) {
138	sess := token.sess
139	if sess == nil || !token.attachmentEffectCurrent() {
140		return
141	}
142	d.injectClipboardPathToTarget(sess, path, &token)
143}
144
145func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, token *attachmentConnectionToken) {
146	var ac *attachedClient
147	if token != nil {
148		ac = token.ac
149	}
150	var tb *tab
151	if ac != nil {
152		tb = sess.tabForAttachment(ac)
153	} else {
154		tb = sess.firstTab()
155	}
156	if tb == nil {
157		return
158	}
159	tb.mu.Lock()
160	p := tb.terminalTargetLocked()
161	tb.mu.Unlock()
162	if p == nil {
163		return
164	}
165
166	p.mu.Lock()
167	bracketed := p.screen.BracketedPasteMode()
168	p.mu.Unlock()
169
170	data := []byte(path)
171	if bracketed {
172		wrapped := make([]byte, 0, len(clipPasteOpenMarker)+len(data)+len(clipPasteCloseMarker))
173		wrapped = append(wrapped, clipPasteOpenMarker...)
174		wrapped = append(wrapped, data...)
175		wrapped = append(wrapped, clipPasteCloseMarker...)
176		data = wrapped
177	}
178	if token != nil && !token.attachmentEffectCurrent() {
179		return
180	}
181	d.writeToPane(sess, p, data)
182}
183
184// clipboardExt maps an ImagePush MIME type to a filename extension. Unknown
185// types fall back to "bin" rather than rejecting the push: the daemon copies
186// bytes as-is regardless (see the design doc's "no image conversion" scope
187// decision).
188func clipboardExt(mime string) string {
189	switch mime {
190	case "image/png":
191		return "png"
192	case "image/jpeg":
193		return "jpg"
194	case "image/webp":
195		return "webp"
196	case "image/gif":
197		return "gif"
198	default:
199		return "bin"
200	}
201}
202
203type clipboardForward struct {
204	owner paneEffectLease
205	token attachmentConnectionToken
206	seq   []byte
207}
208
209// forwardClipboardAsync re-emits an app-originated OSC 52 clipboard set
210// request (already captured off the pane's screen while ptyReader held pane.mu,
211// then handed here after that lock is released) to the session's attached
212// client, if any. Invalid base64 or an oversized decoded payload is dropped
213// silently, matching copy mode's own cap (scopy.OSC52MaxPayloadBytes). The
214// caller must not hold pane.mu, tab.mu, or session.mu.
215//
216// Requests are queued on the session and drained by one worker so clipboard
217// writes keep arrival order without making the PTY reader wait on client I/O.
218func (d *Daemon) forwardClipboardAsync(owner paneEffectLease, b64 string) {
219	seq := scopy.OSC52FromBase64(b64)
220	if seq == nil || !owner.Current() {
221		return
222	}
223	sess := owner.owner.session
224
225	sess.mu.Lock()
226	if sess.ctx != nil {
227		select {
228		case <-sess.ctx.Done():
229			sess.mu.Unlock()
230			return
231		default:
232		}
233	}
234	attachments := sess.snapshotAttachmentsLocked()
235	sess.mu.Unlock()
236
237	items := make([]clipboardForward, 0, len(attachments))
238	for _, ac := range attachments {
239		transport := ac.transport()
240		token := sess.attachmentToken(ac, transport)
241		if token.attachmentCurrent() {
242			items = append(items, clipboardForward{owner: owner, token: token, seq: seq})
243		}
244	}
245	if len(items) == 0 {
246		return
247	}
248	sess.mu.Lock()
249	sess.clipboardQueue = append(sess.clipboardQueue, items...)
250	if !sess.clipboardWorkerRunning {
251		sess.clipboardWorkerRunning = true
252		go d.clipboardWorker(sess)
253	}
254	sess.mu.Unlock()
255}
256
257func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *attachmentEffectTicket) (ports.Transport, error) {
258	token := item.token
259	if token.ac == nil || ticket == nil || ticket.ended.Load() || token.transport.transport == nil {
260		return token.transport.transport, errAttachmentTransition
261	}
262	expected := token.transport
263	ownedTransport, owned := expected.transport.(ports.OwnedSynchronousTransport)
264	send := func() error {
265		ac := token.ac
266		ac.sendMu.Lock()
267		defer ac.sendMu.Unlock()
268		if ticket.ended.Load() || !ac.transportSnapshotCurrent(expected) {
269			return errAttachmentTransition
270		}
271		if !beginClipboardOwnerSend(item.owner, ticket, expected) {
272			return errAttachmentTransition
273		}
274		ac.output.lockView()
275		defer ac.output.unlockView()
276		frame, err := ac.output.sideEffectLocked(item.seq, ac.echoAck.Load())
277		if err == nil {
278			if owned {
279				err = ownedTransport.SendSynchronous(frame)
280			} else {
281				err = expected.transport.Send(frame)
282			}
283		}
284		if err != nil {
285			ticket.reportTransportFailure(expected)
286		}
287		ticket.endTransportSend()
288		return err
289	}
290	if owned {
291		return expected.transport, send()
292	}
293	return d.boundedSendWith(expected.transport, send)
294}
295
296// beginClipboardOwnerSend validates the source pane generation after sendMu
297// admission and marks the transport interval while ownership publication is
298// excluded. Publication therefore orders either before this check (the send is
299// dropped) or after transport admission (the send belongs to the old owner).
300func beginClipboardOwnerSend(lease paneEffectLease, ticket *attachmentEffectTicket, expected transportSnapshot) bool {
301	if lease.pane == nil || lease.owner == nil || lease.owner.session == nil || lease.owner.tab == nil {
302		return false
303	}
304	owner := lease.owner
305	if owner.floatingSlotGeneration != 0 {
306		owner.tab.mu.Lock()
307		defer owner.tab.mu.Unlock()
308	}
309	lease.pane.mu.Lock()
310	defer lease.pane.mu.Unlock()
311	if lease.pane.owner.Load() != owner {
312		return false
313	}
314	if owner.floatingSlotGeneration != 0 &&
315		(owner.tab.floating.pane != lease.pane || owner.tab.floating.generation != owner.floatingSlotGeneration ||
316			(owner.tab.floating.state != floatingHidden && owner.tab.floating.state != floatingVisible)) {
317		return false
318	}
319	return ticket.beginTransportSend(expected)
320}
321
322func (d *Daemon) clipboardWorker(sess *session) {
323	for {
324		item, ok := nextClipboardForward(sess)
325		if !ok {
326			return
327		}
328		if !item.owner.Current() {
329			continue
330		}
331		ticket, admitted := item.token.ac.beginAttachmentEffect(item.token)
332		if !admitted {
333			continue
334		}
335		failed, err := d.boundedSendClipboardForward(item, ticket)
336		if errors.Is(err, errSendTimedOut) {
337			_ = item.token.ac.closeCapturedTransport(failed)
338		}
339		ticket.End()
340		if err != nil && item.owner.Current() {
341			d.detachOnAttachmentSendError(item.token, failed)
342		}
343	}
344}
345
346func nextClipboardForward(sess *session) (clipboardForward, bool) {
347	sess.mu.Lock()
348	defer sess.mu.Unlock()
349	if sess.ctx != nil {
350		select {
351		case <-sess.ctx.Done():
352			sess.clipboardQueue = nil
353			sess.clipboardWorkerRunning = false
354			return clipboardForward{}, false
355		default:
356		}
357	}
358	if len(sess.clipboardQueue) == 0 {
359		sess.clipboardWorkerRunning = false
360		return clipboardForward{}, false
361	}
362	item := sess.clipboardQueue[0]
363	copy(sess.clipboardQueue, sess.clipboardQueue[1:])
364	var zero clipboardForward
365	sess.clipboardQueue[len(sess.clipboardQueue)-1] = zero
366	sess.clipboardQueue = sess.clipboardQueue[:len(sess.clipboardQueue)-1]
367	return item, true
368}

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