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

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