v0.7.0 4c16dd8

bar_script.go RawHistoryFolder

1package daemon
2
3import (
4	"bytes"
5	"context"
6	"errors"
7	"fmt"
8	"strconv"
9	"strings"
10	"time"
11	"unicode/utf8"
12
13	"github.com/bnema/vev/internal/ports"
14)
15
16const (
17	barScriptOutputLimit  = 1024
18	barScriptStderrLimit  = 512
19	barScriptDisplayLimit = 256
20	barScriptTimeout      = time.Second
21)
22
23type barScriptContext struct {
24	Anchor  string
25	Session string
26	Tab     string
27	Pane    string
28	PaneCWD string
29	Cols    int
30	// ClientPID is a local attached client, 0 when only remote clients are
31	// attached. It is exported empty when 0.
32	ClientPID uint32
33}
34
35func (c barScriptContext) env(base []string) []string {
36	vars := map[string]string{
37		"VEV_ANCHOR":     c.Anchor,
38		"VEV_SESSION":    c.Session,
39		"VEV_TAB":        c.Tab,
40		"VEV_PANE":       c.Pane,
41		"VEV_PANE_CWD":   c.PaneCWD,
42		"VEV_COLS":       strconv.Itoa(c.Cols),
43		"VEV_CLIENT_PID": "",
44	}
45	if c.ClientPID != 0 {
46		vars["VEV_CLIENT_PID"] = strconv.FormatUint(uint64(c.ClientPID), 10)
47	}
48	out := make([]string, 0, len(base)+len(vars))
49	for _, entry := range base {
50		key, _, ok := strings.Cut(entry, "=")
51		if ok {
52			if _, replace := vars[key]; replace {
53				continue
54			}
55		}
56		out = append(out, entry)
57	}
58	for _, key := range []string{"VEV_ANCHOR", "VEV_SESSION", "VEV_TAB", "VEV_PANE", "VEV_PANE_CWD", "VEV_COLS", "VEV_CLIENT_PID"} {
59		out = append(out, key+"="+vars[key])
60	}
61	return out
62}
63
64type barScriptRunner struct {
65	runner  ports.ShellCommandRunner
66	timeout time.Duration
67}
68
69func (r barScriptRunner) run(ctx context.Context, command string, env []string, scriptCtx barScriptContext) (string, error) {
70	if strings.TrimSpace(command) == "" {
71		return "", nil
72	}
73	if r.runner == nil {
74		return "", errors.New("bar script command runner is nil")
75	}
76	timeout := r.timeout
77	if timeout <= 0 {
78		timeout = barScriptTimeout
79	}
80	ctx, cancel := context.WithTimeout(ctx, timeout)
81	defer cancel()
82
83	res, err := r.runner.Run(ctx, ports.CommandSpec{
84		Command:     command,
85		Env:         scriptCtx.env(env),
86		Timeout:     timeout,
87		StdoutLimit: barScriptOutputLimit,
88		StderrLimit: barScriptStderrLimit,
89	})
90	if errors.Is(ctx.Err(), context.DeadlineExceeded) {
91		return "", context.DeadlineExceeded
92	}
93	text := sanitizeBarScriptOutput(res.Stdout, barScriptOutputLimit)
94	if err != nil {
95		return text, &barScriptError{
96			exitCode: res.ExitCode,
97			stderr:   sanitizeBarScriptOutput(res.Stderr, barScriptStderrLimit),
98			err:      err,
99		}
100	}
101	return text, nil
102}
103
104func sanitizeBarScriptOutput(raw []byte, limit int) string {
105	if limit <= 0 || limit > len(raw) {
106		limit = len(raw)
107	}
108	raw = raw[:limit]
109	if i := bytes.IndexAny(raw, "\r\n"); i >= 0 {
110		raw = raw[:i]
111	}
112	text := strings.ToValidUTF8(string(raw), "")
113	text = stripANSIAndControls(text)
114	if len(text) <= barScriptDisplayLimit {
115		return text
116	}
117	return trimUTF8Bytes(text, barScriptDisplayLimit)
118}
119
120func stripANSIAndControls(s string) string {
121	var out strings.Builder
122	for i := 0; i < len(s); {
123		r, size := utf8.DecodeRuneInString(s[i:])
124		switch {
125		case r == '\x1b':
126			i = skipEscapeSequence(s, i+size)
127			continue
128		case r == 0x9b:
129			i = skipCSI(s, i+size)
130			continue
131		case r == 0x90 || r == 0x98 || r == 0x9d || r == 0x9e || r == 0x9f:
132			i = skipStringControl(s, i+size)
133			continue
134		case r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f):
135			i += size
136			continue
137		}
138		out.WriteRune(r)
139		i += size
140	}
141	return out.String()
142}
143
144func skipEscapeSequence(s string, i int) int {
145	if i >= len(s) {
146		return i
147	}
148	switch s[i] {
149	case '[':
150		return skipCSI(s, i+1)
151	case ']', 'P', '^', '_', 'X':
152		return skipStringControl(s, i+1)
153	}
154	for i < len(s) && s[i] >= 0x20 && s[i] <= 0x2f {
155		i++
156	}
157	if i < len(s) && s[i] >= 0x30 && s[i] <= 0x7e {
158		return i + 1
159	}
160	return i
161}
162
163func skipCSI(s string, i int) int {
164	for i < len(s) {
165		c := s[i]
166		i++
167		if c >= 0x40 && c <= 0x7e {
168			break
169		}
170	}
171	return i
172}
173
174func skipStringControl(s string, i int) int {
175	for i < len(s) {
176		if s[i] == '\a' {
177			return i + 1
178		}
179		if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '\\' {
180			return i + 2
181		}
182		_, size := utf8.DecodeRuneInString(s[i:])
183		i += size
184	}
185	return i
186}
187
188func trimUTF8Bytes(s string, limit int) string {
189	if len(s) <= limit {
190		return s
191	}
192	for limit > 0 && !utf8.ValidString(s[:limit]) {
193		limit--
194	}
195	return s[:limit]
196}
197
198// barScriptError carries the diagnostic detail needed to explain why a bar
199// script failed: the process exit code and whatever it wrote to stderr.
200type barScriptError struct {
201	exitCode int
202	stderr   string
203	err      error
204}
205
206func (e *barScriptError) Error() string {
207	if e.stderr != "" {
208		return fmt.Sprintf("exit %d: %s", e.exitCode, e.stderr)
209	}
210	return fmt.Sprintf("exit %d", e.exitCode)
211}
212
213func (e *barScriptError) Unwrap() error { return e.err }
214
215// pathFromEnv returns the PATH entry's value, or "" when unset.
216func pathFromEnv(env []string) string {
217	for _, entry := range env {
218		if name, value, ok := environmentEntry(entry); ok && name == "PATH" {
219			return value
220		}
221	}
222	return ""
223}

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