v0.3.38 1e4a8c4

clipboard_darwin.go RawHistoryFolder

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

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