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: every writer replaces sess.env with a whole fresh slice
122	// under sess.mu and nothing mutates the backing array in place, so
123	// capturing the header here is safe.
124	env := sess.env
125	attachments := sess.snapshotAttachmentsLocked()
126	ctx.PaneCWD = sess.cwd
127	sess.mu.Unlock()
128	ctx.ClientPID = barClientPID(attachments)
129	tb := sess.firstTab()
130	if len(attachments) == 0 || tb == nil {
131		return ctx, env, false
132	}
133	tb.mu.Lock()
134	ctx.Tab = tb.stableID
135	ctx.Cols = tb.size.Cols
136	pid := 0
137	if p := tb.focusedPane(); p != nil {
138		ctx.Pane = p.stableID
139		if d != nil && d.proc != nil && p.pty != nil {
140			pid = p.pty.Pid()
141		}
142	}
143	tb.mu.Unlock()
144	if pid != 0 && d != nil && d.proc != nil {
145		if cwd, err := d.proc.Cwd(pid); err == nil && cwd != "" {
146			ctx.PaneCWD = cwd
147		}
148	}
149	return ctx, env, true
150}
151
152// barClientPID picks the local client a bar script should describe: the one
153// whose terminal has focus, else the first local one. Remote clients carry no
154// PID, so a session attached only remotely yields 0.
155func barClientPID(attachments []*attachedClient) uint32 {
156	var first uint32
157	for _, ac := range attachments {
158		pid := ac.clientPID.Load()
159		if pid == 0 {
160			continue
161		}
162		if ac.terminalFocus() == domain.TerminalFocusFocused {
163			return pid
164		}
165		if first == 0 {
166			first = pid
167		}
168	}
169	return first
170}
171
172// signalBarPollerReload wakes the poller without blocking. The channel is
173// buffered to one, so a pending signal already conveys "config changed".
174func (d *Daemon) signalBarPollerReload() {
175	if d == nil || d.barScripts == nil || d.barScripts.reload == nil {
176		return
177	}
178	select {
179	case d.barScripts.reload <- struct{}{}:
180	default:
181	}
182}
183
184func (d *Daemon) barScriptPoller(ctx context.Context) {
185	timer := d.clock.NewTimer(d.barScriptInterval())
186	defer func() { timer.Stop() }()
187	for {
188		select {
189		case <-ctx.Done():
190			return
191		case <-d.barScripts.reload:
192			timer.Stop()
193			timer = d.clock.NewTimer(d.barScriptInterval())
194		case now := <-timer.C():
195			for _, sess := range d.sessionsSnapshot() {
196				d.refreshBarScriptsIfDue(sess, now, false)
197			}
198			timer.Stop()
199			timer = d.clock.NewTimer(d.barScriptInterval())
200		}
201	}
202}
203
204// refreshBarScriptsAllSessions forces a bar-script run for every live session.
205// Called after a config change so a new command takes effect immediately rather
206// than leaving the anchor blank until the poller's next tick.
207func (d *Daemon) refreshBarScriptsAllSessions() {
208	if d == nil || d.barScripts == nil || d.clock == nil {
209		return
210	}
211	now := d.clock.Now()
212	for _, sess := range d.sessionsSnapshot() {
213		d.refreshBarScriptsIfDue(sess, now, true)
214	}
215}
216
217func (d *Daemon) sessionsSnapshot() []*session {
218	d.mu.Lock()
219	defer d.mu.Unlock()
220	return sessionsSnapshot(d.sessions)
221}
222
223func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) {
224	if d == nil || d.barScripts == nil {
225		return
226	}
227	d.barScripts.mu.Lock()
228	defer d.barScripts.mu.Unlock()
229	delete(d.barScripts.outputs, id)
230	delete(d.barScripts.lastRefresh, id)
231	delete(d.barScripts.lastContext, id)
232	delete(d.barScripts.running, id)
233	delete(d.barScripts.pending, id)
234	for _, anchor := range barAnchors {
235		delete(d.barScripts.lastFailure, barScriptFailureKey{id: id, anchor: anchor})
236	}
237}
238
239// shouldLogBarFailure reports whether this failure differs from the last one
240// logged for the same session and anchor, so a persistently broken script
241// warns once instead of every refresh interval.
242func (d *Daemon) shouldLogBarFailure(id domain.SessionID, anchor, signature string) bool {
243	d.barScripts.mu.Lock()
244	defer d.barScripts.mu.Unlock()
245	d.barScripts.initLocked()
246	key := barScriptFailureKey{id: id, anchor: anchor}
247	if d.barScripts.lastFailure[key] == signature {
248		return false
249	}
250	d.barScripts.lastFailure[key] = signature
251	return true
252}
253
254func (d *Daemon) clearBarFailure(id domain.SessionID, anchor string) {
255	d.barScripts.mu.Lock()
256	defer d.barScripts.mu.Unlock()
257	d.barScripts.initLocked()
258	delete(d.barScripts.lastFailure, barScriptFailureKey{id: id, anchor: anchor})
259}
260
261func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool) bool {
262	if d == nil || d.barScripts == nil || sess == nil {
263		return false
264	}
265	baseCtx, env, ok := d.collectBarScriptContext(sess, "")
266	if !ok {
267		return false
268	}
269	if len(env) == 0 {
270		env = d.baseEnv
271	}
272	d.barScripts.mu.Lock()
273	d.barScripts.initLocked()
274	if d.barScripts.lastContext[sess.id] != baseCtx {
275		force = true
276	}
277	lastRefresh := d.barScripts.lastRefresh[sess.id]
278	if d.barScripts.running[sess.id] {
279		if force {
280			d.barScripts.pending[sess.id] = true
281		}
282		d.barScripts.mu.Unlock()
283		return false
284	}
285	if !lastRefresh.IsZero() && now.Sub(lastRefresh) < domain.MinBarInterval {
286		if force {
287			d.scheduleBarScriptRefreshLocked(sess, lastRefresh.Add(domain.MinBarInterval).Sub(now))
288		}
289		d.barScripts.mu.Unlock()
290		return false
291	}
292	if !force && !lastRefresh.IsZero() && now.Sub(lastRefresh) < d.barScripts.cfg.interval {
293		d.barScripts.mu.Unlock()
294		return false
295	}
296	cfg := d.barScripts.cfg
297	if cfg.topRight == "" && cfg.bottomRight == "" {
298		d.barScripts.lastRefresh[sess.id] = now
299		d.barScripts.lastContext[sess.id] = baseCtx
300		current := d.barScripts.outputs[sess.id]
301		changed := current.topRight != "" || current.bottomRight != ""
302		delete(d.barScripts.outputs, sess.id)
303		d.barScripts.mu.Unlock()
304		if changed {
305			d.pokeSessionRender(sess)
306		}
307		return false
308	}
309	d.barScripts.running[sess.id] = true
310	d.barScripts.lastRefresh[sess.id] = now
311	d.barScripts.lastContext[sess.id] = baseCtx
312	runner := d.barScripts.runner
313	version := d.barScripts.version
314	d.barScripts.mu.Unlock()
315	go d.runBarScripts(sess, runner, cfg, baseCtx, env, version)
316	return true
317}
318
319// scheduleBarScriptRefreshLocked requires d.barScripts.mu, not d.mu.
320func (d *Daemon) scheduleBarScriptRefreshLocked(sess *session, delay time.Duration) {
321	if d.barScripts.pending[sess.id] {
322		return
323	}
324	if d.clock == nil {
325		return
326	}
327	if delay < 0 {
328		delay = 0
329	}
330	d.barScripts.pending[sess.id] = true
331	go func() {
332		ctx := sess.ctx
333		if ctx == nil {
334			ctx = context.Background()
335		}
336		timer := d.clock.NewTimer(delay)
337		select {
338		case <-ctx.Done():
339			timer.Stop()
340			return
341		case <-timer.C():
342		}
343		d.barScripts.mu.Lock()
344		delete(d.barScripts.pending, sess.id)
345		d.barScripts.mu.Unlock()
346		d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
347	}()
348}
349
350func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barScriptConfig, base barScriptContext, env []string, version uint64) {
351	if runner == nil {
352		runner = barScriptRunner{}
353	}
354	ctx := sess.ctx
355	if ctx == nil {
356		ctx = context.Background()
357	}
358	top, topOK := d.runOneBarScript(ctx, runner, cfg.topRight, env, base, barAnchorTopRight, sess)
359	bottom, bottomOK := d.runOneBarScript(ctx, runner, cfg.bottomRight, env, base, barAnchorBottomRight, sess)
360	d.barScripts.mu.Lock()
361	d.barScripts.initLocked()
362	if !d.barScripts.running[sess.id] || d.barScripts.version != version {
363		delete(d.barScripts.running, sess.id)
364		d.barScripts.mu.Unlock()
365		return
366	}
367	current := d.barScripts.outputs[sess.id]
368	changed := false
369	if topOK && current.topRight != top {
370		current.topRight = top
371		changed = true
372	}
373	if bottomOK && current.bottomRight != bottom {
374		current.bottomRight = bottom
375		changed = true
376	}
377	d.barScripts.outputs[sess.id] = current
378	pending := d.barScripts.pending[sess.id]
379	delete(d.barScripts.running, sess.id)
380	delete(d.barScripts.pending, sess.id)
381	d.barScripts.mu.Unlock()
382	if changed {
383		d.pokeSessionRender(sess)
384	}
385	if pending {
386		d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
387	}
388}
389
390func (d *Daemon) runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, env []string, base barScriptContext, anchor string, sess *session) (string, bool) {
391	if command == "" {
392		return "", true
393	}
394	base.Anchor = anchor
395	out, err := runner.run(ctx, command, env, base)
396	if err != nil {
397		d.logBarScriptFailure(sess, base.Session, anchor, command, env, err)
398		return "", false
399	}
400	d.clearBarFailure(sess.id, anchor)
401	return out, true
402}
403
404// logBarScriptFailure logs a bar script failure. sessionName must come from a
405// synchronized read of sess.name (e.g. base.Session, captured under sess.mu
406// in collectBarScriptContext) since sess.name is mutable via session rename.
407func (d *Daemon) logBarScriptFailure(sess *session, sessionName, anchor, command string, env []string, err error) {
408	if d.log == nil {
409		return
410	}
411	if !d.shouldLogBarFailure(sess.id, anchor, err.Error()) {
412		return
413	}
414	attrs := []any{"anchor", anchor, "session", sessionName, "command", command, "err", err}
415	if scriptErr, ok := errors.AsType[*barScriptError](err); ok {
416		attrs = append(attrs, "exit_code", scriptErr.exitCode)
417		if scriptErr.stderr != "" {
418			attrs = append(attrs, "stderr", scriptErr.stderr)
419		}
420		if scriptErr.exitCode == 127 {
421			attrs = append(attrs, "hint",
422				"command not found on the daemon's PATH; use an absolute path or ensure it is on the attaching client's PATH",
423				"PATH", pathFromEnv(env))
424		}
425	}
426	d.log.Warn("bar script failed; keeping last good output", attrs...)
427}
428
429func (d *Daemon) pokeSessionRender(sess *session) {
430	if sess == nil {
431		return
432	}
433	// A headless session can still supply a picker preview to an attachment
434	// owned by another session. Its coordinator fans this wake out to those
435	// preview subscribers even though no primary attachment is bound.
436	attachments := sess.snapshotAttachments()
437	if len(attachments) == 0 {
438		if rc := sess.renderCoordinator(); rc != nil {
439			rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "bar_refresh.go"})
440		}
441		return
442	}
443	for _, ac := range attachments {
444		d.invalidateRender(sess, ac, false, "bar_refresh.go")
445	}
446}

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