v0.3.0 da0887c

module.go RawHistoryFolder

1// Package module runs scripts that print ANSI frames on stdout.
2//
3// A frame ends at '\n' or '\f'. Only the most recent frame matters: a module
4// that prints faster than the bar draws simply overwrites its pending frame,
5// so a chatty script costs no memory and no allocation.
6package module
7
8import (
9	"bytes"
10	"context"
11	"io"
12	"log/slog"
13	"os"
14	"os/exec"
15	"path/filepath"
16	"strings"
17	"sync"
18	"sync/atomic"
19	"syscall"
20	"time"
21)
22
23const (
24	// MaxFrame bounds one frame; longer lines are truncated.
25	MaxFrame = 16 << 10
26	// MaxControl bounds one control line (see ParseControl); longer ones are
27	// dropped.
28	MaxControl = 64 << 10
29	readSize   = 32 << 10
30
31	minBackoff = 500 * time.Millisecond
32	maxBackoff = 30 * time.Second
33	// stableRun is how long a script must run before its backoff resets.
34	stableRun = 10 * time.Second
35)
36
37// Zone is where a module's text is placed.
38type Zone uint8
39
40const (
41	Left Zone = iota
42	Center
43	Right
44)
45
46// Module is one running script.
47type Module struct {
48	Name string
49	Zone Zone
50	Exec string
51	// Env is added to the script's environment, as KEY=value entries.
52	Env []string
53	// Interactive gives the script a stdin that carries Send lines. Set it
54	// before Start.
55	Interactive bool
56
57	mu      sync.Mutex
58	pending []byte // latest complete frame
59	dirty   bool
60	failed  bool // the script is not running; show an error marker
61
62	input   chan event    // lines for the script's stdin
63	control chan []byte   // control lines of the script, see TakeControl
64	gen     atomic.Uint64 // counts script starts; see Gen
65
66	// overLogged: an oversize control line was already logged at Warn in this
67	// run of the script. Touched by runOnce and readFrames, which never overlap.
68	overLogged bool
69
70	wake chan<- struct{}
71	log  *slog.Logger
72
73	done sync.WaitGroup // Start adds one; Run releases it when its process is gone
74}
75
76// maxLine is the longest line Send accepts, without its newline.
77const maxLine = 127
78
79// event is one stdin line, newline included.
80type event struct {
81	b [maxLine + 1]byte
82	n uint8
83}
84
85// New creates a module. wake receives a non-blocking signal after each frame.
86func New(name string, zone Zone, command string, wake chan<- struct{}, log *slog.Logger) *Module {
87	return &Module{Name: name, Zone: zone, Exec: command, wake: wake, log: log,
88		pending: make([]byte, 0, MaxFrame), input: make(chan event, 32), control: make(chan []byte, 8)}
89}
90
91// Gen counts the times the script was started. A value that changed means a
92// new process, which knows nothing of the pointer state sent to the old one.
93// It does not allocate.
94func (m *Module) Gen() uint64 { return m.gen.Load() }
95
96// Send queues line (without a newline) for the script's stdin. It never
97// blocks and does not allocate. It reports false when the module is not
98// interactive, the line is longer than 127 bytes, or 32 lines are already
99// waiting (the script is not reading).
100func (m *Module) Send(line []byte) bool {
101	if !m.Interactive || len(line) > maxLine {
102		return false
103	}
104	var ev event
105	n := copy(ev.b[:], line)
106	ev.b[n] = '\n'
107	ev.n = uint8(n + 1)
108	select {
109	case m.input <- ev:
110		return true
111	default:
112		return false
113	}
114}
115
116// Take copies the pending frame into dst (reusing its capacity) and clears the
117// dirty flag. changed is false when nothing new arrived. failed reports that
118// the script is down.
119func (m *Module) Take(dst []byte) (_ []byte, changed, failed bool) {
120	m.mu.Lock()
121	defer m.mu.Unlock()
122	failed = m.failed
123	if !m.dirty {
124		return dst, false, failed
125	}
126	m.dirty = false
127	return append(dst[:0], m.pending...), true, failed
128}
129
130func (m *Module) signal() {
131	select {
132	case m.wake <- struct{}{}:
133	default:
134	}
135}
136
137func (m *Module) publish(frame []byte) {
138	m.mu.Lock()
139	m.pending = append(m.pending[:0], frame...)
140	m.dirty = true
141	m.failed = false
142	m.mu.Unlock()
143	m.signal()
144}
145
146func (m *Module) setFailed() {
147	m.mu.Lock()
148	m.failed = true
149	m.dirty = true
150	m.mu.Unlock()
151	m.signal()
152}
153
154// Start runs the module on its own goroutine until ctx ends. Wait returns once
155// the script and the processes it started are gone.
156func (m *Module) Start(ctx context.Context) {
157	m.done.Add(1)
158	go func() {
159		defer m.done.Done()
160		m.Run(ctx)
161	}()
162}
163
164// Wait blocks until a module started with Start has stopped, or timeout passes.
165func (m *Module) Wait(timeout time.Duration) {
166	ch := make(chan struct{})
167	go func() { m.done.Wait(); close(ch) }()
168	select {
169	case <-ch:
170	case <-time.After(timeout):
171	}
172}
173
174// Run starts the script and restarts it with backoff until ctx ends. It
175// blocks, so call it on its own goroutine; use Start to also be able to Wait.
176func (m *Module) Run(ctx context.Context) {
177	backoff := minBackoff
178	for ctx.Err() == nil {
179		started := time.Now()
180		err := m.runOnce(ctx)
181		if ctx.Err() != nil {
182			return
183		}
184		m.log.Warn("module exited", "module", m.Name, "err", err)
185		m.setFailed()
186		if time.Since(started) > stableRun {
187			backoff = minBackoff
188		}
189		select {
190		case <-ctx.Done():
191			return
192		case <-time.After(backoff):
193		}
194		backoff = min(backoff*2, maxBackoff)
195	}
196}
197
198func (m *Module) runOnce(ctx context.Context) error {
199	m.gen.Add(1)
200	m.overLogged = false // readFrames of the previous run has ended
201	cmd := exec.CommandContext(ctx, "/bin/sh", "-c", m.Exec)
202	cmd.Stderr = &logWriter{log: m.log, name: m.Name}
203	cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGTERM}
204	cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) }
205	cmd.WaitDelay = 2 * time.Second
206	cmd.Env = append(append(os.Environ(), m.Env...), "NEFERBAR_MODULE="+m.Name)
207	// Scripts call "$NEFERBAR_BIN app title" and friends: the path of the very
208	// binary that started them, which need not be in $PATH.
209	if exe, err := os.Executable(); err == nil {
210		cmd.Env = append(cmd.Env, "NEFERBAR_BIN="+exe)
211	}
212	out, err := cmd.StdoutPipe()
213	if err != nil {
214		return err
215	}
216	var in io.WriteCloser
217	if m.Interactive {
218		if in, err = cmd.StdinPipe(); err != nil {
219			return err
220		}
221		// Events queued while no script was running are stale.
222		for drained := false; !drained; {
223			select {
224			case <-m.input:
225			default:
226				drained = true
227			}
228		}
229	}
230	if err = cmd.Start(); err != nil {
231		return err
232	}
233	var stopWriter func()
234	if in != nil {
235		stopWriter = m.pumpInput(in)
236	}
237	m.readFrames(out)
238	if stopWriter != nil {
239		stopWriter()
240	}
241	err = cmd.Wait()
242	// The script may have started children that kept running (a "while" loop
243	// in a subshell, a "sleep"). They share the process group: end them too.
244	_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
245	return err
246}
247
248// pumpInput writes queued events to w until the returned stop function is
249// called or a write fails. stop waits for the writer and closes w.
250func (m *Module) pumpInput(w io.WriteCloser) (stop func()) {
251	done := make(chan struct{})
252	exited := make(chan struct{})
253	go func() {
254		defer close(exited)
255		for {
256			select {
257			case ev := <-m.input:
258				if _, err := w.Write(ev.b[:ev.n]); err != nil {
259					return
260				}
261			case <-done:
262				return
263			}
264		}
265	}()
266	return func() {
267		close(done)
268		// A write blocked on a script that does not read ends when w closes.
269		_ = w.Close()
270		<-exited
271	}
272}
273
274// controlPrefix starts a control line: an OSC 777 sequence of the "neferbar"
275// application, ended by controlEnd (BEL) before the newline.
276const (
277	controlPrefix = "\x1b]777;neferbar;"
278	controlEnd    = '\x07'
279)
280
281// TakeControl returns the oldest control line the script printed, the JSON
282// between the prefix and the BEL, or false when there is none. It never blocks.
283func (m *Module) TakeControl() ([]byte, bool) {
284	select {
285	case b := <-m.control:
286		return b, true
287	default:
288		return nil, false
289	}
290}
291
292// queueControl keeps a copy of the JSON of a control line of an interactive
293// module. A full queue drops the
294// line: the bar is not draining it.
295func (m *Module) queueControl(json []byte) {
296	if !m.Interactive {
297		return // only interactive modules open popups: do not keep or parse the line
298	}
299	select {
300	case m.control <- append([]byte(nil), json...):
301		m.signal()
302	default:
303		m.log.Debug("module control queue full; line dropped", "module", m.Name)
304	}
305}
306
307// line handles one complete line: a control line, or else a frame.
308func (m *Module) line(line []byte, over bool) {
309	if bytes.HasPrefix(line, []byte(controlPrefix)) {
310		switch {
311		case over:
312			// A script can repeat this at will: say it once per run.
313			if !m.overLogged {
314				m.overLogged = true
315				m.log.Warn("module control line too long; dropped (shown once per run)", "module", m.Name, "max", MaxControl)
316			} else {
317				m.log.Debug("module control line too long; dropped", "module", m.Name, "max", MaxControl)
318			}
319			return
320		case len(line) > 0 && line[len(line)-1] == controlEnd:
321			m.queueControl(line[len(controlPrefix) : len(line)-1])
322			return
323		}
324		// No BEL: not a control line; it is shown like any text.
325	}
326	m.publish(line[:min(len(line), MaxFrame)])
327}
328
329// readFrames splits r into frames and publishes each one. Control lines go to
330// the control queue instead.
331func (m *Module) readFrames(r io.Reader) {
332	buf := make([]byte, readSize)
333	line := make([]byte, 0, MaxFrame)
334	over := false // the current line is longer than MaxControl
335	for {
336		n, err := r.Read(buf)
337		chunk := buf[:n]
338		for len(chunk) > 0 {
339			i := bytes.IndexAny(chunk, "\n\f")
340			if i < 0 {
341				line, over = appendCapped(line, chunk, over)
342				break
343			}
344			line, over = appendCapped(line, chunk[:i], over)
345			m.line(line, over)
346			line, over = line[:0], false
347			chunk = chunk[i+1:]
348		}
349		if err != nil {
350			return
351		}
352	}
353}
354
355// appendCapped appends src to dst up to MaxControl bytes and reports whether
356// anything was left out.
357func appendCapped(dst, src []byte, over bool) ([]byte, bool) {
358	if room := MaxControl - len(dst); len(src) > room {
359		src, over = src[:max(room, 0)], true
360	}
361	return append(dst, src...), over
362}
363
364// logWriter forwards a script's stderr lines to the bar's log.
365type logWriter struct {
366	log  *slog.Logger
367	name string
368}
369
370func (w *logWriter) Write(p []byte) (int, error) {
371	text := bytes.TrimRight(p, "\n")
372	if len(text) > 1024 {
373		text = append(text[:1024:1024], "..."...)
374	}
375	w.log.Info("module stderr", "module", w.name, "text", string(text))
376	return len(p), nil
377}
378
379// SetGenForTest makes the module look as if its script was restarted.
380func SetGenForTest(m *Module, gen uint64) { m.gen.Store(gen) }
381
382// DrainForTest empties the queue of Send lines and returns them without their
383// newlines.
384func DrainForTest(m *Module) []string {
385	var out []string
386	for {
387		select {
388		case ev := <-m.input:
389			out = append(out, string(ev.b[:ev.n-1]))
390		default:
391			return out
392		}
393	}
394}
395
396// ReadFramesForTest feeds r to the module as its script's stdout.
397func (m *Module) ReadFramesForTest(r io.Reader) { m.readFrames(r) }
398
399// PublishForTest injects a frame as if the script had printed it.
400func PublishForTest(m *Module, frame []byte) { m.publish(frame) }
401
402// ScriptDir is the directory of the program a module's command starts, or ""
403// when it has none to watch: a bare name such as "date" is found through $PATH,
404// and a command that cannot be split into words is left alone. A leading "~/"
405// is the home directory, as the shell reads it, and a relative path is taken
406// from the current directory.
407func ScriptDir(command string) string {
408	fields := strings.Fields(command)
409	if len(fields) == 0 || !strings.Contains(fields[0], "/") {
410		return ""
411	}
412	path := fields[0]
413	if rest, ok := strings.CutPrefix(path, "~/"); ok {
414		home, err := os.UserHomeDir()
415		if err != nil {
416			return ""
417		}
418		path = filepath.Join(home, rest)
419	}
420	abs, err := filepath.Abs(path)
421	if err != nil {
422		return ""
423	}
424	return filepath.Dir(abs)
425}

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 5

Tags 4

↑↓ moveenter openesc close