fix(daemon): address claude bar anchor review
Signed-off-by: brice <b@bnema.dev>
- commit
- ce26e86aa36b2f39b2798f3d55db479fec33d715
- author
- brice · · 2026-07-06 15:16 UTC
- parent
- a0fb45a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +186 -15
M internal/usecase/daemon/bar_refresh.go +63 -5
| @@ -33,6 +33,7 @@ type barScriptState struct { | |||
| 33 | 33 | lastRefresh map[domain.SessionID]time.Time | |
| 34 | 34 | lastContext map[domain.SessionID]barScriptContext | |
| 35 | 35 | running map[domain.SessionID]bool | |
| 36 | + | pending map[domain.SessionID]bool | |
| 36 | 37 | version uint64 | |
| 37 | 38 | } | |
| 38 | 39 | ||
| @@ -63,6 +64,9 @@ func (s *barScriptState) initLocked() { | |||
| 63 | 64 | if s.running == nil { | |
| 64 | 65 | s.running = make(map[domain.SessionID]bool) | |
| 65 | 66 | } | |
| 67 | + | if s.pending == nil { | |
| 68 | + | s.pending = make(map[domain.SessionID]bool) | |
| 69 | + | } | |
| 66 | 70 | } | |
| 67 | 71 | ||
| 68 | 72 | func (d *Daemon) barScriptSnapshot(sess *session) (string, string) { | |
| @@ -157,6 +161,7 @@ func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) { | |||
| 157 | 161 | delete(d.barScripts.lastRefresh, id) | |
| 158 | 162 | delete(d.barScripts.lastContext, id) | |
| 159 | 163 | delete(d.barScripts.running, id) | |
| 164 | + | delete(d.barScripts.pending, id) | |
| 160 | 165 | } | |
| 161 | 166 | ||
| 162 | 167 | func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool) bool { | |
| @@ -172,7 +177,15 @@ func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool | |||
| 172 | 177 | if d.barScripts.lastContext[sess.id] != baseCtx { | |
| 173 | 178 | force = true | |
| 174 | 179 | } | |
| 175 | - | if d.barScripts.running[sess.id] || (!force && !d.barScripts.lastRefresh[sess.id].IsZero() && now.Sub(d.barScripts.lastRefresh[sess.id]) < d.barScripts.cfg.interval) { | |
| 180 | + | lastRefresh := d.barScripts.lastRefresh[sess.id] | |
| 181 | + | if d.barScripts.running[sess.id] || (!lastRefresh.IsZero() && now.Sub(lastRefresh) < minBarScriptInterval) { | |
| 182 | + | if force { | |
| 183 | + | d.scheduleBarScriptRefreshLocked(sess, lastRefresh.Add(minBarScriptInterval).Sub(now)) | |
| 184 | + | } | |
| 185 | + | d.barScripts.mu.Unlock() | |
| 186 | + | return false | |
| 187 | + | } | |
| 188 | + | if !force && !lastRefresh.IsZero() && now.Sub(lastRefresh) < d.barScripts.cfg.interval { | |
| 176 | 189 | d.barScripts.mu.Unlock() | |
| 177 | 190 | return false | |
| 178 | 191 | } | |
| @@ -187,6 +200,45 @@ func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool | |||
| 187 | 200 | return true | |
| 188 | 201 | } | |
| 189 | 202 | ||
| 203 | + | func (d *Daemon) scheduleBarScriptRefreshLocked(sess *session, delay time.Duration) { | |
| 204 | + | if d.barScripts.pending[sess.id] { | |
| 205 | + | return | |
| 206 | + | } | |
| 207 | + | if delay < 0 { | |
| 208 | + | delay = 0 | |
| 209 | + | } | |
| 210 | + | d.barScripts.pending[sess.id] = true | |
| 211 | + | go func() { | |
| 212 | + | ctx := sess.ctx | |
| 213 | + | if ctx == nil { | |
| 214 | + | ctx = context.Background() | |
| 215 | + | } | |
| 216 | + | if d.clock != nil { | |
| 217 | + | timer := d.clock.NewTimer(delay) | |
| 218 | + | select { | |
| 219 | + | case <-ctx.Done(): | |
| 220 | + | timer.Stop() | |
| 221 | + | return | |
| 222 | + | case <-timer.C(): | |
| 223 | + | } | |
| 224 | + | } else { | |
| 225 | + | select { | |
| 226 | + | case <-ctx.Done(): | |
| 227 | + | return | |
| 228 | + | case <-time.After(delay): | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | d.barScripts.mu.Lock() | |
| 232 | + | delete(d.barScripts.pending, sess.id) | |
| 233 | + | d.barScripts.mu.Unlock() | |
| 234 | + | now := time.Now() | |
| 235 | + | if d.clock != nil { | |
| 236 | + | now = d.clock.Now() | |
| 237 | + | } | |
| 238 | + | d.refreshBarScriptsIfDue(sess, now, true) | |
| 239 | + | }() | |
| 240 | + | } | |
| 241 | + | ||
| 190 | 242 | func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barScriptConfig, base barScriptContext, version uint64) { | |
| 191 | 243 | if runner == nil { | |
| 192 | 244 | runner = barScriptRunner{baseEnv: d.baseEnv} | |
| @@ -195,8 +247,8 @@ func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barS | |||
| 195 | 247 | if ctx == nil { | |
| 196 | 248 | ctx = context.Background() | |
| 197 | 249 | } | |
| 198 | - | top, topOK := runOneBarScript(ctx, runner, cfg.topRight, base, "top-right") | |
| 199 | - | bottom, bottomOK := runOneBarScript(ctx, runner, cfg.bottomRight, base, "bottom-right") | |
| 250 | + | top, topOK := d.runOneBarScript(ctx, runner, cfg.topRight, base, "top-right", sess.name) | |
| 251 | + | bottom, bottomOK := d.runOneBarScript(ctx, runner, cfg.bottomRight, base, "bottom-right", sess.name) | |
| 200 | 252 | d.barScripts.mu.Lock() | |
| 201 | 253 | d.barScripts.initLocked() | |
| 202 | 254 | if !d.barScripts.running[sess.id] || d.barScripts.version != version { | |
| @@ -222,13 +274,19 @@ func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barS | |||
| 222 | 274 | } | |
| 223 | 275 | } | |
| 224 | 276 | ||
| 225 | - | func runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, base barScriptContext, anchor string) (string, bool) { | |
| 277 | + | func (d *Daemon) runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, base barScriptContext, anchor, sessionName string) (string, bool) { | |
| 226 | 278 | if command == "" { | |
| 227 | 279 | return "", true | |
| 228 | 280 | } | |
| 229 | 281 | base.Anchor = anchor | |
| 230 | 282 | out, err := runner.run(ctx, command, base) | |
| 231 | - | return out, err == nil | |
| 283 | + | if err != nil { | |
| 284 | + | if d.log != nil { | |
| 285 | + | d.log.Warn("bar script failed; keeping last good output", "anchor", anchor, "session", sessionName, "err", err) | |
| 286 | + | } | |
| 287 | + | return "", false | |
| 288 | + | } | |
| 289 | + | return out, true | |
| 232 | 290 | } | |
| 233 | 291 | ||
| 234 | 292 | func (d *Daemon) pokeSessionRender(sess *session) { | |
| @@ -33,6 +33,7 @@ type barScriptState struct { | |||||
| 33 | lastRefresh map[domain.SessionID]time.Time | 33 | lastRefresh map[domain.SessionID]time.Time | ||
| 34 | lastContext map[domain.SessionID]barScriptContext | 34 | lastContext map[domain.SessionID]barScriptContext | ||
| 35 | running map[domain.SessionID]bool | 35 | running map[domain.SessionID]bool | ||
| 36 | + | pending map[domain.SessionID]bool | |||
| 36 | version uint64 | 37 | version uint64 | ||
| 37 | } | 38 | } | ||
| 38 | 39 | ||||
| @@ -63,6 +64,9 @@ func (s *barScriptState) initLocked() { | |||||
| 63 | if s.running == nil { | 64 | if s.running == nil { | ||
| 64 | s.running = make(map[domain.SessionID]bool) | 65 | s.running = make(map[domain.SessionID]bool) | ||
| 65 | } | 66 | } | ||
| 67 | + | if s.pending == nil { | |||
| 68 | + | s.pending = make(map[domain.SessionID]bool) | |||
| 69 | + | } | |||
| 66 | } | 70 | } | ||
| 67 | 71 | ||||
| 68 | func (d *Daemon) barScriptSnapshot(sess *session) (string, string) { | 72 | func (d *Daemon) barScriptSnapshot(sess *session) (string, string) { | ||
| @@ -157,6 +161,7 @@ func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) { | |||||
| 157 | delete(d.barScripts.lastRefresh, id) | 161 | delete(d.barScripts.lastRefresh, id) | ||
| 158 | delete(d.barScripts.lastContext, id) | 162 | delete(d.barScripts.lastContext, id) | ||
| 159 | delete(d.barScripts.running, id) | 163 | delete(d.barScripts.running, id) | ||
| 164 | + | delete(d.barScripts.pending, id) | |||
| 160 | } | 165 | } | ||
| 161 | 166 | ||||
| 162 | func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool) bool { | 167 | func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool) bool { | ||
| @@ -172,7 +177,15 @@ func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool | |||||
| 172 | if d.barScripts.lastContext[sess.id] != baseCtx { | 177 | if d.barScripts.lastContext[sess.id] != baseCtx { | ||
| 173 | force = true | 178 | force = true | ||
| 174 | } | 179 | } | ||
| 175 | - | if d.barScripts.running[sess.id] || (!force && !d.barScripts.lastRefresh[sess.id].IsZero() && now.Sub(d.barScripts.lastRefresh[sess.id]) < d.barScripts.cfg.interval) { | 180 | + | lastRefresh := d.barScripts.lastRefresh[sess.id] |
| 181 | + | if d.barScripts.running[sess.id] || (!lastRefresh.IsZero() && now.Sub(lastRefresh) < minBarScriptInterval) { | |||
| 182 | + | if force { | |||
| 183 | + | d.scheduleBarScriptRefreshLocked(sess, lastRefresh.Add(minBarScriptInterval).Sub(now)) | |||
| 184 | + | } | |||
| 185 | + | d.barScripts.mu.Unlock() | |||
| 186 | + | return false | |||
| 187 | + | } | |||
| 188 | + | if !force && !lastRefresh.IsZero() && now.Sub(lastRefresh) < d.barScripts.cfg.interval { | |||
| 176 | d.barScripts.mu.Unlock() | 189 | d.barScripts.mu.Unlock() | ||
| 177 | return false | 190 | return false | ||
| 178 | } | 191 | } | ||
| @@ -187,6 +200,45 @@ func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool | |||||
| 187 | return true | 200 | return true | ||
| 188 | } | 201 | } | ||
| 189 | 202 | ||||
| 203 | + | func (d *Daemon) scheduleBarScriptRefreshLocked(sess *session, delay time.Duration) { | |||
| 204 | + | if d.barScripts.pending[sess.id] { | |||
| 205 | + | return | |||
| 206 | + | } | |||
| 207 | + | if delay < 0 { | |||
| 208 | + | delay = 0 | |||
| 209 | + | } | |||
| 210 | + | d.barScripts.pending[sess.id] = true | |||
| 211 | + | go func() { | |||
| 212 | + | ctx := sess.ctx | |||
| 213 | + | if ctx == nil { | |||
| 214 | + | ctx = context.Background() | |||
| 215 | + | } | |||
| 216 | + | if d.clock != nil { | |||
| 217 | + | timer := d.clock.NewTimer(delay) | |||
| 218 | + | select { | |||
| 219 | + | case <-ctx.Done(): | |||
| 220 | + | timer.Stop() | |||
| 221 | + | return | |||
| 222 | + | case <-timer.C(): | |||
| 223 | + | } | |||
| 224 | + | } else { | |||
| 225 | + | select { | |||
| 226 | + | case <-ctx.Done(): | |||
| 227 | + | return | |||
| 228 | + | case <-time.After(delay): | |||
| 229 | + | } | |||
| 230 | + | } | |||
| 231 | + | d.barScripts.mu.Lock() | |||
| 232 | + | delete(d.barScripts.pending, sess.id) | |||
| 233 | + | d.barScripts.mu.Unlock() | |||
| 234 | + | now := time.Now() | |||
| 235 | + | if d.clock != nil { | |||
| 236 | + | now = d.clock.Now() | |||
| 237 | + | } | |||
| 238 | + | d.refreshBarScriptsIfDue(sess, now, true) | |||
| 239 | + | }() | |||
| 240 | + | } | |||
| 241 | + | ||||
| 190 | func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barScriptConfig, base barScriptContext, version uint64) { | 242 | func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barScriptConfig, base barScriptContext, version uint64) { | ||
| 191 | if runner == nil { | 243 | if runner == nil { | ||
| 192 | runner = barScriptRunner{baseEnv: d.baseEnv} | 244 | runner = barScriptRunner{baseEnv: d.baseEnv} | ||
| @@ -195,8 +247,8 @@ func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barS | |||||
| 195 | if ctx == nil { | 247 | if ctx == nil { | ||
| 196 | ctx = context.Background() | 248 | ctx = context.Background() | ||
| 197 | } | 249 | } | ||
| 198 | - | top, topOK := runOneBarScript(ctx, runner, cfg.topRight, base, "top-right") | 250 | + | top, topOK := d.runOneBarScript(ctx, runner, cfg.topRight, base, "top-right", sess.name) |
| 199 | - | bottom, bottomOK := runOneBarScript(ctx, runner, cfg.bottomRight, base, "bottom-right") | 251 | + | bottom, bottomOK := d.runOneBarScript(ctx, runner, cfg.bottomRight, base, "bottom-right", sess.name) |
| 200 | d.barScripts.mu.Lock() | 252 | d.barScripts.mu.Lock() | ||
| 201 | d.barScripts.initLocked() | 253 | d.barScripts.initLocked() | ||
| 202 | if !d.barScripts.running[sess.id] || d.barScripts.version != version { | 254 | if !d.barScripts.running[sess.id] || d.barScripts.version != version { | ||
| @@ -222,13 +274,19 @@ func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barS | |||||
| 222 | } | 274 | } | ||
| 223 | } | 275 | } | ||
| 224 | 276 | ||||
| 225 | - | func runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, base barScriptContext, anchor string) (string, bool) { | 277 | + | func (d *Daemon) runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, base barScriptContext, anchor, sessionName string) (string, bool) { |
| 226 | if command == "" { | 278 | if command == "" { | ||
| 227 | return "", true | 279 | return "", true | ||
| 228 | } | 280 | } | ||
| 229 | base.Anchor = anchor | 281 | base.Anchor = anchor | ||
| 230 | out, err := runner.run(ctx, command, base) | 282 | out, err := runner.run(ctx, command, base) | ||
| 231 | - | return out, err == nil | 283 | + | if err != nil { |
| 284 | + | if d.log != nil { | |||
| 285 | + | d.log.Warn("bar script failed; keeping last good output", "anchor", anchor, "session", sessionName, "err", err) | |||
| 286 | + | } | |||
| 287 | + | return "", false | |||
| 288 | + | } | |||
| 289 | + | return out, true | |||
| 232 | } | 290 | } | ||
| 233 | 291 | ||||
| 234 | func (d *Daemon) pokeSessionRender(sess *session) { | 292 | func (d *Daemon) pokeSessionRender(sess *session) { | ||
M internal/usecase/daemon/bar_refresh_test.go +68 -0
| @@ -1,8 +1,11 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "context" | |
| 5 | 6 | "errors" | |
| 7 | + | "log/slog" | |
| 8 | + | "strings" | |
| 6 | 9 | "sync" | |
| 7 | 10 | "testing" | |
| 8 | 11 | "time" | |
| @@ -98,6 +101,36 @@ func TestBarScriptRefreshSkipsWithoutAttachedClient(t *testing.T) { | |||
| 98 | 101 | require.Empty(t, r.calls) | |
| 99 | 102 | } | |
| 100 | 103 | ||
| 104 | + | func TestBarScriptForcedRefreshRespectsMinimumInterval(t *testing.T) { | |
| 105 | + | r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}} | |
| 106 | + | d := newBarRefreshTestDaemon(r, 5*time.Second) | |
| 107 | + | sess := newBarRefreshTestSession() | |
| 108 | + | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 109 | + | ||
| 110 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true)) | |
| 111 | + | waitBarRefreshIdle(t, d) | |
| 112 | + | require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, int64(500*time.Millisecond)), true)) | |
| 113 | + | require.Len(t, r.calls, 2) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func TestBarScriptContextChangeDebouncesUntilMinimumInterval(t *testing.T) { | |
| 117 | + | r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}} | |
| 118 | + | d := newBarRefreshTestDaemon(r, 5*time.Second) | |
| 119 | + | sess := newBarRefreshTestSession() | |
| 120 | + | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 121 | + | ||
| 122 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Now(), false)) | |
| 123 | + | waitBarRefreshIdle(t, d) | |
| 124 | + | sess.cwd = "/other" | |
| 125 | + | require.False(t, d.refreshBarScriptsIfDue(sess, time.Now().Add(100*time.Millisecond), false)) | |
| 126 | + | require.Eventually(t, func() bool { | |
| 127 | + | r.mu.Lock() | |
| 128 | + | defer r.mu.Unlock() | |
| 129 | + | return len(r.calls) == 4 | |
| 130 | + | }, 1500*time.Millisecond, 10*time.Millisecond) | |
| 131 | + | require.Equal(t, "/other", r.calls[2].PaneCWD) | |
| 132 | + | } | |
| 133 | + | ||
| 101 | 134 | func TestBarScriptRefreshForcesWhenContextChanges(t *testing.T) { | |
| 102 | 135 | r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}} | |
| 103 | 136 | d := newBarRefreshTestDaemon(r, 5*time.Second) | |
| @@ -113,6 +146,40 @@ func TestBarScriptRefreshForcesWhenContextChanges(t *testing.T) { | |||
| 113 | 146 | require.Equal(t, "/other", r.calls[2].PaneCWD) | |
| 114 | 147 | } | |
| 115 | 148 | ||
| 149 | + | func TestApplyConfigPreservesBarOutputsWhenBarConfigUnchanged(t *testing.T) { | |
| 150 | + | r := &fakeBarRunner{} | |
| 151 | + | d := newBarRefreshTestDaemon(r, time.Second) | |
| 152 | + | sess := newBarRefreshTestSession() | |
| 153 | + | d.barScripts.outputs[sess.id] = barScriptOutputs{topRight: "top-good", bottomRight: "bottom-good"} | |
| 154 | + | ||
| 155 | + | d.ApplyConfig(domain.Config{Bar: domain.BarConfig{TopRight: "top", BottomRight: "bottom", Interval: time.Second}}) | |
| 156 | + | ||
| 157 | + | state := d.barStateFor(sess, "") | |
| 158 | + | require.Equal(t, "top-good", state.topRight) | |
| 159 | + | require.Equal(t, "bottom-good", state.bottomRight) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func TestBarScriptFailureLogsAndRetainsLastGood(t *testing.T) { | |
| 163 | + | var logs bytes.Buffer | |
| 164 | + | r := &fakeBarRunner{outs: []string{"top-good", "bottom-good"}} | |
| 165 | + | d := newBarRefreshTestDaemon(r, time.Second) | |
| 166 | + | d.log = slog.New(slog.NewTextHandler(&logs, nil)) | |
| 167 | + | sess := newBarRefreshTestSession() | |
| 168 | + | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 169 | + | ||
| 170 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true)) | |
| 171 | + | waitBarRefreshIdle(t, d) | |
| 172 | + | r.errs = []error{nil, nil, errors.New("boom"), errors.New("bang")} | |
| 173 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(11, 0), true)) | |
| 174 | + | waitBarRefreshIdle(t, d) | |
| 175 | + | ||
| 176 | + | state := d.barStateFor(sess, "") | |
| 177 | + | require.Equal(t, "top-good", state.topRight) | |
| 178 | + | require.Equal(t, "bottom-good", state.bottomRight) | |
| 179 | + | require.True(t, strings.Contains(logs.String(), "bar script failed; keeping last good output"), logs.String()) | |
| 180 | + | require.True(t, strings.Contains(logs.String(), "anchor=top-right"), logs.String()) | |
| 181 | + | } | |
| 182 | + | ||
| 116 | 183 | func TestBarScriptRunDoesNotRestoreClearedSessionState(t *testing.T) { | |
| 117 | 184 | r := &fakeBarRunner{outs: []string{"top", "bottom"}} | |
| 118 | 185 | d := newBarRefreshTestDaemon(r, time.Second) | |
| @@ -178,6 +245,7 @@ func newBarRefreshTestDaemon(r *fakeBarRunner, interval time.Duration) *Daemon { | |||
| 178 | 245 | lastRefresh: make(map[domain.SessionID]time.Time), | |
| 179 | 246 | lastContext: make(map[domain.SessionID]barScriptContext), | |
| 180 | 247 | running: make(map[domain.SessionID]bool), | |
| 248 | + | pending: make(map[domain.SessionID]bool), | |
| 181 | 249 | }} | |
| 182 | 250 | return d | |
| 183 | 251 | } | |
| @@ -1,8 +1,11 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bytes" | |||
| 4 | "context" | 5 | "context" | ||
| 5 | "errors" | 6 | "errors" | ||
| 7 | + | "log/slog" | |||
| 8 | + | "strings" | |||
| 6 | "sync" | 9 | "sync" | ||
| 7 | "testing" | 10 | "testing" | ||
| 8 | "time" | 11 | "time" | ||
| @@ -98,6 +101,36 @@ func TestBarScriptRefreshSkipsWithoutAttachedClient(t *testing.T) { | |||||
| 98 | require.Empty(t, r.calls) | 101 | require.Empty(t, r.calls) | ||
| 99 | } | 102 | } | ||
| 100 | 103 | ||||
| 104 | + | func TestBarScriptForcedRefreshRespectsMinimumInterval(t *testing.T) { | |||
| 105 | + | r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}} | |||
| 106 | + | d := newBarRefreshTestDaemon(r, 5*time.Second) | |||
| 107 | + | sess := newBarRefreshTestSession() | |||
| 108 | + | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |||
| 109 | + | ||||
| 110 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true)) | |||
| 111 | + | waitBarRefreshIdle(t, d) | |||
| 112 | + | require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, int64(500*time.Millisecond)), true)) | |||
| 113 | + | require.Len(t, r.calls, 2) | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func TestBarScriptContextChangeDebouncesUntilMinimumInterval(t *testing.T) { | |||
| 117 | + | r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}} | |||
| 118 | + | d := newBarRefreshTestDaemon(r, 5*time.Second) | |||
| 119 | + | sess := newBarRefreshTestSession() | |||
| 120 | + | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |||
| 121 | + | ||||
| 122 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Now(), false)) | |||
| 123 | + | waitBarRefreshIdle(t, d) | |||
| 124 | + | sess.cwd = "/other" | |||
| 125 | + | require.False(t, d.refreshBarScriptsIfDue(sess, time.Now().Add(100*time.Millisecond), false)) | |||
| 126 | + | require.Eventually(t, func() bool { | |||
| 127 | + | r.mu.Lock() | |||
| 128 | + | defer r.mu.Unlock() | |||
| 129 | + | return len(r.calls) == 4 | |||
| 130 | + | }, 1500*time.Millisecond, 10*time.Millisecond) | |||
| 131 | + | require.Equal(t, "/other", r.calls[2].PaneCWD) | |||
| 132 | + | } | |||
| 133 | + | ||||
| 101 | func TestBarScriptRefreshForcesWhenContextChanges(t *testing.T) { | 134 | func TestBarScriptRefreshForcesWhenContextChanges(t *testing.T) { | ||
| 102 | r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}} | 135 | r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}} | ||
| 103 | d := newBarRefreshTestDaemon(r, 5*time.Second) | 136 | d := newBarRefreshTestDaemon(r, 5*time.Second) | ||
| @@ -113,6 +146,40 @@ func TestBarScriptRefreshForcesWhenContextChanges(t *testing.T) { | |||||
| 113 | require.Equal(t, "/other", r.calls[2].PaneCWD) | 146 | require.Equal(t, "/other", r.calls[2].PaneCWD) | ||
| 114 | } | 147 | } | ||
| 115 | 148 | ||||
| 149 | + | func TestApplyConfigPreservesBarOutputsWhenBarConfigUnchanged(t *testing.T) { | |||
| 150 | + | r := &fakeBarRunner{} | |||
| 151 | + | d := newBarRefreshTestDaemon(r, time.Second) | |||
| 152 | + | sess := newBarRefreshTestSession() | |||
| 153 | + | d.barScripts.outputs[sess.id] = barScriptOutputs{topRight: "top-good", bottomRight: "bottom-good"} | |||
| 154 | + | ||||
| 155 | + | d.ApplyConfig(domain.Config{Bar: domain.BarConfig{TopRight: "top", BottomRight: "bottom", Interval: time.Second}}) | |||
| 156 | + | ||||
| 157 | + | state := d.barStateFor(sess, "") | |||
| 158 | + | require.Equal(t, "top-good", state.topRight) | |||
| 159 | + | require.Equal(t, "bottom-good", state.bottomRight) | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func TestBarScriptFailureLogsAndRetainsLastGood(t *testing.T) { | |||
| 163 | + | var logs bytes.Buffer | |||
| 164 | + | r := &fakeBarRunner{outs: []string{"top-good", "bottom-good"}} | |||
| 165 | + | d := newBarRefreshTestDaemon(r, time.Second) | |||
| 166 | + | d.log = slog.New(slog.NewTextHandler(&logs, nil)) | |||
| 167 | + | sess := newBarRefreshTestSession() | |||
| 168 | + | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |||
| 169 | + | ||||
| 170 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true)) | |||
| 171 | + | waitBarRefreshIdle(t, d) | |||
| 172 | + | r.errs = []error{nil, nil, errors.New("boom"), errors.New("bang")} | |||
| 173 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(11, 0), true)) | |||
| 174 | + | waitBarRefreshIdle(t, d) | |||
| 175 | + | ||||
| 176 | + | state := d.barStateFor(sess, "") | |||
| 177 | + | require.Equal(t, "top-good", state.topRight) | |||
| 178 | + | require.Equal(t, "bottom-good", state.bottomRight) | |||
| 179 | + | require.True(t, strings.Contains(logs.String(), "bar script failed; keeping last good output"), logs.String()) | |||
| 180 | + | require.True(t, strings.Contains(logs.String(), "anchor=top-right"), logs.String()) | |||
| 181 | + | } | |||
| 182 | + | ||||
| 116 | func TestBarScriptRunDoesNotRestoreClearedSessionState(t *testing.T) { | 183 | func TestBarScriptRunDoesNotRestoreClearedSessionState(t *testing.T) { | ||
| 117 | r := &fakeBarRunner{outs: []string{"top", "bottom"}} | 184 | r := &fakeBarRunner{outs: []string{"top", "bottom"}} | ||
| 118 | d := newBarRefreshTestDaemon(r, time.Second) | 185 | d := newBarRefreshTestDaemon(r, time.Second) | ||
| @@ -178,6 +245,7 @@ func newBarRefreshTestDaemon(r *fakeBarRunner, interval time.Duration) *Daemon { | |||||
| 178 | lastRefresh: make(map[domain.SessionID]time.Time), | 245 | lastRefresh: make(map[domain.SessionID]time.Time), | ||
| 179 | lastContext: make(map[domain.SessionID]barScriptContext), | 246 | lastContext: make(map[domain.SessionID]barScriptContext), | ||
| 180 | running: make(map[domain.SessionID]bool), | 247 | running: make(map[domain.SessionID]bool), | ||
| 248 | + | pending: make(map[domain.SessionID]bool), | |||
| 181 | }} | 249 | }} | ||
| 182 | return d | 250 | return d | ||
| 183 | } | 251 | } | ||
M internal/usecase/daemon/bar_script.go +15 -1
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "os/exec" | |
| 9 | 9 | "strconv" | |
| 10 | 10 | "strings" | |
| 11 | + | "syscall" | |
| 11 | 12 | "time" | |
| 12 | 13 | "unicode/utf8" | |
| 13 | 14 | ) | |
| @@ -70,6 +71,14 @@ func (r barScriptRunner) run(ctx context.Context, command string, scriptCtx barS | |||
| 70 | 71 | ||
| 71 | 72 | cmd := exec.CommandContext(ctx, "sh", "-c", command) | |
| 72 | 73 | cmd.Env = scriptCtx.env(r.baseEnv) | |
| 74 | + | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | |
| 75 | + | cmd.WaitDelay = timeout | |
| 76 | + | cmd.Cancel = func() error { | |
| 77 | + | if cmd.Process == nil { | |
| 78 | + | return nil | |
| 79 | + | } | |
| 80 | + | return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) | |
| 81 | + | } | |
| 73 | 82 | var stdout boundedBuffer | |
| 74 | 83 | stdout.limit = barScriptOutputLimit | |
| 75 | 84 | cmd.Stdout = &stdout | |
| @@ -156,9 +165,14 @@ func skipEscapeSequence(s string, i int) int { | |||
| 156 | 165 | return skipCSI(s, i+1) | |
| 157 | 166 | case ']', 'P', '^', '_', 'X': | |
| 158 | 167 | return skipStringControl(s, i+1) | |
| 159 | - | default: | |
| 168 | + | } | |
| 169 | + | for i < len(s) && s[i] >= 0x20 && s[i] <= 0x2f { | |
| 170 | + | i++ | |
| 171 | + | } | |
| 172 | + | if i < len(s) && s[i] >= 0x30 && s[i] <= 0x7e { | |
| 160 | 173 | return i + 1 | |
| 161 | 174 | } | |
| 175 | + | return i | |
| 162 | 176 | } | |
| 163 | 177 | ||
| 164 | 178 | func skipCSI(s string, i int) int { | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "os/exec" | 8 | "os/exec" | ||
| 9 | "strconv" | 9 | "strconv" | ||
| 10 | "strings" | 10 | "strings" | ||
| 11 | + | "syscall" | |||
| 11 | "time" | 12 | "time" | ||
| 12 | "unicode/utf8" | 13 | "unicode/utf8" | ||
| 13 | ) | 14 | ) | ||
| @@ -70,6 +71,14 @@ func (r barScriptRunner) run(ctx context.Context, command string, scriptCtx barS | |||||
| 70 | 71 | ||||
| 71 | cmd := exec.CommandContext(ctx, "sh", "-c", command) | 72 | cmd := exec.CommandContext(ctx, "sh", "-c", command) | ||
| 72 | cmd.Env = scriptCtx.env(r.baseEnv) | 73 | cmd.Env = scriptCtx.env(r.baseEnv) | ||
| 74 | + | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | |||
| 75 | + | cmd.WaitDelay = timeout | |||
| 76 | + | cmd.Cancel = func() error { | |||
| 77 | + | if cmd.Process == nil { | |||
| 78 | + | return nil | |||
| 79 | + | } | |||
| 80 | + | return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) | |||
| 81 | + | } | |||
| 73 | var stdout boundedBuffer | 82 | var stdout boundedBuffer | ||
| 74 | stdout.limit = barScriptOutputLimit | 83 | stdout.limit = barScriptOutputLimit | ||
| 75 | cmd.Stdout = &stdout | 84 | cmd.Stdout = &stdout | ||
| @@ -156,9 +165,14 @@ func skipEscapeSequence(s string, i int) int { | |||||
| 156 | return skipCSI(s, i+1) | 165 | return skipCSI(s, i+1) | ||
| 157 | case ']', 'P', '^', '_', 'X': | 166 | case ']', 'P', '^', '_', 'X': | ||
| 158 | return skipStringControl(s, i+1) | 167 | return skipStringControl(s, i+1) | ||
| 159 | - | default: | 168 | + | } |
| 169 | + | for i < len(s) && s[i] >= 0x20 && s[i] <= 0x2f { | |||
| 170 | + | i++ | |||
| 171 | + | } | |||
| 172 | + | if i < len(s) && s[i] >= 0x30 && s[i] <= 0x7e { | |||
| 160 | return i + 1 | 173 | return i + 1 | ||
| 161 | } | 174 | } | ||
| 175 | + | return i | |||
| 162 | } | 176 | } | ||
| 163 | 177 | ||||
| 164 | func skipCSI(s string, i int) int { | 178 | func skipCSI(s string, i int) int { | ||
M internal/usecase/daemon/bar_script_test.go +14 -0
| @@ -20,6 +20,8 @@ func TestBarScriptSanitizeOutput(t *testing.T) { | |||
| 20 | 20 | {name: "ansi stripped", raw: "\x1b[31mred\x1b[0m", limit: barScriptOutputLimit, want: "red"}, | |
| 21 | 21 | {name: "controls stripped", raw: "a\x00b\x07c", limit: barScriptOutputLimit, want: "abc"}, | |
| 22 | 22 | {name: "osc stripped", raw: "a\x1b]0;bad\ab", limit: barScriptOutputLimit, want: "ab"}, | |
| 23 | + | {name: "charset escape stripped", raw: "a\x1b(Bb\x1b)0c", limit: barScriptOutputLimit, want: "abc"}, | |
| 24 | + | {name: "non csi escape stripped", raw: "a\x1b7b\x1b8c", limit: barScriptOutputLimit, want: "abc"}, | |
| 23 | 25 | {name: "c1 controls stripped", raw: "a\u009b31mb\u009d0;bad\ac", limit: barScriptOutputLimit, want: "abc"}, | |
| 24 | 26 | {name: "utf8 and nerd font preserved", raw: " 14:32 ↑3", limit: barScriptOutputLimit, want: " 14:32 ↑3"}, | |
| 25 | 27 | {name: "display capped", raw: strings.Repeat("a", barScriptDisplayLimit+10), limit: barScriptOutputLimit, want: strings.Repeat("a", barScriptDisplayLimit)}, | |
| @@ -100,6 +102,18 @@ func TestBarScriptRunner(t *testing.T) { | |||
| 100 | 102 | } | |
| 101 | 103 | } | |
| 102 | 104 | ||
| 105 | + | func TestBarScriptRunnerBackgroundChildStdoutTimeout(t *testing.T) { | |
| 106 | + | runner := barScriptRunner{timeout: 50 * time.Millisecond, baseEnv: os.Environ()} | |
| 107 | + | start := time.Now() | |
| 108 | + | _, err := runner.run(context.Background(), "sleep 1 &", barScriptContext{}) | |
| 109 | + | if err == nil { | |
| 110 | + | t.Fatalf("background child run err = nil, want timeout/wait error") | |
| 111 | + | } | |
| 112 | + | if elapsed := time.Since(start); elapsed > 500*time.Millisecond { | |
| 113 | + | t.Fatalf("background child run elapsed = %s, want <= 500ms", elapsed) | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 103 | 117 | func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) { | |
| 104 | 118 | runner := barScriptRunner{timeout: time.Second, baseEnv: os.Environ()} | |
| 105 | 119 | got, err := runner.run(context.Background(), "yes a | head -c 4096", barScriptContext{}) | |
| @@ -20,6 +20,8 @@ func TestBarScriptSanitizeOutput(t *testing.T) { | |||||
| 20 | {name: "ansi stripped", raw: "\x1b[31mred\x1b[0m", limit: barScriptOutputLimit, want: "red"}, | 20 | {name: "ansi stripped", raw: "\x1b[31mred\x1b[0m", limit: barScriptOutputLimit, want: "red"}, | ||
| 21 | {name: "controls stripped", raw: "a\x00b\x07c", limit: barScriptOutputLimit, want: "abc"}, | 21 | {name: "controls stripped", raw: "a\x00b\x07c", limit: barScriptOutputLimit, want: "abc"}, | ||
| 22 | {name: "osc stripped", raw: "a\x1b]0;bad\ab", limit: barScriptOutputLimit, want: "ab"}, | 22 | {name: "osc stripped", raw: "a\x1b]0;bad\ab", limit: barScriptOutputLimit, want: "ab"}, | ||
| 23 | + | {name: "charset escape stripped", raw: "a\x1b(Bb\x1b)0c", limit: barScriptOutputLimit, want: "abc"}, | |||
| 24 | + | {name: "non csi escape stripped", raw: "a\x1b7b\x1b8c", limit: barScriptOutputLimit, want: "abc"}, | |||
| 23 | {name: "c1 controls stripped", raw: "a\u009b31mb\u009d0;bad\ac", limit: barScriptOutputLimit, want: "abc"}, | 25 | {name: "c1 controls stripped", raw: "a\u009b31mb\u009d0;bad\ac", limit: barScriptOutputLimit, want: "abc"}, | ||
| 24 | {name: "utf8 and nerd font preserved", raw: " 14:32 ↑3", limit: barScriptOutputLimit, want: " 14:32 ↑3"}, | 26 | {name: "utf8 and nerd font preserved", raw: " 14:32 ↑3", limit: barScriptOutputLimit, want: " 14:32 ↑3"}, | ||
| 25 | {name: "display capped", raw: strings.Repeat("a", barScriptDisplayLimit+10), limit: barScriptOutputLimit, want: strings.Repeat("a", barScriptDisplayLimit)}, | 27 | {name: "display capped", raw: strings.Repeat("a", barScriptDisplayLimit+10), limit: barScriptOutputLimit, want: strings.Repeat("a", barScriptDisplayLimit)}, | ||
| @@ -100,6 +102,18 @@ func TestBarScriptRunner(t *testing.T) { | |||||
| 100 | } | 102 | } | ||
| 101 | } | 103 | } | ||
| 102 | 104 | ||||
| 105 | + | func TestBarScriptRunnerBackgroundChildStdoutTimeout(t *testing.T) { | |||
| 106 | + | runner := barScriptRunner{timeout: 50 * time.Millisecond, baseEnv: os.Environ()} | |||
| 107 | + | start := time.Now() | |||
| 108 | + | _, err := runner.run(context.Background(), "sleep 1 &", barScriptContext{}) | |||
| 109 | + | if err == nil { | |||
| 110 | + | t.Fatalf("background child run err = nil, want timeout/wait error") | |||
| 111 | + | } | |||
| 112 | + | if elapsed := time.Since(start); elapsed > 500*time.Millisecond { | |||
| 113 | + | t.Fatalf("background child run elapsed = %s, want <= 500ms", elapsed) | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | ||||
| 103 | func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) { | 117 | func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) { | ||
| 104 | runner := barScriptRunner{timeout: time.Second, baseEnv: os.Environ()} | 118 | runner := barScriptRunner{timeout: time.Second, baseEnv: os.Environ()} | ||
| 105 | got, err := runner.run(context.Background(), "yes a | head -c 4096", barScriptContext{}) | 119 | got, err := runner.run(context.Background(), "yes a | head -c 4096", barScriptContext{}) | ||
M internal/usecase/daemon/config.go +10 -5
| @@ -30,11 +30,16 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) { | |||
| 30 | 30 | d.themeMode.Store(uint32(cfg.Theme)) | |
| 31 | 31 | if d.barScripts != nil { | |
| 32 | 32 | d.barScripts.mu.Lock() | |
| 33 | - | d.barScripts.cfg = barConfigFromDomain(cfg.Bar) | |
| 34 | - | d.barScripts.outputs = make(map[domain.SessionID]barScriptOutputs) | |
| 35 | - | d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time) | |
| 36 | - | d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext) | |
| 37 | - | d.barScripts.version++ | |
| 33 | + | d.barScripts.initLocked() | |
| 34 | + | barCfg := barConfigFromDomain(cfg.Bar) | |
| 35 | + | if d.barScripts.cfg != barCfg { | |
| 36 | + | d.barScripts.cfg = barCfg | |
| 37 | + | d.barScripts.outputs = make(map[domain.SessionID]barScriptOutputs) | |
| 38 | + | d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time) | |
| 39 | + | d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext) | |
| 40 | + | d.barScripts.pending = make(map[domain.SessionID]bool) | |
| 41 | + | d.barScripts.version++ | |
| 42 | + | } | |
| 38 | 43 | d.barScripts.mu.Unlock() | |
| 39 | 44 | } | |
| 40 | 45 | d.reapplyThemeAllSessions() | |
| @@ -30,11 +30,16 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) { | |||||
| 30 | d.themeMode.Store(uint32(cfg.Theme)) | 30 | d.themeMode.Store(uint32(cfg.Theme)) | ||
| 31 | if d.barScripts != nil { | 31 | if d.barScripts != nil { | ||
| 32 | d.barScripts.mu.Lock() | 32 | d.barScripts.mu.Lock() | ||
| 33 | - | d.barScripts.cfg = barConfigFromDomain(cfg.Bar) | 33 | + | d.barScripts.initLocked() |
| 34 | - | d.barScripts.outputs = make(map[domain.SessionID]barScriptOutputs) | 34 | + | barCfg := barConfigFromDomain(cfg.Bar) |
| 35 | - | d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time) | 35 | + | if d.barScripts.cfg != barCfg { |
| 36 | - | d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext) | 36 | + | d.barScripts.cfg = barCfg |
| 37 | - | d.barScripts.version++ | 37 | + | d.barScripts.outputs = make(map[domain.SessionID]barScriptOutputs) |
| 38 | + | d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time) | |||
| 39 | + | d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext) | |||
| 40 | + | d.barScripts.pending = make(map[domain.SessionID]bool) | |||
| 41 | + | d.barScripts.version++ | |||
| 42 | + | } | |||
| 38 | d.barScripts.mu.Unlock() | 43 | d.barScripts.mu.Unlock() | ||
| 39 | } | 44 | } | ||
| 40 | d.reapplyThemeAllSessions() | 45 | d.reapplyThemeAllSessions() | ||
M internal/usecase/daemon/status.go +1 -1
| @@ -231,7 +231,7 @@ func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSe | |||
| 231 | 231 | // its " text" width plus a one-cell gap so drawRightPlainText always fits. | |
| 232 | 232 | copyReserve := 1 | |
| 233 | 233 | if feedback != "" { | |
| 234 | - | copyReserve = len([]rune(feedback)) + 2 | |
| 234 | + | copyReserve = statusTextWidth(feedback) + 2 | |
| 235 | 235 | } | |
| 236 | 236 | physicalBudget := rowLen - leftUsed - copyReserve | |
| 237 | 237 | if physicalBudget <= 0 || len(entries) == 0 { | |
| @@ -231,7 +231,7 @@ func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSe | |||||
| 231 | // its " text" width plus a one-cell gap so drawRightPlainText always fits. | 231 | // its " text" width plus a one-cell gap so drawRightPlainText always fits. | ||
| 232 | copyReserve := 1 | 232 | copyReserve := 1 | ||
| 233 | if feedback != "" { | 233 | if feedback != "" { | ||
| 234 | - | copyReserve = len([]rune(feedback)) + 2 | 234 | + | copyReserve = statusTextWidth(feedback) + 2 |
| 235 | } | 235 | } | ||
| 236 | physicalBudget := rowLen - leftUsed - copyReserve | 236 | physicalBudget := rowLen - leftUsed - copyReserve | ||
| 237 | if physicalBudget <= 0 || len(entries) == 0 { | 237 | if physicalBudget <= 0 || len(entries) == 0 { | ||
M internal/usecase/daemon/status_test.go +13 -0
| @@ -716,6 +716,19 @@ func TestStatusBarMRUWholeEntryFittingWithBottomRightScript(t *testing.T) { | |||
| 716 | 716 | require.True(t, strings.HasSuffix(text, " git"), text) | |
| 717 | 717 | } | |
| 718 | 718 | ||
| 719 | + | func TestStatusBarMRUFittingReservesWideRightAnchorWidth(t *testing.T) { | |
| 720 | + | state := barState{status: statusSnapshot{session: "cur"}, bottomRight: "界界", mru: []mruSession{{name: "a"}}} | |
| 721 | + | row := make([]renderer.Cell, 12) | |
| 722 | + | ||
| 723 | + | drawStatusBarState(row, state, resolveThemeStyles(nil)) | |
| 724 | + | ||
| 725 | + | text := rowText(row) | |
| 726 | + | require.NotContains(t, text, " a ") | |
| 727 | + | require.True(t, strings.HasSuffix(text, " 界 界 "), text) | |
| 728 | + | require.True(t, row[9].Continuation) | |
| 729 | + | require.True(t, row[11].Continuation) | |
| 730 | + | } | |
| 731 | + | ||
| 719 | 732 | func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) { | |
| 720 | 733 | state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []mruSession{{name: "a"}, {name: "b"}, {name: "c"}}} | |
| 721 | 734 | row := make([]renderer.Cell, 20) | |
| @@ -716,6 +716,19 @@ func TestStatusBarMRUWholeEntryFittingWithBottomRightScript(t *testing.T) { | |||||
| 716 | require.True(t, strings.HasSuffix(text, " git"), text) | 716 | require.True(t, strings.HasSuffix(text, " git"), text) | ||
| 717 | } | 717 | } | ||
| 718 | 718 | ||||
| 719 | + | func TestStatusBarMRUFittingReservesWideRightAnchorWidth(t *testing.T) { | |||
| 720 | + | state := barState{status: statusSnapshot{session: "cur"}, bottomRight: "界界", mru: []mruSession{{name: "a"}}} | |||
| 721 | + | row := make([]renderer.Cell, 12) | |||
| 722 | + | ||||
| 723 | + | drawStatusBarState(row, state, resolveThemeStyles(nil)) | |||
| 724 | + | ||||
| 725 | + | text := rowText(row) | |||
| 726 | + | require.NotContains(t, text, " a ") | |||
| 727 | + | require.True(t, strings.HasSuffix(text, " 界 界 "), text) | |||
| 728 | + | require.True(t, row[9].Continuation) | |||
| 729 | + | require.True(t, row[11].Continuation) | |||
| 730 | + | } | |||
| 731 | + | ||||
| 719 | func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) { | 732 | func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) { | ||
| 720 | state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []mruSession{{name: "a"}, {name: "b"}, {name: "c"}}} | 733 | state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []mruSession{{name: "a"}, {name: "b"}, {name: "c"}}} | ||
| 721 | row := make([]renderer.Cell, 20) | 734 | row := make([]renderer.Cell, 20) | ||
M scripts/vev-bar-bottom-right +2 -3
| @@ -26,9 +26,8 @@ line=$name | |||
| 26 | 26 | ||
| 27 | 27 | counts=$(git -C "$VEV_PANE_CWD" rev-list --left-right --count '@{upstream}...HEAD' 2>/dev/null || true) | |
| 28 | 28 | if [ -n "$counts" ]; then | |
| 29 | - | set -- $counts | |
| 30 | - | behind=${1:-0} | |
| 31 | - | ahead=${2:-0} | |
| 29 | + | behind=$(printf '%s\n' "$counts" | awk '{ print $1 }') | |
| 30 | + | ahead=$(printf '%s\n' "$counts" | awk '{ print $2 }') | |
| 32 | 31 | case $ahead in | |
| 33 | 32 | ''|*[!0-9]*) ahead=0 ;; | |
| 34 | 33 | esac | |
| @@ -26,9 +26,8 @@ line=$name | |||||
| 26 | 26 | ||||
| 27 | counts=$(git -C "$VEV_PANE_CWD" rev-list --left-right --count '@{upstream}...HEAD' 2>/dev/null || true) | 27 | counts=$(git -C "$VEV_PANE_CWD" rev-list --left-right --count '@{upstream}...HEAD' 2>/dev/null || true) | ||
| 28 | if [ -n "$counts" ]; then | 28 | if [ -n "$counts" ]; then | ||
| 29 | - | set -- $counts | 29 | + | behind=$(printf '%s\n' "$counts" | awk '{ print $1 }') |
| 30 | - | behind=${1:-0} | 30 | + | ahead=$(printf '%s\n' "$counts" | awk '{ print $2 }') |
| 31 | - | ahead=${2:-0} | |||
| 32 | case $ahead in | 31 | case $ahead in | ||
| 33 | ''|*[!0-9]*) ahead=0 ;; | 32 | ''|*[!0-9]*) ahead=0 ;; | ||
| 34 | esac | 33 | esac | ||