detached a593915

fix(daemon): refresh bar scripts immediately on config change

ApplyConfig cleared cached anchor output and repainted without triggering a run,
so the segment stayed blank until the next poller tick. With a long bar.interval
that reads as the config being ignored. Force a refresh for every live session
when the bar config actually changed.
commit
a593915d05d8a850137c6b128364c7b82b743f21
author
brice · · 2026-07-20 08:18 UTC
parent
c9a1da5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +47 -0
M internal/usecase/daemon/bar_refresh.go +13 -0
@@ -150,6 +150,19 @@ func (d *Daemon) barScriptPoller(ctx context.Context) {
150150 }
151151 }
152152
153+// refreshBarScriptsAllSessions forces a bar-script run for every live session.
154+// Called after a config change so a new command takes effect immediately rather
155+// than leaving the anchor blank until the poller's next tick.
156+func (d *Daemon) refreshBarScriptsAllSessions() {
157+ if d == nil || d.barScripts == nil || d.clock == nil {
158+ return
159+ }
160+ now := d.clock.Now()
161+ for _, sess := range d.sessionsSnapshot() {
162+ d.refreshBarScriptsIfDue(sess, now, true)
163+ }
164+}
165+
153166 func (d *Daemon) sessionsSnapshot() []*session {
154167 d.mu.Lock()
155168 defer d.mu.Unlock()
@@ -150,6 +150,19 @@ func (d *Daemon) barScriptPoller(ctx context.Context) {
150 }150 }
151 }151 }
152 152
153+// refreshBarScriptsAllSessions forces a bar-script run for every live session.
154+// Called after a config change so a new command takes effect immediately rather
155+// than leaving the anchor blank until the poller's next tick.
156+func (d *Daemon) refreshBarScriptsAllSessions() {
157+ if d == nil || d.barScripts == nil || d.clock == nil {
158+ return
159+ }
160+ now := d.clock.Now()
161+ for _, sess := range d.sessionsSnapshot() {
162+ d.refreshBarScriptsIfDue(sess, now, true)
163+ }
164+}
165+
153 func (d *Daemon) sessionsSnapshot() []*session {166 func (d *Daemon) sessionsSnapshot() []*session {
154 d.mu.Lock()167 d.mu.Lock()
155 defer d.mu.Unlock()168 defer d.mu.Unlock()
M internal/usecase/daemon/bar_refresh_test.go +28 -0
@@ -213,6 +213,34 @@ func TestApplyConfigPreservesBarOutputsWhenBarConfigUnchanged(t *testing.T) {
213213 require.Equal(t, "bottom-good", state.bottomRight)
214214 }
215215
216+func TestRefreshBarScriptsAllSessionsForcesRun(t *testing.T) {
217+ r := &fakeBarRunner{outs: []string{"top1", "bottom1"}}
218+ d := newBarRefreshTestDaemon(r, 60*time.Second)
219+ sess := newBarRefreshTestSession()
220+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
221+ d.sessions = map[domain.SessionID]*session{sess.id: sess}
222+
223+ // Nothing has run yet, and the 60s interval means a non-forced tick would
224+ // not be due for a long time.
225+ d.refreshBarScriptsAllSessions()
226+ waitBarRefreshIdle(t, d)
227+
228+ require.Len(t, r.calls, 2, "should force a run for every live session")
229+ require.Equal(t, "top1", d.barStateFor(sess, "").topRight)
230+}
231+
232+func TestApplyConfigBumpsVersionOnlyWhenBarConfigChanges(t *testing.T) {
233+ r := &fakeBarRunner{}
234+ d := newBarRefreshTestDaemon(r, time.Second)
235+
236+ before := d.barScripts.version
237+ d.ApplyConfig(domain.Config{Bar: domain.BarConfig{TopRight: "top", BottomRight: "bottom", Interval: time.Second}})
238+ require.Equal(t, before, d.barScripts.version, "identical bar config must not invalidate")
239+
240+ d.ApplyConfig(domain.Config{Bar: domain.BarConfig{TopRight: "changed", BottomRight: "bottom", Interval: time.Second}})
241+ require.Equal(t, before+1, d.barScripts.version, "changed bar config must invalidate")
242+}
243+
216244 func TestBarScriptFailureLogsAndRetainsLastGood(t *testing.T) {
217245 var logs bytes.Buffer
218246 r := &fakeBarRunner{outs: []string{"top-good", "bottom-good"}}
@@ -213,6 +213,34 @@ func TestApplyConfigPreservesBarOutputsWhenBarConfigUnchanged(t *testing.T) {
213 require.Equal(t, "bottom-good", state.bottomRight)213 require.Equal(t, "bottom-good", state.bottomRight)
214 }214 }
215 215
216+func TestRefreshBarScriptsAllSessionsForcesRun(t *testing.T) {
217+ r := &fakeBarRunner{outs: []string{"top1", "bottom1"}}
218+ d := newBarRefreshTestDaemon(r, 60*time.Second)
219+ sess := newBarRefreshTestSession()
220+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
221+ d.sessions = map[domain.SessionID]*session{sess.id: sess}
222+
223+ // Nothing has run yet, and the 60s interval means a non-forced tick would
224+ // not be due for a long time.
225+ d.refreshBarScriptsAllSessions()
226+ waitBarRefreshIdle(t, d)
227+
228+ require.Len(t, r.calls, 2, "should force a run for every live session")
229+ require.Equal(t, "top1", d.barStateFor(sess, "").topRight)
230+}
231+
232+func TestApplyConfigBumpsVersionOnlyWhenBarConfigChanges(t *testing.T) {
233+ r := &fakeBarRunner{}
234+ d := newBarRefreshTestDaemon(r, time.Second)
235+
236+ before := d.barScripts.version
237+ d.ApplyConfig(domain.Config{Bar: domain.BarConfig{TopRight: "top", BottomRight: "bottom", Interval: time.Second}})
238+ require.Equal(t, before, d.barScripts.version, "identical bar config must not invalidate")
239+
240+ d.ApplyConfig(domain.Config{Bar: domain.BarConfig{TopRight: "changed", BottomRight: "bottom", Interval: time.Second}})
241+ require.Equal(t, before+1, d.barScripts.version, "changed bar config must invalidate")
242+}
243+
216 func TestBarScriptFailureLogsAndRetainsLastGood(t *testing.T) {244 func TestBarScriptFailureLogsAndRetainsLastGood(t *testing.T) {
217 var logs bytes.Buffer245 var logs bytes.Buffer
218 r := &fakeBarRunner{outs: []string{"top-good", "bottom-good"}}246 r := &fakeBarRunner{outs: []string{"top-good", "bottom-good"}}
M internal/usecase/daemon/config.go +6 -0
@@ -57,6 +57,7 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
5757 tabs := cfg.Tabs
5858 d.tabsConfig.Store(&tabs)
5959 d.storeThemeConfig(cfg)
60+ barChanged := false
6061 if d.barScripts != nil {
6162 d.barScripts.mu.Lock()
6263 d.barScripts.initLocked()
@@ -67,10 +68,15 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
6768 d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time)
6869 d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext)
6970 d.barScripts.pending = make(map[domain.SessionID]bool)
71+ d.barScripts.lastFailure = make(map[barScriptFailureKey]string)
7072 d.barScripts.version++
73+ barChanged = true
7174 }
7275 d.barScripts.mu.Unlock()
7376 }
77+ if barChanged {
78+ d.refreshBarScriptsAllSessions()
79+ }
7480 d.reapplyThemeAllSessions()
7581 d.repaintAllAttachedClients()
7682 }
@@ -57,6 +57,7 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
57 tabs := cfg.Tabs57 tabs := cfg.Tabs
58 d.tabsConfig.Store(&tabs)58 d.tabsConfig.Store(&tabs)
59 d.storeThemeConfig(cfg)59 d.storeThemeConfig(cfg)
60+ barChanged := false
60 if d.barScripts != nil {61 if d.barScripts != nil {
61 d.barScripts.mu.Lock()62 d.barScripts.mu.Lock()
62 d.barScripts.initLocked()63 d.barScripts.initLocked()
@@ -67,10 +68,15 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
67 d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time)68 d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time)
68 d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext)69 d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext)
69 d.barScripts.pending = make(map[domain.SessionID]bool)70 d.barScripts.pending = make(map[domain.SessionID]bool)
71+ d.barScripts.lastFailure = make(map[barScriptFailureKey]string)
70 d.barScripts.version++72 d.barScripts.version++
73+ barChanged = true
71 }74 }
72 d.barScripts.mu.Unlock()75 d.barScripts.mu.Unlock()
73 }76 }
77+ if barChanged {
78+ d.refreshBarScriptsAllSessions()
79+ }
74 d.reapplyThemeAllSessions()80 d.reapplyThemeAllSessions()
75 d.repaintAllAttachedClients()81 d.repaintAllAttachedClients()
76 }82 }

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