v0.33.2 57f21d6

clipboard.go RawHistoryFolder

1// Package clipboard provides a clipboard adapter with Wayland/X11 preference and toolkit fallback.
2package clipboard
3
4import (
5	"bytes"
6	"context"
7	"fmt"
8	"os"
9	"os/exec"
10	"strings"
11
12	"github.com/bnema/dumber/internal/application/port"
13	"github.com/bnema/dumber/internal/domain/entity"
14	"github.com/bnema/dumber/internal/logging"
15	"github.com/bnema/puregotk/v4/gdk"
16	"github.com/bnema/puregotk/v4/gio"
17	"github.com/bnema/puregotk/v4/glib"
18	"github.com/rs/zerolog"
19)
20
21type toolkitClipboard interface {
22	ReadText(ctx context.Context) (string, error)
23	WriteText(ctx context.Context, text string) error
24	WriteImage(ctx context.Context, image entity.ImageData) error
25}
26
27// Adapter implements port.Clipboard using wl-clipboard on Wayland, then xclip/xsel on X11.
28// If no system clipboard tool is available, it falls back to the toolkit clipboard.
29type Adapter struct {
30	toolkit  toolkitClipboard
31	copyCmd  string
32	pasteCmd string
33}
34
35func (a *Adapter) ensureToolkitClipboard() toolkitClipboard {
36	if a == nil {
37		return nil
38	}
39	if a.toolkit == nil {
40		a.toolkit = newToolkitClipboard()
41	}
42	return a.toolkit
43}
44
45var (
46	lookPath            = exec.LookPath
47	commandContext      = exec.CommandContext
48	newToolkitClipboard = defaultToolkitClipboard
49)
50
51// New creates a new clipboard adapter.
52// Detects Wayland vs X11 and selects appropriate clipboard tool.
53func New() port.Clipboard {
54	a := &Adapter{toolkit: newToolkitClipboard()}
55	a.copyCmd, a.pasteCmd = selectSystemClipboard()
56	return a
57}
58
59func defaultToolkitClipboard() toolkitClipboard {
60	display := gdk.DisplayGetDefault()
61	if display == nil {
62		return nil
63	}
64
65	clipboard := display.GetClipboard()
66	if clipboard == nil {
67		return nil
68	}
69
70	return &gdkToolkitClipboard{clipboard: clipboard}
71}
72
73type gdkToolkitClipboard struct {
74	clipboard *gdk.Clipboard
75}
76
77func (c *gdkToolkitClipboard) WriteText(ctx context.Context, text string) error {
78	return withToolkitClipboard(ctx, func() error {
79		if c == nil || c.clipboard == nil {
80			return fmt.Errorf("toolkit clipboard not available")
81		}
82		c.clipboard.SetText(text)
83		return nil
84	})
85}
86
87func (c *gdkToolkitClipboard) ReadText(ctx context.Context) (string, error) {
88	var text string
89	err := withToolkitClipboard(ctx, func() error {
90		if c == nil || c.clipboard == nil {
91			return fmt.Errorf("toolkit clipboard not available")
92		}
93
94		resultCh := make(chan struct {
95			text string
96			err  error
97		}, 1)
98		cb := gio.AsyncReadyCallback(func(_ uintptr, result uintptr, _ uintptr) {
99			asyncResult := &gio.AsyncResultBase{}
100			asyncResult.SetGoPointer(result)
101			readText, readErr := c.clipboard.ReadTextFinish(asyncResult)
102			resultCh <- struct {
103				text string
104				err  error
105			}{text: readText, err: readErr}
106		})
107		c.clipboard.ReadTextAsync(nil, &cb, 0)
108
109		mainContext := glib.MainContextDefault()
110		if mainContext == nil {
111			return fmt.Errorf("toolkit main context not available")
112		}
113
114		for {
115			select {
116			case result := <-resultCh:
117				text = result.text
118				return result.err
119			default:
120			}
121			if ctx.Err() != nil {
122				return ctx.Err()
123			}
124			mainContext.Iteration(true)
125		}
126	})
127	return text, err
128}
129
130func (c *gdkToolkitClipboard) WriteImage(ctx context.Context, image entity.ImageData) error {
131	return withToolkitClipboard(ctx, func() error {
132		if c == nil || c.clipboard == nil {
133			return fmt.Errorf("toolkit clipboard not available")
134		}
135
136		texture, err := gdk.NewTextureFromBytes(glib.NewBytes(image.Bytes, uint(len(image.Bytes))))
137		if err != nil {
138			return err
139		}
140		if texture == nil {
141			return fmt.Errorf("toolkit clipboard returned nil texture")
142		}
143
144		c.clipboard.SetTexture(texture)
145		return nil
146	})
147}
148
149func withToolkitClipboard(ctx context.Context, fn func() error) error {
150	mainContext := glib.MainContextDefault()
151	if mainContext == nil || mainContext.IsOwner() {
152		return fn()
153	}
154
155	resultCh := make(chan error, 1)
156	cb := glib.SourceFunc(func(_ uintptr) bool {
157		resultCh <- fn()
158		return false
159	})
160	glib.IdleAdd(&cb, 0)
161	select {
162	case err := <-resultCh:
163		return err
164	case <-ctx.Done():
165		return ctx.Err()
166	}
167}
168
169func selectSystemClipboard() (copyCmd, pasteCmd string) {
170	// Check for Wayland first.
171	if os.Getenv("WAYLAND_DISPLAY") != "" {
172		if path, err := lookPath("wl-copy"); err == nil {
173			copyCmd = path
174			if pastePath, err := lookPath("wl-paste"); err == nil {
175				pasteCmd = pastePath
176			}
177		}
178	}
179
180	// Fall back to X11 if Wayland tools are not available.
181	if copyCmd == "" && os.Getenv("DISPLAY") != "" {
182		if path, err := lookPath("xclip"); err == nil {
183			copyCmd = path
184			pasteCmd = path
185		} else if path, err := lookPath("xsel"); err == nil {
186			copyCmd = path
187			pasteCmd = path
188		}
189	}
190
191	return copyCmd, pasteCmd
192}
193
194// WriteText copies text to the clipboard.
195func (a *Adapter) WriteText(ctx context.Context, text string) error {
196	log := logging.FromContext(ctx)
197	var commandErr error
198	var toolkitErr error
199	if a.copyCmd != "" {
200		err := a.writeTextWithCommand(ctx, text, log)
201		if err == nil {
202			return nil
203		}
204		commandErr = err
205	}
206	if toolkit := a.ensureToolkitClipboard(); toolkit != nil {
207		err := toolkit.WriteText(ctx, text)
208		if err == nil {
209			log.Debug().Str("backend", "toolkit").Int("len", len(text)).Msg("clipboard write success")
210			return nil
211		}
212		toolkitErr = err
213		log.Debug().Err(err).Msg("toolkit clipboard write failed; falling back")
214	}
215	if commandErr != nil {
216		return commandErr
217	}
218	if toolkitErr != nil {
219		log.Error().Err(toolkitErr).Msg("clipboard write failed")
220		return toolkitErr
221	}
222	err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")
223	log.Error().Err(err).Msg("clipboard write failed")
224	return err
225}
226
227// WriteImage copies image bytes to the clipboard.
228// xsel is not supported because the adapter has no reliable binary image mode
229// for it in this codebase.
230func (a *Adapter) WriteImage(ctx context.Context, image entity.ImageData) error {
231	log := logging.FromContext(ctx)
232	var commandErr error
233	var toolkitErr error
234
235	if len(image.Bytes) == 0 {
236		err := fmt.Errorf("empty image data")
237		log.Error().Err(err).Msg("clipboard image write failed")
238		return err
239	}
240	if toolkit := a.ensureToolkitClipboard(); toolkit != nil {
241		err := toolkit.WriteImage(ctx, image)
242		if err == nil {
243			log.Debug().
244				Str("backend", "toolkit").
245				Str("mime_type", image.MimeType).
246				Int("bytes", len(image.Bytes)).
247				Msg("clipboard image write success")
248			return nil
249		}
250		toolkitErr = err
251		log.Debug().Err(err).Msg("toolkit clipboard image write failed; falling back")
252	}
253	if a.copyCmd != "" {
254		err := a.writeImageWithCommand(ctx, image, log)
255		if err == nil {
256			return nil
257		}
258		commandErr = err
259	}
260	if commandErr != nil {
261		return commandErr
262	}
263	if toolkitErr != nil {
264		log.Error().Err(toolkitErr).Msg("clipboard image write failed")
265		return toolkitErr
266	}
267	err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")
268	log.Error().Err(err).Msg("clipboard image write failed")
269	return err
270}
271
272func (a *Adapter) writeTextWithCommand(ctx context.Context, text string, log *zerolog.Logger) error {
273	if a.copyCmd == "" {
274		err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")
275		log.Error().Err(err).Msg("clipboard write failed")
276		return err
277	}
278
279	var cmd *exec.Cmd
280	if strings.Contains(a.copyCmd, "wl-copy") {
281		cmd = commandContext(ctx, a.copyCmd)
282	} else if strings.Contains(a.copyCmd, "xclip") {
283		cmd = commandContext(ctx, a.copyCmd, "-selection", "clipboard")
284	} else if strings.Contains(a.copyCmd, "xsel") {
285		cmd = commandContext(ctx, a.copyCmd, "--clipboard", "--input")
286	} else {
287		err := fmt.Errorf("unknown clipboard tool: %s", a.copyCmd)
288		log.Error().Err(err).Msg("clipboard write failed")
289		return err
290	}
291
292	cmd.Stdin = strings.NewReader(text)
293	if err := cmd.Run(); err != nil {
294		err = fmt.Errorf("run clipboard tool %s: %w", a.copyCmd, err)
295		log.Error().Err(err).Str("tool", a.copyCmd).Msg("clipboard write failed")
296		return err
297	}
298
299	log.Debug().Str("tool", a.copyCmd).Int("len", len(text)).Msg("clipboard write success")
300	return nil
301}
302
303func (a *Adapter) writeImageWithCommand(ctx context.Context, image entity.ImageData, log *zerolog.Logger) error {
304	if a.copyCmd == "" {
305		err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")
306		log.Error().Err(err).Msg("clipboard image write failed")
307		return err
308	}
309	mimeType := image.MimeType
310	if mimeType == "" {
311		mimeType = "image/png"
312	}
313
314	var cmd *exec.Cmd
315	if strings.Contains(a.copyCmd, "wl-copy") {
316		cmd = commandContext(ctx, a.copyCmd, "--type", mimeType)
317	} else if strings.Contains(a.copyCmd, "xclip") {
318		cmd = commandContext(ctx, a.copyCmd, "-selection", "clipboard", "-t", mimeType, "-i")
319	} else {
320		err := fmt.Errorf("clipboard tool does not support image writes: %s", a.copyCmd)
321		log.Error().Err(err).Msg("clipboard image write failed")
322		return err
323	}
324
325	cmd.Stdin = bytes.NewReader(image.Bytes)
326	if err := cmd.Run(); err != nil {
327		err = fmt.Errorf("run clipboard tool %s: %w", a.copyCmd, err)
328		log.Error().Err(err).Str("tool", a.copyCmd).Msg("clipboard image write failed")
329		return err
330	}
331
332	log.Debug().Str("tool", a.copyCmd).Int("bytes", len(image.Bytes)).Msg("clipboard image write success")
333	return nil
334}
335
336func (a *Adapter) readTextWithCommand(ctx context.Context, log *zerolog.Logger) (string, error) {
337	if a.pasteCmd == "" {
338		err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")
339		log.Error().Err(err).Msg("clipboard read failed")
340		return "", err
341	}
342
343	var cmd *exec.Cmd
344	if strings.Contains(a.pasteCmd, "wl-paste") {
345		cmd = commandContext(ctx, a.pasteCmd, "--no-newline")
346	} else if strings.Contains(a.pasteCmd, "xclip") {
347		cmd = commandContext(ctx, a.pasteCmd, "-selection", "clipboard", "-o")
348	} else if strings.Contains(a.pasteCmd, "xsel") {
349		cmd = commandContext(ctx, a.pasteCmd, "--clipboard", "--output")
350	} else {
351		err := fmt.Errorf("unknown clipboard tool: %s", a.pasteCmd)
352		log.Error().Err(err).Msg("clipboard read failed")
353		return "", err
354	}
355
356	out, err := cmd.Output()
357	if err != nil {
358		err = fmt.Errorf("run clipboard tool %s: %w", a.pasteCmd, err)
359		log.Debug().Err(err).Str("tool", a.pasteCmd).Msg("clipboard read failed")
360		return "", err
361	}
362
363	log.Debug().Str("tool", a.pasteCmd).Int("len", len(out)).Msg("clipboard read success")
364	return string(out), nil
365}
366
367// ReadText reads text from the clipboard.
368func (a *Adapter) ReadText(ctx context.Context) (string, error) {
369	log := logging.FromContext(ctx)
370	var commandErr error
371
372	if a.pasteCmd != "" {
373		text, err := a.readTextWithCommand(ctx, log)
374		if err == nil {
375			return text, nil
376		}
377		commandErr = err
378		log.Debug().Err(err).Msg("system clipboard read failed; falling back")
379	}
380
381	if toolkit := a.ensureToolkitClipboard(); toolkit != nil {
382		text, err := toolkit.ReadText(ctx)
383		if err == nil {
384			log.Debug().Str("backend", "toolkit").Int("len", len(text)).Msg("clipboard read success")
385			return text, nil
386		}
387		log.Debug().Err(err).Msg("toolkit clipboard read failed")
388		if commandErr != nil {
389			return "", commandErr
390		}
391		return "", err
392	}
393
394	if commandErr != nil {
395		return "", commandErr
396	}
397	err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")
398	log.Error().Err(err).Msg("clipboard read failed")
399	return "", err
400}
401
402// Clear clears the clipboard contents.
403func (a *Adapter) Clear(ctx context.Context) error {
404	return a.WriteText(ctx, "")
405}
406
407// HasText returns true if the clipboard contains text data.
408func (a *Adapter) HasText(ctx context.Context) (bool, error) {
409	text, err := a.ReadText(ctx)
410	if err != nil {
411		// Empty clipboard often returns error, treat as no text
412		return false, nil
413	}
414	return text != "", nil
415}

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 8

Tags 42

↑↓ moveenter openesc close