v0.3.0 99dc266

bar_refresh.go RawHistoryFolder

1package daemon
2
3import (
4	"context"
5	"errors"
6	"sync"
7	"time"
8
9	"github.com/bnema/vev/internal/domain"
10)
11
12type barScriptExecutor interface {
13	run(ctx context.Context, command string, env []string, scriptCtx barScriptContext) (string, error)
14}
15
16// Anchor names, exported to bar scripts as VEV_ANCHOR and documented in
17// docs/configuration.md. barAnchors must list every anchor so cleanup in
18// clearBarScriptsForSession stays in sync with the anchors run in
19// runBarScripts.
20const (
21	barAnchorTopRight    = "top-right"
22	barAnchorBottomRight = "bottom-right"
23)
24
25var barAnchors = []string{barAnchorTopRight, barAnchorBottomRight}
26
27type barScriptConfig struct {
28	topRight    string
29	bottomRight string
30	interval    time.Duration
31}
32
33type barScriptOutputs struct {
34	topRight    string
35	bottomRight string
36}
37
38type barScriptFailureKey struct {
39	id     domain.SessionID
40	anchor string
41}
42
43type barScriptState struct {
44	mu          sync.Mutex
45	cfg         barScriptConfig
46	runner      barScriptExecutor
47	outputs     map[domain.SessionID]barScriptOutputs
48	lastRefresh map[domain.SessionID]time.Time
49	lastContext map[domain.SessionID]barScriptContext
50	running     map[domain.SessionID]bool
51	pending     map[domain.SessionID]bool
52	lastFailure map[barScriptFailureKey]string
53	version     uint64
54
55	// reload wakes barScriptPoller so an interval change takes effect without
56	// waiting out the timer armed under the previous interval.
57	reload chan struct{}
58}
59
60func effectiveBarInterval(d time.Duration) time.Duration {
61	if d <= 0 {
62		return 5 * time.Second
63	}
64	if d < domain.MinBarInterval {
65		return domain.MinBarInterval
66	}
67	return d
68}
69
70func barConfigFromDomain(cfg domain.BarConfig) barScriptConfig {
71	return barScriptConfig{topRight: cfg.TopRight, bottomRight: cfg.BottomRight, interval: effectiveBarInterval(cfg.Interval)}
72}
73
74func (s *barScriptState) initLocked() {
75	if s.outputs == nil {
76		s.outputs = make(map[domain.SessionID]barScriptOutputs)
77	}
78	if s.lastRefresh == nil {
79		s.lastRefresh = make(map[domain.SessionID]time.Time)
80	}
81	if s.lastContext == nil {
82		s.lastContext = make(map[domain.SessionID]barScriptContext)
83	}
84	if s.running == nil {
85		s.running = make(map[domain.SessionID]bool)
86	}
87	if s.pending == nil {
88		s.pending = make(map[domain.SessionID]bool)
89	}
90	if s.lastFailure == nil {
91		s.lastFailure = make(map[barScriptFailureKey]string)
92	}
93}
94
95func (d *Daemon) barScriptSnapshot(sess *session) (string, string) {
96	if d == nil || d.barScripts == nil || sess == nil {
97		return "", ""
98	}
99	d.barScripts.mu.Lock()
100	defer d.barScripts.mu.Unlock()
101	out := d.barScripts.outputs[sess.id]
102	return out.topRight, out.bottomRight
103}
104
105func (d *Daemon) barScriptInterval() time.Duration {
106	if d == nil || d.barScripts == nil {
107		return 5 * time.Second
108	}
109	d.barScripts.mu.Lock()
110	defer d.barScripts.mu.Unlock()
111	return effectiveBarInterval(d.barScripts.cfg.interval)
112}
113
114func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScriptContext, []string, bool) {
115	ctx := barScriptContext{Anchor: anchor}
116	if sess == nil {
117		return ctx, nil, false
118	}
119	sess.mu.Lock()
120	ctx.Session = sess.name
121	// No copy needed: writers always replace sess.env with a whole fresh
122	// slice under sess.mu (daemon.go, resume.go), and nothing mutates the
123	// backing array in place, so capturing the header here is safe.
124	env := sess.env
125	attachments := sess.snapshotAttachmentsLocked()
126	ctx.PaneCWD = sess.cwd
127	sess.mu.Unlock()
128	tb := sess.firstTab()
129	if len(attachments) == 0 || tb == nil {
130		return ctx, env, false
131	}
132	tb.mu.Lock()
133	ctx.Tab = tb.stableID
134	ctx.Cols = tb.size.Cols
135	pid := 0
136	if p := tb.focusedPane(); p != nil {
137		ctx.Pane = p.stableID
138		if d != nil && d.procCwd != nil && p.pty != nil {
139			pid = p.pty.Pid()
140		}
141	}
142	tb.mu.Unlock()
143	if pid != 0 && d != nil && d.procCwd != nil {
144		if cwd, err := d.procCwd(pid); err == nil && cwd != "" {
145			ctx.PaneCWD = cwd
146		}
147	}
148	return ctx, env, true
149}
150
151// signalBarPollerReload wakes the poller without blocking. The channel is
152// buffered to one, so a pending signal already conveys "config changed".
153func (d *Daemon) signalBarPollerReload() {
154	if d == nil || d.barScripts == nil || d.barScripts.reload == nil {
155		return
156	}
157	select {
158	case d.barScripts.reload <- struct{}{}:
159	default:
160	}
161}
162
163func (d *Daemon) barScriptPoller(ctx context.Context) {
164	timer := d.clock.NewTimer(d.barScriptInterval())
165	defer func() { timer.Stop() }()
166	for {
167		select {
168		case <-ctx.Done():
169			return
170		case <-d.barScripts.reload:
171			timer.Stop()
172			timer = d.clock.NewTimer(d.barScriptInterval())
173		case now := <-timer.C():
174			for _, sess := range d.sessionsSnapshot() {
175				d.refreshBarScriptsIfDue(sess, now, false)
176			}
177			timer.Stop()
178			timer = d.clock.NewTimer(d.barScriptInterval())
179		}
180	}
181}
182
183// refreshBarScriptsAllSessions forces a bar-script run for every live session.
184// Called after a config change so a new command takes effect immediately rather
185// than leaving the anchor blank until the poller's next tick.
186func (d *Daemon) refreshBarScriptsAllSessions() {
187	if d == nil || d.barScripts == nil || d.clock == nil {
188		return
189	}
190	now := d.clock.Now()
191	for _, sess := range d.sessionsSnapshot() {
192		d.refreshBarScriptsIfDue(sess, now, true)
193	}
194}
195
196func (d *Daemon) sessionsSnapshot() []*session {
197	d.mu.Lock()
198	defer d.mu.Unlock()
199	return sessionsSnapshot(d.sessions)
200}
201
202func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) {
203	if d == nil || d.barScripts == nil {
204		return
205	}
206	d.barScripts.mu.Lock()
207	defer d.barScripts.mu.Unlock()
208	delete(d.barScripts.outputs, id)
209	delete(d.barScripts.lastRefresh, id)
210	delete(d.barScripts.lastContext, id)
211	delete(d.barScripts.running, id)
212	delete(d.barScripts.pending, id)
213	for _, anchor := range barAnchors {
214		delete(d.barScripts.lastFailure, barScriptFailureKey{id: id, anchor: anchor})
215	}
216}
217
218// shouldLogBarFailure reports whether this failure differs from the last one
219// logged for the same session and anchor, so a persistently broken script
220// warns once instead of every refresh interval.
221func (d *Daemon) shouldLogBarFailure(id domain.SessionID, anchor, signature string) bool {
222	d.barScripts.mu.Lock()
223	defer d.barScripts.mu.Unlock()
224	d.barScripts.initLocked()
225	key := barScriptFailureKey{id: id, anchor: anchor}
226	if d.barScripts.lastFailure[key] == signature {
227		return false
228	}
229	d.barScripts.lastFailure[key] = signature
230	return true
231}
232
233func (d *Daemon) clearBarFailure(id domain.SessionID, anchor string) {
234	d.barScripts.mu.Lock()
235	defer d.barScripts.mu.Unlock()
236	d.barScripts.initLocked()
237	delete(d.barScripts.lastFailure, barScriptFailureKey{id: id, anchor: anchor})
238}
239
240func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool) bool {
241	if d == nil || d.barScripts == nil || sess == nil {
242		return false
243	}
244	baseCtx, env, ok := d.collectBarScriptContext(sess, "")
245	if !ok {
246		return false
247	}
248	if len(env) == 0 {
249		env = d.baseEnv
250	}
251	d.barScripts.mu.Lock()
252	d.barScripts.initLocked()
253	if d.barScripts.lastContext[sess.id] != baseCtx {
254		force = true
255	}
256	lastRefresh := d.barScripts.lastRefresh[sess.id]
257	if d.barScripts.running[sess.id] {
258		if force {
259			d.barScripts.pending[sess.id] = true
260		}
261		d.barScripts.mu.Unlock()
262		return false
263	}
264	if !lastRefresh.IsZero() && now.Sub(lastRefresh) < domain.MinBarInterval {
265		if force {
266			d.scheduleBarScriptRefreshLocked(sess, lastRefresh.Add(domain.MinBarInterval).Sub(now))
267		}
268		d.barScripts.mu.Unlock()
269		return false
270	}
271	if !force && !lastRefresh.IsZero() && now.Sub(lastRefresh) < d.barScripts.cfg.interval {
272		d.barScripts.mu.Unlock()
273		return false
274	}
275	cfg := d.barScripts.cfg
276	if cfg.topRight == "" && cfg.bottomRight == "" {
277		d.barScripts.lastRefresh[sess.id] = now
278		d.barScripts.lastContext[sess.id] = baseCtx
279		current := d.barScripts.outputs[sess.id]
280		changed := current.topRight != "" || current.bottomRight != ""
281		delete(d.barScripts.outputs, sess.id)
282		d.barScripts.mu.Unlock()
283		if changed {
284			d.pokeSessionRender(sess)
285		}
286		return false
287	}
288	d.barScripts.running[sess.id] = true
289	d.barScripts.lastRefresh[sess.id] = now
290	d.barScripts.lastContext[sess.id] = baseCtx
291	runner := d.barScripts.runner
292	version := d.barScripts.version
293	d.barScripts.mu.Unlock()
294	go d.runBarScripts(sess, runner, cfg, baseCtx, env, version)
295	return true
296}
297
298func (d *Daemon) scheduleBarScriptRefreshLocked(sess *session, delay time.Duration) {
299	if d.barScripts.pending[sess.id] {
300		return
301	}
302	if d.clock == nil {
303		return
304	}
305	if delay < 0 {
306		delay = 0
307	}
308	d.barScripts.pending[sess.id] = true
309	go func() {
310		ctx := sess.ctx
311		if ctx == nil {
312			ctx = context.Background()
313		}
314		timer := d.clock.NewTimer(delay)
315		select {
316		case <-ctx.Done():
317			timer.Stop()
318			return
319		case <-timer.C():
320		}
321		d.barScripts.mu.Lock()
322		delete(d.barScripts.pending, sess.id)
323		d.barScripts.mu.Unlock()
324		d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
325	}()
326}
327
328func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barScriptConfig, base barScriptContext, env []string, version uint64) {
329	if runner == nil {
330		runner = barScriptRunner{}
331	}
332	ctx := sess.ctx
333	if ctx == nil {
334		ctx = context.Background()
335	}
336	top, topOK := d.runOneBarScript(ctx, runner, cfg.topRight, env, base, barAnchorTopRight, sess)
337	bottom, bottomOK := d.runOneBarScript(ctx, runner, cfg.bottomRight, env, base, barAnchorBottomRight, sess)
338	d.barScripts.mu.Lock()
339	d.barScripts.initLocked()
340	if !d.barScripts.running[sess.id] || d.barScripts.version != version {
341		delete(d.barScripts.running, sess.id)
342		d.barScripts.mu.Unlock()
343		return
344	}
345	current := d.barScripts.outputs[sess.id]
346	changed := false
347	if topOK && current.topRight != top {
348		current.topRight = top
349		changed = true
350	}
351	if bottomOK && current.bottomRight != bottom {
352		current.bottomRight = bottom
353		changed = true
354	}
355	d.barScripts.outputs[sess.id] = current
356	pending := d.barScripts.pending[sess.id]
357	delete(d.barScripts.running, sess.id)
358	delete(d.barScripts.pending, sess.id)
359	d.barScripts.mu.Unlock()
360	if changed {
361		d.pokeSessionRender(sess)
362	}
363	if pending {
364		d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
365	}
366}
367
368func (d *Daemon) runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, env []string, base barScriptContext, anchor string, sess *session) (string, bool) {
369	if command == "" {
370		return "", true
371	}
372	base.Anchor = anchor
373	out, err := runner.run(ctx, command, env, base)
374	if err != nil {
375		d.logBarScriptFailure(sess, base.Session, anchor, command, env, err)
376		return "", false
377	}
378	d.clearBarFailure(sess.id, anchor)
379	return out, true
380}
381
382// logBarScriptFailure logs a bar script failure. sessionName must come from a
383// synchronized read of sess.name (e.g. base.Session, captured under sess.mu
384// in collectBarScriptContext) since sess.name is mutable via session rename.
385func (d *Daemon) logBarScriptFailure(sess *session, sessionName, anchor, command string, env []string, err error) {
386	if d.log == nil {
387		return
388	}
389	if !d.shouldLogBarFailure(sess.id, anchor, err.Error()) {
390		return
391	}
392	attrs := []any{"anchor", anchor, "session", sessionName, "command", command, "err", err}
393	if scriptErr, ok := errors.AsType[*barScriptError](err); ok {
394		attrs = append(attrs, "exit_code", scriptErr.exitCode)
395		if scriptErr.stderr != "" {
396			attrs = append(attrs, "stderr", scriptErr.stderr)
397		}
398		if scriptErr.exitCode == 127 {
399			attrs = append(attrs, "hint",
400				"command not found on the daemon's PATH; use an absolute path or ensure it is on the attaching client's PATH",
401				"PATH", pathFromEnv(env))
402		}
403	}
404	d.log.Warn("bar script failed; keeping last good output", attrs...)
405}
406
407func (d *Daemon) pokeSessionRender(sess *session) {
408	if sess == nil {
409		return
410	}
411	// A headless session can still supply a picker preview to an attachment
412	// owned by another session. Its coordinator fans this wake out to those
413	// preview subscribers even though no primary attachment is bound.
414	attachments := sess.snapshotAttachments()
415	if len(attachments) == 0 {
416		if rc := sess.renderCoordinator(); rc != nil {
417			rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "bar_refresh.go"})
418		}
419		return
420	}
421	for _, ac := range attachments {
422		d.invalidateRender(sess, ac, false, "bar_refresh.go")
423	}
424}

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