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	"syscall"
19	"time"
20)
21
22const (
23	// MaxFrame bounds one frame; longer lines are truncated.
24	MaxFrame = 16 << 10
25	readSize = 32 << 10
26
27	minBackoff = 500 * time.Millisecond
28	maxBackoff = 30 * time.Second
29	// stableRun is how long a script must run before its backoff resets.
30	stableRun = 10 * time.Second
31)
32
33// Zone is where a module's text is placed.
34type Zone uint8
35
36const (
37	Left Zone = iota
38	Center
39	Right
40)
41
42// Module is one running script.
43type Module struct {
44	Name string
45	Zone Zone
46	Exec string
47	// Env is added to the script's environment, as KEY=value entries.
48	Env []string
49
50	mu      sync.Mutex
51	pending []byte // latest complete frame
52	dirty   bool
53	failed  bool // the script is not running; show an error marker
54
55	wake chan<- struct{}
56	log  *slog.Logger
57
58	done sync.WaitGroup // Start adds one; Run releases it when its process is gone
59}
60
61// New creates a module. wake receives a non-blocking signal after each frame.
62func New(name string, zone Zone, command string, wake chan<- struct{}, log *slog.Logger) *Module {
63	return &Module{Name: name, Zone: zone, Exec: command, wake: wake, log: log,
64		pending: make([]byte, 0, MaxFrame)}
65}
66
67// Take copies the pending frame into dst (reusing its capacity) and clears the
68// dirty flag. changed is false when nothing new arrived. failed reports that
69// the script is down.
70func (m *Module) Take(dst []byte) (_ []byte, changed, failed bool) {
71	m.mu.Lock()
72	defer m.mu.Unlock()
73	failed = m.failed
74	if !m.dirty {
75		return dst, false, failed
76	}
77	m.dirty = false
78	return append(dst[:0], m.pending...), true, failed
79}
80
81func (m *Module) signal() {
82	select {
83	case m.wake <- struct{}{}:
84	default:
85	}
86}
87
88func (m *Module) publish(frame []byte) {
89	m.mu.Lock()
90	m.pending = append(m.pending[:0], frame...)
91	m.dirty = true
92	m.failed = false
93	m.mu.Unlock()
94	m.signal()
95}
96
97func (m *Module) setFailed() {
98	m.mu.Lock()
99	m.failed = true
100	m.dirty = true
101	m.mu.Unlock()
102	m.signal()
103}
104
105// Start runs the module on its own goroutine until ctx ends. Wait returns once
106// the script and the processes it started are gone.
107func (m *Module) Start(ctx context.Context) {
108	m.done.Add(1)
109	go func() {
110		defer m.done.Done()
111		m.Run(ctx)
112	}()
113}
114
115// Wait blocks until a module started with Start has stopped, or timeout passes.
116func (m *Module) Wait(timeout time.Duration) {
117	ch := make(chan struct{})
118	go func() { m.done.Wait(); close(ch) }()
119	select {
120	case <-ch:
121	case <-time.After(timeout):
122	}
123}
124
125// Run starts the script and restarts it with backoff until ctx ends. It
126// blocks, so call it on its own goroutine; use Start to also be able to Wait.
127func (m *Module) Run(ctx context.Context) {
128	backoff := minBackoff
129	for ctx.Err() == nil {
130		started := time.Now()
131		err := m.runOnce(ctx)
132		if ctx.Err() != nil {
133			return
134		}
135		m.log.Warn("module exited", "module", m.Name, "err", err)
136		m.setFailed()
137		if time.Since(started) > stableRun {
138			backoff = minBackoff
139		}
140		select {
141		case <-ctx.Done():
142			return
143		case <-time.After(backoff):
144		}
145		backoff = min(backoff*2, maxBackoff)
146	}
147}
148
149func (m *Module) runOnce(ctx context.Context) error {
150	cmd := exec.CommandContext(ctx, "/bin/sh", "-c", m.Exec)
151	cmd.Stderr = &logWriter{log: m.log, name: m.Name}
152	cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGTERM}
153	cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) }
154	cmd.WaitDelay = 2 * time.Second
155	cmd.Env = append(append(os.Environ(), m.Env...), "NEFERBAR_MODULE="+m.Name)
156	// Scripts call "$NEFERBAR_BIN app title" and friends: the path of the very
157	// binary that started them, which need not be in $PATH.
158	if exe, err := os.Executable(); err == nil {
159		cmd.Env = append(cmd.Env, "NEFERBAR_BIN="+exe)
160	}
161	out, err := cmd.StdoutPipe()
162	if err != nil {
163		return err
164	}
165	if err = cmd.Start(); err != nil {
166		return err
167	}
168	m.readFrames(out)
169	err = cmd.Wait()
170	// The script may have started children that kept running (a "while" loop
171	// in a subshell, a "sleep"). They share the process group: end them too.
172	_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
173	return err
174}
175
176// readFrames splits r into frames and publishes each one.
177func (m *Module) readFrames(r io.Reader) {
178	buf := make([]byte, readSize)
179	frame := make([]byte, 0, MaxFrame)
180	for {
181		n, err := r.Read(buf)
182		chunk := buf[:n]
183		for len(chunk) > 0 {
184			i := bytes.IndexAny(chunk, "\n\f")
185			if i < 0 {
186				frame = appendCapped(frame, chunk)
187				break
188			}
189			frame = appendCapped(frame, chunk[:i])
190			m.publish(frame)
191			frame = frame[:0]
192			chunk = chunk[i+1:]
193		}
194		if err != nil {
195			return
196		}
197	}
198}
199
200func appendCapped(dst, src []byte) []byte {
201	if room := MaxFrame - len(dst); len(src) > room {
202		src = src[:max(room, 0)]
203	}
204	return append(dst, src...)
205}
206
207// logWriter forwards a script's stderr lines to the bar's log.
208type logWriter struct {
209	log  *slog.Logger
210	name string
211}
212
213func (w *logWriter) Write(p []byte) (int, error) {
214	text := bytes.TrimRight(p, "\n")
215	if len(text) > 1024 {
216		text = append(text[:1024:1024], "..."...)
217	}
218	w.log.Info("module stderr", "module", w.name, "text", string(text))
219	return len(p), nil
220}
221
222// PublishForTest injects a frame as if the script had printed it.
223func PublishForTest(m *Module, frame []byte) { m.publish(frame) }
224
225// ScriptDir is the directory of the program a module's command starts, or ""
226// when it has none to watch: a bare name such as "date" is found through $PATH,
227// and a command that cannot be split into words is left alone. A leading "~/"
228// is the home directory, as the shell reads it, and a relative path is taken
229// from the current directory.
230func ScriptDir(command string) string {
231	fields := strings.Fields(command)
232	if len(fields) == 0 || !strings.Contains(fields[0], "/") {
233		return ""
234	}
235	path := fields[0]
236	if rest, ok := strings.CutPrefix(path, "~/"); ok {
237		home, err := os.UserHomeDir()
238		if err != nil {
239			return ""
240		}
241		path = filepath.Join(home, rest)
242	}
243	abs, err := filepath.Abs(path)
244	if err != nil {
245		return ""
246	}
247	return filepath.Dir(abs)
248}

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