v0.26.0 7e66a81

clipboard_darwin.go RawHistoryFolder

1//go:build darwin
2
3package tui
4
5import (
6	"context"
7	"crypto/rand"
8	"encoding/hex"
9	"errors"
10	"fmt"
11	"image/png"
12	"io"
13	"os"
14	"os/exec"
15	"path/filepath"
16	"strings"
17	"time"
18
19	"golang.org/x/image/tiff"
20)
21
22var errClipboardImageTooLarge = errors.New("clipboard image exceeds size limit")
23
24const readClipboardImageScript = `
25on run argv
26	set outPath to item 1 of argv
27	try
28		set imgData to the clipboard as «class PNGf»
29		set imgKind to "png"
30	on error
31		try
32			set imgData to the clipboard as «class TIFF»
33			set imgKind to "tiff"
34		on error
35			return "NO_IMAGE"
36		end try
37	end try
38
39	set outFile to POSIX file outPath
40	set fileRef to open for access outFile with write permission
41	try
42		set eof of fileRef to 0
43		write imgData to fileRef
44		close access fileRef
45	on error errMsg number errNum
46		try
47			close access fileRef
48		end try
49		error errMsg number errNum
50	end try
51	return imgKind
52end run
53`
54
55func ReadClipboardImage(ctx context.Context) (ClipboardImage, bool, error) {
56	if ctx == nil {
57		ctx = context.Background()
58	}
59	ctx, cancel := context.WithTimeout(ctx, clipboardImageReadTimeout)
60	defer cancel()
61
62	path, data, ok, err := readClipboardImagePNG(ctx)
63	if err != nil {
64		return ClipboardImage{}, false, err
65	}
66	if !ok {
67		return ClipboardImage{}, false, nil
68	}
69	_ = removeClipboardImageFile(path)
70	return ClipboardImage{MimeType: "image/png", Data: data}, true, nil
71}
72
73func ReadClipboardImagePNG() (string, []byte, bool, error) {
74	return readClipboardImagePNG(context.Background())
75}
76
77func readClipboardImagePNG(ctx context.Context) (string, []byte, bool, error) {
78	dir := clipboardImageDir()
79	if err := os.MkdirAll(dir, 0o700); err != nil {
80		return "", nil, false, err
81	}
82	path := filepath.Join(dir, "clipboard-"+time.Now().Format("20060102-150405")+"-"+randomHex(4)+".png")
83	rawPath := path + ".raw"
84	defer os.Remove(rawPath)
85
86	kind, err := writeClipboardImageData(ctx, rawPath)
87	if err != nil {
88		return "", nil, false, err
89	}
90	if kind == "" {
91		return "", nil, false, nil
92	}
93
94	switch kind {
95	case "png":
96		if err := os.Rename(rawPath, path); err != nil {
97			return "", nil, false, err
98		}
99	case "tiff":
100		if err := convertTIFFFileToPNG(rawPath, path); err != nil {
101			return "", nil, false, err
102		}
103	default:
104		clipPath, ok := findClipboardImagePath(kind)
105		if !ok {
106			return "", nil, false, fmt.Errorf("unexpected clipboard image kind %q", kind)
107		}
108		if err := copyClipboardImageFileToPNG(clipPath, path); err != nil {
109			if err == errClipboardImageTooLarge {
110				return "", nil, false, nil
111			}
112			return "", nil, false, err
113		}
114	}
115
116	data, err := readClipboardImageFile(path)
117	if err == errClipboardImageTooLarge {
118		if err := removeClipboardImageFile(path); err != nil {
119			return "", nil, false, err
120		}
121		return "", nil, false, nil
122	}
123	if err != nil {
124		return "", nil, false, err
125	}
126	return path, data, true, nil
127}
128
129func writeClipboardImageData(ctx context.Context, path string) (string, error) {
130	cmd := exec.CommandContext(ctx, "/usr/bin/osascript", "-e", readClipboardImageScript, path)
131	out, err := cmd.CombinedOutput()
132	trimmed := strings.TrimSpace(string(out))
133	if err != nil {
134		if strings.Contains(trimmed, "NO_IMAGE") || strings.Contains(trimmed, "Can’t make") || strings.Contains(trimmed, "Can't make") {
135			return "", nil
136		}
137		if kind, ok := clipboardImageKind(trimmed); ok {
138			return kind, nil
139		}
140		if path, ok := findClipboardImagePath(trimmed); ok {
141			return path, nil
142		}
143		if trimmed == "" {
144			return "", fmt.Errorf("osascript failed: %w", err)
145		}
146		return "", fmt.Errorf("osascript failed: %s", trimmed)
147	}
148	if trimmed == "NO_IMAGE" {
149		return "", nil
150	}
151	if kind, ok := clipboardImageKind(trimmed); ok {
152		return kind, nil
153	}
154	return trimmed, nil
155}
156
157func clipboardImageKind(s string) (string, bool) {
158	for _, line := range strings.Split(s, "\n") {
159		switch strings.TrimSpace(line) {
160		case "png":
161			return "png", true
162		case "tiff":
163			return "tiff", true
164		}
165	}
166	return "", false
167}
168
169func readClipboardImageFile(path string) ([]byte, error) {
170	file, err := os.Open(path)
171	if err != nil {
172		return nil, err
173	}
174	defer file.Close()
175
176	data, err := io.ReadAll(io.LimitReader(file, int64(maxClipboardImageBytes)+1))
177	if err != nil {
178		return nil, err
179	}
180	if len(data) > maxClipboardImageBytes {
181		return nil, errClipboardImageTooLarge
182	}
183	return data, nil
184}
185
186func removeClipboardImageFile(path string) error {
187	if err := os.Remove(path); err != nil {
188		return fmt.Errorf("remove temporary clipboard image: %w", err)
189	}
190	return nil
191}
192
193func copyClipboardImageFileToPNG(srcPath, dstPath string) error {
194	switch strings.ToLower(filepath.Ext(srcPath)) {
195	case ".png":
196		data, err := readClipboardImageFile(srcPath)
197		if err != nil {
198			return err
199		}
200		return os.WriteFile(dstPath, data, 0o600)
201	case ".tif", ".tiff":
202		return convertTIFFFileToPNG(srcPath, dstPath)
203	default:
204		return fmt.Errorf("clipboard file is not a supported image type: %s", srcPath)
205	}
206}
207
208func findClipboardImagePath(s string) (string, bool) {
209	if p, ok := clipboardImagePath(s); ok {
210		return p, true
211	}
212	for _, ext := range []string{".png", ".tiff", ".tif"} {
213		lower := strings.ToLower(s)
214		end := strings.Index(lower, ext)
215		if end < 0 {
216			continue
217		}
218		end += len(ext)
219		start := strings.LastIndex(s[:end], "/")
220		if start < 0 {
221			continue
222		}
223		for start > 0 {
224			c := s[start-1]
225			if c == '\'' || c == '"' || c == '\n' || c == '\r' || c == '\t' {
226				break
227			}
228			start--
229		}
230		if p, ok := clipboardImagePath(s[start:end]); ok {
231			return p, true
232		}
233	}
234	return "", false
235}
236
237func clipboardImagePath(s string) (string, bool) {
238	p := strings.TrimSpace(s)
239	p = strings.Trim(p, "'\"")
240	if p == "" {
241		return "", false
242	}
243	info, err := os.Stat(p)
244	if err != nil || info.IsDir() {
245		return "", false
246	}
247	switch strings.ToLower(filepath.Ext(p)) {
248	case ".png", ".tif", ".tiff":
249		return p, true
250	default:
251		return "", false
252	}
253}
254
255func convertTIFFFileToPNG(srcPath, dstPath string) error {
256	in, err := os.Open(srcPath)
257	if err != nil {
258		return err
259	}
260	defer in.Close()
261
262	img, err := tiff.Decode(in)
263	if err != nil {
264		return err
265	}
266
267	out, err := os.Create(dstPath)
268	if err != nil {
269		return err
270	}
271	defer out.Close()
272
273	return png.Encode(out, img)
274}
275
276func clipboardImageDir() string {
277	if info, err := os.Stat("/tmp"); err == nil && info.IsDir() {
278		return filepath.Join("/tmp", "zut-clipboard-images")
279	}
280	return filepath.Join(os.TempDir(), "zut-clipboard-images")
281}
282
283func randomHex(n int) string {
284	buf := make([]byte, n)
285	if _, err := rand.Read(buf); err != nil {
286		return fmt.Sprintf("%d", time.Now().UnixNano())
287	}
288	return hex.EncodeToString(buf)
289}

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 1

Tags 476

↑↓ moveenter openesc close