v0.1.0 7deb448

wlpaste.go RawHistoryFolder

1//go:build linux
2
3// Package clipboard implements ports.ClipboardReader over Wayland's
4// wl-clipboard CLI (wl-paste). It is the client-side seam that lets a remote
5// attach forward a locally clipped image to the daemon (see
6// docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md); an
7// xclip or OSC-52 backend could implement the same interface later without
8// touching the wire protocol.
9package clipboard
10
11import (
12	"bytes"
13	"context"
14	"os/exec"
15	"strings"
16	"time"
17
18	"github.com/bnema/vev/internal/ports"
19)
20
21// mimePreference orders candidate image MIME types the same way pi (the
22// reference TUI agent whose local Ctrl+V behavior this replicates) does:
23// png first, then the other common web/image formats.
24var mimePreference = []string{"image/png", "image/jpeg", "image/webp", "image/gif"}
25
26// listTypesTimeout bounds the "what's on the clipboard" probe so a hung or
27// missing wl-paste never stalls the stdin pump for long.
28const listTypesTimeout = 1 * time.Second
29
30// defaultReadTimeout bounds the actual clipboard data read. Without this, a
31// wl-paste that hangs mid-read (as opposed to during the --list-types probe,
32// which listTypesTimeout already covers) would stall the client's stdin pump
33// indefinitely, since ReadImage is called with context.Background() from the
34// interceptor. Keep this short so a slow clipboard read cannot noticeably
35// freeze typing for longer than the image-transfer MVP's small payload cap.
36const defaultReadTimeout = time.Second
37
38// WlPaste implements ports.ClipboardReader by shelling out to wl-paste. It
39// holds no state and is safe for concurrent use; every call runs a fresh
40// subprocess.
41type WlPaste struct {
42	// execCommand constructs the command to run; overridable in tests so
43	// command construction can be verified without requiring wl-paste (or
44	// even Wayland) to be installed.
45	execCommand func(ctx context.Context, name string, args ...string) *exec.Cmd
46	// readTimeout bounds the data-read command; zero means defaultReadTimeout
47	// (a zero-value WlPaste{execCommand: ...}, as tests construct directly,
48	// still gets a sane default). Tests needing a short deadline set it
49	// explicitly.
50	readTimeout time.Duration
51}
52
53// New returns a WlPaste clipboard reader that shells out to the real
54// wl-paste binary.
55func New() ports.ClipboardReader {
56	return &WlPaste{execCommand: exec.CommandContext, readTimeout: defaultReadTimeout}
57}
58
59func (w *WlPaste) readTimeoutOrDefault() time.Duration {
60	if w.readTimeout <= 0 {
61		return defaultReadTimeout
62	}
63	return w.readTimeout
64}
65
66// ReadImage implements ports.ClipboardReader. It lists the clipboard's
67// offered MIME types, picks the best supported image type (if any), and
68// reads it. Any failure (wl-paste missing, no image on the clipboard, a
69// read error or timeout) is reported as ports.ErrNoClipboardImage so callers
70// uniformly fall back to forwarding the keystroke that triggered the read.
71func (w *WlPaste) ReadImage(ctx context.Context) (string, []byte, error) {
72	listCtx, cancel := context.WithTimeout(ctx, listTypesTimeout)
73	defer cancel()
74
75	listCmd := w.execCommand(listCtx, "wl-paste", "--list-types")
76	out, err := listCmd.Output()
77	if err != nil {
78		return "", nil, ports.ErrNoClipboardImage
79	}
80
81	mime := pickImageMime(string(out))
82	if mime == "" {
83		return "", nil, ports.ErrNoClipboardImage
84	}
85
86	readCtx, readCancel := context.WithTimeout(ctx, w.readTimeoutOrDefault())
87	defer readCancel()
88
89	readCmd := w.execCommand(readCtx, "wl-paste", "--type", mime, "--no-newline")
90	var buf bytes.Buffer
91	readCmd.Stdout = &buf
92	if err := readCmd.Run(); err != nil {
93		return "", nil, ports.ErrNoClipboardImage
94	}
95	return mime, buf.Bytes(), nil
96}
97
98// pickImageMime scans wl-paste --list-types output (one MIME type per line)
99// and returns the first match in mimePreference order, or "" if the
100// clipboard offers no supported image type.
101func pickImageMime(listTypesOutput string) string {
102	offered := make(map[string]bool)
103	for _, line := range strings.Split(listTypesOutput, "\n") {
104		line = strings.TrimSpace(line)
105		if line != "" {
106			offered[line] = true
107		}
108	}
109	for _, mime := range mimePreference {
110		if offered[mime] {
111			return mime
112		}
113	}
114	return ""
115}

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