detached edf4b33

fix(daemon): address PR review feedback

Signed-off-by: brice <b@bnema.dev>
commit
edf4b33934cf3675505fd854b4b6549c5ac82d79
author
brice · · 2026-07-06 16:26 UTC
parent
ce26e86
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +301 -139
M internal/adapters/config/config.go +37 -14
@@ -16,10 +16,7 @@ import (
1616 "github.com/bnema/vev/internal/ports"
1717 )
1818
19-const (
20- pollInterval = 2 * time.Second
21- minimumBarInterval = time.Second
22-)
19+const pollInterval = 2 * time.Second
2320
2421 var processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`)
2522
@@ -65,18 +62,28 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
6562 }
6663 cfg.Theme = mode
6764 case key == "bar.top-right":
68- cfg.Bar.TopRight = parseBarCommand(value)
65+ command, ok := parseBarCommand(value)
66+ if !ok {
67+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.top-right %q", value)})
68+ continue
69+ }
70+ cfg.Bar.TopRight = command
6971 case key == "bar.bottom-right":
70- cfg.Bar.BottomRight = parseBarCommand(value)
72+ command, ok := parseBarCommand(value)
73+ if !ok {
74+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.bottom-right %q", value)})
75+ continue
76+ }
77+ cfg.Bar.BottomRight = command
7178 case key == "bar.interval":
7279 interval, err := time.ParseDuration(value)
7380 if err != nil {
7481 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.interval %q", value)})
7582 continue
7683 }
77- if interval < minimumBarInterval {
78- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("bar.interval below minimum %q", minimumBarInterval)})
79- interval = minimumBarInterval
84+ if interval < domain.MinBarInterval {
85+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("bar.interval below minimum %q", domain.MinBarInterval)})
86+ interval = domain.MinBarInterval
8087 }
8188 cfg.Bar.Interval = interval
8289 case strings.HasPrefix(key, "code."):
@@ -217,14 +224,16 @@ func parseProcessList(value string, lineNo int) ([]string, []domain.Warning) {
217224 return out, warnings
218225 }
219226
220-func parseBarCommand(value string) string {
227+func parseBarCommand(value string) (string, bool) {
221228 value = strings.TrimSpace(value)
222229 if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
223- if unquoted, err := strconv.Unquote(value); err == nil {
224- return unquoted
230+ unquoted, err := strconv.Unquote(value)
231+ if err != nil {
232+ return value, false
225233 }
234+ return unquoted, true
226235 }
227- return value
236+ return value, true
228237 }
229238
230239 func parseTheme(value string) (domain.ThemeMode, bool) {
@@ -241,8 +250,22 @@ func parseTheme(value string) (domain.ThemeMode, bool) {
241250 }
242251
243252 func stripInlineComment(line string) string {
253+ inQuote := false
254+ escaped := false
244255 for i, r := range line {
245- if r == '#' && (i == 0 || isSpace(rune(line[i-1]))) {
256+ if escaped {
257+ escaped = false
258+ continue
259+ }
260+ if inQuote && r == '\\' {
261+ escaped = true
262+ continue
263+ }
264+ if r == '"' {
265+ inQuote = !inQuote
266+ continue
267+ }
268+ if !inQuote && r == '#' && (i == 0 || isSpace(rune(line[i-1]))) {
246269 return strings.TrimSpace(line[:i])
247270 }
248271 }
@@ -16,10 +16,7 @@ import (
16 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
17 )17 )
18 18
19-const (19+const pollInterval = 2 * time.Second
20- pollInterval = 2 * time.Second
21- minimumBarInterval = time.Second
22-)
23 20
24 var processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`)21 var processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`)
25 22
@@ -65,18 +62,28 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
65 }62 }
66 cfg.Theme = mode63 cfg.Theme = mode
67 case key == "bar.top-right":64 case key == "bar.top-right":
68- cfg.Bar.TopRight = parseBarCommand(value)65+ command, ok := parseBarCommand(value)
66+ if !ok {
67+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.top-right %q", value)})
68+ continue
69+ }
70+ cfg.Bar.TopRight = command
69 case key == "bar.bottom-right":71 case key == "bar.bottom-right":
70- cfg.Bar.BottomRight = parseBarCommand(value)72+ command, ok := parseBarCommand(value)
73+ if !ok {
74+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.bottom-right %q", value)})
75+ continue
76+ }
77+ cfg.Bar.BottomRight = command
71 case key == "bar.interval":78 case key == "bar.interval":
72 interval, err := time.ParseDuration(value)79 interval, err := time.ParseDuration(value)
73 if err != nil {80 if err != nil {
74 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.interval %q", value)})81 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.interval %q", value)})
75 continue82 continue
76 }83 }
77- if interval < minimumBarInterval {84+ if interval < domain.MinBarInterval {
78- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("bar.interval below minimum %q", minimumBarInterval)})85+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("bar.interval below minimum %q", domain.MinBarInterval)})
79- interval = minimumBarInterval86+ interval = domain.MinBarInterval
80 }87 }
81 cfg.Bar.Interval = interval88 cfg.Bar.Interval = interval
82 case strings.HasPrefix(key, "code."):89 case strings.HasPrefix(key, "code."):
@@ -217,14 +224,16 @@ func parseProcessList(value string, lineNo int) ([]string, []domain.Warning) {
217 return out, warnings224 return out, warnings
218 }225 }
219 226
220-func parseBarCommand(value string) string {227+func parseBarCommand(value string) (string, bool) {
221 value = strings.TrimSpace(value)228 value = strings.TrimSpace(value)
222 if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {229 if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
223- if unquoted, err := strconv.Unquote(value); err == nil {230+ unquoted, err := strconv.Unquote(value)
224- return unquoted231+ if err != nil {
232+ return value, false
225 }233 }
234+ return unquoted, true
226 }235 }
227- return value236+ return value, true
228 }237 }
229 238
230 func parseTheme(value string) (domain.ThemeMode, bool) {239 func parseTheme(value string) (domain.ThemeMode, bool) {
@@ -241,8 +250,22 @@ func parseTheme(value string) (domain.ThemeMode, bool) {
241 }250 }
242 251
243 func stripInlineComment(line string) string {252 func stripInlineComment(line string) string {
253+ inQuote := false
254+ escaped := false
244 for i, r := range line {255 for i, r := range line {
245- if r == '#' && (i == 0 || isSpace(rune(line[i-1]))) {256+ if escaped {
257+ escaped = false
258+ continue
259+ }
260+ if inQuote && r == '\\' {
261+ escaped = true
262+ continue
263+ }
264+ if r == '"' {
265+ inQuote = !inQuote
266+ continue
267+ }
268+ if !inQuote && r == '#' && (i == 0 || isSpace(rune(line[i-1]))) {
246 return strings.TrimSpace(line[:i])269 return strings.TrimSpace(line[:i])
247 }270 }
248 }271 }
M internal/adapters/config/config_test.go +29 -0
@@ -119,6 +119,35 @@ func TestParse(t *testing.T) {
119119 Codes: map[string]string{},
120120 },
121121 },
122+ {
123+ name: "bar settings quoted commands preserve hash",
124+ input: "bar.top-right = \"echo foo #1\" # inline comment\n",
125+ want: domain.Config{
126+ Theme: domain.ThemeAuto,
127+ Bar: domain.BarConfig{
128+ TopRight: "echo foo #1",
129+ BottomRight: "vev-bar-bottom-right",
130+ Interval: 5 * time.Second,
131+ },
132+ BindingEntries: []domain.ConfigEntry{},
133+ Codes: map[string]string{},
134+ },
135+ },
136+ {
137+ name: "bar settings malformed quoted command warns and keeps default",
138+ input: "bar.top-right = \"bad\\q\"\n",
139+ want: domain.Config{
140+ Theme: domain.ThemeAuto,
141+ Bar: domain.BarConfig{
142+ TopRight: "vev-bar-top-right",
143+ BottomRight: "vev-bar-bottom-right",
144+ Interval: 5 * time.Second,
145+ },
146+ BindingEntries: []domain.ConfigEntry{},
147+ Codes: map[string]string{},
148+ },
149+ wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid bar.top-right \"\\\"bad\\\\q\\\"\""}},
150+ },
122151 {
123152 name: "bar settings empty commands disable anchors",
124153 input: "bar.top-right =\nbar.bottom-right =\nbar.interval = 5s\n",
@@ -119,6 +119,35 @@ func TestParse(t *testing.T) {
119 Codes: map[string]string{},119 Codes: map[string]string{},
120 },120 },
121 },121 },
122+ {
123+ name: "bar settings quoted commands preserve hash",
124+ input: "bar.top-right = \"echo foo #1\" # inline comment\n",
125+ want: domain.Config{
126+ Theme: domain.ThemeAuto,
127+ Bar: domain.BarConfig{
128+ TopRight: "echo foo #1",
129+ BottomRight: "vev-bar-bottom-right",
130+ Interval: 5 * time.Second,
131+ },
132+ BindingEntries: []domain.ConfigEntry{},
133+ Codes: map[string]string{},
134+ },
135+ },
136+ {
137+ name: "bar settings malformed quoted command warns and keeps default",
138+ input: "bar.top-right = \"bad\\q\"\n",
139+ want: domain.Config{
140+ Theme: domain.ThemeAuto,
141+ Bar: domain.BarConfig{
142+ TopRight: "vev-bar-top-right",
143+ BottomRight: "vev-bar-bottom-right",
144+ Interval: 5 * time.Second,
145+ },
146+ BindingEntries: []domain.ConfigEntry{},
147+ Codes: map[string]string{},
148+ },
149+ wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid bar.top-right \"\\\"bad\\\\q\\\"\""}},
150+ },
122 {151 {
123 name: "bar settings empty commands disable anchors",152 name: "bar settings empty commands disable anchors",
124 input: "bar.top-right =\nbar.bottom-right =\nbar.interval = 5s\n",153 input: "bar.top-right =\nbar.bottom-right =\nbar.interval = 5s\n",
M internal/domain/config.go +3 -0
@@ -2,6 +2,9 @@ package domain
22
33 import "time"
44
5+// MinBarInterval is the minimum supported bar refresh interval.
6+const MinBarInterval = time.Second
7+
58 // ThemeMode selects how vev derives its color scheme.
69 type ThemeMode int
710
@@ -2,6 +2,9 @@ package domain
2 2
3 import "time"3 import "time"
4 4
5+// MinBarInterval is the minimum supported bar refresh interval.
6+const MinBarInterval = time.Second
7+
5 // ThemeMode selects how vev derives its color scheme.8 // ThemeMode selects how vev derives its color scheme.
6 type ThemeMode int9 type ThemeMode int
7 10
M internal/usecase/daemon/bar_refresh.go +39 -26
@@ -8,8 +8,6 @@ import (
88 "github.com/bnema/vev/internal/domain"
99 )
1010
11-const minBarScriptInterval = time.Second
12-
1311 type barScriptExecutor interface {
1412 run(context.Context, string, barScriptContext) (string, error)
1513 }
@@ -41,8 +39,8 @@ func effectiveBarInterval(d time.Duration) time.Duration {
4139 if d <= 0 {
4240 return 5 * time.Second
4341 }
44- if d < minBarScriptInterval {
45- return minBarScriptInterval
42+ if d < domain.MinBarInterval {
43+ return domain.MinBarInterval
4644 }
4745 return d
4846 }
@@ -178,9 +176,16 @@ func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool
178176 force = true
179177 }
180178 lastRefresh := d.barScripts.lastRefresh[sess.id]
181- if d.barScripts.running[sess.id] || (!lastRefresh.IsZero() && now.Sub(lastRefresh) < minBarScriptInterval) {
179+ if d.barScripts.running[sess.id] {
180+ if force {
181+ d.barScripts.pending[sess.id] = true
182+ }
183+ d.barScripts.mu.Unlock()
184+ return false
185+ }
186+ if !lastRefresh.IsZero() && now.Sub(lastRefresh) < domain.MinBarInterval {
182187 if force {
183- d.scheduleBarScriptRefreshLocked(sess, lastRefresh.Add(minBarScriptInterval).Sub(now))
188+ d.scheduleBarScriptRefreshLocked(sess, lastRefresh.Add(domain.MinBarInterval).Sub(now))
184189 }
185190 d.barScripts.mu.Unlock()
186191 return false
@@ -189,10 +194,22 @@ func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool
189194 d.barScripts.mu.Unlock()
190195 return false
191196 }
197+ cfg := d.barScripts.cfg
198+ if cfg.topRight == "" && cfg.bottomRight == "" {
199+ d.barScripts.lastRefresh[sess.id] = now
200+ d.barScripts.lastContext[sess.id] = baseCtx
201+ current := d.barScripts.outputs[sess.id]
202+ changed := current.topRight != "" || current.bottomRight != ""
203+ delete(d.barScripts.outputs, sess.id)
204+ d.barScripts.mu.Unlock()
205+ if changed {
206+ d.pokeSessionRender(sess)
207+ }
208+ return false
209+ }
192210 d.barScripts.running[sess.id] = true
193211 d.barScripts.lastRefresh[sess.id] = now
194212 d.barScripts.lastContext[sess.id] = baseCtx
195- cfg := d.barScripts.cfg
196213 runner := d.barScripts.runner
197214 version := d.barScripts.version
198215 d.barScripts.mu.Unlock()
@@ -204,6 +221,9 @@ func (d *Daemon) scheduleBarScriptRefreshLocked(sess *session, delay time.Durati
204221 if d.barScripts.pending[sess.id] {
205222 return
206223 }
224+ if d.clock == nil {
225+ return
226+ }
207227 if delay < 0 {
208228 delay = 0
209229 }
@@ -213,29 +233,17 @@ func (d *Daemon) scheduleBarScriptRefreshLocked(sess *session, delay time.Durati
213233 if ctx == nil {
214234 ctx = context.Background()
215235 }
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- }
236+ timer := d.clock.NewTimer(delay)
237+ select {
238+ case <-ctx.Done():
239+ timer.Stop()
240+ return
241+ case <-timer.C():
230242 }
231243 d.barScripts.mu.Lock()
232244 delete(d.barScripts.pending, sess.id)
233245 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)
246+ d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
239247 }()
240248 }
241249
@@ -267,11 +275,16 @@ func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barS
267275 changed = true
268276 }
269277 d.barScripts.outputs[sess.id] = current
278+ pending := d.barScripts.pending[sess.id]
270279 delete(d.barScripts.running, sess.id)
280+ delete(d.barScripts.pending, sess.id)
271281 d.barScripts.mu.Unlock()
272282 if changed {
273283 d.pokeSessionRender(sess)
274284 }
285+ if pending {
286+ d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
287+ }
275288 }
276289
277290 func (d *Daemon) runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, base barScriptContext, anchor, sessionName string) (string, bool) {
@@ -8,8 +8,6 @@ import (
8 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
9 )9 )
10 10
11-const minBarScriptInterval = time.Second
12-
13 type barScriptExecutor interface {11 type barScriptExecutor interface {
14 run(context.Context, string, barScriptContext) (string, error)12 run(context.Context, string, barScriptContext) (string, error)
15 }13 }
@@ -41,8 +39,8 @@ func effectiveBarInterval(d time.Duration) time.Duration {
41 if d <= 0 {39 if d <= 0 {
42 return 5 * time.Second40 return 5 * time.Second
43 }41 }
44- if d < minBarScriptInterval {42+ if d < domain.MinBarInterval {
45- return minBarScriptInterval43+ return domain.MinBarInterval
46 }44 }
47 return d45 return d
48 }46 }
@@ -178,9 +176,16 @@ func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool
178 force = true176 force = true
179 }177 }
180 lastRefresh := d.barScripts.lastRefresh[sess.id]178 lastRefresh := d.barScripts.lastRefresh[sess.id]
181- if d.barScripts.running[sess.id] || (!lastRefresh.IsZero() && now.Sub(lastRefresh) < minBarScriptInterval) {179+ if d.barScripts.running[sess.id] {
180+ if force {
181+ d.barScripts.pending[sess.id] = true
182+ }
183+ d.barScripts.mu.Unlock()
184+ return false
185+ }
186+ if !lastRefresh.IsZero() && now.Sub(lastRefresh) < domain.MinBarInterval {
182 if force {187 if force {
183- d.scheduleBarScriptRefreshLocked(sess, lastRefresh.Add(minBarScriptInterval).Sub(now))188+ d.scheduleBarScriptRefreshLocked(sess, lastRefresh.Add(domain.MinBarInterval).Sub(now))
184 }189 }
185 d.barScripts.mu.Unlock()190 d.barScripts.mu.Unlock()
186 return false191 return false
@@ -189,10 +194,22 @@ func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool
189 d.barScripts.mu.Unlock()194 d.barScripts.mu.Unlock()
190 return false195 return false
191 }196 }
197+ cfg := d.barScripts.cfg
198+ if cfg.topRight == "" && cfg.bottomRight == "" {
199+ d.barScripts.lastRefresh[sess.id] = now
200+ d.barScripts.lastContext[sess.id] = baseCtx
201+ current := d.barScripts.outputs[sess.id]
202+ changed := current.topRight != "" || current.bottomRight != ""
203+ delete(d.barScripts.outputs, sess.id)
204+ d.barScripts.mu.Unlock()
205+ if changed {
206+ d.pokeSessionRender(sess)
207+ }
208+ return false
209+ }
192 d.barScripts.running[sess.id] = true210 d.barScripts.running[sess.id] = true
193 d.barScripts.lastRefresh[sess.id] = now211 d.barScripts.lastRefresh[sess.id] = now
194 d.barScripts.lastContext[sess.id] = baseCtx212 d.barScripts.lastContext[sess.id] = baseCtx
195- cfg := d.barScripts.cfg
196 runner := d.barScripts.runner213 runner := d.barScripts.runner
197 version := d.barScripts.version214 version := d.barScripts.version
198 d.barScripts.mu.Unlock()215 d.barScripts.mu.Unlock()
@@ -204,6 +221,9 @@ func (d *Daemon) scheduleBarScriptRefreshLocked(sess *session, delay time.Durati
204 if d.barScripts.pending[sess.id] {221 if d.barScripts.pending[sess.id] {
205 return222 return
206 }223 }
224+ if d.clock == nil {
225+ return
226+ }
207 if delay < 0 {227 if delay < 0 {
208 delay = 0228 delay = 0
209 }229 }
@@ -213,29 +233,17 @@ func (d *Daemon) scheduleBarScriptRefreshLocked(sess *session, delay time.Durati
213 if ctx == nil {233 if ctx == nil {
214 ctx = context.Background()234 ctx = context.Background()
215 }235 }
216- if d.clock != nil {236+ timer := d.clock.NewTimer(delay)
217- timer := d.clock.NewTimer(delay)237+ select {
218- select {238+ case <-ctx.Done():
219- case <-ctx.Done():239+ timer.Stop()
220- timer.Stop()240+ return
221- return241+ case <-timer.C():
222- case <-timer.C():
223- }
224- } else {
225- select {
226- case <-ctx.Done():
227- return
228- case <-time.After(delay):
229- }
230 }242 }
231 d.barScripts.mu.Lock()243 d.barScripts.mu.Lock()
232 delete(d.barScripts.pending, sess.id)244 delete(d.barScripts.pending, sess.id)
233 d.barScripts.mu.Unlock()245 d.barScripts.mu.Unlock()
234- now := time.Now()246+ d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
235- if d.clock != nil {
236- now = d.clock.Now()
237- }
238- d.refreshBarScriptsIfDue(sess, now, true)
239 }()247 }()
240 }248 }
241 249
@@ -267,11 +275,16 @@ func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barS
267 changed = true275 changed = true
268 }276 }
269 d.barScripts.outputs[sess.id] = current277 d.barScripts.outputs[sess.id] = current
278+ pending := d.barScripts.pending[sess.id]
270 delete(d.barScripts.running, sess.id)279 delete(d.barScripts.running, sess.id)
280+ delete(d.barScripts.pending, sess.id)
271 d.barScripts.mu.Unlock()281 d.barScripts.mu.Unlock()
272 if changed {282 if changed {
273 d.pokeSessionRender(sess)283 d.pokeSessionRender(sess)
274 }284 }
285+ if pending {
286+ d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
287+ }
275 }288 }
276 289
277 func (d *Daemon) runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, base barScriptContext, anchor, sessionName string) (string, bool) {290 func (d *Daemon) runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, base barScriptContext, anchor, sessionName string) (string, bool) {
M internal/usecase/daemon/bar_refresh_test.go +46 -1
@@ -11,6 +11,7 @@ import (
1111 "time"
1212
1313 "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
1415 "github.com/bnema/vev/internal/usecase/layout"
1516 "github.com/stretchr/testify/require"
1617 )
@@ -101,6 +102,22 @@ func TestBarScriptRefreshSkipsWithoutAttachedClient(t *testing.T) {
101102 require.Empty(t, r.calls)
102103 }
103104
105+func TestBarScriptRefreshSkipsRunnerWhenCommandsDisabled(t *testing.T) {
106+ r := &fakeBarRunner{}
107+ d := newBarRefreshTestDaemon(r, time.Second)
108+ d.barScripts.cfg.topRight = ""
109+ d.barScripts.cfg.bottomRight = ""
110+ sess := newBarRefreshTestSession()
111+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
112+ d.barScripts.outputs[sess.id] = barScriptOutputs{topRight: "old", bottomRight: "old"}
113+
114+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
115+ require.Empty(t, r.calls)
116+ state := d.barStateFor(sess, "")
117+ require.Empty(t, state.topRight)
118+ require.Empty(t, state.bottomRight)
119+}
120+
104121 func TestBarScriptForcedRefreshRespectsMinimumInterval(t *testing.T) {
105122 r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
106123 d := newBarRefreshTestDaemon(r, 5*time.Second)
@@ -111,6 +128,11 @@ func TestBarScriptForcedRefreshRespectsMinimumInterval(t *testing.T) {
111128 waitBarRefreshIdle(t, d)
112129 require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, int64(500*time.Millisecond)), true))
113130 require.Len(t, r.calls, 2)
131+ require.Eventually(t, func() bool {
132+ r.mu.Lock()
133+ defer r.mu.Unlock()
134+ return len(r.calls) == 4
135+ }, 1500*time.Millisecond, 10*time.Millisecond)
114136 }
115137
116138 func TestBarScriptContextChangeDebouncesUntilMinimumInterval(t *testing.T) {
@@ -196,6 +218,22 @@ func TestBarScriptRunDoesNotRestoreClearedSessionState(t *testing.T) {
196218 require.Empty(t, state.bottomRight)
197219 }
198220
221+func TestBarScriptRunConsumesPendingAfterRunningClears(t *testing.T) {
222+ r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
223+ d := newBarRefreshTestDaemon(r, time.Second)
224+ sess := newBarRefreshTestSession()
225+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
226+
227+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
228+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(11, 0), true))
229+ waitBarRefreshIdle(t, d)
230+ require.Eventually(t, func() bool {
231+ r.mu.Lock()
232+ defer r.mu.Unlock()
233+ return len(r.calls) == 4
234+ }, time.Second, time.Millisecond)
235+}
236+
199237 func TestBarScriptRunIgnoresStaleConfigVersion(t *testing.T) {
200238 r := &fakeBarRunner{outs: []string{"top", "bottom"}}
201239 d := newBarRefreshTestDaemon(r, time.Second)
@@ -238,7 +276,7 @@ func TestBarScriptRefreshIsPerSession(t *testing.T) {
238276 }
239277
240278 func newBarRefreshTestDaemon(r *fakeBarRunner, interval time.Duration) *Daemon {
241- d := &Daemon{barScripts: &barScriptState{
279+ d := &Daemon{clock: barRefreshTestClock{}, barScripts: &barScriptState{
242280 cfg: barScriptConfig{topRight: "top", bottomRight: "bottom", interval: effectiveBarInterval(interval)},
243281 runner: r,
244282 outputs: make(map[domain.SessionID]barScriptOutputs),
@@ -250,6 +288,13 @@ func newBarRefreshTestDaemon(r *fakeBarRunner, interval time.Duration) *Daemon {
250288 return d
251289 }
252290
291+type barRefreshTestClock struct{}
292+
293+func (barRefreshTestClock) Now() time.Time { return time.Now() }
294+func (barRefreshTestClock) NewTimer(d time.Duration) ports.Timer {
295+ return realTimer{t: time.NewTimer(d)}
296+}
297+
253298 func newBarRefreshTestSession() *session {
254299 inactive := newTabWithStableID("tab-inactive", "pane-inactive", nil, domain.Size{Cols: 80, Rows: 22})
255300 active := newTabWithStableID("tab-active", "pane-active", nil, domain.Size{Cols: 80, Rows: 22})
@@ -11,6 +11,7 @@ import (
11 "time"11 "time"
12 12
13 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
14 "github.com/bnema/vev/internal/usecase/layout"15 "github.com/bnema/vev/internal/usecase/layout"
15 "github.com/stretchr/testify/require"16 "github.com/stretchr/testify/require"
16 )17 )
@@ -101,6 +102,22 @@ func TestBarScriptRefreshSkipsWithoutAttachedClient(t *testing.T) {
101 require.Empty(t, r.calls)102 require.Empty(t, r.calls)
102 }103 }
103 104
105+func TestBarScriptRefreshSkipsRunnerWhenCommandsDisabled(t *testing.T) {
106+ r := &fakeBarRunner{}
107+ d := newBarRefreshTestDaemon(r, time.Second)
108+ d.barScripts.cfg.topRight = ""
109+ d.barScripts.cfg.bottomRight = ""
110+ sess := newBarRefreshTestSession()
111+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
112+ d.barScripts.outputs[sess.id] = barScriptOutputs{topRight: "old", bottomRight: "old"}
113+
114+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true))
115+ require.Empty(t, r.calls)
116+ state := d.barStateFor(sess, "")
117+ require.Empty(t, state.topRight)
118+ require.Empty(t, state.bottomRight)
119+}
120+
104 func TestBarScriptForcedRefreshRespectsMinimumInterval(t *testing.T) {121 func TestBarScriptForcedRefreshRespectsMinimumInterval(t *testing.T) {
105 r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}122 r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
106 d := newBarRefreshTestDaemon(r, 5*time.Second)123 d := newBarRefreshTestDaemon(r, 5*time.Second)
@@ -111,6 +128,11 @@ func TestBarScriptForcedRefreshRespectsMinimumInterval(t *testing.T) {
111 waitBarRefreshIdle(t, d)128 waitBarRefreshIdle(t, d)
112 require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, int64(500*time.Millisecond)), true))129 require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, int64(500*time.Millisecond)), true))
113 require.Len(t, r.calls, 2)130 require.Len(t, r.calls, 2)
131+ require.Eventually(t, func() bool {
132+ r.mu.Lock()
133+ defer r.mu.Unlock()
134+ return len(r.calls) == 4
135+ }, 1500*time.Millisecond, 10*time.Millisecond)
114 }136 }
115 137
116 func TestBarScriptContextChangeDebouncesUntilMinimumInterval(t *testing.T) {138 func TestBarScriptContextChangeDebouncesUntilMinimumInterval(t *testing.T) {
@@ -196,6 +218,22 @@ func TestBarScriptRunDoesNotRestoreClearedSessionState(t *testing.T) {
196 require.Empty(t, state.bottomRight)218 require.Empty(t, state.bottomRight)
197 }219 }
198 220
221+func TestBarScriptRunConsumesPendingAfterRunningClears(t *testing.T) {
222+ r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}}
223+ d := newBarRefreshTestDaemon(r, time.Second)
224+ sess := newBarRefreshTestSession()
225+ sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
226+
227+ require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true))
228+ require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(11, 0), true))
229+ waitBarRefreshIdle(t, d)
230+ require.Eventually(t, func() bool {
231+ r.mu.Lock()
232+ defer r.mu.Unlock()
233+ return len(r.calls) == 4
234+ }, time.Second, time.Millisecond)
235+}
236+
199 func TestBarScriptRunIgnoresStaleConfigVersion(t *testing.T) {237 func TestBarScriptRunIgnoresStaleConfigVersion(t *testing.T) {
200 r := &fakeBarRunner{outs: []string{"top", "bottom"}}238 r := &fakeBarRunner{outs: []string{"top", "bottom"}}
201 d := newBarRefreshTestDaemon(r, time.Second)239 d := newBarRefreshTestDaemon(r, time.Second)
@@ -238,7 +276,7 @@ func TestBarScriptRefreshIsPerSession(t *testing.T) {
238 }276 }
239 277
240 func newBarRefreshTestDaemon(r *fakeBarRunner, interval time.Duration) *Daemon {278 func newBarRefreshTestDaemon(r *fakeBarRunner, interval time.Duration) *Daemon {
241- d := &Daemon{barScripts: &barScriptState{279+ d := &Daemon{clock: barRefreshTestClock{}, barScripts: &barScriptState{
242 cfg: barScriptConfig{topRight: "top", bottomRight: "bottom", interval: effectiveBarInterval(interval)},280 cfg: barScriptConfig{topRight: "top", bottomRight: "bottom", interval: effectiveBarInterval(interval)},
243 runner: r,281 runner: r,
244 outputs: make(map[domain.SessionID]barScriptOutputs),282 outputs: make(map[domain.SessionID]barScriptOutputs),
@@ -250,6 +288,13 @@ func newBarRefreshTestDaemon(r *fakeBarRunner, interval time.Duration) *Daemon {
250 return d288 return d
251 }289 }
252 290
291+type barRefreshTestClock struct{}
292+
293+func (barRefreshTestClock) Now() time.Time { return time.Now() }
294+func (barRefreshTestClock) NewTimer(d time.Duration) ports.Timer {
295+ return realTimer{t: time.NewTimer(d)}
296+}
297+
253 func newBarRefreshTestSession() *session {298 func newBarRefreshTestSession() *session {
254 inactive := newTabWithStableID("tab-inactive", "pane-inactive", nil, domain.Size{Cols: 80, Rows: 22})299 inactive := newTabWithStableID("tab-inactive", "pane-inactive", nil, domain.Size{Cols: 80, Rows: 22})
255 active := newTabWithStableID("tab-active", "pane-active", nil, domain.Size{Cols: 80, Rows: 22})300 active := newTabWithStableID("tab-active", "pane-active", nil, domain.Size{Cols: 80, Rows: 22})
M internal/usecase/daemon/bar_script_test.go +23 -18
@@ -80,25 +80,30 @@ func TestBarScriptContextEnv(t *testing.T) {
8080
8181 func TestBarScriptRunner(t *testing.T) {
8282 runner := barScriptRunner{timeout: 50 * time.Millisecond, baseEnv: os.Environ()}
83- got, err := runner.run(context.Background(), "", barScriptContext{})
84- if err != nil || got != "" {
85- t.Fatalf("empty command = %q, %v; want empty nil", got, err)
86- }
87-
88- got, err = runner.run(context.Background(), "printf 'ok\\nignored'", barScriptContext{})
89- if err != nil {
90- t.Fatalf("run command: %v", err)
91- }
92- if got != "ok" {
93- t.Fatalf("run command = %q, want ok", got)
94- }
95-
96- got, err = runner.run(context.Background(), "sleep 1; printf late", barScriptContext{})
97- if !errors.Is(err, context.DeadlineExceeded) {
98- t.Fatalf("timeout err = %v, want DeadlineExceeded", err)
83+ tests := []struct {
84+ name string
85+ command string
86+ want string
87+ wantErr error
88+ }{
89+ {name: "empty command", command: "", want: ""},
90+ {name: "success first line", command: "printf 'ok\\nignored'", want: "ok"},
91+ {name: "timeout", command: "sleep 1; printf late", wantErr: context.DeadlineExceeded},
9992 }
100- if got != "" {
101- t.Fatalf("timeout output = %q, want empty", got)
93+ for _, tt := range tests {
94+ t.Run(tt.name, func(t *testing.T) {
95+ got, err := runner.run(context.Background(), tt.command, barScriptContext{})
96+ if tt.wantErr != nil {
97+ if !errors.Is(err, tt.wantErr) {
98+ t.Fatalf("run err = %v, want %v", err, tt.wantErr)
99+ }
100+ } else if err != nil {
101+ t.Fatalf("run err = %v, want nil", err)
102+ }
103+ if got != tt.want {
104+ t.Fatalf("run output = %q, want %q", got, tt.want)
105+ }
106+ })
102107 }
103108 }
104109
@@ -80,25 +80,30 @@ func TestBarScriptContextEnv(t *testing.T) {
80 80
81 func TestBarScriptRunner(t *testing.T) {81 func TestBarScriptRunner(t *testing.T) {
82 runner := barScriptRunner{timeout: 50 * time.Millisecond, baseEnv: os.Environ()}82 runner := barScriptRunner{timeout: 50 * time.Millisecond, baseEnv: os.Environ()}
83- got, err := runner.run(context.Background(), "", barScriptContext{})83+ tests := []struct {
84- if err != nil || got != "" {84+ name string
85- t.Fatalf("empty command = %q, %v; want empty nil", got, err)85+ command string
86- }86+ want string
87-87+ wantErr error
88- got, err = runner.run(context.Background(), "printf 'ok\\nignored'", barScriptContext{})88+ }{
89- if err != nil {89+ {name: "empty command", command: "", want: ""},
90- t.Fatalf("run command: %v", err)90+ {name: "success first line", command: "printf 'ok\\nignored'", want: "ok"},
91- }91+ {name: "timeout", command: "sleep 1; printf late", wantErr: context.DeadlineExceeded},
92- if got != "ok" {
93- t.Fatalf("run command = %q, want ok", got)
94- }
95-
96- got, err = runner.run(context.Background(), "sleep 1; printf late", barScriptContext{})
97- if !errors.Is(err, context.DeadlineExceeded) {
98- t.Fatalf("timeout err = %v, want DeadlineExceeded", err)
99 }92 }
100- if got != "" {93+ for _, tt := range tests {
101- t.Fatalf("timeout output = %q, want empty", got)94+ t.Run(tt.name, func(t *testing.T) {
95+ got, err := runner.run(context.Background(), tt.command, barScriptContext{})
96+ if tt.wantErr != nil {
97+ if !errors.Is(err, tt.wantErr) {
98+ t.Fatalf("run err = %v, want %v", err, tt.wantErr)
99+ }
100+ } else if err != nil {
101+ t.Fatalf("run err = %v, want nil", err)
102+ }
103+ if got != tt.want {
104+ t.Fatalf("run output = %q, want %q", got, tt.want)
105+ }
106+ })
102 }107 }
103 }108 }
104 109
M internal/usecase/daemon/status.go +4 -3
@@ -238,12 +238,13 @@ func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSe
238238 return nil
239239 }
240240 cost := func(e mruSession) int {
241- n := 2 + len([]rune(e.name))
241+ name := e.name
242242 if e.ephemeral {
243- n++
243+ name += "*"
244244 }
245+ n := 2 + statusTextWidth(name)
245246 if e.attention {
246- n += 2
247+ n += 1 + renderer.RuneWidth(attentionGlyph)
247248 }
248249 return n
249250 }
@@ -238,12 +238,13 @@ func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSe
238 return nil238 return nil
239 }239 }
240 cost := func(e mruSession) int {240 cost := func(e mruSession) int {
241- n := 2 + len([]rune(e.name))241+ name := e.name
242 if e.ephemeral {242 if e.ephemeral {
243- n++243+ name += "*"
244 }244 }
245+ n := 2 + statusTextWidth(name)
245 if e.attention {246 if e.attention {
246- n += 2247+ n += 1 + renderer.RuneWidth(attentionGlyph)
247 }248 }
248 return n249 return n
249 }250 }
M internal/usecase/daemon/status_test.go +120 -77
@@ -398,32 +398,50 @@ func TestStatusMarksEphemeralSession(t *testing.T) {
398398 require.Equal(t, " 0* ", rowText(frame.Row(3)))
399399 }
400400
401-func TestTopBarRightAnchorRendersFlushRightWhenFullyFits(t *testing.T) {
402- row := make([]renderer.Cell, 20)
403- status := statusSnapshot{tabs: []statusTab{{name: "1", active: true}}}
404-
405- drawTopBarSnapshot(row, status, 0, "14:32", resolveThemeStyles(nil))
406-
407- require.Equal(t, " 1 14:32", rowText(row))
408-}
409-
410-func TestTopBarRightAnchorHidesOnOverlap(t *testing.T) {
411- row := make([]renderer.Cell, 10)
412- status := statusSnapshot{tabs: []statusTab{{name: "1"}, {name: "2"}}}
413-
414- drawTopBarSnapshot(row, status, 0, "12345", resolveThemeStyles(nil))
415-
416- require.Equal(t, " 1 2 ", rowText(row))
417-}
418-
419-func TestTopBarRightAnchorUsesDisplayWidth(t *testing.T) {
420- row := make([]renderer.Cell, 12)
421- status := statusSnapshot{tabs: []statusTab{{name: "1", active: true}}}
401+func TestTopBarRightAnchor(t *testing.T) {
402+ tests := []struct {
403+ name string
404+ width int
405+ status statusSnapshot
406+ topRight string
407+ want string
408+ continuationCell int
409+ }{
410+ {
411+ name: "renders flush right when fully fits",
412+ width: 20,
413+ status: statusSnapshot{tabs: []statusTab{{name: "1", active: true}}},
414+ topRight: "14:32",
415+ want: " 1 14:32",
416+ },
417+ {
418+ name: "hides on overlap",
419+ width: 10,
420+ status: statusSnapshot{tabs: []statusTab{{name: "1"}, {name: "2"}}},
421+ topRight: "12345",
422+ want: " 1 2 ",
423+ },
424+ {
425+ name: "uses display width",
426+ width: 12,
427+ status: statusSnapshot{tabs: []statusTab{{name: "1", active: true}}},
428+ topRight: "界a",
429+ want: " 1 界 a",
430+ continuationCell: 10,
431+ },
432+ }
433+ for _, tt := range tests {
434+ t.Run(tt.name, func(t *testing.T) {
435+ row := make([]renderer.Cell, tt.width)
422436
423- drawTopBarSnapshot(row, status, 0, "界a", resolveThemeStyles(nil))
437+ drawTopBarSnapshot(row, tt.status, 0, tt.topRight, resolveThemeStyles(nil))
424438
425- require.Equal(t, " 1 界 a", rowText(row))
426- require.True(t, row[10].Continuation)
439+ require.Equal(t, tt.want, rowText(row))
440+ if tt.continuationCell > 0 {
441+ require.True(t, row[tt.continuationCell].Continuation)
442+ }
443+ })
444+ }
427445 }
428446
429447 func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
@@ -672,61 +690,86 @@ func TestStatusBarMRUWidthAwareBudget(t *testing.T) {
672690 }
673691 }
674692
675-func TestStatusBarBottomRightScriptTextAndCopyFeedback(t *testing.T) {
676- state := barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []mruSession{{name: "a"}}}
677- row := make([]renderer.Cell, 32)
678-
679- drawStatusBarState(row, state, resolveThemeStyles(nil))
680-
681- text := rowText(row)
682- require.Contains(t, text, " a")
683- require.True(t, strings.HasSuffix(text, " main ↑3 * copied"), text)
684-}
685-
686-func TestStatusBarBottomRightHidesOnOverlap(t *testing.T) {
687- state := barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []mruSession{{name: "fresh"}}}
688- row := make([]renderer.Cell, 16)
689-
690- drawStatusBarState(row, state, resolveThemeStyles(nil))
691-
692- text := rowText(row)
693- require.Equal(t, " cur ", text)
694- require.NotContains(t, text, "main")
695- require.NotContains(t, text, "copied")
696-}
697-
698-func TestStatusBarEmptyBottomRightScriptKeepsCopyFeedbackBehavior(t *testing.T) {
699- state := barState{status: statusSnapshot{session: "cur"}, bottomRight: "", copyFeedback: "copied", mru: []mruSession{{name: "fresh"}}}
700- row := make([]renderer.Cell, 12)
701-
702- drawStatusBarState(row, state, resolveThemeStyles(nil))
703-
704- require.Equal(t, " cur copied", rowText(row))
705-}
706-
707-func TestStatusBarMRUWholeEntryFittingWithBottomRightScript(t *testing.T) {
708- state := barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []mruSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}}
709- row := make([]renderer.Cell, 24)
710-
711- drawStatusBarState(row, state, resolveThemeStyles(nil))
712-
713- text := rowText(row)
714- require.Contains(t, text, " fresh")
715- require.NotContains(t, text, "middle")
716- require.True(t, strings.HasSuffix(text, " git"), text)
717-}
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)
693+func TestStatusBarBottomRightAndMRUFitting(t *testing.T) {
694+ tests := []struct {
695+ name string
696+ width int
697+ state barState
698+ want string
699+ wantContains []string
700+ wantNotContains []string
701+ wantSuffix string
702+ continuationCells []int
703+ }{
704+ {
705+ name: "script text and copy feedback",
706+ width: 32,
707+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []mruSession{{name: "a"}}},
708+ wantContains: []string{" a"},
709+ wantSuffix: " main ↑3 * copied",
710+ },
711+ {
712+ name: "hide on overlap",
713+ width: 16,
714+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []mruSession{{name: "fresh"}}},
715+ want: " cur ",
716+ wantNotContains: []string{"main", "copied"},
717+ },
718+ {
719+ name: "empty script keeps copy feedback behavior",
720+ width: 12,
721+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "", copyFeedback: "copied", mru: []mruSession{{name: "fresh"}}},
722+ want: " cur copied",
723+ },
724+ {
725+ name: "mru whole entry fitting with script text",
726+ width: 24,
727+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []mruSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}},
728+ wantContains: []string{" fresh"},
729+ wantNotContains: []string{"middle"},
730+ wantSuffix: " git",
731+ },
732+ {
733+ name: "mru fitting reserves wide right anchor width",
734+ width: 12,
735+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "界界", mru: []mruSession{{name: "a"}}},
736+ wantNotContains: []string{" a "},
737+ wantSuffix: " 界 界 ",
738+ continuationCells: []int{9, 11},
739+ },
740+ {
741+ name: "mru fitting counts wide session names",
742+ width: 20,
743+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []mruSession{{name: "界"}, {name: "界"}, {name: "b"}}},
744+ wantContains: []string{" 界 "},
745+ wantNotContains: []string{" b "},
746+ wantSuffix: " git",
747+ },
748+ }
749+ for _, tt := range tests {
750+ t.Run(tt.name, func(t *testing.T) {
751+ row := make([]renderer.Cell, tt.width)
722752
723- drawStatusBarState(row, state, resolveThemeStyles(nil))
753+ drawStatusBarState(row, tt.state, resolveThemeStyles(nil))
724754
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)
755+ text := rowText(row)
756+ if tt.want != "" {
757+ require.Equal(t, tt.want, text)
758+ }
759+ for _, want := range tt.wantContains {
760+ require.Contains(t, text, want)
761+ }
762+ for _, hidden := range tt.wantNotContains {
763+ require.NotContains(t, text, hidden)
764+ }
765+ if tt.wantSuffix != "" {
766+ require.True(t, strings.HasSuffix(text, tt.wantSuffix), text)
767+ }
768+ for _, cell := range tt.continuationCells {
769+ require.True(t, row[cell].Continuation)
770+ }
771+ })
772+ }
730773 }
731774
732775 func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {
@@ -398,32 +398,50 @@ func TestStatusMarksEphemeralSession(t *testing.T) {
398 require.Equal(t, " 0* ", rowText(frame.Row(3)))398 require.Equal(t, " 0* ", rowText(frame.Row(3)))
399 }399 }
400 400
401-func TestTopBarRightAnchorRendersFlushRightWhenFullyFits(t *testing.T) {401+func TestTopBarRightAnchor(t *testing.T) {
402- row := make([]renderer.Cell, 20)402+ tests := []struct {
403- status := statusSnapshot{tabs: []statusTab{{name: "1", active: true}}}403+ name string
404-404+ width int
405- drawTopBarSnapshot(row, status, 0, "14:32", resolveThemeStyles(nil))405+ status statusSnapshot
406-406+ topRight string
407- require.Equal(t, " 1 14:32", rowText(row))407+ want string
408-}408+ continuationCell int
409-409+ }{
410-func TestTopBarRightAnchorHidesOnOverlap(t *testing.T) {410+ {
411- row := make([]renderer.Cell, 10)411+ name: "renders flush right when fully fits",
412- status := statusSnapshot{tabs: []statusTab{{name: "1"}, {name: "2"}}}412+ width: 20,
413-413+ status: statusSnapshot{tabs: []statusTab{{name: "1", active: true}}},
414- drawTopBarSnapshot(row, status, 0, "12345", resolveThemeStyles(nil))414+ topRight: "14:32",
415-415+ want: " 1 14:32",
416- require.Equal(t, " 1 2 ", rowText(row))416+ },
417-}417+ {
418-418+ name: "hides on overlap",
419-func TestTopBarRightAnchorUsesDisplayWidth(t *testing.T) {419+ width: 10,
420- row := make([]renderer.Cell, 12)420+ status: statusSnapshot{tabs: []statusTab{{name: "1"}, {name: "2"}}},
421- status := statusSnapshot{tabs: []statusTab{{name: "1", active: true}}}421+ topRight: "12345",
422+ want: " 1 2 ",
423+ },
424+ {
425+ name: "uses display width",
426+ width: 12,
427+ status: statusSnapshot{tabs: []statusTab{{name: "1", active: true}}},
428+ topRight: "界a",
429+ want: " 1 界 a",
430+ continuationCell: 10,
431+ },
432+ }
433+ for _, tt := range tests {
434+ t.Run(tt.name, func(t *testing.T) {
435+ row := make([]renderer.Cell, tt.width)
422 436
423- drawTopBarSnapshot(row, status, 0, "界a", resolveThemeStyles(nil))437+ drawTopBarSnapshot(row, tt.status, 0, tt.topRight, resolveThemeStyles(nil))
424 438
425- require.Equal(t, " 1 界 a", rowText(row))439+ require.Equal(t, tt.want, rowText(row))
426- require.True(t, row[10].Continuation)440+ if tt.continuationCell > 0 {
441+ require.True(t, row[tt.continuationCell].Continuation)
442+ }
443+ })
444+ }
427 }445 }
428 446
429 func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {447 func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
@@ -672,61 +690,86 @@ func TestStatusBarMRUWidthAwareBudget(t *testing.T) {
672 }690 }
673 }691 }
674 692
675-func TestStatusBarBottomRightScriptTextAndCopyFeedback(t *testing.T) {693+func TestStatusBarBottomRightAndMRUFitting(t *testing.T) {
676- state := barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []mruSession{{name: "a"}}}694+ tests := []struct {
677- row := make([]renderer.Cell, 32)695+ name string
678-696+ width int
679- drawStatusBarState(row, state, resolveThemeStyles(nil))697+ state barState
680-698+ want string
681- text := rowText(row)699+ wantContains []string
682- require.Contains(t, text, " a")700+ wantNotContains []string
683- require.True(t, strings.HasSuffix(text, " main ↑3 * copied"), text)701+ wantSuffix string
684-}702+ continuationCells []int
685-703+ }{
686-func TestStatusBarBottomRightHidesOnOverlap(t *testing.T) {704+ {
687- state := barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []mruSession{{name: "fresh"}}}705+ name: "script text and copy feedback",
688- row := make([]renderer.Cell, 16)706+ width: 32,
689-707+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []mruSession{{name: "a"}}},
690- drawStatusBarState(row, state, resolveThemeStyles(nil))708+ wantContains: []string{" a"},
691-709+ wantSuffix: " main ↑3 * copied",
692- text := rowText(row)710+ },
693- require.Equal(t, " cur ", text)711+ {
694- require.NotContains(t, text, "main")712+ name: "hide on overlap",
695- require.NotContains(t, text, "copied")713+ width: 16,
696-}714+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []mruSession{{name: "fresh"}}},
697-715+ want: " cur ",
698-func TestStatusBarEmptyBottomRightScriptKeepsCopyFeedbackBehavior(t *testing.T) {716+ wantNotContains: []string{"main", "copied"},
699- state := barState{status: statusSnapshot{session: "cur"}, bottomRight: "", copyFeedback: "copied", mru: []mruSession{{name: "fresh"}}}717+ },
700- row := make([]renderer.Cell, 12)718+ {
701-719+ name: "empty script keeps copy feedback behavior",
702- drawStatusBarState(row, state, resolveThemeStyles(nil))720+ width: 12,
703-721+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "", copyFeedback: "copied", mru: []mruSession{{name: "fresh"}}},
704- require.Equal(t, " cur copied", rowText(row))722+ want: " cur copied",
705-}723+ },
706-724+ {
707-func TestStatusBarMRUWholeEntryFittingWithBottomRightScript(t *testing.T) {725+ name: "mru whole entry fitting with script text",
708- state := barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []mruSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}}726+ width: 24,
709- row := make([]renderer.Cell, 24)727+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []mruSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}},
710-728+ wantContains: []string{" fresh"},
711- drawStatusBarState(row, state, resolveThemeStyles(nil))729+ wantNotContains: []string{"middle"},
712-730+ wantSuffix: " git",
713- text := rowText(row)731+ },
714- require.Contains(t, text, " fresh")732+ {
715- require.NotContains(t, text, "middle")733+ name: "mru fitting reserves wide right anchor width",
716- require.True(t, strings.HasSuffix(text, " git"), text)734+ width: 12,
717-}735+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "界界", mru: []mruSession{{name: "a"}}},
718-736+ wantNotContains: []string{" a "},
719-func TestStatusBarMRUFittingReservesWideRightAnchorWidth(t *testing.T) {737+ wantSuffix: " 界 界 ",
720- state := barState{status: statusSnapshot{session: "cur"}, bottomRight: "界界", mru: []mruSession{{name: "a"}}}738+ continuationCells: []int{9, 11},
721- row := make([]renderer.Cell, 12)739+ },
740+ {
741+ name: "mru fitting counts wide session names",
742+ width: 20,
743+ state: barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []mruSession{{name: "界"}, {name: "界"}, {name: "b"}}},
744+ wantContains: []string{" 界 "},
745+ wantNotContains: []string{" b "},
746+ wantSuffix: " git",
747+ },
748+ }
749+ for _, tt := range tests {
750+ t.Run(tt.name, func(t *testing.T) {
751+ row := make([]renderer.Cell, tt.width)
722 752
723- drawStatusBarState(row, state, resolveThemeStyles(nil))753+ drawStatusBarState(row, tt.state, resolveThemeStyles(nil))
724 754
725- text := rowText(row)755+ text := rowText(row)
726- require.NotContains(t, text, " a ")756+ if tt.want != "" {
727- require.True(t, strings.HasSuffix(text, " 界 界 "), text)757+ require.Equal(t, tt.want, text)
728- require.True(t, row[9].Continuation)758+ }
729- require.True(t, row[11].Continuation)759+ for _, want := range tt.wantContains {
760+ require.Contains(t, text, want)
761+ }
762+ for _, hidden := range tt.wantNotContains {
763+ require.NotContains(t, text, hidden)
764+ }
765+ if tt.wantSuffix != "" {
766+ require.True(t, strings.HasSuffix(text, tt.wantSuffix), text)
767+ }
768+ for _, cell := range tt.continuationCells {
769+ require.True(t, row[cell].Continuation)
770+ }
771+ })
772+ }
730 }773 }
731 774
732 func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {775 func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) {

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