v0.3.0 ea9abc7

destination.go RawHistoryFolder

1// Package destination chooses default capture locations.
2//
3// Screenshot and Video return a fresh, timestamped file path inside the user's
4// default directory, which exists on return. The path is only a proposal: it is not
5// reserved, so the writer must still create the file with O_EXCL (all capture
6// writers do) and report a conflict instead of overwriting.
7package destination
8
9import (
10	"bufio"
11	"errors"
12	"fmt"
13	"io"
14	"os"
15	"path/filepath"
16	"strings"
17	"time"
18)
19
20const (
21	dirMode = 0o700
22	// stampLayout has nanosecond precision so two captures started in the
23	// same second get different names.
24	stampLayout = "20060102-150405.000000000"
25	prefix      = "nefercap-"
26	maxPath     = 4096
27	maxConfig   = 64 << 10 // user-dirs.dirs is a handful of short lines
28)
29
30// ErrNoHome means no absolute home directory is available for a default.
31var ErrNoHome = errors.New("destination: no absolute home directory")
32
33// Screenshot returns a new .png path in the user's XDG pictures directory
34// (Screenshots subdirectory), falling back to $HOME/Pictures/Screenshots.
35func Screenshot() (string, error) { return ScreenshotIn("") }
36
37// ScreenshotIn is Screenshot inside dir, an absolute directory that is created
38// when missing. An empty dir selects the XDG default.
39func ScreenshotIn(dir string) (string, error) {
40	if dir != "" {
41		return propose(".png", func() (string, error) { return dir, nil })
42	}
43	return propose(".png", func() (string, error) {
44		return userDir("XDG_PICTURES_DIR", "Pictures", "Screenshots")
45	})
46}
47
48// Video returns a new .mp4 path in the user's XDG videos directory, falling
49// back to $HOME/Videos.
50func Video() (string, error) { return VideoIn("") }
51
52// VideoIn is Video inside dir, an absolute directory that is created when
53// missing. An empty dir selects the XDG default.
54func VideoIn(dir string) (string, error) {
55	if dir != "" {
56		return propose(".mp4", func() (string, error) { return dir, nil })
57	}
58	return propose(".mp4", func() (string, error) {
59		return userDir("XDG_VIDEOS_DIR", "Videos")
60	})
61}
62
63func propose(ext string, auto func() (string, error)) (string, error) {
64	dir, err := auto()
65	if err != nil {
66		return "", err
67	}
68	if err := os.MkdirAll(dir, dirMode); err != nil {
69		return "", fmt.Errorf("destination: create directory: %w", err)
70	}
71	fi, err := os.Stat(dir)
72	if err != nil {
73		return "", fmt.Errorf("destination: stat directory: %w", err)
74	}
75	if !fi.IsDir() {
76		return "", fmt.Errorf("destination: %w: not a directory", os.ErrInvalid)
77	}
78	return filepath.Join(dir, prefix+time.Now().Format(stampLayout)+ext), nil
79}
80
81// userDir resolves the XDG user directory named by key: the environment
82// variable first, then user-dirs.dirs, then $HOME/<fallback...>. Elements of
83// fallback after the first (Screenshots) are also appended to an XDG-provided
84// directory.
85func userDir(key string, fallback ...string) (string, error) {
86	home, err := os.UserHomeDir()
87	if err != nil || !filepath.IsAbs(home) {
88		return "", ErrNoHome
89	}
90	home = filepath.Clean(home)
91	sub := fallback[1:]
92
93	if d, ok := expand(os.Getenv(key), home); ok {
94		return filepath.Join(append([]string{d}, sub...)...), nil
95	}
96	if d, ok := expand(fromConfig(key, home), home); ok {
97		return filepath.Join(append([]string{d}, sub...)...), nil
98	}
99	return filepath.Join(append([]string{home}, fallback...)...), nil
100}
101
102// expand accepts an absolute path, optionally starting with $HOME or ${HOME}.
103// No shell, no other variables, no command substitution. The home directory
104// itself is rejected: user-dirs uses it to mean "disabled".
105func expand(v, home string) (string, bool) {
106	if v == "" || len(v) > maxPath || strings.IndexByte(v, 0) >= 0 {
107		return "", false
108	}
109	switch {
110	case v == "$HOME" || strings.HasPrefix(v, "$HOME/"):
111		v = home + v[len("$HOME"):]
112	case v == "${HOME}" || strings.HasPrefix(v, "${HOME}/"):
113		v = home + v[len("${HOME}"):]
114	}
115	if strings.ContainsAny(v, "$`") || !filepath.IsAbs(v) {
116		return "", false
117	}
118	v = filepath.Clean(v)
119	if v == home || v == string(filepath.Separator) {
120		return "", false
121	}
122	return v, true
123}
124
125// fromConfig reads KEY="value" from the user-dirs.dirs file, bounded in size.
126func fromConfig(key, home string) string {
127	cfg := os.Getenv("XDG_CONFIG_HOME")
128	if !filepath.IsAbs(cfg) {
129		cfg = filepath.Join(home, ".config")
130	}
131	f, err := os.Open(filepath.Join(cfg, "user-dirs.dirs"))
132	if err != nil {
133		return ""
134	}
135	defer f.Close()
136	sc := bufio.NewScanner(io.LimitReader(f, maxConfig))
137	sc.Buffer(make([]byte, 0, 1024), maxPath+256)
138	var val string
139	for sc.Scan() {
140		line := strings.TrimSpace(sc.Text())
141		k, v, ok := strings.Cut(line, "=")
142		if !ok || strings.TrimSpace(k) != key {
143			continue
144		}
145		val = unquote(strings.TrimSpace(v)) // the last assignment wins
146	}
147	return val
148}
149
150// unquote handles the double-quoted, backslash-escaped form written by
151// xdg-user-dirs-update, and bare values.
152func unquote(v string) string {
153	if len(v) < 2 || v[0] != '"' {
154		return v
155	}
156	var b strings.Builder
157	for i := 1; i < len(v); i++ {
158		switch c := v[i]; c {
159		case '\\':
160			if i+1 < len(v) {
161				i++
162				b.WriteByte(v[i])
163			}
164		case '"':
165			return b.String()
166		default:
167			b.WriteByte(c)
168		}
169	}
170	return "" // unterminated
171}

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 2

Tags 9

↑↓ moveenter openesc close