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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +301 -139
M internal/adapters/config/config.go +37 -14
| @@ -16,10 +16,7 @@ import ( | |||
| 16 | 16 | "github.com/bnema/vev/internal/ports" | |
| 17 | 17 | ) | |
| 18 | 18 | ||
| 19 | - | const ( | |
| 20 | - | pollInterval = 2 * time.Second | |
| 21 | - | minimumBarInterval = time.Second | |
| 22 | - | ) | |
| 19 | + | const pollInterval = 2 * time.Second | |
| 23 | 20 | ||
| 24 | 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 | 63 | cfg.Theme = mode | |
| 67 | 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 | 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 | 78 | case key == "bar.interval": | |
| 72 | 79 | interval, err := time.ParseDuration(value) | |
| 73 | 80 | if err != nil { | |
| 74 | 81 | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.interval %q", value)}) | |
| 75 | 82 | continue | |
| 76 | 83 | } | |
| 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 | |
| 80 | 87 | } | |
| 81 | 88 | cfg.Bar.Interval = interval | |
| 82 | 89 | case strings.HasPrefix(key, "code."): | |
| @@ -217,14 +224,16 @@ func parseProcessList(value string, lineNo int) ([]string, []domain.Warning) { | |||
| 217 | 224 | return out, warnings | |
| 218 | 225 | } | |
| 219 | 226 | ||
| 220 | - | func parseBarCommand(value string) string { | |
| 227 | + | func parseBarCommand(value string) (string, bool) { | |
| 221 | 228 | value = strings.TrimSpace(value) | |
| 222 | 229 | 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 | |
| 225 | 233 | } | |
| 234 | + | return unquoted, true | |
| 226 | 235 | } | |
| 227 | - | return value | |
| 236 | + | return value, true | |
| 228 | 237 | } | |
| 229 | 238 | ||
| 230 | 239 | func parseTheme(value string) (domain.ThemeMode, bool) { | |
| @@ -241,8 +250,22 @@ func parseTheme(value string) (domain.ThemeMode, bool) { | |||
| 241 | 250 | } | |
| 242 | 251 | ||
| 243 | 252 | func stripInlineComment(line string) string { | |
| 253 | + | inQuote := false | |
| 254 | + | escaped := false | |
| 244 | 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 | 269 | return strings.TrimSpace(line[:i]) | |
| 247 | 270 | } | |
| 248 | 271 | } | |
| @@ -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 = mode | 63 | 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 | continue | 82 | 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 = minimumBarInterval | 86 | + | interval = domain.MinBarInterval |
| 80 | } | 87 | } | ||
| 81 | cfg.Bar.Interval = interval | 88 | 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, warnings | 224 | 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 unquoted | 231 | + | if err != nil { |
| 232 | + | return value, false | |||
| 225 | } | 233 | } | ||
| 234 | + | return unquoted, true | |||
| 226 | } | 235 | } | ||
| 227 | - | return value | 236 | + | 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) { | |||
| 119 | 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 | 152 | name: "bar settings empty commands disable anchors", | |
| 124 | 153 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import "time" | |
| 4 | 4 | ||
| 5 | + | // MinBarInterval is the minimum supported bar refresh interval. | |
| 6 | + | const MinBarInterval = time.Second | |
| 7 | + | ||
| 5 | 8 | // ThemeMode selects how vev derives its color scheme. | |
| 6 | 9 | type ThemeMode int | |
| 7 | 10 | ||
| @@ -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 int | 9 | type ThemeMode int | ||
| 7 | 10 | ||||
M internal/usecase/daemon/bar_refresh.go +39 -26
| @@ -8,8 +8,6 @@ import ( | |||
| 8 | 8 | "github.com/bnema/vev/internal/domain" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | - | const minBarScriptInterval = time.Second | |
| 12 | - | ||
| 13 | 11 | type barScriptExecutor interface { | |
| 14 | 12 | run(context.Context, string, barScriptContext) (string, error) | |
| 15 | 13 | } | |
| @@ -41,8 +39,8 @@ func effectiveBarInterval(d time.Duration) time.Duration { | |||
| 41 | 39 | if d <= 0 { | |
| 42 | 40 | return 5 * time.Second | |
| 43 | 41 | } | |
| 44 | - | if d < minBarScriptInterval { | |
| 45 | - | return minBarScriptInterval | |
| 42 | + | if d < domain.MinBarInterval { | |
| 43 | + | return domain.MinBarInterval | |
| 46 | 44 | } | |
| 47 | 45 | return d | |
| 48 | 46 | } | |
| @@ -178,9 +176,16 @@ func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool | |||
| 178 | 176 | force = true | |
| 179 | 177 | } | |
| 180 | 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 | 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 | 190 | d.barScripts.mu.Unlock() | |
| 186 | 191 | return false | |
| @@ -189,10 +194,22 @@ func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool | |||
| 189 | 194 | d.barScripts.mu.Unlock() | |
| 190 | 195 | 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 | 210 | d.barScripts.running[sess.id] = true | |
| 193 | 211 | d.barScripts.lastRefresh[sess.id] = now | |
| 194 | 212 | d.barScripts.lastContext[sess.id] = baseCtx | |
| 195 | - | cfg := d.barScripts.cfg | |
| 196 | 213 | runner := d.barScripts.runner | |
| 197 | 214 | version := d.barScripts.version | |
| 198 | 215 | d.barScripts.mu.Unlock() | |
| @@ -204,6 +221,9 @@ func (d *Daemon) scheduleBarScriptRefreshLocked(sess *session, delay time.Durati | |||
| 204 | 221 | if d.barScripts.pending[sess.id] { | |
| 205 | 222 | return | |
| 206 | 223 | } | |
| 224 | + | if d.clock == nil { | |
| 225 | + | return | |
| 226 | + | } | |
| 207 | 227 | if delay < 0 { | |
| 208 | 228 | delay = 0 | |
| 209 | 229 | } | |
| @@ -213,29 +233,17 @@ func (d *Daemon) scheduleBarScriptRefreshLocked(sess *session, delay time.Durati | |||
| 213 | 233 | if ctx == nil { | |
| 214 | 234 | ctx = context.Background() | |
| 215 | 235 | } | |
| 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(): | |
| 230 | 242 | } | |
| 231 | 243 | d.barScripts.mu.Lock() | |
| 232 | 244 | delete(d.barScripts.pending, sess.id) | |
| 233 | 245 | 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) | |
| 239 | 247 | }() | |
| 240 | 248 | } | |
| 241 | 249 | ||
| @@ -267,11 +275,16 @@ func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barS | |||
| 267 | 275 | changed = true | |
| 268 | 276 | } | |
| 269 | 277 | d.barScripts.outputs[sess.id] = current | |
| 278 | + | pending := d.barScripts.pending[sess.id] | |
| 270 | 279 | delete(d.barScripts.running, sess.id) | |
| 280 | + | delete(d.barScripts.pending, sess.id) | |
| 271 | 281 | d.barScripts.mu.Unlock() | |
| 272 | 282 | if changed { | |
| 273 | 283 | d.pokeSessionRender(sess) | |
| 274 | 284 | } | |
| 285 | + | if pending { | |
| 286 | + | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | |
| 287 | + | } | |
| 275 | 288 | } | |
| 276 | 289 | ||
| 277 | 290 | 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.Second | 40 | return 5 * time.Second | ||
| 43 | } | 41 | } | ||
| 44 | - | if d < minBarScriptInterval { | 42 | + | if d < domain.MinBarInterval { |
| 45 | - | return minBarScriptInterval | 43 | + | return domain.MinBarInterval |
| 46 | } | 44 | } | ||
| 47 | return d | 45 | return d | ||
| 48 | } | 46 | } | ||
| @@ -178,9 +176,16 @@ func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool | |||||
| 178 | force = true | 176 | 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 false | 191 | 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 false | 195 | 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] = true | 210 | d.barScripts.running[sess.id] = true | ||
| 193 | d.barScripts.lastRefresh[sess.id] = now | 211 | d.barScripts.lastRefresh[sess.id] = now | ||
| 194 | d.barScripts.lastContext[sess.id] = baseCtx | 212 | d.barScripts.lastContext[sess.id] = baseCtx | ||
| 195 | - | cfg := d.barScripts.cfg | |||
| 196 | runner := d.barScripts.runner | 213 | runner := d.barScripts.runner | ||
| 197 | version := d.barScripts.version | 214 | 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 | return | 222 | return | ||
| 206 | } | 223 | } | ||
| 224 | + | if d.clock == nil { | |||
| 225 | + | return | |||
| 226 | + | } | |||
| 207 | if delay < 0 { | 227 | if delay < 0 { | ||
| 208 | delay = 0 | 228 | 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 | - | return | 241 | + | 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 = true | 275 | changed = true | ||
| 268 | } | 276 | } | ||
| 269 | d.barScripts.outputs[sess.id] = current | 277 | 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 ( | |||
| 11 | 11 | "time" | |
| 12 | 12 | ||
| 13 | 13 | "github.com/bnema/vev/internal/domain" | |
| 14 | + | "github.com/bnema/vev/internal/ports" | |
| 14 | 15 | "github.com/bnema/vev/internal/usecase/layout" | |
| 15 | 16 | "github.com/stretchr/testify/require" | |
| 16 | 17 | ) | |
| @@ -101,6 +102,22 @@ func TestBarScriptRefreshSkipsWithoutAttachedClient(t *testing.T) { | |||
| 101 | 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 | 121 | func TestBarScriptForcedRefreshRespectsMinimumInterval(t *testing.T) { | |
| 105 | 122 | r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}} | |
| 106 | 123 | d := newBarRefreshTestDaemon(r, 5*time.Second) | |
| @@ -111,6 +128,11 @@ func TestBarScriptForcedRefreshRespectsMinimumInterval(t *testing.T) { | |||
| 111 | 128 | waitBarRefreshIdle(t, d) | |
| 112 | 129 | require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, int64(500*time.Millisecond)), true)) | |
| 113 | 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 | 138 | func TestBarScriptContextChangeDebouncesUntilMinimumInterval(t *testing.T) { | |
| @@ -196,6 +218,22 @@ func TestBarScriptRunDoesNotRestoreClearedSessionState(t *testing.T) { | |||
| 196 | 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 | 237 | func TestBarScriptRunIgnoresStaleConfigVersion(t *testing.T) { | |
| 200 | 238 | r := &fakeBarRunner{outs: []string{"top", "bottom"}} | |
| 201 | 239 | d := newBarRefreshTestDaemon(r, time.Second) | |
| @@ -238,7 +276,7 @@ func TestBarScriptRefreshIsPerSession(t *testing.T) { | |||
| 238 | 276 | } | |
| 239 | 277 | ||
| 240 | 278 | func newBarRefreshTestDaemon(r *fakeBarRunner, interval time.Duration) *Daemon { | |
| 241 | - | d := &Daemon{barScripts: &barScriptState{ | |
| 279 | + | d := &Daemon{clock: barRefreshTestClock{}, barScripts: &barScriptState{ | |
| 242 | 280 | cfg: barScriptConfig{topRight: "top", bottomRight: "bottom", interval: effectiveBarInterval(interval)}, | |
| 243 | 281 | runner: r, | |
| 244 | 282 | outputs: make(map[domain.SessionID]barScriptOutputs), | |
| @@ -250,6 +288,13 @@ func newBarRefreshTestDaemon(r *fakeBarRunner, interval time.Duration) *Daemon { | |||
| 250 | 288 | 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 | 298 | func newBarRefreshTestSession() *session { | |
| 254 | 299 | inactive := newTabWithStableID("tab-inactive", "pane-inactive", nil, domain.Size{Cols: 80, Rows: 22}) | |
| 255 | 300 | 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 d | 288 | 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) { | |||
| 80 | 80 | ||
| 81 | 81 | func TestBarScriptRunner(t *testing.T) { | |
| 82 | 82 | 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}, | |
| 99 | 92 | } | |
| 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 | + | }) | |
| 102 | 107 | } | |
| 103 | 108 | } | |
| 104 | 109 | ||
| @@ -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 | |||
| 238 | 238 | return nil | |
| 239 | 239 | } | |
| 240 | 240 | cost := func(e mruSession) int { | |
| 241 | - | n := 2 + len([]rune(e.name)) | |
| 241 | + | name := e.name | |
| 242 | 242 | if e.ephemeral { | |
| 243 | - | n++ | |
| 243 | + | name += "*" | |
| 244 | 244 | } | |
| 245 | + | n := 2 + statusTextWidth(name) | |
| 245 | 246 | if e.attention { | |
| 246 | - | n += 2 | |
| 247 | + | n += 1 + renderer.RuneWidth(attentionGlyph) | |
| 247 | 248 | } | |
| 248 | 249 | return n | |
| 249 | 250 | } | |
| @@ -238,12 +238,13 @@ func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSe | |||||
| 238 | return nil | 238 | 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 += 2 | 247 | + | n += 1 + renderer.RuneWidth(attentionGlyph) |
| 247 | } | 248 | } | ||
| 248 | return n | 249 | return n | ||
| 249 | } | 250 | } | ||
M internal/usecase/daemon/status_test.go +120 -77
| @@ -398,32 +398,50 @@ func TestStatusMarksEphemeralSession(t *testing.T) { | |||
| 398 | 398 | require.Equal(t, " 0* ", rowText(frame.Row(3))) | |
| 399 | 399 | } | |
| 400 | 400 | ||
| 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) | |
| 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)) | |
| 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 | + | } | |
| 427 | 445 | } | |
| 428 | 446 | ||
| 429 | 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) { | |
| 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) | |
| 722 | 752 | ||
| 723 | - | drawStatusBarState(row, state, resolveThemeStyles(nil)) | |
| 753 | + | drawStatusBarState(row, tt.state, resolveThemeStyles(nil)) | |
| 724 | 754 | ||
| 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 | + | } | |
| 730 | 773 | } | |
| 731 | 774 | ||
| 732 | 775 | 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) { | ||