Merge pull request #30 from bnema/feat/bar-right-anchor-scripts
feat(daemon): add scriptable bar right anchors
25 files · +1794 -15
M Makefile +4 -0
| @@ -2,6 +2,10 @@ | |||
| 2 | 2 | ||
| 3 | 3 | install: | |
| 4 | 4 | go install . | |
| 5 | + | @bin="$$(go env GOBIN)"; \ | |
| 6 | + | if [ -z "$$bin" ]; then bin="$$(go env GOPATH)/bin"; fi; \ | |
| 7 | + | mkdir -p "$$bin"; \ | |
| 8 | + | install -m 0755 scripts/vev-bar-top-right scripts/vev-bar-bottom-right "$$bin" | |
| 5 | 9 | ||
| 6 | 10 | test: | |
| 7 | 11 | go test ./... -race | |
| @@ -2,6 +2,10 @@ | |||||
| 2 | 2 | ||||
| 3 | install: | 3 | install: | ||
| 4 | go install . | 4 | go install . | ||
| 5 | + | @bin="$$(go env GOBIN)"; \ | |||
| 6 | + | if [ -z "$$bin" ]; then bin="$$(go env GOPATH)/bin"; fi; \ | |||
| 7 | + | mkdir -p "$$bin"; \ | |||
| 8 | + | install -m 0755 scripts/vev-bar-top-right scripts/vev-bar-bottom-right "$$bin" | |||
| 5 | 9 | ||||
| 6 | test: | 10 | test: | ||
| 7 | go test ./... -race | 11 | go test ./... -race | ||
M README.md +16 -1
| @@ -15,10 +15,12 @@ go install github.com/bnema/vev@latest | |||
| 15 | 15 | From a checkout: | |
| 16 | 16 | ||
| 17 | 17 | ```sh | |
| 18 | - | make install # go install . | |
| 18 | + | make install # install vev and default bar scripts to Go's bin directory | |
| 19 | 19 | go build -o vev . # local binary | |
| 20 | 20 | ``` | |
| 21 | 21 | ||
| 22 | + | `make install` places `vev-bar-top-right` and `vev-bar-bottom-right` beside the installed `vev` binary. If you install another way, copy the scripts from `scripts/` into a directory on `PATH` or replace the bar commands in config. | |
| 23 | + | ||
| 22 | 24 | ## Usage | |
| 23 | 25 | ||
| 24 | 26 | ```text | |
| @@ -69,6 +71,11 @@ switch-tab-9 = alt+9 | |||
| 69 | 71 | # set it empty to disable process relaunch. | |
| 70 | 72 | snapshot.restore_processes = vi,vim,nvim,emacs,man,less,more,tail,top,htop,btop,claude,codex,pi,opencode | |
| 71 | 73 | ||
| 74 | + | # Right bar anchors. Empty command values disable the matching anchor. | |
| 75 | + | bar.top-right = vev-bar-top-right | |
| 76 | + | bar.bottom-right = vev-bar-bottom-right | |
| 77 | + | bar.interval = 5s | |
| 78 | + | ||
| 72 | 79 | # Command palette codes are trimmed, uppercased, then checked as 2-3 letters or digits. | |
| 73 | 80 | code.new-tab = CNT | |
| 74 | 81 | code.new-session = CNS | |
| @@ -98,6 +105,14 @@ Configuring an action replaces all of its built-in aliases. In the example above | |||
| 98 | 105 | ||
| 99 | 106 | Key specs support `alt+<char>`, `alt+space`, and `alt+left/right/up/down`; digit key specs support `alt+1` through `alt+9`, not `alt+0`. `jump-attention` first opens the oldest attention tab in the current session; if none exists, it opens the oldest attention tab in another session. Invalid entries are logged as warnings and skipped where possible; duplicate config keys use the last value, binding conflicts keep the later action's defaults, and command-code conflicts drop the conflicting override. | |
| 100 | 107 | ||
| 108 | + | ### Bar right anchors | |
| 109 | + | ||
| 110 | + | The top and bottom bar right anchors run configurable commands on the daemon host. By default, `bar.top-right = vev-bar-top-right` prints hour, CPU, and memory text, and `bar.bottom-right = vev-bar-bottom-right` prints compact git state for the focused pane's `VEV_PANE_CWD`. Set either value empty to disable that anchor, or set it to another command to replace it. | |
| 111 | + | ||
| 112 | + | Both anchors share `bar.interval`, which defaults to `5s`; values below `1s` are clamped to `1s`. vev also refreshes around attach, resize, focus, and copy-feedback changes. Commands are bounded by a timeout and output limit. On failure or timeout, vev keeps the last good value; if there is none, the anchor is empty. | |
| 113 | + | ||
| 114 | + | Scripts receive these environment variables when available: `VEV_ANCHOR` (`top-right` or `bottom-right`), `VEV_SESSION`, `VEV_TAB`, `VEV_PANE`, `VEV_PANE_CWD`, and `VEV_COLS`. vev reads stdout only, uses the first line only, treats it as plain UTF-8 display text, and strips ANSI escape sequences and other control characters. Unicode and Nerd Font glyphs are supported. The v1 API does not support colors, styles, JSON, streaming output, or per-anchor intervals. The default git script runs `git status --porcelain` each refresh, so increase `bar.interval` or replace the command if that is too expensive for a large repository. | |
| 115 | + | ||
| 101 | 116 | ## Remote attach | |
| 102 | 117 | ||
| 103 | 118 | ```sh | |
| @@ -15,10 +15,12 @@ go install github.com/bnema/vev@latest | |||||
| 15 | From a checkout: | 15 | From a checkout: | ||
| 16 | 16 | ||||
| 17 | ```sh | 17 | ```sh | ||
| 18 | - | make install # go install . | 18 | + | make install # install vev and default bar scripts to Go's bin directory |
| 19 | go build -o vev . # local binary | 19 | go build -o vev . # local binary | ||
| 20 | ``` | 20 | ``` | ||
| 21 | 21 | ||||
| 22 | + | `make install` places `vev-bar-top-right` and `vev-bar-bottom-right` beside the installed `vev` binary. If you install another way, copy the scripts from `scripts/` into a directory on `PATH` or replace the bar commands in config. | |||
| 23 | + | ||||
| 22 | ## Usage | 24 | ## Usage | ||
| 23 | 25 | ||||
| 24 | ```text | 26 | ```text | ||
| @@ -69,6 +71,11 @@ switch-tab-9 = alt+9 | |||||
| 69 | # set it empty to disable process relaunch. | 71 | # set it empty to disable process relaunch. | ||
| 70 | snapshot.restore_processes = vi,vim,nvim,emacs,man,less,more,tail,top,htop,btop,claude,codex,pi,opencode | 72 | snapshot.restore_processes = vi,vim,nvim,emacs,man,less,more,tail,top,htop,btop,claude,codex,pi,opencode | ||
| 71 | 73 | ||||
| 74 | + | # Right bar anchors. Empty command values disable the matching anchor. | |||
| 75 | + | bar.top-right = vev-bar-top-right | |||
| 76 | + | bar.bottom-right = vev-bar-bottom-right | |||
| 77 | + | bar.interval = 5s | |||
| 78 | + | ||||
| 72 | # Command palette codes are trimmed, uppercased, then checked as 2-3 letters or digits. | 79 | # Command palette codes are trimmed, uppercased, then checked as 2-3 letters or digits. | ||
| 73 | code.new-tab = CNT | 80 | code.new-tab = CNT | ||
| 74 | code.new-session = CNS | 81 | code.new-session = CNS | ||
| @@ -98,6 +105,14 @@ Configuring an action replaces all of its built-in aliases. In the example above | |||||
| 98 | 105 | ||||
| 99 | Key specs support `alt+<char>`, `alt+space`, and `alt+left/right/up/down`; digit key specs support `alt+1` through `alt+9`, not `alt+0`. `jump-attention` first opens the oldest attention tab in the current session; if none exists, it opens the oldest attention tab in another session. Invalid entries are logged as warnings and skipped where possible; duplicate config keys use the last value, binding conflicts keep the later action's defaults, and command-code conflicts drop the conflicting override. | 106 | Key specs support `alt+<char>`, `alt+space`, and `alt+left/right/up/down`; digit key specs support `alt+1` through `alt+9`, not `alt+0`. `jump-attention` first opens the oldest attention tab in the current session; if none exists, it opens the oldest attention tab in another session. Invalid entries are logged as warnings and skipped where possible; duplicate config keys use the last value, binding conflicts keep the later action's defaults, and command-code conflicts drop the conflicting override. | ||
| 100 | 107 | ||||
| 108 | + | ### Bar right anchors | |||
| 109 | + | ||||
| 110 | + | The top and bottom bar right anchors run configurable commands on the daemon host. By default, `bar.top-right = vev-bar-top-right` prints hour, CPU, and memory text, and `bar.bottom-right = vev-bar-bottom-right` prints compact git state for the focused pane's `VEV_PANE_CWD`. Set either value empty to disable that anchor, or set it to another command to replace it. | |||
| 111 | + | ||||
| 112 | + | Both anchors share `bar.interval`, which defaults to `5s`; values below `1s` are clamped to `1s`. vev also refreshes around attach, resize, focus, and copy-feedback changes. Commands are bounded by a timeout and output limit. On failure or timeout, vev keeps the last good value; if there is none, the anchor is empty. | |||
| 113 | + | ||||
| 114 | + | Scripts receive these environment variables when available: `VEV_ANCHOR` (`top-right` or `bottom-right`), `VEV_SESSION`, `VEV_TAB`, `VEV_PANE`, `VEV_PANE_CWD`, and `VEV_COLS`. vev reads stdout only, uses the first line only, treats it as plain UTF-8 display text, and strips ANSI escape sequences and other control characters. Unicode and Nerd Font glyphs are supported. The v1 API does not support colors, styles, JSON, streaming output, or per-anchor intervals. The default git script runs `git status --porcelain` each refresh, so increase `bar.interval` or replace the command if that is too expensive for a large repository. | |||
| 115 | + | ||||
| 101 | ## Remote attach | 116 | ## Remote attach | ||
| 102 | 117 | ||||
| 103 | ```sh | 118 | ```sh | ||
M internal/adapters/config/config.go +53 -1
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "io" | |
| 9 | 9 | "os" | |
| 10 | 10 | "regexp" | |
| 11 | + | "strconv" | |
| 11 | 12 | "strings" | |
| 12 | 13 | "time" | |
| 13 | 14 | ||
| @@ -60,6 +61,31 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 60 | 61 | continue | |
| 61 | 62 | } | |
| 62 | 63 | cfg.Theme = mode | |
| 64 | + | case key == "bar.top-right": | |
| 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 | |
| 71 | + | case key == "bar.bottom-right": | |
| 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 | |
| 78 | + | case key == "bar.interval": | |
| 79 | + | interval, err := time.ParseDuration(value) | |
| 80 | + | if err != nil { | |
| 81 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.interval %q", value)}) | |
| 82 | + | continue | |
| 83 | + | } | |
| 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 | |
| 87 | + | } | |
| 88 | + | cfg.Bar.Interval = interval | |
| 63 | 89 | case strings.HasPrefix(key, "code."): | |
| 64 | 90 | codeKey := strings.TrimPrefix(key, "code.") | |
| 65 | 91 | if codeKey == "" { | |
| @@ -198,6 +224,18 @@ func parseProcessList(value string, lineNo int) ([]string, []domain.Warning) { | |||
| 198 | 224 | return out, warnings | |
| 199 | 225 | } | |
| 200 | 226 | ||
| 227 | + | func parseBarCommand(value string) (string, bool) { | |
| 228 | + | value = strings.TrimSpace(value) | |
| 229 | + | if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' { | |
| 230 | + | unquoted, err := strconv.Unquote(value) | |
| 231 | + | if err != nil { | |
| 232 | + | return value, false | |
| 233 | + | } | |
| 234 | + | return unquoted, true | |
| 235 | + | } | |
| 236 | + | return value, true | |
| 237 | + | } | |
| 238 | + | ||
| 201 | 239 | func parseTheme(value string) (domain.ThemeMode, bool) { | |
| 202 | 240 | switch strings.ToLower(strings.TrimSpace(value)) { | |
| 203 | 241 | case "auto", "": | |
| @@ -212,8 +250,22 @@ func parseTheme(value string) (domain.ThemeMode, bool) { | |||
| 212 | 250 | } | |
| 213 | 251 | ||
| 214 | 252 | func stripInlineComment(line string) string { | |
| 253 | + | inQuote := false | |
| 254 | + | escaped := false | |
| 215 | 255 | for i, r := range line { | |
| 216 | - | 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]))) { | |
| 217 | 269 | return strings.TrimSpace(line[:i]) | |
| 218 | 270 | } | |
| 219 | 271 | } | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "io" | 8 | "io" | ||
| 9 | "os" | 9 | "os" | ||
| 10 | "regexp" | 10 | "regexp" | ||
| 11 | + | "strconv" | |||
| 11 | "strings" | 12 | "strings" | ||
| 12 | "time" | 13 | "time" | ||
| 13 | 14 | ||||
| @@ -60,6 +61,31 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||||
| 60 | continue | 61 | continue | ||
| 61 | } | 62 | } | ||
| 62 | cfg.Theme = mode | 63 | cfg.Theme = mode | ||
| 64 | + | case key == "bar.top-right": | |||
| 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 | |||
| 71 | + | case key == "bar.bottom-right": | |||
| 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 | |||
| 78 | + | case key == "bar.interval": | |||
| 79 | + | interval, err := time.ParseDuration(value) | |||
| 80 | + | if err != nil { | |||
| 81 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.interval %q", value)}) | |||
| 82 | + | continue | |||
| 83 | + | } | |||
| 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 | |||
| 87 | + | } | |||
| 88 | + | cfg.Bar.Interval = interval | |||
| 63 | case strings.HasPrefix(key, "code."): | 89 | case strings.HasPrefix(key, "code."): | ||
| 64 | codeKey := strings.TrimPrefix(key, "code.") | 90 | codeKey := strings.TrimPrefix(key, "code.") | ||
| 65 | if codeKey == "" { | 91 | if codeKey == "" { | ||
| @@ -198,6 +224,18 @@ func parseProcessList(value string, lineNo int) ([]string, []domain.Warning) { | |||||
| 198 | return out, warnings | 224 | return out, warnings | ||
| 199 | } | 225 | } | ||
| 200 | 226 | ||||
| 227 | + | func parseBarCommand(value string) (string, bool) { | |||
| 228 | + | value = strings.TrimSpace(value) | |||
| 229 | + | if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' { | |||
| 230 | + | unquoted, err := strconv.Unquote(value) | |||
| 231 | + | if err != nil { | |||
| 232 | + | return value, false | |||
| 233 | + | } | |||
| 234 | + | return unquoted, true | |||
| 235 | + | } | |||
| 236 | + | return value, true | |||
| 237 | + | } | |||
| 238 | + | ||||
| 201 | func parseTheme(value string) (domain.ThemeMode, bool) { | 239 | func parseTheme(value string) (domain.ThemeMode, bool) { | ||
| 202 | switch strings.ToLower(strings.TrimSpace(value)) { | 240 | switch strings.ToLower(strings.TrimSpace(value)) { | ||
| 203 | case "auto", "": | 241 | case "auto", "": | ||
| @@ -212,8 +250,22 @@ func parseTheme(value string) (domain.ThemeMode, bool) { | |||||
| 212 | } | 250 | } | ||
| 213 | 251 | ||||
| 214 | func stripInlineComment(line string) string { | 252 | func stripInlineComment(line string) string { | ||
| 253 | + | inQuote := false | |||
| 254 | + | escaped := false | |||
| 215 | for i, r := range line { | 255 | for i, r := range line { | ||
| 216 | - | 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]))) { | |||
| 217 | return strings.TrimSpace(line[:i]) | 269 | return strings.TrimSpace(line[:i]) | ||
| 218 | } | 270 | } | ||
| 219 | } | 271 | } | ||
M internal/adapters/config/config_test.go +104 -0
| @@ -91,6 +91,107 @@ func TestParse(t *testing.T) { | |||
| 91 | 91 | {Line: 3, Msg: "missing key"}, | |
| 92 | 92 | }, | |
| 93 | 93 | }, | |
| 94 | + | { | |
| 95 | + | name: "bar settings custom values", | |
| 96 | + | input: "bar.top-right = custom-top\nbar.bottom-right = custom-bottom\nbar.interval = 2s\n", | |
| 97 | + | want: domain.Config{ | |
| 98 | + | Theme: domain.ThemeAuto, | |
| 99 | + | Bar: domain.BarConfig{ | |
| 100 | + | TopRight: "custom-top", | |
| 101 | + | BottomRight: "custom-bottom", | |
| 102 | + | Interval: 2 * time.Second, | |
| 103 | + | }, | |
| 104 | + | BindingEntries: []domain.ConfigEntry{}, | |
| 105 | + | Codes: map[string]string{}, | |
| 106 | + | }, | |
| 107 | + | }, | |
| 108 | + | { | |
| 109 | + | name: "bar settings quoted commands unquote", | |
| 110 | + | input: "bar.top-right = \"date +%H:%M\"\nbar.bottom-right = \"\"\n", | |
| 111 | + | want: domain.Config{ | |
| 112 | + | Theme: domain.ThemeAuto, | |
| 113 | + | Bar: domain.BarConfig{ | |
| 114 | + | TopRight: "date +%H:%M", | |
| 115 | + | BottomRight: "", | |
| 116 | + | Interval: 5 * time.Second, | |
| 117 | + | }, | |
| 118 | + | BindingEntries: []domain.ConfigEntry{}, | |
| 119 | + | Codes: map[string]string{}, | |
| 120 | + | }, | |
| 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 | + | }, | |
| 151 | + | { | |
| 152 | + | name: "bar settings empty commands disable anchors", | |
| 153 | + | input: "bar.top-right =\nbar.bottom-right =\nbar.interval = 5s\n", | |
| 154 | + | want: domain.Config{ | |
| 155 | + | Theme: domain.ThemeAuto, | |
| 156 | + | Bar: domain.BarConfig{ | |
| 157 | + | TopRight: "", | |
| 158 | + | BottomRight: "", | |
| 159 | + | Interval: 5 * time.Second, | |
| 160 | + | }, | |
| 161 | + | BindingEntries: []domain.ConfigEntry{}, | |
| 162 | + | Codes: map[string]string{}, | |
| 163 | + | }, | |
| 164 | + | }, | |
| 165 | + | { | |
| 166 | + | name: "bar interval invalid warns and keeps default", | |
| 167 | + | input: "bar.interval = nope\n", | |
| 168 | + | want: domain.Config{ | |
| 169 | + | Theme: domain.ThemeAuto, | |
| 170 | + | Bar: domain.BarConfig{ | |
| 171 | + | TopRight: "vev-bar-top-right", | |
| 172 | + | BottomRight: "vev-bar-bottom-right", | |
| 173 | + | Interval: 5 * time.Second, | |
| 174 | + | }, | |
| 175 | + | BindingEntries: []domain.ConfigEntry{}, | |
| 176 | + | Codes: map[string]string{}, | |
| 177 | + | }, | |
| 178 | + | wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid bar.interval \"nope\""}}, | |
| 179 | + | }, | |
| 180 | + | { | |
| 181 | + | name: "bar interval below minimum clamps and warns", | |
| 182 | + | input: "bar.interval = 100ms\n", | |
| 183 | + | want: domain.Config{ | |
| 184 | + | Theme: domain.ThemeAuto, | |
| 185 | + | Bar: domain.BarConfig{ | |
| 186 | + | TopRight: "vev-bar-top-right", | |
| 187 | + | BottomRight: "vev-bar-bottom-right", | |
| 188 | + | Interval: time.Second, | |
| 189 | + | }, | |
| 190 | + | BindingEntries: []domain.ConfigEntry{}, | |
| 191 | + | Codes: map[string]string{}, | |
| 192 | + | }, | |
| 193 | + | wantWarnings: []domain.Warning{{Line: 1, Msg: "bar.interval below minimum \"1s\""}}, | |
| 194 | + | }, | |
| 94 | 195 | { | |
| 95 | 196 | name: "snapshot restore processes", | |
| 96 | 197 | input: "snapshot.restore_processes = claude, codex, pi, opencode, btop\n", | |
| @@ -140,6 +241,9 @@ func TestParse(t *testing.T) { | |||
| 140 | 241 | t.Run(tt.name, func(t *testing.T) { | |
| 141 | 242 | t.Parallel() | |
| 142 | 243 | ||
| 244 | + | if tt.want.Bar.Interval == 0 { | |
| 245 | + | tt.want.Bar = domain.Defaults().Bar | |
| 246 | + | } | |
| 143 | 247 | if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet { | |
| 144 | 248 | tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...) | |
| 145 | 249 | } | |
| @@ -91,6 +91,107 @@ func TestParse(t *testing.T) { | |||||
| 91 | {Line: 3, Msg: "missing key"}, | 91 | {Line: 3, Msg: "missing key"}, | ||
| 92 | }, | 92 | }, | ||
| 93 | }, | 93 | }, | ||
| 94 | + | { | |||
| 95 | + | name: "bar settings custom values", | |||
| 96 | + | input: "bar.top-right = custom-top\nbar.bottom-right = custom-bottom\nbar.interval = 2s\n", | |||
| 97 | + | want: domain.Config{ | |||
| 98 | + | Theme: domain.ThemeAuto, | |||
| 99 | + | Bar: domain.BarConfig{ | |||
| 100 | + | TopRight: "custom-top", | |||
| 101 | + | BottomRight: "custom-bottom", | |||
| 102 | + | Interval: 2 * time.Second, | |||
| 103 | + | }, | |||
| 104 | + | BindingEntries: []domain.ConfigEntry{}, | |||
| 105 | + | Codes: map[string]string{}, | |||
| 106 | + | }, | |||
| 107 | + | }, | |||
| 108 | + | { | |||
| 109 | + | name: "bar settings quoted commands unquote", | |||
| 110 | + | input: "bar.top-right = \"date +%H:%M\"\nbar.bottom-right = \"\"\n", | |||
| 111 | + | want: domain.Config{ | |||
| 112 | + | Theme: domain.ThemeAuto, | |||
| 113 | + | Bar: domain.BarConfig{ | |||
| 114 | + | TopRight: "date +%H:%M", | |||
| 115 | + | BottomRight: "", | |||
| 116 | + | Interval: 5 * time.Second, | |||
| 117 | + | }, | |||
| 118 | + | BindingEntries: []domain.ConfigEntry{}, | |||
| 119 | + | Codes: map[string]string{}, | |||
| 120 | + | }, | |||
| 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 | + | }, | |||
| 151 | + | { | |||
| 152 | + | name: "bar settings empty commands disable anchors", | |||
| 153 | + | input: "bar.top-right =\nbar.bottom-right =\nbar.interval = 5s\n", | |||
| 154 | + | want: domain.Config{ | |||
| 155 | + | Theme: domain.ThemeAuto, | |||
| 156 | + | Bar: domain.BarConfig{ | |||
| 157 | + | TopRight: "", | |||
| 158 | + | BottomRight: "", | |||
| 159 | + | Interval: 5 * time.Second, | |||
| 160 | + | }, | |||
| 161 | + | BindingEntries: []domain.ConfigEntry{}, | |||
| 162 | + | Codes: map[string]string{}, | |||
| 163 | + | }, | |||
| 164 | + | }, | |||
| 165 | + | { | |||
| 166 | + | name: "bar interval invalid warns and keeps default", | |||
| 167 | + | input: "bar.interval = nope\n", | |||
| 168 | + | want: domain.Config{ | |||
| 169 | + | Theme: domain.ThemeAuto, | |||
| 170 | + | Bar: domain.BarConfig{ | |||
| 171 | + | TopRight: "vev-bar-top-right", | |||
| 172 | + | BottomRight: "vev-bar-bottom-right", | |||
| 173 | + | Interval: 5 * time.Second, | |||
| 174 | + | }, | |||
| 175 | + | BindingEntries: []domain.ConfigEntry{}, | |||
| 176 | + | Codes: map[string]string{}, | |||
| 177 | + | }, | |||
| 178 | + | wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid bar.interval \"nope\""}}, | |||
| 179 | + | }, | |||
| 180 | + | { | |||
| 181 | + | name: "bar interval below minimum clamps and warns", | |||
| 182 | + | input: "bar.interval = 100ms\n", | |||
| 183 | + | want: domain.Config{ | |||
| 184 | + | Theme: domain.ThemeAuto, | |||
| 185 | + | Bar: domain.BarConfig{ | |||
| 186 | + | TopRight: "vev-bar-top-right", | |||
| 187 | + | BottomRight: "vev-bar-bottom-right", | |||
| 188 | + | Interval: time.Second, | |||
| 189 | + | }, | |||
| 190 | + | BindingEntries: []domain.ConfigEntry{}, | |||
| 191 | + | Codes: map[string]string{}, | |||
| 192 | + | }, | |||
| 193 | + | wantWarnings: []domain.Warning{{Line: 1, Msg: "bar.interval below minimum \"1s\""}}, | |||
| 194 | + | }, | |||
| 94 | { | 195 | { | ||
| 95 | name: "snapshot restore processes", | 196 | name: "snapshot restore processes", | ||
| 96 | input: "snapshot.restore_processes = claude, codex, pi, opencode, btop\n", | 197 | input: "snapshot.restore_processes = claude, codex, pi, opencode, btop\n", | ||
| @@ -140,6 +241,9 @@ func TestParse(t *testing.T) { | |||||
| 140 | t.Run(tt.name, func(t *testing.T) { | 241 | t.Run(tt.name, func(t *testing.T) { | ||
| 141 | t.Parallel() | 242 | t.Parallel() | ||
| 142 | 243 | ||||
| 244 | + | if tt.want.Bar.Interval == 0 { | |||
| 245 | + | tt.want.Bar = domain.Defaults().Bar | |||
| 246 | + | } | |||
| 143 | if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet { | 247 | if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet { | ||
| 144 | tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...) | 248 | tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...) | ||
| 145 | } | 249 | } | ||
A internal/adapters/shellcmd/runner.go +63 -0
| @@ -0,0 +1,63 @@ | |||
| 1 | + | package shellcmd | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "io" | |
| 7 | + | "os/exec" | |
| 8 | + | "syscall" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // Runner executes bounded shell commands for bar scripts and similar usecases. | |
| 14 | + | type Runner struct{} | |
| 15 | + | ||
| 16 | + | // New returns a production shell command runner. | |
| 17 | + | func New() Runner { return Runner{} } | |
| 18 | + | ||
| 19 | + | // Run executes spec.Command through sh -c, capturing bounded stdout and discarding stderr. | |
| 20 | + | func (Runner) Run(ctx context.Context, spec ports.CommandSpec) ([]byte, error) { | |
| 21 | + | cmd := exec.CommandContext(ctx, "sh", "-c", spec.Command) | |
| 22 | + | cmd.Env = spec.Env | |
| 23 | + | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | |
| 24 | + | cmd.WaitDelay = spec.Timeout | |
| 25 | + | cmd.Cancel = func() error { | |
| 26 | + | if cmd.Process == nil { | |
| 27 | + | return nil | |
| 28 | + | } | |
| 29 | + | return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) | |
| 30 | + | } | |
| 31 | + | var stdout boundedBuffer | |
| 32 | + | stdout.limit = spec.StdoutLimit | |
| 33 | + | cmd.Stdout = &stdout | |
| 34 | + | cmd.Stderr = io.Discard | |
| 35 | + | if err := cmd.Run(); err != nil { | |
| 36 | + | return stdout.Bytes(), err | |
| 37 | + | } | |
| 38 | + | return stdout.Bytes(), nil | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | type boundedBuffer struct { | |
| 42 | + | buf bytes.Buffer | |
| 43 | + | limit int | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (b *boundedBuffer) Write(p []byte) (int, error) { | |
| 47 | + | if b.limit <= 0 { | |
| 48 | + | return len(p), nil | |
| 49 | + | } | |
| 50 | + | remaining := b.limit - b.buf.Len() | |
| 51 | + | if remaining > 0 { | |
| 52 | + | if len(p) < remaining { | |
| 53 | + | remaining = len(p) | |
| 54 | + | } | |
| 55 | + | _, _ = b.buf.Write(p[:remaining]) | |
| 56 | + | } | |
| 57 | + | return len(p), nil | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func (b *boundedBuffer) Bytes() []byte { return b.buf.Bytes() } | |
| 61 | + | ||
| 62 | + | var _ ports.ShellCommandRunner = Runner{} | |
| 63 | + | var _ io.Writer = (*boundedBuffer)(nil) | |
| @@ -0,0 +1,63 @@ | |||||
| 1 | + | package shellcmd | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "io" | |||
| 7 | + | "os/exec" | |||
| 8 | + | "syscall" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/internal/ports" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // Runner executes bounded shell commands for bar scripts and similar usecases. | |||
| 14 | + | type Runner struct{} | |||
| 15 | + | ||||
| 16 | + | // New returns a production shell command runner. | |||
| 17 | + | func New() Runner { return Runner{} } | |||
| 18 | + | ||||
| 19 | + | // Run executes spec.Command through sh -c, capturing bounded stdout and discarding stderr. | |||
| 20 | + | func (Runner) Run(ctx context.Context, spec ports.CommandSpec) ([]byte, error) { | |||
| 21 | + | cmd := exec.CommandContext(ctx, "sh", "-c", spec.Command) | |||
| 22 | + | cmd.Env = spec.Env | |||
| 23 | + | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} | |||
| 24 | + | cmd.WaitDelay = spec.Timeout | |||
| 25 | + | cmd.Cancel = func() error { | |||
| 26 | + | if cmd.Process == nil { | |||
| 27 | + | return nil | |||
| 28 | + | } | |||
| 29 | + | return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) | |||
| 30 | + | } | |||
| 31 | + | var stdout boundedBuffer | |||
| 32 | + | stdout.limit = spec.StdoutLimit | |||
| 33 | + | cmd.Stdout = &stdout | |||
| 34 | + | cmd.Stderr = io.Discard | |||
| 35 | + | if err := cmd.Run(); err != nil { | |||
| 36 | + | return stdout.Bytes(), err | |||
| 37 | + | } | |||
| 38 | + | return stdout.Bytes(), nil | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | type boundedBuffer struct { | |||
| 42 | + | buf bytes.Buffer | |||
| 43 | + | limit int | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func (b *boundedBuffer) Write(p []byte) (int, error) { | |||
| 47 | + | if b.limit <= 0 { | |||
| 48 | + | return len(p), nil | |||
| 49 | + | } | |||
| 50 | + | remaining := b.limit - b.buf.Len() | |||
| 51 | + | if remaining > 0 { | |||
| 52 | + | if len(p) < remaining { | |||
| 53 | + | remaining = len(p) | |||
| 54 | + | } | |||
| 55 | + | _, _ = b.buf.Write(p[:remaining]) | |||
| 56 | + | } | |||
| 57 | + | return len(p), nil | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func (b *boundedBuffer) Bytes() []byte { return b.buf.Bytes() } | |||
| 61 | + | ||||
| 62 | + | var _ ports.ShellCommandRunner = Runner{} | |||
| 63 | + | var _ io.Writer = (*boundedBuffer)(nil) | |||
A internal/adapters/shellcmd/runner_test.go +107 -0
| @@ -0,0 +1,107 @@ | |||
| 1 | + | package shellcmd | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestRunnerRun(t *testing.T) { | |
| 14 | + | tests := []struct { | |
| 15 | + | name string | |
| 16 | + | command string | |
| 17 | + | limit int | |
| 18 | + | want string | |
| 19 | + | }{ | |
| 20 | + | {name: "success", command: "printf 'ok'", limit: 1024, want: "ok"}, | |
| 21 | + | {name: "bounded stdout", command: "yes a | head -c 4096", limit: 8, want: "a\na\na\na\n"}, | |
| 22 | + | {name: "stderr discarded", command: "printf out; printf err >&2", limit: 1024, want: "out"}, | |
| 23 | + | {name: "zero limit captures nothing", command: "printf output", limit: 0, want: ""}, | |
| 24 | + | {name: "negative limit captures nothing", command: "printf output", limit: -1, want: ""}, | |
| 25 | + | } | |
| 26 | + | for _, tt := range tests { | |
| 27 | + | t.Run(tt.name, func(t *testing.T) { | |
| 28 | + | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |
| 29 | + | defer cancel() | |
| 30 | + | ||
| 31 | + | got, err := New().Run(ctx, ports.CommandSpec{Command: tt.command, Timeout: time.Second, StdoutLimit: tt.limit}) | |
| 32 | + | if err != nil { | |
| 33 | + | t.Fatalf("Run() error = %v", err) | |
| 34 | + | } | |
| 35 | + | if string(got) != tt.want { | |
| 36 | + | t.Fatalf("Run() = %q, want %q", string(got), tt.want) | |
| 37 | + | } | |
| 38 | + | }) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestRunnerRunReturnsPartialStdoutOnError(t *testing.T) { | |
| 43 | + | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |
| 44 | + | defer cancel() | |
| 45 | + | ||
| 46 | + | got, err := New().Run(ctx, ports.CommandSpec{Command: "printf 'abcdef'; exit 7", Timeout: time.Second, StdoutLimit: 3}) | |
| 47 | + | if err == nil { | |
| 48 | + | t.Fatal("Run() error = nil, want non-zero exit error") | |
| 49 | + | } | |
| 50 | + | if string(got) != "abc" { | |
| 51 | + | t.Fatalf("Run() = %q, want bounded partial stdout %q", string(got), "abc") | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestRunnerRunTimeoutReturnsPromptly(t *testing.T) { | |
| 56 | + | ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) | |
| 57 | + | defer cancel() | |
| 58 | + | start := time.Now() | |
| 59 | + | ||
| 60 | + | _, err := New().Run(ctx, ports.CommandSpec{Command: "sleep 1", Timeout: 50 * time.Millisecond, StdoutLimit: 1024}) | |
| 61 | + | ||
| 62 | + | if err == nil { | |
| 63 | + | t.Fatal("Run() error = nil, want timeout or wait error") | |
| 64 | + | } | |
| 65 | + | if !errors.Is(ctx.Err(), context.DeadlineExceeded) { | |
| 66 | + | t.Fatalf("context error = %v, want DeadlineExceeded", ctx.Err()) | |
| 67 | + | } | |
| 68 | + | if elapsed := time.Since(start); elapsed > 500*time.Millisecond { | |
| 69 | + | t.Fatalf("Run() elapsed = %s, want <= 500ms", elapsed) | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestRunnerRunBackgroundChildDoesNotWedgeAfterTimeout(t *testing.T) { | |
| 74 | + | ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) | |
| 75 | + | defer cancel() | |
| 76 | + | start := time.Now() | |
| 77 | + | ||
| 78 | + | _, err := New().Run(ctx, ports.CommandSpec{Command: "sleep 1 &", Timeout: 50 * time.Millisecond, StdoutLimit: 1024}) | |
| 79 | + | ||
| 80 | + | if err == nil { | |
| 81 | + | t.Fatal("Run() error = nil, want timeout or wait error") | |
| 82 | + | } | |
| 83 | + | if !errors.Is(ctx.Err(), context.DeadlineExceeded) { | |
| 84 | + | t.Fatalf("context error = %v, want DeadlineExceeded", ctx.Err()) | |
| 85 | + | } | |
| 86 | + | if elapsed := time.Since(start); elapsed > 500*time.Millisecond { | |
| 87 | + | t.Fatalf("Run() elapsed = %s, want <= 500ms", elapsed) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func TestRunnerRunUsesProvidedEnvironment(t *testing.T) { | |
| 92 | + | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |
| 93 | + | defer cancel() | |
| 94 | + | ||
| 95 | + | got, err := New().Run(ctx, ports.CommandSpec{ | |
| 96 | + | Command: "printf '%s' \"$VEV_TEST_VALUE\"", | |
| 97 | + | Env: []string{"PATH=/bin:/usr/bin", "VEV_TEST_VALUE=from-env"}, | |
| 98 | + | Timeout: time.Second, | |
| 99 | + | StdoutLimit: 1024, | |
| 100 | + | }) | |
| 101 | + | if err != nil { | |
| 102 | + | t.Fatalf("Run() error = %v", err) | |
| 103 | + | } | |
| 104 | + | if strings.TrimSpace(string(got)) != "from-env" { | |
| 105 | + | t.Fatalf("Run() env output = %q, want from-env", string(got)) | |
| 106 | + | } | |
| 107 | + | } | |
| @@ -0,0 +1,107 @@ | |||||
| 1 | + | package shellcmd | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/internal/ports" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestRunnerRun(t *testing.T) { | |||
| 14 | + | tests := []struct { | |||
| 15 | + | name string | |||
| 16 | + | command string | |||
| 17 | + | limit int | |||
| 18 | + | want string | |||
| 19 | + | }{ | |||
| 20 | + | {name: "success", command: "printf 'ok'", limit: 1024, want: "ok"}, | |||
| 21 | + | {name: "bounded stdout", command: "yes a | head -c 4096", limit: 8, want: "a\na\na\na\n"}, | |||
| 22 | + | {name: "stderr discarded", command: "printf out; printf err >&2", limit: 1024, want: "out"}, | |||
| 23 | + | {name: "zero limit captures nothing", command: "printf output", limit: 0, want: ""}, | |||
| 24 | + | {name: "negative limit captures nothing", command: "printf output", limit: -1, want: ""}, | |||
| 25 | + | } | |||
| 26 | + | for _, tt := range tests { | |||
| 27 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 28 | + | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |||
| 29 | + | defer cancel() | |||
| 30 | + | ||||
| 31 | + | got, err := New().Run(ctx, ports.CommandSpec{Command: tt.command, Timeout: time.Second, StdoutLimit: tt.limit}) | |||
| 32 | + | if err != nil { | |||
| 33 | + | t.Fatalf("Run() error = %v", err) | |||
| 34 | + | } | |||
| 35 | + | if string(got) != tt.want { | |||
| 36 | + | t.Fatalf("Run() = %q, want %q", string(got), tt.want) | |||
| 37 | + | } | |||
| 38 | + | }) | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestRunnerRunReturnsPartialStdoutOnError(t *testing.T) { | |||
| 43 | + | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |||
| 44 | + | defer cancel() | |||
| 45 | + | ||||
| 46 | + | got, err := New().Run(ctx, ports.CommandSpec{Command: "printf 'abcdef'; exit 7", Timeout: time.Second, StdoutLimit: 3}) | |||
| 47 | + | if err == nil { | |||
| 48 | + | t.Fatal("Run() error = nil, want non-zero exit error") | |||
| 49 | + | } | |||
| 50 | + | if string(got) != "abc" { | |||
| 51 | + | t.Fatalf("Run() = %q, want bounded partial stdout %q", string(got), "abc") | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestRunnerRunTimeoutReturnsPromptly(t *testing.T) { | |||
| 56 | + | ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) | |||
| 57 | + | defer cancel() | |||
| 58 | + | start := time.Now() | |||
| 59 | + | ||||
| 60 | + | _, err := New().Run(ctx, ports.CommandSpec{Command: "sleep 1", Timeout: 50 * time.Millisecond, StdoutLimit: 1024}) | |||
| 61 | + | ||||
| 62 | + | if err == nil { | |||
| 63 | + | t.Fatal("Run() error = nil, want timeout or wait error") | |||
| 64 | + | } | |||
| 65 | + | if !errors.Is(ctx.Err(), context.DeadlineExceeded) { | |||
| 66 | + | t.Fatalf("context error = %v, want DeadlineExceeded", ctx.Err()) | |||
| 67 | + | } | |||
| 68 | + | if elapsed := time.Since(start); elapsed > 500*time.Millisecond { | |||
| 69 | + | t.Fatalf("Run() elapsed = %s, want <= 500ms", elapsed) | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func TestRunnerRunBackgroundChildDoesNotWedgeAfterTimeout(t *testing.T) { | |||
| 74 | + | ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) | |||
| 75 | + | defer cancel() | |||
| 76 | + | start := time.Now() | |||
| 77 | + | ||||
| 78 | + | _, err := New().Run(ctx, ports.CommandSpec{Command: "sleep 1 &", Timeout: 50 * time.Millisecond, StdoutLimit: 1024}) | |||
| 79 | + | ||||
| 80 | + | if err == nil { | |||
| 81 | + | t.Fatal("Run() error = nil, want timeout or wait error") | |||
| 82 | + | } | |||
| 83 | + | if !errors.Is(ctx.Err(), context.DeadlineExceeded) { | |||
| 84 | + | t.Fatalf("context error = %v, want DeadlineExceeded", ctx.Err()) | |||
| 85 | + | } | |||
| 86 | + | if elapsed := time.Since(start); elapsed > 500*time.Millisecond { | |||
| 87 | + | t.Fatalf("Run() elapsed = %s, want <= 500ms", elapsed) | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func TestRunnerRunUsesProvidedEnvironment(t *testing.T) { | |||
| 92 | + | ctx, cancel := context.WithTimeout(context.Background(), time.Second) | |||
| 93 | + | defer cancel() | |||
| 94 | + | ||||
| 95 | + | got, err := New().Run(ctx, ports.CommandSpec{ | |||
| 96 | + | Command: "printf '%s' \"$VEV_TEST_VALUE\"", | |||
| 97 | + | Env: []string{"PATH=/bin:/usr/bin", "VEV_TEST_VALUE=from-env"}, | |||
| 98 | + | Timeout: time.Second, | |||
| 99 | + | StdoutLimit: 1024, | |||
| 100 | + | }) | |||
| 101 | + | if err != nil { | |||
| 102 | + | t.Fatalf("Run() error = %v", err) | |||
| 103 | + | } | |||
| 104 | + | if strings.TrimSpace(string(got)) != "from-env" { | |||
| 105 | + | t.Fatalf("Run() env output = %q, want from-env", string(got)) | |||
| 106 | + | } | |||
| 107 | + | } | |||
M internal/app/run.go +2 -0
| @@ -28,6 +28,7 @@ import ( | |||
| 28 | 28 | "github.com/bnema/vev/internal/adapters/dgram" | |
| 29 | 29 | "github.com/bnema/vev/internal/adapters/ipc" | |
| 30 | 30 | "github.com/bnema/vev/internal/adapters/pty" | |
| 31 | + | "github.com/bnema/vev/internal/adapters/shellcmd" | |
| 31 | 32 | snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot" | |
| 32 | 33 | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| 33 | 34 | "github.com/bnema/vev/internal/adapters/term" | |
| @@ -284,6 +285,7 @@ func runDaemon() error { | |||
| 284 | 285 | } | |
| 285 | 286 | logConfigWarnings(log, warnings) | |
| 286 | 287 | daemonOpts = append(daemonOpts, daemon.WithConfig(cfg)) | |
| 288 | + | daemonOpts = append(daemonOpts, daemon.WithBarScriptCommandRunner(shellcmd.New())) | |
| 287 | 289 | daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome)) | |
| 288 | 290 | daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir()))) | |
| 289 | 291 | storePath := persist.StorePath(platform.StateDir()) | |
| @@ -28,6 +28,7 @@ import ( | |||||
| 28 | "github.com/bnema/vev/internal/adapters/dgram" | 28 | "github.com/bnema/vev/internal/adapters/dgram" | ||
| 29 | "github.com/bnema/vev/internal/adapters/ipc" | 29 | "github.com/bnema/vev/internal/adapters/ipc" | ||
| 30 | "github.com/bnema/vev/internal/adapters/pty" | 30 | "github.com/bnema/vev/internal/adapters/pty" | ||
| 31 | + | "github.com/bnema/vev/internal/adapters/shellcmd" | |||
| 31 | snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot" | 32 | snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot" | ||
| 32 | "github.com/bnema/vev/internal/adapters/sshstdio" | 33 | "github.com/bnema/vev/internal/adapters/sshstdio" | ||
| 33 | "github.com/bnema/vev/internal/adapters/term" | 34 | "github.com/bnema/vev/internal/adapters/term" | ||
| @@ -284,6 +285,7 @@ func runDaemon() error { | |||||
| 284 | } | 285 | } | ||
| 285 | logConfigWarnings(log, warnings) | 286 | logConfigWarnings(log, warnings) | ||
| 286 | daemonOpts = append(daemonOpts, daemon.WithConfig(cfg)) | 287 | daemonOpts = append(daemonOpts, daemon.WithConfig(cfg)) | ||
| 288 | + | daemonOpts = append(daemonOpts, daemon.WithBarScriptCommandRunner(shellcmd.New())) | |||
| 287 | daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome)) | 289 | daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome)) | ||
| 288 | daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir()))) | 290 | daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir()))) | ||
| 289 | storePath := persist.StorePath(platform.StateDir()) | 291 | storePath := persist.StorePath(platform.StateDir()) | ||
M internal/domain/config.go +19 -1
| @@ -1,5 +1,10 @@ | |||
| 1 | 1 | package domain | |
| 2 | 2 | ||
| 3 | + | import "time" | |
| 4 | + | ||
| 5 | + | // MinBarInterval is the minimum supported bar refresh interval. | |
| 6 | + | const MinBarInterval = time.Second | |
| 7 | + | ||
| 3 | 8 | // ThemeMode selects how vev derives its color scheme. | |
| 4 | 9 | type ThemeMode int | |
| 5 | 10 | ||
| @@ -35,11 +40,19 @@ func DefaultSnapshotRestoreProcesses() []string { | |||
| 35 | 40 | return append([]string(nil), defaultSnapshotRestoreProcesses...) | |
| 36 | 41 | } | |
| 37 | 42 | ||
| 43 | + | // BarConfig contains user-configurable bar right-anchor settings. | |
| 44 | + | type BarConfig struct { | |
| 45 | + | TopRight string | |
| 46 | + | BottomRight string | |
| 47 | + | Interval time.Duration | |
| 48 | + | } | |
| 49 | + | ||
| 38 | 50 | // Config is the user-editable vev configuration after parsing. Unknown binding | |
| 39 | 51 | // keys are preserved here (in BindingEntries, in file order) so the usecase | |
| 40 | 52 | // layer can decide which actions it understands. | |
| 41 | 53 | type Config struct { | |
| 42 | 54 | Theme ThemeMode | |
| 55 | + | Bar BarConfig | |
| 43 | 56 | BindingEntries []ConfigEntry | |
| 44 | 57 | Codes map[string]string | |
| 45 | 58 | Snapshot SnapshotConfig | |
| @@ -55,7 +68,12 @@ type Warning struct { | |||
| 55 | 68 | // Defaults returns vev's default configuration. | |
| 56 | 69 | func Defaults() Config { | |
| 57 | 70 | return Config{ | |
| 58 | - | Theme: ThemeAuto, | |
| 71 | + | Theme: ThemeAuto, | |
| 72 | + | Bar: BarConfig{ | |
| 73 | + | TopRight: "vev-bar-top-right", | |
| 74 | + | BottomRight: "vev-bar-bottom-right", | |
| 75 | + | Interval: 5 * time.Second, | |
| 76 | + | }, | |
| 59 | 77 | BindingEntries: []ConfigEntry{}, | |
| 60 | 78 | Codes: map[string]string{}, | |
| 61 | 79 | Snapshot: SnapshotConfig{ | |
| @@ -1,5 +1,10 @@ | |||||
| 1 | package domain | 1 | package domain | ||
| 2 | 2 | ||||
| 3 | + | import "time" | |||
| 4 | + | ||||
| 5 | + | // MinBarInterval is the minimum supported bar refresh interval. | |||
| 6 | + | const MinBarInterval = time.Second | |||
| 7 | + | ||||
| 3 | // ThemeMode selects how vev derives its color scheme. | 8 | // ThemeMode selects how vev derives its color scheme. | ||
| 4 | type ThemeMode int | 9 | type ThemeMode int | ||
| 5 | 10 | ||||
| @@ -35,11 +40,19 @@ func DefaultSnapshotRestoreProcesses() []string { | |||||
| 35 | return append([]string(nil), defaultSnapshotRestoreProcesses...) | 40 | return append([]string(nil), defaultSnapshotRestoreProcesses...) | ||
| 36 | } | 41 | } | ||
| 37 | 42 | ||||
| 43 | + | // BarConfig contains user-configurable bar right-anchor settings. | |||
| 44 | + | type BarConfig struct { | |||
| 45 | + | TopRight string | |||
| 46 | + | BottomRight string | |||
| 47 | + | Interval time.Duration | |||
| 48 | + | } | |||
| 49 | + | ||||
| 38 | // Config is the user-editable vev configuration after parsing. Unknown binding | 50 | // Config is the user-editable vev configuration after parsing. Unknown binding | ||
| 39 | // keys are preserved here (in BindingEntries, in file order) so the usecase | 51 | // keys are preserved here (in BindingEntries, in file order) so the usecase | ||
| 40 | // layer can decide which actions it understands. | 52 | // layer can decide which actions it understands. | ||
| 41 | type Config struct { | 53 | type Config struct { | ||
| 42 | Theme ThemeMode | 54 | Theme ThemeMode | ||
| 55 | + | Bar BarConfig | |||
| 43 | BindingEntries []ConfigEntry | 56 | BindingEntries []ConfigEntry | ||
| 44 | Codes map[string]string | 57 | Codes map[string]string | ||
| 45 | Snapshot SnapshotConfig | 58 | Snapshot SnapshotConfig | ||
| @@ -55,7 +68,12 @@ type Warning struct { | |||||
| 55 | // Defaults returns vev's default configuration. | 68 | // Defaults returns vev's default configuration. | ||
| 56 | func Defaults() Config { | 69 | func Defaults() Config { | ||
| 57 | return Config{ | 70 | return Config{ | ||
| 58 | - | Theme: ThemeAuto, | 71 | + | Theme: ThemeAuto, |
| 72 | + | Bar: BarConfig{ | |||
| 73 | + | TopRight: "vev-bar-top-right", | |||
| 74 | + | BottomRight: "vev-bar-bottom-right", | |||
| 75 | + | Interval: 5 * time.Second, | |||
| 76 | + | }, | |||
| 59 | BindingEntries: []ConfigEntry{}, | 77 | BindingEntries: []ConfigEntry{}, | ||
| 60 | Codes: map[string]string{}, | 78 | Codes: map[string]string{}, | ||
| 61 | Snapshot: SnapshotConfig{ | 79 | Snapshot: SnapshotConfig{ | ||
M internal/ports/mocks/mocks.go +95 -0
| @@ -2068,6 +2068,101 @@ func (_c *MockTimer_Stop_Call) RunAndReturn(run func() bool) *MockTimer_Stop_Cal | |||
| 2068 | 2068 | return _c | |
| 2069 | 2069 | } | |
| 2070 | 2070 | ||
| 2071 | + | // NewMockShellCommandRunner creates a new instance of MockShellCommandRunner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 2072 | + | // The first argument is typically a *testing.T value. | |
| 2073 | + | func NewMockShellCommandRunner(t interface { | |
| 2074 | + | mock.TestingT | |
| 2075 | + | Cleanup(func()) | |
| 2076 | + | }) *MockShellCommandRunner { | |
| 2077 | + | mock := &MockShellCommandRunner{} | |
| 2078 | + | mock.Mock.Test(t) | |
| 2079 | + | ||
| 2080 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 2081 | + | ||
| 2082 | + | return mock | |
| 2083 | + | } | |
| 2084 | + | ||
| 2085 | + | // MockShellCommandRunner is an autogenerated mock type for the ShellCommandRunner type | |
| 2086 | + | type MockShellCommandRunner struct { | |
| 2087 | + | mock.Mock | |
| 2088 | + | } | |
| 2089 | + | ||
| 2090 | + | type MockShellCommandRunner_Expecter struct { | |
| 2091 | + | mock *mock.Mock | |
| 2092 | + | } | |
| 2093 | + | ||
| 2094 | + | func (_m *MockShellCommandRunner) EXPECT() *MockShellCommandRunner_Expecter { | |
| 2095 | + | return &MockShellCommandRunner_Expecter{mock: &_m.Mock} | |
| 2096 | + | } | |
| 2097 | + | ||
| 2098 | + | // Run provides a mock function for the type MockShellCommandRunner | |
| 2099 | + | func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) ([]byte, error) { | |
| 2100 | + | ret := _mock.Called(ctx, spec) | |
| 2101 | + | ||
| 2102 | + | if len(ret) == 0 { | |
| 2103 | + | panic("no return value specified for Run") | |
| 2104 | + | } | |
| 2105 | + | ||
| 2106 | + | var r0 []byte | |
| 2107 | + | var r1 error | |
| 2108 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ([]byte, error)); ok { | |
| 2109 | + | return returnFunc(ctx, spec) | |
| 2110 | + | } | |
| 2111 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) []byte); ok { | |
| 2112 | + | r0 = returnFunc(ctx, spec) | |
| 2113 | + | } else { | |
| 2114 | + | if ret.Get(0) != nil { | |
| 2115 | + | r0 = ret.Get(0).([]byte) | |
| 2116 | + | } | |
| 2117 | + | } | |
| 2118 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CommandSpec) error); ok { | |
| 2119 | + | r1 = returnFunc(ctx, spec) | |
| 2120 | + | } else { | |
| 2121 | + | r1 = ret.Error(1) | |
| 2122 | + | } | |
| 2123 | + | return r0, r1 | |
| 2124 | + | } | |
| 2125 | + | ||
| 2126 | + | // MockShellCommandRunner_Run_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Run' | |
| 2127 | + | type MockShellCommandRunner_Run_Call struct { | |
| 2128 | + | *mock.Call | |
| 2129 | + | } | |
| 2130 | + | ||
| 2131 | + | // Run is a helper method to define mock.On call | |
| 2132 | + | // - ctx context.Context | |
| 2133 | + | // - spec ports.CommandSpec | |
| 2134 | + | func (_e *MockShellCommandRunner_Expecter) Run(ctx any, spec any) *MockShellCommandRunner_Run_Call { | |
| 2135 | + | return &MockShellCommandRunner_Run_Call{Call: _e.mock.On("Run", ctx, spec)} | |
| 2136 | + | } | |
| 2137 | + | ||
| 2138 | + | func (_c *MockShellCommandRunner_Run_Call) Run(run func(ctx context.Context, spec ports.CommandSpec)) *MockShellCommandRunner_Run_Call { | |
| 2139 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 2140 | + | var arg0 context.Context | |
| 2141 | + | if args[0] != nil { | |
| 2142 | + | arg0 = args[0].(context.Context) | |
| 2143 | + | } | |
| 2144 | + | var arg1 ports.CommandSpec | |
| 2145 | + | if args[1] != nil { | |
| 2146 | + | arg1 = args[1].(ports.CommandSpec) | |
| 2147 | + | } | |
| 2148 | + | run( | |
| 2149 | + | arg0, | |
| 2150 | + | arg1, | |
| 2151 | + | ) | |
| 2152 | + | }) | |
| 2153 | + | return _c | |
| 2154 | + | } | |
| 2155 | + | ||
| 2156 | + | func (_c *MockShellCommandRunner_Run_Call) Return(bytes []byte, err error) *MockShellCommandRunner_Run_Call { | |
| 2157 | + | _c.Call.Return(bytes, err) | |
| 2158 | + | return _c | |
| 2159 | + | } | |
| 2160 | + | ||
| 2161 | + | func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) ([]byte, error)) *MockShellCommandRunner_Run_Call { | |
| 2162 | + | _c.Call.Return(run) | |
| 2163 | + | return _c | |
| 2164 | + | } | |
| 2165 | + | ||
| 2071 | 2166 | // NewMockProcessInspector creates a new instance of MockProcessInspector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 2072 | 2167 | // The first argument is typically a *testing.T value. | |
| 2073 | 2168 | func NewMockProcessInspector(t interface { | |
| @@ -2068,6 +2068,101 @@ func (_c *MockTimer_Stop_Call) RunAndReturn(run func() bool) *MockTimer_Stop_Cal | |||||
| 2068 | return _c | 2068 | return _c | ||
| 2069 | } | 2069 | } | ||
| 2070 | 2070 | ||||
| 2071 | + | // NewMockShellCommandRunner creates a new instance of MockShellCommandRunner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 2072 | + | // The first argument is typically a *testing.T value. | |||
| 2073 | + | func NewMockShellCommandRunner(t interface { | |||
| 2074 | + | mock.TestingT | |||
| 2075 | + | Cleanup(func()) | |||
| 2076 | + | }) *MockShellCommandRunner { | |||
| 2077 | + | mock := &MockShellCommandRunner{} | |||
| 2078 | + | mock.Mock.Test(t) | |||
| 2079 | + | ||||
| 2080 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 2081 | + | ||||
| 2082 | + | return mock | |||
| 2083 | + | } | |||
| 2084 | + | ||||
| 2085 | + | // MockShellCommandRunner is an autogenerated mock type for the ShellCommandRunner type | |||
| 2086 | + | type MockShellCommandRunner struct { | |||
| 2087 | + | mock.Mock | |||
| 2088 | + | } | |||
| 2089 | + | ||||
| 2090 | + | type MockShellCommandRunner_Expecter struct { | |||
| 2091 | + | mock *mock.Mock | |||
| 2092 | + | } | |||
| 2093 | + | ||||
| 2094 | + | func (_m *MockShellCommandRunner) EXPECT() *MockShellCommandRunner_Expecter { | |||
| 2095 | + | return &MockShellCommandRunner_Expecter{mock: &_m.Mock} | |||
| 2096 | + | } | |||
| 2097 | + | ||||
| 2098 | + | // Run provides a mock function for the type MockShellCommandRunner | |||
| 2099 | + | func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) ([]byte, error) { | |||
| 2100 | + | ret := _mock.Called(ctx, spec) | |||
| 2101 | + | ||||
| 2102 | + | if len(ret) == 0 { | |||
| 2103 | + | panic("no return value specified for Run") | |||
| 2104 | + | } | |||
| 2105 | + | ||||
| 2106 | + | var r0 []byte | |||
| 2107 | + | var r1 error | |||
| 2108 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ([]byte, error)); ok { | |||
| 2109 | + | return returnFunc(ctx, spec) | |||
| 2110 | + | } | |||
| 2111 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) []byte); ok { | |||
| 2112 | + | r0 = returnFunc(ctx, spec) | |||
| 2113 | + | } else { | |||
| 2114 | + | if ret.Get(0) != nil { | |||
| 2115 | + | r0 = ret.Get(0).([]byte) | |||
| 2116 | + | } | |||
| 2117 | + | } | |||
| 2118 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CommandSpec) error); ok { | |||
| 2119 | + | r1 = returnFunc(ctx, spec) | |||
| 2120 | + | } else { | |||
| 2121 | + | r1 = ret.Error(1) | |||
| 2122 | + | } | |||
| 2123 | + | return r0, r1 | |||
| 2124 | + | } | |||
| 2125 | + | ||||
| 2126 | + | // MockShellCommandRunner_Run_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Run' | |||
| 2127 | + | type MockShellCommandRunner_Run_Call struct { | |||
| 2128 | + | *mock.Call | |||
| 2129 | + | } | |||
| 2130 | + | ||||
| 2131 | + | // Run is a helper method to define mock.On call | |||
| 2132 | + | // - ctx context.Context | |||
| 2133 | + | // - spec ports.CommandSpec | |||
| 2134 | + | func (_e *MockShellCommandRunner_Expecter) Run(ctx any, spec any) *MockShellCommandRunner_Run_Call { | |||
| 2135 | + | return &MockShellCommandRunner_Run_Call{Call: _e.mock.On("Run", ctx, spec)} | |||
| 2136 | + | } | |||
| 2137 | + | ||||
| 2138 | + | func (_c *MockShellCommandRunner_Run_Call) Run(run func(ctx context.Context, spec ports.CommandSpec)) *MockShellCommandRunner_Run_Call { | |||
| 2139 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 2140 | + | var arg0 context.Context | |||
| 2141 | + | if args[0] != nil { | |||
| 2142 | + | arg0 = args[0].(context.Context) | |||
| 2143 | + | } | |||
| 2144 | + | var arg1 ports.CommandSpec | |||
| 2145 | + | if args[1] != nil { | |||
| 2146 | + | arg1 = args[1].(ports.CommandSpec) | |||
| 2147 | + | } | |||
| 2148 | + | run( | |||
| 2149 | + | arg0, | |||
| 2150 | + | arg1, | |||
| 2151 | + | ) | |||
| 2152 | + | }) | |||
| 2153 | + | return _c | |||
| 2154 | + | } | |||
| 2155 | + | ||||
| 2156 | + | func (_c *MockShellCommandRunner_Run_Call) Return(bytes []byte, err error) *MockShellCommandRunner_Run_Call { | |||
| 2157 | + | _c.Call.Return(bytes, err) | |||
| 2158 | + | return _c | |||
| 2159 | + | } | |||
| 2160 | + | ||||
| 2161 | + | func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) ([]byte, error)) *MockShellCommandRunner_Run_Call { | |||
| 2162 | + | _c.Call.Return(run) | |||
| 2163 | + | return _c | |||
| 2164 | + | } | |||
| 2165 | + | ||||
| 2071 | // NewMockProcessInspector creates a new instance of MockProcessInspector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | 2166 | // NewMockProcessInspector creates a new instance of MockProcessInspector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | ||
| 2072 | // The first argument is typically a *testing.T value. | 2167 | // The first argument is typically a *testing.T value. | ||
| 2073 | func NewMockProcessInspector(t interface { | 2168 | func NewMockProcessInspector(t interface { | ||
M internal/ports/ports.go +13 -0
| @@ -96,6 +96,19 @@ type Timer interface { | |||
| 96 | 96 | Stop() bool | |
| 97 | 97 | } | |
| 98 | 98 | ||
| 99 | + | // CommandSpec describes a bounded shell command execution request. | |
| 100 | + | type CommandSpec struct { | |
| 101 | + | Command string | |
| 102 | + | Env []string | |
| 103 | + | Timeout time.Duration | |
| 104 | + | StdoutLimit int | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // ShellCommandRunner runs shell commands and returns bounded stdout bytes. | |
| 108 | + | type ShellCommandRunner interface { | |
| 109 | + | Run(ctx context.Context, spec CommandSpec) ([]byte, error) | |
| 110 | + | } | |
| 111 | + | ||
| 99 | 112 | // ProcessInspector reads process metadata for daemon snapshot/persistence use. | |
| 100 | 113 | // Implementations may live in platform packages; usecases depend on this port. | |
| 101 | 114 | type ProcessInspector interface { | |
| @@ -96,6 +96,19 @@ type Timer interface { | |||||
| 96 | Stop() bool | 96 | Stop() bool | ||
| 97 | } | 97 | } | ||
| 98 | 98 | ||||
| 99 | + | // CommandSpec describes a bounded shell command execution request. | |||
| 100 | + | type CommandSpec struct { | |||
| 101 | + | Command string | |||
| 102 | + | Env []string | |||
| 103 | + | Timeout time.Duration | |||
| 104 | + | StdoutLimit int | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // ShellCommandRunner runs shell commands and returns bounded stdout bytes. | |||
| 108 | + | type ShellCommandRunner interface { | |||
| 109 | + | Run(ctx context.Context, spec CommandSpec) ([]byte, error) | |||
| 110 | + | } | |||
| 111 | + | ||||
| 99 | // ProcessInspector reads process metadata for daemon snapshot/persistence use. | 112 | // ProcessInspector reads process metadata for daemon snapshot/persistence use. | ||
| 100 | // Implementations may live in platform packages; usecases depend on this port. | 113 | // Implementations may live in platform packages; usecases depend on this port. | ||
| 101 | type ProcessInspector interface { | 114 | type ProcessInspector interface { | ||
A internal/usecase/daemon/bar_refresh.go +317 -0
| @@ -0,0 +1,317 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "sync" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | type barScriptExecutor interface { | |
| 12 | + | run(context.Context, string, barScriptContext) (string, error) | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | type barScriptConfig struct { | |
| 16 | + | topRight string | |
| 17 | + | bottomRight string | |
| 18 | + | interval time.Duration | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | type barScriptOutputs struct { | |
| 22 | + | topRight string | |
| 23 | + | bottomRight string | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | type barScriptState struct { | |
| 27 | + | mu sync.Mutex | |
| 28 | + | cfg barScriptConfig | |
| 29 | + | runner barScriptExecutor | |
| 30 | + | outputs map[domain.SessionID]barScriptOutputs | |
| 31 | + | lastRefresh map[domain.SessionID]time.Time | |
| 32 | + | lastContext map[domain.SessionID]barScriptContext | |
| 33 | + | running map[domain.SessionID]bool | |
| 34 | + | pending map[domain.SessionID]bool | |
| 35 | + | version uint64 | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func effectiveBarInterval(d time.Duration) time.Duration { | |
| 39 | + | if d <= 0 { | |
| 40 | + | return 5 * time.Second | |
| 41 | + | } | |
| 42 | + | if d < domain.MinBarInterval { | |
| 43 | + | return domain.MinBarInterval | |
| 44 | + | } | |
| 45 | + | return d | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func barConfigFromDomain(cfg domain.BarConfig) barScriptConfig { | |
| 49 | + | return barScriptConfig{topRight: cfg.TopRight, bottomRight: cfg.BottomRight, interval: effectiveBarInterval(cfg.Interval)} | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func (s *barScriptState) initLocked() { | |
| 53 | + | if s.outputs == nil { | |
| 54 | + | s.outputs = make(map[domain.SessionID]barScriptOutputs) | |
| 55 | + | } | |
| 56 | + | if s.lastRefresh == nil { | |
| 57 | + | s.lastRefresh = make(map[domain.SessionID]time.Time) | |
| 58 | + | } | |
| 59 | + | if s.lastContext == nil { | |
| 60 | + | s.lastContext = make(map[domain.SessionID]barScriptContext) | |
| 61 | + | } | |
| 62 | + | if s.running == nil { | |
| 63 | + | s.running = make(map[domain.SessionID]bool) | |
| 64 | + | } | |
| 65 | + | if s.pending == nil { | |
| 66 | + | s.pending = make(map[domain.SessionID]bool) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func (d *Daemon) barScriptSnapshot(sess *session) (string, string) { | |
| 71 | + | if d == nil || d.barScripts == nil || sess == nil { | |
| 72 | + | return "", "" | |
| 73 | + | } | |
| 74 | + | d.barScripts.mu.Lock() | |
| 75 | + | defer d.barScripts.mu.Unlock() | |
| 76 | + | out := d.barScripts.outputs[sess.id] | |
| 77 | + | return out.topRight, out.bottomRight | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func (d *Daemon) barScriptInterval() time.Duration { | |
| 81 | + | if d == nil || d.barScripts == nil { | |
| 82 | + | return 5 * time.Second | |
| 83 | + | } | |
| 84 | + | d.barScripts.mu.Lock() | |
| 85 | + | defer d.barScripts.mu.Unlock() | |
| 86 | + | return effectiveBarInterval(d.barScripts.cfg.interval) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScriptContext, bool) { | |
| 90 | + | ctx := barScriptContext{Anchor: anchor} | |
| 91 | + | if sess == nil { | |
| 92 | + | return ctx, false | |
| 93 | + | } | |
| 94 | + | sess.mu.Lock() | |
| 95 | + | ctx.Session = sess.name | |
| 96 | + | active := sess.active | |
| 97 | + | var tb *tab | |
| 98 | + | if active >= 0 && active < len(sess.tabs) { | |
| 99 | + | tb = sess.tabs[active] | |
| 100 | + | } | |
| 101 | + | ac := sess.client | |
| 102 | + | ctx.PaneCWD = sess.cwd | |
| 103 | + | sess.mu.Unlock() | |
| 104 | + | if ac == nil || tb == nil { | |
| 105 | + | return ctx, false | |
| 106 | + | } | |
| 107 | + | tb.mu.Lock() | |
| 108 | + | ctx.Tab = tb.stableID | |
| 109 | + | ctx.Cols = tb.size.Cols | |
| 110 | + | pid := 0 | |
| 111 | + | if p := tb.focusedPane(); p != nil { | |
| 112 | + | ctx.Pane = p.stableID | |
| 113 | + | if d != nil && d.procCwd != nil && p.pty != nil { | |
| 114 | + | pid = p.pty.Pid() | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | tb.mu.Unlock() | |
| 118 | + | if pid != 0 && d != nil && d.procCwd != nil { | |
| 119 | + | if cwd, err := d.procCwd(pid); err == nil && cwd != "" { | |
| 120 | + | ctx.PaneCWD = cwd | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | return ctx, true | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func (d *Daemon) barScriptPoller(ctx context.Context) { | |
| 127 | + | timer := d.clock.NewTimer(d.barScriptInterval()) | |
| 128 | + | defer timer.Stop() | |
| 129 | + | for { | |
| 130 | + | select { | |
| 131 | + | case <-ctx.Done(): | |
| 132 | + | return | |
| 133 | + | case now := <-timer.C(): | |
| 134 | + | for _, sess := range d.sessionsSnapshot() { | |
| 135 | + | d.refreshBarScriptsIfDue(sess, now, false) | |
| 136 | + | } | |
| 137 | + | timer.Reset(d.barScriptInterval()) | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func (d *Daemon) sessionsSnapshot() []*session { | |
| 143 | + | d.mu.Lock() | |
| 144 | + | defer d.mu.Unlock() | |
| 145 | + | sessions := make([]*session, 0, len(d.sessions)) | |
| 146 | + | for _, sess := range d.sessions { | |
| 147 | + | sessions = append(sessions, sess) | |
| 148 | + | } | |
| 149 | + | return sessions | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) { | |
| 153 | + | if d == nil || d.barScripts == nil { | |
| 154 | + | return | |
| 155 | + | } | |
| 156 | + | d.barScripts.mu.Lock() | |
| 157 | + | defer d.barScripts.mu.Unlock() | |
| 158 | + | delete(d.barScripts.outputs, id) | |
| 159 | + | delete(d.barScripts.lastRefresh, id) | |
| 160 | + | delete(d.barScripts.lastContext, id) | |
| 161 | + | delete(d.barScripts.running, id) | |
| 162 | + | delete(d.barScripts.pending, id) | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool) bool { | |
| 166 | + | if d == nil || d.barScripts == nil || sess == nil { | |
| 167 | + | return false | |
| 168 | + | } | |
| 169 | + | baseCtx, ok := d.collectBarScriptContext(sess, "") | |
| 170 | + | if !ok { | |
| 171 | + | return false | |
| 172 | + | } | |
| 173 | + | d.barScripts.mu.Lock() | |
| 174 | + | d.barScripts.initLocked() | |
| 175 | + | if d.barScripts.lastContext[sess.id] != baseCtx { | |
| 176 | + | force = true | |
| 177 | + | } | |
| 178 | + | lastRefresh := d.barScripts.lastRefresh[sess.id] | |
| 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 { | |
| 187 | + | if force { | |
| 188 | + | d.scheduleBarScriptRefreshLocked(sess, lastRefresh.Add(domain.MinBarInterval).Sub(now)) | |
| 189 | + | } | |
| 190 | + | d.barScripts.mu.Unlock() | |
| 191 | + | return false | |
| 192 | + | } | |
| 193 | + | if !force && !lastRefresh.IsZero() && now.Sub(lastRefresh) < d.barScripts.cfg.interval { | |
| 194 | + | d.barScripts.mu.Unlock() | |
| 195 | + | return false | |
| 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 | + | } | |
| 210 | + | d.barScripts.running[sess.id] = true | |
| 211 | + | d.barScripts.lastRefresh[sess.id] = now | |
| 212 | + | d.barScripts.lastContext[sess.id] = baseCtx | |
| 213 | + | runner := d.barScripts.runner | |
| 214 | + | version := d.barScripts.version | |
| 215 | + | d.barScripts.mu.Unlock() | |
| 216 | + | go d.runBarScripts(sess, runner, cfg, baseCtx, version) | |
| 217 | + | return true | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | func (d *Daemon) scheduleBarScriptRefreshLocked(sess *session, delay time.Duration) { | |
| 221 | + | if d.barScripts.pending[sess.id] { | |
| 222 | + | return | |
| 223 | + | } | |
| 224 | + | if d.clock == nil { | |
| 225 | + | return | |
| 226 | + | } | |
| 227 | + | if delay < 0 { | |
| 228 | + | delay = 0 | |
| 229 | + | } | |
| 230 | + | d.barScripts.pending[sess.id] = true | |
| 231 | + | go func() { | |
| 232 | + | ctx := sess.ctx | |
| 233 | + | if ctx == nil { | |
| 234 | + | ctx = context.Background() | |
| 235 | + | } | |
| 236 | + | timer := d.clock.NewTimer(delay) | |
| 237 | + | select { | |
| 238 | + | case <-ctx.Done(): | |
| 239 | + | timer.Stop() | |
| 240 | + | return | |
| 241 | + | case <-timer.C(): | |
| 242 | + | } | |
| 243 | + | d.barScripts.mu.Lock() | |
| 244 | + | delete(d.barScripts.pending, sess.id) | |
| 245 | + | d.barScripts.mu.Unlock() | |
| 246 | + | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | |
| 247 | + | }() | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barScriptConfig, base barScriptContext, version uint64) { | |
| 251 | + | if runner == nil { | |
| 252 | + | runner = barScriptRunner{baseEnv: d.baseEnv} | |
| 253 | + | } | |
| 254 | + | ctx := sess.ctx | |
| 255 | + | if ctx == nil { | |
| 256 | + | ctx = context.Background() | |
| 257 | + | } | |
| 258 | + | top, topOK := d.runOneBarScript(ctx, runner, cfg.topRight, base, "top-right", sess.name) | |
| 259 | + | bottom, bottomOK := d.runOneBarScript(ctx, runner, cfg.bottomRight, base, "bottom-right", sess.name) | |
| 260 | + | d.barScripts.mu.Lock() | |
| 261 | + | d.barScripts.initLocked() | |
| 262 | + | if !d.barScripts.running[sess.id] || d.barScripts.version != version { | |
| 263 | + | delete(d.barScripts.running, sess.id) | |
| 264 | + | d.barScripts.mu.Unlock() | |
| 265 | + | return | |
| 266 | + | } | |
| 267 | + | current := d.barScripts.outputs[sess.id] | |
| 268 | + | changed := false | |
| 269 | + | if topOK && current.topRight != top { | |
| 270 | + | current.topRight = top | |
| 271 | + | changed = true | |
| 272 | + | } | |
| 273 | + | if bottomOK && current.bottomRight != bottom { | |
| 274 | + | current.bottomRight = bottom | |
| 275 | + | changed = true | |
| 276 | + | } | |
| 277 | + | d.barScripts.outputs[sess.id] = current | |
| 278 | + | pending := d.barScripts.pending[sess.id] | |
| 279 | + | delete(d.barScripts.running, sess.id) | |
| 280 | + | delete(d.barScripts.pending, sess.id) | |
| 281 | + | d.barScripts.mu.Unlock() | |
| 282 | + | if changed { | |
| 283 | + | d.pokeSessionRender(sess) | |
| 284 | + | } | |
| 285 | + | if pending { | |
| 286 | + | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | func (d *Daemon) runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, base barScriptContext, anchor, sessionName string) (string, bool) { | |
| 291 | + | if command == "" { | |
| 292 | + | return "", true | |
| 293 | + | } | |
| 294 | + | base.Anchor = anchor | |
| 295 | + | out, err := runner.run(ctx, command, base) | |
| 296 | + | if err != nil { | |
| 297 | + | if d.log != nil { | |
| 298 | + | d.log.Warn("bar script failed; keeping last good output", "anchor", anchor, "session", sessionName, "err", err) | |
| 299 | + | } | |
| 300 | + | return "", false | |
| 301 | + | } | |
| 302 | + | return out, true | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | func (d *Daemon) pokeSessionRender(sess *session) { | |
| 306 | + | if sess == nil { | |
| 307 | + | return | |
| 308 | + | } | |
| 309 | + | if tb := sess.activeTab(); tb != nil { | |
| 310 | + | tb.mu.Lock() | |
| 311 | + | p := tb.focusedPane() | |
| 312 | + | tb.mu.Unlock() | |
| 313 | + | if p != nil { | |
| 314 | + | signal(p.dirty) | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | } | |
| @@ -0,0 +1,317 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "sync" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | type barScriptExecutor interface { | |||
| 12 | + | run(context.Context, string, barScriptContext) (string, error) | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | type barScriptConfig struct { | |||
| 16 | + | topRight string | |||
| 17 | + | bottomRight string | |||
| 18 | + | interval time.Duration | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | type barScriptOutputs struct { | |||
| 22 | + | topRight string | |||
| 23 | + | bottomRight string | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | type barScriptState struct { | |||
| 27 | + | mu sync.Mutex | |||
| 28 | + | cfg barScriptConfig | |||
| 29 | + | runner barScriptExecutor | |||
| 30 | + | outputs map[domain.SessionID]barScriptOutputs | |||
| 31 | + | lastRefresh map[domain.SessionID]time.Time | |||
| 32 | + | lastContext map[domain.SessionID]barScriptContext | |||
| 33 | + | running map[domain.SessionID]bool | |||
| 34 | + | pending map[domain.SessionID]bool | |||
| 35 | + | version uint64 | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func effectiveBarInterval(d time.Duration) time.Duration { | |||
| 39 | + | if d <= 0 { | |||
| 40 | + | return 5 * time.Second | |||
| 41 | + | } | |||
| 42 | + | if d < domain.MinBarInterval { | |||
| 43 | + | return domain.MinBarInterval | |||
| 44 | + | } | |||
| 45 | + | return d | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func barConfigFromDomain(cfg domain.BarConfig) barScriptConfig { | |||
| 49 | + | return barScriptConfig{topRight: cfg.TopRight, bottomRight: cfg.BottomRight, interval: effectiveBarInterval(cfg.Interval)} | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func (s *barScriptState) initLocked() { | |||
| 53 | + | if s.outputs == nil { | |||
| 54 | + | s.outputs = make(map[domain.SessionID]barScriptOutputs) | |||
| 55 | + | } | |||
| 56 | + | if s.lastRefresh == nil { | |||
| 57 | + | s.lastRefresh = make(map[domain.SessionID]time.Time) | |||
| 58 | + | } | |||
| 59 | + | if s.lastContext == nil { | |||
| 60 | + | s.lastContext = make(map[domain.SessionID]barScriptContext) | |||
| 61 | + | } | |||
| 62 | + | if s.running == nil { | |||
| 63 | + | s.running = make(map[domain.SessionID]bool) | |||
| 64 | + | } | |||
| 65 | + | if s.pending == nil { | |||
| 66 | + | s.pending = make(map[domain.SessionID]bool) | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func (d *Daemon) barScriptSnapshot(sess *session) (string, string) { | |||
| 71 | + | if d == nil || d.barScripts == nil || sess == nil { | |||
| 72 | + | return "", "" | |||
| 73 | + | } | |||
| 74 | + | d.barScripts.mu.Lock() | |||
| 75 | + | defer d.barScripts.mu.Unlock() | |||
| 76 | + | out := d.barScripts.outputs[sess.id] | |||
| 77 | + | return out.topRight, out.bottomRight | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func (d *Daemon) barScriptInterval() time.Duration { | |||
| 81 | + | if d == nil || d.barScripts == nil { | |||
| 82 | + | return 5 * time.Second | |||
| 83 | + | } | |||
| 84 | + | d.barScripts.mu.Lock() | |||
| 85 | + | defer d.barScripts.mu.Unlock() | |||
| 86 | + | return effectiveBarInterval(d.barScripts.cfg.interval) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScriptContext, bool) { | |||
| 90 | + | ctx := barScriptContext{Anchor: anchor} | |||
| 91 | + | if sess == nil { | |||
| 92 | + | return ctx, false | |||
| 93 | + | } | |||
| 94 | + | sess.mu.Lock() | |||
| 95 | + | ctx.Session = sess.name | |||
| 96 | + | active := sess.active | |||
| 97 | + | var tb *tab | |||
| 98 | + | if active >= 0 && active < len(sess.tabs) { | |||
| 99 | + | tb = sess.tabs[active] | |||
| 100 | + | } | |||
| 101 | + | ac := sess.client | |||
| 102 | + | ctx.PaneCWD = sess.cwd | |||
| 103 | + | sess.mu.Unlock() | |||
| 104 | + | if ac == nil || tb == nil { | |||
| 105 | + | return ctx, false | |||
| 106 | + | } | |||
| 107 | + | tb.mu.Lock() | |||
| 108 | + | ctx.Tab = tb.stableID | |||
| 109 | + | ctx.Cols = tb.size.Cols | |||
| 110 | + | pid := 0 | |||
| 111 | + | if p := tb.focusedPane(); p != nil { | |||
| 112 | + | ctx.Pane = p.stableID | |||
| 113 | + | if d != nil && d.procCwd != nil && p.pty != nil { | |||
| 114 | + | pid = p.pty.Pid() | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | tb.mu.Unlock() | |||
| 118 | + | if pid != 0 && d != nil && d.procCwd != nil { | |||
| 119 | + | if cwd, err := d.procCwd(pid); err == nil && cwd != "" { | |||
| 120 | + | ctx.PaneCWD = cwd | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | return ctx, true | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func (d *Daemon) barScriptPoller(ctx context.Context) { | |||
| 127 | + | timer := d.clock.NewTimer(d.barScriptInterval()) | |||
| 128 | + | defer timer.Stop() | |||
| 129 | + | for { | |||
| 130 | + | select { | |||
| 131 | + | case <-ctx.Done(): | |||
| 132 | + | return | |||
| 133 | + | case now := <-timer.C(): | |||
| 134 | + | for _, sess := range d.sessionsSnapshot() { | |||
| 135 | + | d.refreshBarScriptsIfDue(sess, now, false) | |||
| 136 | + | } | |||
| 137 | + | timer.Reset(d.barScriptInterval()) | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func (d *Daemon) sessionsSnapshot() []*session { | |||
| 143 | + | d.mu.Lock() | |||
| 144 | + | defer d.mu.Unlock() | |||
| 145 | + | sessions := make([]*session, 0, len(d.sessions)) | |||
| 146 | + | for _, sess := range d.sessions { | |||
| 147 | + | sessions = append(sessions, sess) | |||
| 148 | + | } | |||
| 149 | + | return sessions | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) { | |||
| 153 | + | if d == nil || d.barScripts == nil { | |||
| 154 | + | return | |||
| 155 | + | } | |||
| 156 | + | d.barScripts.mu.Lock() | |||
| 157 | + | defer d.barScripts.mu.Unlock() | |||
| 158 | + | delete(d.barScripts.outputs, id) | |||
| 159 | + | delete(d.barScripts.lastRefresh, id) | |||
| 160 | + | delete(d.barScripts.lastContext, id) | |||
| 161 | + | delete(d.barScripts.running, id) | |||
| 162 | + | delete(d.barScripts.pending, id) | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool) bool { | |||
| 166 | + | if d == nil || d.barScripts == nil || sess == nil { | |||
| 167 | + | return false | |||
| 168 | + | } | |||
| 169 | + | baseCtx, ok := d.collectBarScriptContext(sess, "") | |||
| 170 | + | if !ok { | |||
| 171 | + | return false | |||
| 172 | + | } | |||
| 173 | + | d.barScripts.mu.Lock() | |||
| 174 | + | d.barScripts.initLocked() | |||
| 175 | + | if d.barScripts.lastContext[sess.id] != baseCtx { | |||
| 176 | + | force = true | |||
| 177 | + | } | |||
| 178 | + | lastRefresh := d.barScripts.lastRefresh[sess.id] | |||
| 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 { | |||
| 187 | + | if force { | |||
| 188 | + | d.scheduleBarScriptRefreshLocked(sess, lastRefresh.Add(domain.MinBarInterval).Sub(now)) | |||
| 189 | + | } | |||
| 190 | + | d.barScripts.mu.Unlock() | |||
| 191 | + | return false | |||
| 192 | + | } | |||
| 193 | + | if !force && !lastRefresh.IsZero() && now.Sub(lastRefresh) < d.barScripts.cfg.interval { | |||
| 194 | + | d.barScripts.mu.Unlock() | |||
| 195 | + | return false | |||
| 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 | + | } | |||
| 210 | + | d.barScripts.running[sess.id] = true | |||
| 211 | + | d.barScripts.lastRefresh[sess.id] = now | |||
| 212 | + | d.barScripts.lastContext[sess.id] = baseCtx | |||
| 213 | + | runner := d.barScripts.runner | |||
| 214 | + | version := d.barScripts.version | |||
| 215 | + | d.barScripts.mu.Unlock() | |||
| 216 | + | go d.runBarScripts(sess, runner, cfg, baseCtx, version) | |||
| 217 | + | return true | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | func (d *Daemon) scheduleBarScriptRefreshLocked(sess *session, delay time.Duration) { | |||
| 221 | + | if d.barScripts.pending[sess.id] { | |||
| 222 | + | return | |||
| 223 | + | } | |||
| 224 | + | if d.clock == nil { | |||
| 225 | + | return | |||
| 226 | + | } | |||
| 227 | + | if delay < 0 { | |||
| 228 | + | delay = 0 | |||
| 229 | + | } | |||
| 230 | + | d.barScripts.pending[sess.id] = true | |||
| 231 | + | go func() { | |||
| 232 | + | ctx := sess.ctx | |||
| 233 | + | if ctx == nil { | |||
| 234 | + | ctx = context.Background() | |||
| 235 | + | } | |||
| 236 | + | timer := d.clock.NewTimer(delay) | |||
| 237 | + | select { | |||
| 238 | + | case <-ctx.Done(): | |||
| 239 | + | timer.Stop() | |||
| 240 | + | return | |||
| 241 | + | case <-timer.C(): | |||
| 242 | + | } | |||
| 243 | + | d.barScripts.mu.Lock() | |||
| 244 | + | delete(d.barScripts.pending, sess.id) | |||
| 245 | + | d.barScripts.mu.Unlock() | |||
| 246 | + | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | |||
| 247 | + | }() | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barScriptConfig, base barScriptContext, version uint64) { | |||
| 251 | + | if runner == nil { | |||
| 252 | + | runner = barScriptRunner{baseEnv: d.baseEnv} | |||
| 253 | + | } | |||
| 254 | + | ctx := sess.ctx | |||
| 255 | + | if ctx == nil { | |||
| 256 | + | ctx = context.Background() | |||
| 257 | + | } | |||
| 258 | + | top, topOK := d.runOneBarScript(ctx, runner, cfg.topRight, base, "top-right", sess.name) | |||
| 259 | + | bottom, bottomOK := d.runOneBarScript(ctx, runner, cfg.bottomRight, base, "bottom-right", sess.name) | |||
| 260 | + | d.barScripts.mu.Lock() | |||
| 261 | + | d.barScripts.initLocked() | |||
| 262 | + | if !d.barScripts.running[sess.id] || d.barScripts.version != version { | |||
| 263 | + | delete(d.barScripts.running, sess.id) | |||
| 264 | + | d.barScripts.mu.Unlock() | |||
| 265 | + | return | |||
| 266 | + | } | |||
| 267 | + | current := d.barScripts.outputs[sess.id] | |||
| 268 | + | changed := false | |||
| 269 | + | if topOK && current.topRight != top { | |||
| 270 | + | current.topRight = top | |||
| 271 | + | changed = true | |||
| 272 | + | } | |||
| 273 | + | if bottomOK && current.bottomRight != bottom { | |||
| 274 | + | current.bottomRight = bottom | |||
| 275 | + | changed = true | |||
| 276 | + | } | |||
| 277 | + | d.barScripts.outputs[sess.id] = current | |||
| 278 | + | pending := d.barScripts.pending[sess.id] | |||
| 279 | + | delete(d.barScripts.running, sess.id) | |||
| 280 | + | delete(d.barScripts.pending, sess.id) | |||
| 281 | + | d.barScripts.mu.Unlock() | |||
| 282 | + | if changed { | |||
| 283 | + | d.pokeSessionRender(sess) | |||
| 284 | + | } | |||
| 285 | + | if pending { | |||
| 286 | + | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | |||
| 287 | + | } | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | func (d *Daemon) runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, base barScriptContext, anchor, sessionName string) (string, bool) { | |||
| 291 | + | if command == "" { | |||
| 292 | + | return "", true | |||
| 293 | + | } | |||
| 294 | + | base.Anchor = anchor | |||
| 295 | + | out, err := runner.run(ctx, command, base) | |||
| 296 | + | if err != nil { | |||
| 297 | + | if d.log != nil { | |||
| 298 | + | d.log.Warn("bar script failed; keeping last good output", "anchor", anchor, "session", sessionName, "err", err) | |||
| 299 | + | } | |||
| 300 | + | return "", false | |||
| 301 | + | } | |||
| 302 | + | return out, true | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | func (d *Daemon) pokeSessionRender(sess *session) { | |||
| 306 | + | if sess == nil { | |||
| 307 | + | return | |||
| 308 | + | } | |||
| 309 | + | if tb := sess.activeTab(); tb != nil { | |||
| 310 | + | tb.mu.Lock() | |||
| 311 | + | p := tb.focusedPane() | |||
| 312 | + | tb.mu.Unlock() | |||
| 313 | + | if p != nil { | |||
| 314 | + | signal(p.dirty) | |||
| 315 | + | } | |||
| 316 | + | } | |||
| 317 | + | } | |||
A internal/usecase/daemon/bar_refresh_test.go +312 -0
| @@ -0,0 +1,312 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "log/slog" | |
| 8 | + | "strings" | |
| 9 | + | "sync" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/vev/internal/domain" | |
| 14 | + | "github.com/bnema/vev/internal/ports" | |
| 15 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 16 | + | "github.com/stretchr/testify/require" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | type fakeBarRunner struct { | |
| 20 | + | mu sync.Mutex | |
| 21 | + | calls []barScriptContext | |
| 22 | + | outs []string | |
| 23 | + | errs []error | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func (r *fakeBarRunner) run(_ context.Context, _ string, ctx barScriptContext) (string, error) { | |
| 27 | + | r.mu.Lock() | |
| 28 | + | defer r.mu.Unlock() | |
| 29 | + | r.calls = append(r.calls, ctx) | |
| 30 | + | i := len(r.calls) - 1 | |
| 31 | + | if i < len(r.errs) && r.errs[i] != nil { | |
| 32 | + | return "", r.errs[i] | |
| 33 | + | } | |
| 34 | + | if i < len(r.outs) { | |
| 35 | + | return r.outs[i], nil | |
| 36 | + | } | |
| 37 | + | return "", nil | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestBarScriptRefreshIntervalClampingTickAndLastGoodOnFailure(t *testing.T) { | |
| 41 | + | r := &fakeBarRunner{outs: []string{"top1", "bot1", "top2", "bot2"}} | |
| 42 | + | d := newBarRefreshTestDaemon(r, 100*time.Millisecond) | |
| 43 | + | sess := newBarRefreshTestSession() | |
| 44 | + | sess.client = &attachedClient{size: domain.Size{Cols: 120, Rows: 24}} | |
| 45 | + | ||
| 46 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false)) | |
| 47 | + | waitBarRefreshIdle(t, d) | |
| 48 | + | require.Equal(t, 2, len(r.calls)) | |
| 49 | + | require.Equal(t, "top1", d.barStateFor(sess, "").topRight) | |
| 50 | + | require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, int64(500*time.Millisecond)), false), "clamped interval should skip sub-second tick") | |
| 51 | + | ||
| 52 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(1, 0), false)) | |
| 53 | + | waitBarRefreshIdle(t, d) | |
| 54 | + | require.Equal(t, 4, len(r.calls)) | |
| 55 | + | require.Equal(t, "top2", d.barStateFor(sess, "").topRight) | |
| 56 | + | ||
| 57 | + | r.errs = []error{nil, nil, nil, nil, errors.New("boom"), errors.New("boom")} | |
| 58 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(2, 0), false)) | |
| 59 | + | waitBarRefreshIdle(t, d) | |
| 60 | + | state := d.barStateFor(sess, "") | |
| 61 | + | require.Equal(t, "top2", state.topRight) | |
| 62 | + | require.Equal(t, "bot2", state.bottomRight) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func TestBarScriptContextUsesActivePaneSessionAndClientCols(t *testing.T) { | |
| 66 | + | r := &fakeBarRunner{outs: []string{"top", "bottom"}} | |
| 67 | + | d := newBarRefreshTestDaemon(r, time.Second) | |
| 68 | + | sess := newBarRefreshTestSession() | |
| 69 | + | sess.name = "work" | |
| 70 | + | sess.cwd = "/repo" | |
| 71 | + | sess.client = &attachedClient{size: domain.Size{Cols: 132, Rows: 40}} | |
| 72 | + | sess.active = 1 | |
| 73 | + | sess.tabs[1].size = domain.Size{Cols: 132, Rows: 38} | |
| 74 | + | active := sess.tabs[1].focusedPane() | |
| 75 | + | active.stableID = "pane-active" | |
| 76 | + | active.pty = newScriptPTY(nil) | |
| 77 | + | d.procCwd = func(pid int) (string, error) { | |
| 78 | + | require.Equal(t, 4242, pid) | |
| 79 | + | return "/pane-repo", nil | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true)) | |
| 83 | + | waitBarRefreshIdle(t, d) | |
| 84 | + | require.Len(t, r.calls, 2) | |
| 85 | + | for _, call := range r.calls { | |
| 86 | + | require.Equal(t, "work", call.Session) | |
| 87 | + | require.Equal(t, "tab-active", call.Tab) | |
| 88 | + | require.Equal(t, "pane-active", call.Pane) | |
| 89 | + | require.Equal(t, "/pane-repo", call.PaneCWD) | |
| 90 | + | require.Equal(t, 132, call.Cols) | |
| 91 | + | } | |
| 92 | + | require.Equal(t, "top-right", r.calls[0].Anchor) | |
| 93 | + | require.Equal(t, "bottom-right", r.calls[1].Anchor) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func TestBarScriptRefreshSkipsWithoutAttachedClient(t *testing.T) { | |
| 97 | + | r := &fakeBarRunner{} | |
| 98 | + | d := newBarRefreshTestDaemon(r, time.Second) | |
| 99 | + | sess := newBarRefreshTestSession() | |
| 100 | + | ||
| 101 | + | require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true)) | |
| 102 | + | require.Empty(t, r.calls) | |
| 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 | + | ||
| 121 | + | func TestBarScriptForcedRefreshRespectsMinimumInterval(t *testing.T) { | |
| 122 | + | r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}} | |
| 123 | + | d := newBarRefreshTestDaemon(r, 5*time.Second) | |
| 124 | + | sess := newBarRefreshTestSession() | |
| 125 | + | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 126 | + | ||
| 127 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true)) | |
| 128 | + | waitBarRefreshIdle(t, d) | |
| 129 | + | require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, int64(500*time.Millisecond)), true)) | |
| 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) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func TestBarScriptContextChangeDebouncesUntilMinimumInterval(t *testing.T) { | |
| 139 | + | r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}} | |
| 140 | + | d := newBarRefreshTestDaemon(r, 5*time.Second) | |
| 141 | + | sess := newBarRefreshTestSession() | |
| 142 | + | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 143 | + | ||
| 144 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Now(), false)) | |
| 145 | + | waitBarRefreshIdle(t, d) | |
| 146 | + | sess.cwd = "/other" | |
| 147 | + | require.False(t, d.refreshBarScriptsIfDue(sess, time.Now().Add(100*time.Millisecond), false)) | |
| 148 | + | require.Eventually(t, func() bool { | |
| 149 | + | r.mu.Lock() | |
| 150 | + | defer r.mu.Unlock() | |
| 151 | + | return len(r.calls) == 4 | |
| 152 | + | }, 1500*time.Millisecond, 10*time.Millisecond) | |
| 153 | + | require.Equal(t, "/other", r.calls[2].PaneCWD) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func TestBarScriptRefreshForcesWhenContextChanges(t *testing.T) { | |
| 157 | + | r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}} | |
| 158 | + | d := newBarRefreshTestDaemon(r, 5*time.Second) | |
| 159 | + | sess := newBarRefreshTestSession() | |
| 160 | + | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 161 | + | ||
| 162 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false)) | |
| 163 | + | waitBarRefreshIdle(t, d) | |
| 164 | + | sess.cwd = "/other" | |
| 165 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(1, 0), false)) | |
| 166 | + | waitBarRefreshIdle(t, d) | |
| 167 | + | require.Len(t, r.calls, 4) | |
| 168 | + | require.Equal(t, "/other", r.calls[2].PaneCWD) | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func TestApplyConfigPreservesBarOutputsWhenBarConfigUnchanged(t *testing.T) { | |
| 172 | + | r := &fakeBarRunner{} | |
| 173 | + | d := newBarRefreshTestDaemon(r, time.Second) | |
| 174 | + | sess := newBarRefreshTestSession() | |
| 175 | + | d.barScripts.outputs[sess.id] = barScriptOutputs{topRight: "top-good", bottomRight: "bottom-good"} | |
| 176 | + | ||
| 177 | + | d.ApplyConfig(domain.Config{Bar: domain.BarConfig{TopRight: "top", BottomRight: "bottom", Interval: time.Second}}) | |
| 178 | + | ||
| 179 | + | state := d.barStateFor(sess, "") | |
| 180 | + | require.Equal(t, "top-good", state.topRight) | |
| 181 | + | require.Equal(t, "bottom-good", state.bottomRight) | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func TestBarScriptFailureLogsAndRetainsLastGood(t *testing.T) { | |
| 185 | + | var logs bytes.Buffer | |
| 186 | + | r := &fakeBarRunner{outs: []string{"top-good", "bottom-good"}} | |
| 187 | + | d := newBarRefreshTestDaemon(r, time.Second) | |
| 188 | + | d.log = slog.New(slog.NewTextHandler(&logs, nil)) | |
| 189 | + | sess := newBarRefreshTestSession() | |
| 190 | + | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 191 | + | ||
| 192 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true)) | |
| 193 | + | waitBarRefreshIdle(t, d) | |
| 194 | + | r.errs = []error{nil, nil, errors.New("boom"), errors.New("bang")} | |
| 195 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(11, 0), true)) | |
| 196 | + | waitBarRefreshIdle(t, d) | |
| 197 | + | ||
| 198 | + | state := d.barStateFor(sess, "") | |
| 199 | + | require.Equal(t, "top-good", state.topRight) | |
| 200 | + | require.Equal(t, "bottom-good", state.bottomRight) | |
| 201 | + | require.True(t, strings.Contains(logs.String(), "bar script failed; keeping last good output"), logs.String()) | |
| 202 | + | require.True(t, strings.Contains(logs.String(), "anchor=top-right"), logs.String()) | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func TestBarScriptRunDoesNotRestoreClearedSessionState(t *testing.T) { | |
| 206 | + | r := &fakeBarRunner{outs: []string{"top", "bottom"}} | |
| 207 | + | d := newBarRefreshTestDaemon(r, time.Second) | |
| 208 | + | sess := newBarRefreshTestSession() | |
| 209 | + | d.barScripts.mu.Lock() | |
| 210 | + | d.barScripts.running[sess.id] = true | |
| 211 | + | d.barScripts.mu.Unlock() | |
| 212 | + | ||
| 213 | + | d.clearBarScriptsForSession(sess.id) | |
| 214 | + | d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, 0) | |
| 215 | + | ||
| 216 | + | state := d.barStateFor(sess, "") | |
| 217 | + | require.Empty(t, state.topRight) | |
| 218 | + | require.Empty(t, state.bottomRight) | |
| 219 | + | } | |
| 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 | + | ||
| 237 | + | func TestBarScriptRunIgnoresStaleConfigVersion(t *testing.T) { | |
| 238 | + | r := &fakeBarRunner{outs: []string{"top", "bottom"}} | |
| 239 | + | d := newBarRefreshTestDaemon(r, time.Second) | |
| 240 | + | sess := newBarRefreshTestSession() | |
| 241 | + | d.barScripts.mu.Lock() | |
| 242 | + | d.barScripts.running[sess.id] = true | |
| 243 | + | d.barScripts.version = 2 | |
| 244 | + | d.barScripts.mu.Unlock() | |
| 245 | + | ||
| 246 | + | d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, 1) | |
| 247 | + | ||
| 248 | + | state := d.barStateFor(sess, "") | |
| 249 | + | require.Empty(t, state.topRight) | |
| 250 | + | require.Empty(t, state.bottomRight) | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | func TestBarScriptRefreshIsPerSession(t *testing.T) { | |
| 254 | + | r := &fakeBarRunner{outs: []string{"top-a", "bottom-a", "top-b", "bottom-b"}} | |
| 255 | + | d := newBarRefreshTestDaemon(r, time.Second) | |
| 256 | + | a := newBarRefreshTestSession() | |
| 257 | + | a.id = "a" | |
| 258 | + | a.name = "a" | |
| 259 | + | a.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 260 | + | b := newBarRefreshTestSession() | |
| 261 | + | b.id = "b" | |
| 262 | + | b.name = "b" | |
| 263 | + | b.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 264 | + | ||
| 265 | + | require.True(t, d.refreshBarScriptsIfDue(a, time.Unix(0, 0), false)) | |
| 266 | + | waitBarRefreshIdle(t, d) | |
| 267 | + | require.True(t, d.refreshBarScriptsIfDue(b, time.Unix(0, 0), false)) | |
| 268 | + | waitBarRefreshIdle(t, d) | |
| 269 | + | ||
| 270 | + | stateA := d.barStateFor(a, "") | |
| 271 | + | stateB := d.barStateFor(b, "") | |
| 272 | + | require.Equal(t, "top-a", stateA.topRight) | |
| 273 | + | require.Equal(t, "bottom-a", stateA.bottomRight) | |
| 274 | + | require.Equal(t, "top-b", stateB.topRight) | |
| 275 | + | require.Equal(t, "bottom-b", stateB.bottomRight) | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | func newBarRefreshTestDaemon(r *fakeBarRunner, interval time.Duration) *Daemon { | |
| 279 | + | d := &Daemon{clock: barRefreshTestClock{}, barScripts: &barScriptState{ | |
| 280 | + | cfg: barScriptConfig{topRight: "top", bottomRight: "bottom", interval: effectiveBarInterval(interval)}, | |
| 281 | + | runner: r, | |
| 282 | + | outputs: make(map[domain.SessionID]barScriptOutputs), | |
| 283 | + | lastRefresh: make(map[domain.SessionID]time.Time), | |
| 284 | + | lastContext: make(map[domain.SessionID]barScriptContext), | |
| 285 | + | running: make(map[domain.SessionID]bool), | |
| 286 | + | pending: make(map[domain.SessionID]bool), | |
| 287 | + | }} | |
| 288 | + | return d | |
| 289 | + | } | |
| 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 | + | ||
| 298 | + | func newBarRefreshTestSession() *session { | |
| 299 | + | inactive := newTabWithStableID("tab-inactive", "pane-inactive", nil, domain.Size{Cols: 80, Rows: 22}) | |
| 300 | + | active := newTabWithStableID("tab-active", "pane-active", nil, domain.Size{Cols: 80, Rows: 22}) | |
| 301 | + | active.tree.Focus = layout.PaneID("pane-1") | |
| 302 | + | return &session{id: "s", name: "s", cwd: "/tmp", tabs: []*tab{inactive, active}, active: 0, ctx: context.Background()} | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | func waitBarRefreshIdle(t *testing.T, d *Daemon) { | |
| 306 | + | t.Helper() | |
| 307 | + | require.Eventually(t, func() bool { | |
| 308 | + | d.barScripts.mu.Lock() | |
| 309 | + | defer d.barScripts.mu.Unlock() | |
| 310 | + | return len(d.barScripts.running) == 0 | |
| 311 | + | }, time.Second, time.Millisecond) | |
| 312 | + | } | |
| @@ -0,0 +1,312 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "errors" | |||
| 7 | + | "log/slog" | |||
| 8 | + | "strings" | |||
| 9 | + | "sync" | |||
| 10 | + | "testing" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/vev/internal/domain" | |||
| 14 | + | "github.com/bnema/vev/internal/ports" | |||
| 15 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 16 | + | "github.com/stretchr/testify/require" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | type fakeBarRunner struct { | |||
| 20 | + | mu sync.Mutex | |||
| 21 | + | calls []barScriptContext | |||
| 22 | + | outs []string | |||
| 23 | + | errs []error | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func (r *fakeBarRunner) run(_ context.Context, _ string, ctx barScriptContext) (string, error) { | |||
| 27 | + | r.mu.Lock() | |||
| 28 | + | defer r.mu.Unlock() | |||
| 29 | + | r.calls = append(r.calls, ctx) | |||
| 30 | + | i := len(r.calls) - 1 | |||
| 31 | + | if i < len(r.errs) && r.errs[i] != nil { | |||
| 32 | + | return "", r.errs[i] | |||
| 33 | + | } | |||
| 34 | + | if i < len(r.outs) { | |||
| 35 | + | return r.outs[i], nil | |||
| 36 | + | } | |||
| 37 | + | return "", nil | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestBarScriptRefreshIntervalClampingTickAndLastGoodOnFailure(t *testing.T) { | |||
| 41 | + | r := &fakeBarRunner{outs: []string{"top1", "bot1", "top2", "bot2"}} | |||
| 42 | + | d := newBarRefreshTestDaemon(r, 100*time.Millisecond) | |||
| 43 | + | sess := newBarRefreshTestSession() | |||
| 44 | + | sess.client = &attachedClient{size: domain.Size{Cols: 120, Rows: 24}} | |||
| 45 | + | ||||
| 46 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false)) | |||
| 47 | + | waitBarRefreshIdle(t, d) | |||
| 48 | + | require.Equal(t, 2, len(r.calls)) | |||
| 49 | + | require.Equal(t, "top1", d.barStateFor(sess, "").topRight) | |||
| 50 | + | require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, int64(500*time.Millisecond)), false), "clamped interval should skip sub-second tick") | |||
| 51 | + | ||||
| 52 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(1, 0), false)) | |||
| 53 | + | waitBarRefreshIdle(t, d) | |||
| 54 | + | require.Equal(t, 4, len(r.calls)) | |||
| 55 | + | require.Equal(t, "top2", d.barStateFor(sess, "").topRight) | |||
| 56 | + | ||||
| 57 | + | r.errs = []error{nil, nil, nil, nil, errors.New("boom"), errors.New("boom")} | |||
| 58 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(2, 0), false)) | |||
| 59 | + | waitBarRefreshIdle(t, d) | |||
| 60 | + | state := d.barStateFor(sess, "") | |||
| 61 | + | require.Equal(t, "top2", state.topRight) | |||
| 62 | + | require.Equal(t, "bot2", state.bottomRight) | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func TestBarScriptContextUsesActivePaneSessionAndClientCols(t *testing.T) { | |||
| 66 | + | r := &fakeBarRunner{outs: []string{"top", "bottom"}} | |||
| 67 | + | d := newBarRefreshTestDaemon(r, time.Second) | |||
| 68 | + | sess := newBarRefreshTestSession() | |||
| 69 | + | sess.name = "work" | |||
| 70 | + | sess.cwd = "/repo" | |||
| 71 | + | sess.client = &attachedClient{size: domain.Size{Cols: 132, Rows: 40}} | |||
| 72 | + | sess.active = 1 | |||
| 73 | + | sess.tabs[1].size = domain.Size{Cols: 132, Rows: 38} | |||
| 74 | + | active := sess.tabs[1].focusedPane() | |||
| 75 | + | active.stableID = "pane-active" | |||
| 76 | + | active.pty = newScriptPTY(nil) | |||
| 77 | + | d.procCwd = func(pid int) (string, error) { | |||
| 78 | + | require.Equal(t, 4242, pid) | |||
| 79 | + | return "/pane-repo", nil | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true)) | |||
| 83 | + | waitBarRefreshIdle(t, d) | |||
| 84 | + | require.Len(t, r.calls, 2) | |||
| 85 | + | for _, call := range r.calls { | |||
| 86 | + | require.Equal(t, "work", call.Session) | |||
| 87 | + | require.Equal(t, "tab-active", call.Tab) | |||
| 88 | + | require.Equal(t, "pane-active", call.Pane) | |||
| 89 | + | require.Equal(t, "/pane-repo", call.PaneCWD) | |||
| 90 | + | require.Equal(t, 132, call.Cols) | |||
| 91 | + | } | |||
| 92 | + | require.Equal(t, "top-right", r.calls[0].Anchor) | |||
| 93 | + | require.Equal(t, "bottom-right", r.calls[1].Anchor) | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func TestBarScriptRefreshSkipsWithoutAttachedClient(t *testing.T) { | |||
| 97 | + | r := &fakeBarRunner{} | |||
| 98 | + | d := newBarRefreshTestDaemon(r, time.Second) | |||
| 99 | + | sess := newBarRefreshTestSession() | |||
| 100 | + | ||||
| 101 | + | require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true)) | |||
| 102 | + | require.Empty(t, r.calls) | |||
| 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 | + | ||||
| 121 | + | func TestBarScriptForcedRefreshRespectsMinimumInterval(t *testing.T) { | |||
| 122 | + | r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}} | |||
| 123 | + | d := newBarRefreshTestDaemon(r, 5*time.Second) | |||
| 124 | + | sess := newBarRefreshTestSession() | |||
| 125 | + | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |||
| 126 | + | ||||
| 127 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true)) | |||
| 128 | + | waitBarRefreshIdle(t, d) | |||
| 129 | + | require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, int64(500*time.Millisecond)), true)) | |||
| 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) | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func TestBarScriptContextChangeDebouncesUntilMinimumInterval(t *testing.T) { | |||
| 139 | + | r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}} | |||
| 140 | + | d := newBarRefreshTestDaemon(r, 5*time.Second) | |||
| 141 | + | sess := newBarRefreshTestSession() | |||
| 142 | + | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |||
| 143 | + | ||||
| 144 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Now(), false)) | |||
| 145 | + | waitBarRefreshIdle(t, d) | |||
| 146 | + | sess.cwd = "/other" | |||
| 147 | + | require.False(t, d.refreshBarScriptsIfDue(sess, time.Now().Add(100*time.Millisecond), false)) | |||
| 148 | + | require.Eventually(t, func() bool { | |||
| 149 | + | r.mu.Lock() | |||
| 150 | + | defer r.mu.Unlock() | |||
| 151 | + | return len(r.calls) == 4 | |||
| 152 | + | }, 1500*time.Millisecond, 10*time.Millisecond) | |||
| 153 | + | require.Equal(t, "/other", r.calls[2].PaneCWD) | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func TestBarScriptRefreshForcesWhenContextChanges(t *testing.T) { | |||
| 157 | + | r := &fakeBarRunner{outs: []string{"top1", "bottom1", "top2", "bottom2"}} | |||
| 158 | + | d := newBarRefreshTestDaemon(r, 5*time.Second) | |||
| 159 | + | sess := newBarRefreshTestSession() | |||
| 160 | + | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |||
| 161 | + | ||||
| 162 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false)) | |||
| 163 | + | waitBarRefreshIdle(t, d) | |||
| 164 | + | sess.cwd = "/other" | |||
| 165 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(1, 0), false)) | |||
| 166 | + | waitBarRefreshIdle(t, d) | |||
| 167 | + | require.Len(t, r.calls, 4) | |||
| 168 | + | require.Equal(t, "/other", r.calls[2].PaneCWD) | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func TestApplyConfigPreservesBarOutputsWhenBarConfigUnchanged(t *testing.T) { | |||
| 172 | + | r := &fakeBarRunner{} | |||
| 173 | + | d := newBarRefreshTestDaemon(r, time.Second) | |||
| 174 | + | sess := newBarRefreshTestSession() | |||
| 175 | + | d.barScripts.outputs[sess.id] = barScriptOutputs{topRight: "top-good", bottomRight: "bottom-good"} | |||
| 176 | + | ||||
| 177 | + | d.ApplyConfig(domain.Config{Bar: domain.BarConfig{TopRight: "top", BottomRight: "bottom", Interval: time.Second}}) | |||
| 178 | + | ||||
| 179 | + | state := d.barStateFor(sess, "") | |||
| 180 | + | require.Equal(t, "top-good", state.topRight) | |||
| 181 | + | require.Equal(t, "bottom-good", state.bottomRight) | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | func TestBarScriptFailureLogsAndRetainsLastGood(t *testing.T) { | |||
| 185 | + | var logs bytes.Buffer | |||
| 186 | + | r := &fakeBarRunner{outs: []string{"top-good", "bottom-good"}} | |||
| 187 | + | d := newBarRefreshTestDaemon(r, time.Second) | |||
| 188 | + | d.log = slog.New(slog.NewTextHandler(&logs, nil)) | |||
| 189 | + | sess := newBarRefreshTestSession() | |||
| 190 | + | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |||
| 191 | + | ||||
| 192 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(10, 0), true)) | |||
| 193 | + | waitBarRefreshIdle(t, d) | |||
| 194 | + | r.errs = []error{nil, nil, errors.New("boom"), errors.New("bang")} | |||
| 195 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(11, 0), true)) | |||
| 196 | + | waitBarRefreshIdle(t, d) | |||
| 197 | + | ||||
| 198 | + | state := d.barStateFor(sess, "") | |||
| 199 | + | require.Equal(t, "top-good", state.topRight) | |||
| 200 | + | require.Equal(t, "bottom-good", state.bottomRight) | |||
| 201 | + | require.True(t, strings.Contains(logs.String(), "bar script failed; keeping last good output"), logs.String()) | |||
| 202 | + | require.True(t, strings.Contains(logs.String(), "anchor=top-right"), logs.String()) | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | func TestBarScriptRunDoesNotRestoreClearedSessionState(t *testing.T) { | |||
| 206 | + | r := &fakeBarRunner{outs: []string{"top", "bottom"}} | |||
| 207 | + | d := newBarRefreshTestDaemon(r, time.Second) | |||
| 208 | + | sess := newBarRefreshTestSession() | |||
| 209 | + | d.barScripts.mu.Lock() | |||
| 210 | + | d.barScripts.running[sess.id] = true | |||
| 211 | + | d.barScripts.mu.Unlock() | |||
| 212 | + | ||||
| 213 | + | d.clearBarScriptsForSession(sess.id) | |||
| 214 | + | d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, 0) | |||
| 215 | + | ||||
| 216 | + | state := d.barStateFor(sess, "") | |||
| 217 | + | require.Empty(t, state.topRight) | |||
| 218 | + | require.Empty(t, state.bottomRight) | |||
| 219 | + | } | |||
| 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 | + | ||||
| 237 | + | func TestBarScriptRunIgnoresStaleConfigVersion(t *testing.T) { | |||
| 238 | + | r := &fakeBarRunner{outs: []string{"top", "bottom"}} | |||
| 239 | + | d := newBarRefreshTestDaemon(r, time.Second) | |||
| 240 | + | sess := newBarRefreshTestSession() | |||
| 241 | + | d.barScripts.mu.Lock() | |||
| 242 | + | d.barScripts.running[sess.id] = true | |||
| 243 | + | d.barScripts.version = 2 | |||
| 244 | + | d.barScripts.mu.Unlock() | |||
| 245 | + | ||||
| 246 | + | d.runBarScripts(sess, r, d.barScripts.cfg, barScriptContext{}, 1) | |||
| 247 | + | ||||
| 248 | + | state := d.barStateFor(sess, "") | |||
| 249 | + | require.Empty(t, state.topRight) | |||
| 250 | + | require.Empty(t, state.bottomRight) | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | func TestBarScriptRefreshIsPerSession(t *testing.T) { | |||
| 254 | + | r := &fakeBarRunner{outs: []string{"top-a", "bottom-a", "top-b", "bottom-b"}} | |||
| 255 | + | d := newBarRefreshTestDaemon(r, time.Second) | |||
| 256 | + | a := newBarRefreshTestSession() | |||
| 257 | + | a.id = "a" | |||
| 258 | + | a.name = "a" | |||
| 259 | + | a.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |||
| 260 | + | b := newBarRefreshTestSession() | |||
| 261 | + | b.id = "b" | |||
| 262 | + | b.name = "b" | |||
| 263 | + | b.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |||
| 264 | + | ||||
| 265 | + | require.True(t, d.refreshBarScriptsIfDue(a, time.Unix(0, 0), false)) | |||
| 266 | + | waitBarRefreshIdle(t, d) | |||
| 267 | + | require.True(t, d.refreshBarScriptsIfDue(b, time.Unix(0, 0), false)) | |||
| 268 | + | waitBarRefreshIdle(t, d) | |||
| 269 | + | ||||
| 270 | + | stateA := d.barStateFor(a, "") | |||
| 271 | + | stateB := d.barStateFor(b, "") | |||
| 272 | + | require.Equal(t, "top-a", stateA.topRight) | |||
| 273 | + | require.Equal(t, "bottom-a", stateA.bottomRight) | |||
| 274 | + | require.Equal(t, "top-b", stateB.topRight) | |||
| 275 | + | require.Equal(t, "bottom-b", stateB.bottomRight) | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | func newBarRefreshTestDaemon(r *fakeBarRunner, interval time.Duration) *Daemon { | |||
| 279 | + | d := &Daemon{clock: barRefreshTestClock{}, barScripts: &barScriptState{ | |||
| 280 | + | cfg: barScriptConfig{topRight: "top", bottomRight: "bottom", interval: effectiveBarInterval(interval)}, | |||
| 281 | + | runner: r, | |||
| 282 | + | outputs: make(map[domain.SessionID]barScriptOutputs), | |||
| 283 | + | lastRefresh: make(map[domain.SessionID]time.Time), | |||
| 284 | + | lastContext: make(map[domain.SessionID]barScriptContext), | |||
| 285 | + | running: make(map[domain.SessionID]bool), | |||
| 286 | + | pending: make(map[domain.SessionID]bool), | |||
| 287 | + | }} | |||
| 288 | + | return d | |||
| 289 | + | } | |||
| 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 | + | ||||
| 298 | + | func newBarRefreshTestSession() *session { | |||
| 299 | + | inactive := newTabWithStableID("tab-inactive", "pane-inactive", nil, domain.Size{Cols: 80, Rows: 22}) | |||
| 300 | + | active := newTabWithStableID("tab-active", "pane-active", nil, domain.Size{Cols: 80, Rows: 22}) | |||
| 301 | + | active.tree.Focus = layout.PaneID("pane-1") | |||
| 302 | + | return &session{id: "s", name: "s", cwd: "/tmp", tabs: []*tab{inactive, active}, active: 0, ctx: context.Background()} | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | func waitBarRefreshIdle(t *testing.T, d *Daemon) { | |||
| 306 | + | t.Helper() | |||
| 307 | + | require.Eventually(t, func() bool { | |||
| 308 | + | d.barScripts.mu.Lock() | |||
| 309 | + | defer d.barScripts.mu.Unlock() | |||
| 310 | + | return len(d.barScripts.running) == 0 | |||
| 311 | + | }, time.Second, time.Millisecond) | |||
| 312 | + | } | |||
A internal/usecase/daemon/bar_script.go +183 -0
| @@ -0,0 +1,183 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "strconv" | |
| 8 | + | "strings" | |
| 9 | + | "time" | |
| 10 | + | "unicode/utf8" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/vev/internal/ports" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | const ( | |
| 16 | + | barScriptOutputLimit = 1024 | |
| 17 | + | barScriptDisplayLimit = 256 | |
| 18 | + | barScriptTimeout = time.Second | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | type barScriptContext struct { | |
| 22 | + | Anchor string | |
| 23 | + | Session string | |
| 24 | + | Tab string | |
| 25 | + | Pane string | |
| 26 | + | PaneCWD string | |
| 27 | + | Cols int | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func (c barScriptContext) env(base []string) []string { | |
| 31 | + | vars := map[string]string{ | |
| 32 | + | "VEV_ANCHOR": c.Anchor, | |
| 33 | + | "VEV_SESSION": c.Session, | |
| 34 | + | "VEV_TAB": c.Tab, | |
| 35 | + | "VEV_PANE": c.Pane, | |
| 36 | + | "VEV_PANE_CWD": c.PaneCWD, | |
| 37 | + | "VEV_COLS": strconv.Itoa(c.Cols), | |
| 38 | + | } | |
| 39 | + | out := make([]string, 0, len(base)+len(vars)) | |
| 40 | + | for _, entry := range base { | |
| 41 | + | key, _, ok := strings.Cut(entry, "=") | |
| 42 | + | if ok { | |
| 43 | + | if _, replace := vars[key]; replace { | |
| 44 | + | continue | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | out = append(out, entry) | |
| 48 | + | } | |
| 49 | + | for _, key := range []string{"VEV_ANCHOR", "VEV_SESSION", "VEV_TAB", "VEV_PANE", "VEV_PANE_CWD", "VEV_COLS"} { | |
| 50 | + | out = append(out, key+"="+vars[key]) | |
| 51 | + | } | |
| 52 | + | return out | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | type barScriptRunner struct { | |
| 56 | + | runner ports.ShellCommandRunner | |
| 57 | + | timeout time.Duration | |
| 58 | + | baseEnv []string | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func (r barScriptRunner) run(ctx context.Context, command string, scriptCtx barScriptContext) (string, error) { | |
| 62 | + | if strings.TrimSpace(command) == "" { | |
| 63 | + | return "", nil | |
| 64 | + | } | |
| 65 | + | if r.runner == nil { | |
| 66 | + | return "", errors.New("bar script command runner is nil") | |
| 67 | + | } | |
| 68 | + | timeout := r.timeout | |
| 69 | + | if timeout <= 0 { | |
| 70 | + | timeout = barScriptTimeout | |
| 71 | + | } | |
| 72 | + | ctx, cancel := context.WithTimeout(ctx, timeout) | |
| 73 | + | defer cancel() | |
| 74 | + | ||
| 75 | + | stdout, err := r.runner.Run(ctx, ports.CommandSpec{ | |
| 76 | + | Command: command, | |
| 77 | + | Env: scriptCtx.env(r.baseEnv), | |
| 78 | + | Timeout: timeout, | |
| 79 | + | StdoutLimit: barScriptOutputLimit, | |
| 80 | + | }) | |
| 81 | + | if errors.Is(ctx.Err(), context.DeadlineExceeded) { | |
| 82 | + | return "", context.DeadlineExceeded | |
| 83 | + | } | |
| 84 | + | text := sanitizeBarScriptOutput(stdout, barScriptOutputLimit) | |
| 85 | + | if err != nil { | |
| 86 | + | return text, err | |
| 87 | + | } | |
| 88 | + | return text, nil | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func sanitizeBarScriptOutput(raw []byte, limit int) string { | |
| 92 | + | if limit <= 0 || limit > len(raw) { | |
| 93 | + | limit = len(raw) | |
| 94 | + | } | |
| 95 | + | raw = raw[:limit] | |
| 96 | + | if i := bytes.IndexAny(raw, "\r\n"); i >= 0 { | |
| 97 | + | raw = raw[:i] | |
| 98 | + | } | |
| 99 | + | text := strings.ToValidUTF8(string(raw), "") | |
| 100 | + | text = stripANSIAndControls(text) | |
| 101 | + | if len(text) <= barScriptDisplayLimit { | |
| 102 | + | return text | |
| 103 | + | } | |
| 104 | + | return trimUTF8Bytes(text, barScriptDisplayLimit) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func stripANSIAndControls(s string) string { | |
| 108 | + | var out strings.Builder | |
| 109 | + | for i := 0; i < len(s); { | |
| 110 | + | r, size := utf8.DecodeRuneInString(s[i:]) | |
| 111 | + | switch { | |
| 112 | + | case r == '\x1b': | |
| 113 | + | i = skipEscapeSequence(s, i+size) | |
| 114 | + | continue | |
| 115 | + | case r == 0x9b: | |
| 116 | + | i = skipCSI(s, i+size) | |
| 117 | + | continue | |
| 118 | + | case r == 0x90 || r == 0x98 || r == 0x9d || r == 0x9e || r == 0x9f: | |
| 119 | + | i = skipStringControl(s, i+size) | |
| 120 | + | continue | |
| 121 | + | case r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f): | |
| 122 | + | i += size | |
| 123 | + | continue | |
| 124 | + | } | |
| 125 | + | out.WriteRune(r) | |
| 126 | + | i += size | |
| 127 | + | } | |
| 128 | + | return out.String() | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func skipEscapeSequence(s string, i int) int { | |
| 132 | + | if i >= len(s) { | |
| 133 | + | return i | |
| 134 | + | } | |
| 135 | + | switch s[i] { | |
| 136 | + | case '[': | |
| 137 | + | return skipCSI(s, i+1) | |
| 138 | + | case ']', 'P', '^', '_', 'X': | |
| 139 | + | return skipStringControl(s, i+1) | |
| 140 | + | } | |
| 141 | + | for i < len(s) && s[i] >= 0x20 && s[i] <= 0x2f { | |
| 142 | + | i++ | |
| 143 | + | } | |
| 144 | + | if i < len(s) && s[i] >= 0x30 && s[i] <= 0x7e { | |
| 145 | + | return i + 1 | |
| 146 | + | } | |
| 147 | + | return i | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func skipCSI(s string, i int) int { | |
| 151 | + | for i < len(s) { | |
| 152 | + | c := s[i] | |
| 153 | + | i++ | |
| 154 | + | if c >= 0x40 && c <= 0x7e { | |
| 155 | + | break | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | return i | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func skipStringControl(s string, i int) int { | |
| 162 | + | for i < len(s) { | |
| 163 | + | if s[i] == '\a' { | |
| 164 | + | return i + 1 | |
| 165 | + | } | |
| 166 | + | if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '\\' { | |
| 167 | + | return i + 2 | |
| 168 | + | } | |
| 169 | + | _, size := utf8.DecodeRuneInString(s[i:]) | |
| 170 | + | i += size | |
| 171 | + | } | |
| 172 | + | return i | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func trimUTF8Bytes(s string, limit int) string { | |
| 176 | + | if len(s) <= limit { | |
| 177 | + | return s | |
| 178 | + | } | |
| 179 | + | for limit > 0 && !utf8.ValidString(s[:limit]) { | |
| 180 | + | limit-- | |
| 181 | + | } | |
| 182 | + | return s[:limit] | |
| 183 | + | } | |
| @@ -0,0 +1,183 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "errors" | |||
| 7 | + | "strconv" | |||
| 8 | + | "strings" | |||
| 9 | + | "time" | |||
| 10 | + | "unicode/utf8" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/vev/internal/ports" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | const ( | |||
| 16 | + | barScriptOutputLimit = 1024 | |||
| 17 | + | barScriptDisplayLimit = 256 | |||
| 18 | + | barScriptTimeout = time.Second | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | type barScriptContext struct { | |||
| 22 | + | Anchor string | |||
| 23 | + | Session string | |||
| 24 | + | Tab string | |||
| 25 | + | Pane string | |||
| 26 | + | PaneCWD string | |||
| 27 | + | Cols int | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func (c barScriptContext) env(base []string) []string { | |||
| 31 | + | vars := map[string]string{ | |||
| 32 | + | "VEV_ANCHOR": c.Anchor, | |||
| 33 | + | "VEV_SESSION": c.Session, | |||
| 34 | + | "VEV_TAB": c.Tab, | |||
| 35 | + | "VEV_PANE": c.Pane, | |||
| 36 | + | "VEV_PANE_CWD": c.PaneCWD, | |||
| 37 | + | "VEV_COLS": strconv.Itoa(c.Cols), | |||
| 38 | + | } | |||
| 39 | + | out := make([]string, 0, len(base)+len(vars)) | |||
| 40 | + | for _, entry := range base { | |||
| 41 | + | key, _, ok := strings.Cut(entry, "=") | |||
| 42 | + | if ok { | |||
| 43 | + | if _, replace := vars[key]; replace { | |||
| 44 | + | continue | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | out = append(out, entry) | |||
| 48 | + | } | |||
| 49 | + | for _, key := range []string{"VEV_ANCHOR", "VEV_SESSION", "VEV_TAB", "VEV_PANE", "VEV_PANE_CWD", "VEV_COLS"} { | |||
| 50 | + | out = append(out, key+"="+vars[key]) | |||
| 51 | + | } | |||
| 52 | + | return out | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | type barScriptRunner struct { | |||
| 56 | + | runner ports.ShellCommandRunner | |||
| 57 | + | timeout time.Duration | |||
| 58 | + | baseEnv []string | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func (r barScriptRunner) run(ctx context.Context, command string, scriptCtx barScriptContext) (string, error) { | |||
| 62 | + | if strings.TrimSpace(command) == "" { | |||
| 63 | + | return "", nil | |||
| 64 | + | } | |||
| 65 | + | if r.runner == nil { | |||
| 66 | + | return "", errors.New("bar script command runner is nil") | |||
| 67 | + | } | |||
| 68 | + | timeout := r.timeout | |||
| 69 | + | if timeout <= 0 { | |||
| 70 | + | timeout = barScriptTimeout | |||
| 71 | + | } | |||
| 72 | + | ctx, cancel := context.WithTimeout(ctx, timeout) | |||
| 73 | + | defer cancel() | |||
| 74 | + | ||||
| 75 | + | stdout, err := r.runner.Run(ctx, ports.CommandSpec{ | |||
| 76 | + | Command: command, | |||
| 77 | + | Env: scriptCtx.env(r.baseEnv), | |||
| 78 | + | Timeout: timeout, | |||
| 79 | + | StdoutLimit: barScriptOutputLimit, | |||
| 80 | + | }) | |||
| 81 | + | if errors.Is(ctx.Err(), context.DeadlineExceeded) { | |||
| 82 | + | return "", context.DeadlineExceeded | |||
| 83 | + | } | |||
| 84 | + | text := sanitizeBarScriptOutput(stdout, barScriptOutputLimit) | |||
| 85 | + | if err != nil { | |||
| 86 | + | return text, err | |||
| 87 | + | } | |||
| 88 | + | return text, nil | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func sanitizeBarScriptOutput(raw []byte, limit int) string { | |||
| 92 | + | if limit <= 0 || limit > len(raw) { | |||
| 93 | + | limit = len(raw) | |||
| 94 | + | } | |||
| 95 | + | raw = raw[:limit] | |||
| 96 | + | if i := bytes.IndexAny(raw, "\r\n"); i >= 0 { | |||
| 97 | + | raw = raw[:i] | |||
| 98 | + | } | |||
| 99 | + | text := strings.ToValidUTF8(string(raw), "") | |||
| 100 | + | text = stripANSIAndControls(text) | |||
| 101 | + | if len(text) <= barScriptDisplayLimit { | |||
| 102 | + | return text | |||
| 103 | + | } | |||
| 104 | + | return trimUTF8Bytes(text, barScriptDisplayLimit) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func stripANSIAndControls(s string) string { | |||
| 108 | + | var out strings.Builder | |||
| 109 | + | for i := 0; i < len(s); { | |||
| 110 | + | r, size := utf8.DecodeRuneInString(s[i:]) | |||
| 111 | + | switch { | |||
| 112 | + | case r == '\x1b': | |||
| 113 | + | i = skipEscapeSequence(s, i+size) | |||
| 114 | + | continue | |||
| 115 | + | case r == 0x9b: | |||
| 116 | + | i = skipCSI(s, i+size) | |||
| 117 | + | continue | |||
| 118 | + | case r == 0x90 || r == 0x98 || r == 0x9d || r == 0x9e || r == 0x9f: | |||
| 119 | + | i = skipStringControl(s, i+size) | |||
| 120 | + | continue | |||
| 121 | + | case r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f): | |||
| 122 | + | i += size | |||
| 123 | + | continue | |||
| 124 | + | } | |||
| 125 | + | out.WriteRune(r) | |||
| 126 | + | i += size | |||
| 127 | + | } | |||
| 128 | + | return out.String() | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | func skipEscapeSequence(s string, i int) int { | |||
| 132 | + | if i >= len(s) { | |||
| 133 | + | return i | |||
| 134 | + | } | |||
| 135 | + | switch s[i] { | |||
| 136 | + | case '[': | |||
| 137 | + | return skipCSI(s, i+1) | |||
| 138 | + | case ']', 'P', '^', '_', 'X': | |||
| 139 | + | return skipStringControl(s, i+1) | |||
| 140 | + | } | |||
| 141 | + | for i < len(s) && s[i] >= 0x20 && s[i] <= 0x2f { | |||
| 142 | + | i++ | |||
| 143 | + | } | |||
| 144 | + | if i < len(s) && s[i] >= 0x30 && s[i] <= 0x7e { | |||
| 145 | + | return i + 1 | |||
| 146 | + | } | |||
| 147 | + | return i | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func skipCSI(s string, i int) int { | |||
| 151 | + | for i < len(s) { | |||
| 152 | + | c := s[i] | |||
| 153 | + | i++ | |||
| 154 | + | if c >= 0x40 && c <= 0x7e { | |||
| 155 | + | break | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | return i | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func skipStringControl(s string, i int) int { | |||
| 162 | + | for i < len(s) { | |||
| 163 | + | if s[i] == '\a' { | |||
| 164 | + | return i + 1 | |||
| 165 | + | } | |||
| 166 | + | if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '\\' { | |||
| 167 | + | return i + 2 | |||
| 168 | + | } | |||
| 169 | + | _, size := utf8.DecodeRuneInString(s[i:]) | |||
| 170 | + | i += size | |||
| 171 | + | } | |||
| 172 | + | return i | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | func trimUTF8Bytes(s string, limit int) string { | |||
| 176 | + | if len(s) <= limit { | |||
| 177 | + | return s | |||
| 178 | + | } | |||
| 179 | + | for limit > 0 && !utf8.ValidString(s[:limit]) { | |||
| 180 | + | limit-- | |||
| 181 | + | } | |||
| 182 | + | return s[:limit] | |||
| 183 | + | } | |||
A internal/usecase/daemon/bar_script_test.go +182 -0
| @@ -0,0 +1,182 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "slices" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/vev/internal/ports" | |
| 12 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 13 | + | "github.com/stretchr/testify/mock" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestBarScriptSanitizeOutput(t *testing.T) { | |
| 17 | + | tests := []struct { | |
| 18 | + | name string | |
| 19 | + | raw string | |
| 20 | + | limit int | |
| 21 | + | want string | |
| 22 | + | }{ | |
| 23 | + | {name: "first line only", raw: "one\ntwo\n", limit: barScriptOutputLimit, want: "one"}, | |
| 24 | + | {name: "ansi stripped", raw: "\x1b[31mred\x1b[0m", limit: barScriptOutputLimit, want: "red"}, | |
| 25 | + | {name: "controls stripped", raw: "a\x00b\x07c", limit: barScriptOutputLimit, want: "abc"}, | |
| 26 | + | {name: "osc stripped", raw: "a\x1b]0;bad\ab", limit: barScriptOutputLimit, want: "ab"}, | |
| 27 | + | {name: "charset escape stripped", raw: "a\x1b(Bb\x1b)0c", limit: barScriptOutputLimit, want: "abc"}, | |
| 28 | + | {name: "non csi escape stripped", raw: "a\x1b7b\x1b8c", limit: barScriptOutputLimit, want: "abc"}, | |
| 29 | + | {name: "c1 controls stripped", raw: "a\u009b31mb\u009d0;bad\ac", limit: barScriptOutputLimit, want: "abc"}, | |
| 30 | + | {name: "utf8 and nerd font preserved", raw: " 14:32 ↑3", limit: barScriptOutputLimit, want: " 14:32 ↑3"}, | |
| 31 | + | {name: "display capped", raw: strings.Repeat("a", barScriptDisplayLimit+10), limit: barScriptOutputLimit, want: strings.Repeat("a", barScriptDisplayLimit)}, | |
| 32 | + | {name: "capture capped before first line", raw: strings.Repeat("a", 8) + "\nignored", limit: 4, want: "aaaa"}, | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | for _, tt := range tests { | |
| 36 | + | t.Run(tt.name, func(t *testing.T) { | |
| 37 | + | if got := sanitizeBarScriptOutput([]byte(tt.raw), tt.limit); got != tt.want { | |
| 38 | + | t.Fatalf("sanitizeBarScriptOutput() = %q, want %q", got, tt.want) | |
| 39 | + | } | |
| 40 | + | }) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestBarScriptContextEnv(t *testing.T) { | |
| 45 | + | base := []string{ | |
| 46 | + | "PATH=/bin", | |
| 47 | + | "VEV_ANCHOR=old", | |
| 48 | + | "VEV_SESSION=old", | |
| 49 | + | "VEV_TAB=old", | |
| 50 | + | "VEV_PANE=old", | |
| 51 | + | "VEV_PANE_CWD=/old", | |
| 52 | + | "VEV_COLS=1", | |
| 53 | + | } | |
| 54 | + | ctx := barScriptContext{Anchor: "top-right", Session: "work", Tab: "2", Pane: "pane-3", PaneCWD: "/repo", Cols: 120} | |
| 55 | + | got := ctx.env(base) | |
| 56 | + | ||
| 57 | + | want := map[string]string{ | |
| 58 | + | "VEV_ANCHOR": "top-right", | |
| 59 | + | "VEV_SESSION": "work", | |
| 60 | + | "VEV_TAB": "2", | |
| 61 | + | "VEV_PANE": "pane-3", | |
| 62 | + | "VEV_PANE_CWD": "/repo", | |
| 63 | + | "VEV_COLS": "120", | |
| 64 | + | } | |
| 65 | + | seen := map[string]int{} | |
| 66 | + | for _, entry := range got { | |
| 67 | + | key, val, ok := strings.Cut(entry, "=") | |
| 68 | + | if !ok { | |
| 69 | + | continue | |
| 70 | + | } | |
| 71 | + | if _, isVEV := want[key]; isVEV { | |
| 72 | + | seen[key]++ | |
| 73 | + | if val != want[key] { | |
| 74 | + | t.Fatalf("%s = %q, want %q in env %v", key, val, want[key], got) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | for key := range want { | |
| 79 | + | if seen[key] != 1 { | |
| 80 | + | t.Fatalf("%s appears %d times, want 1 in env %v", key, seen[key], got) | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestBarScriptRunner(t *testing.T) { | |
| 86 | + | t.Run("nil runner returns explicit error for command", func(t *testing.T) { | |
| 87 | + | runner := barScriptRunner{} | |
| 88 | + | got, err := runner.run(context.Background(), "date", barScriptContext{}) | |
| 89 | + | if err == nil || err.Error() != "bar script command runner is nil" { | |
| 90 | + | t.Fatalf("run err = %v, want explicit nil runner error", err) | |
| 91 | + | } | |
| 92 | + | if got != "" { | |
| 93 | + | t.Fatalf("run output = %q, want empty", got) | |
| 94 | + | } | |
| 95 | + | }) | |
| 96 | + | ||
| 97 | + | t.Run("empty command skips port runner", func(t *testing.T) { | |
| 98 | + | runner := barScriptRunner{} | |
| 99 | + | got, err := runner.run(context.Background(), " \t\n ", barScriptContext{}) | |
| 100 | + | if err != nil { | |
| 101 | + | t.Fatalf("run err = %v, want nil", err) | |
| 102 | + | } | |
| 103 | + | if got != "" { | |
| 104 | + | t.Fatalf("run output = %q, want empty", got) | |
| 105 | + | } | |
| 106 | + | }) | |
| 107 | + | ||
| 108 | + | t.Run("success sanitizes first line and passes command spec", func(t *testing.T) { | |
| 109 | + | portRunner := portsmocks.NewMockShellCommandRunner(t) | |
| 110 | + | portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool { | |
| 111 | + | return spec.Command == "printf 'ok\\nignored'" && | |
| 112 | + | spec.Timeout == 50*time.Millisecond && | |
| 113 | + | spec.StdoutLimit == barScriptOutputLimit && | |
| 114 | + | containsEnv(spec.Env, "PATH=/bin") && | |
| 115 | + | containsEnv(spec.Env, "VEV_ANCHOR=top-right") && | |
| 116 | + | containsEnv(spec.Env, "VEV_SESSION=work") && | |
| 117 | + | containsEnv(spec.Env, "VEV_TAB=tab-1") && | |
| 118 | + | containsEnv(spec.Env, "VEV_PANE=pane-1") && | |
| 119 | + | containsEnv(spec.Env, "VEV_PANE_CWD=/repo") && | |
| 120 | + | containsEnv(spec.Env, "VEV_COLS=120") && | |
| 121 | + | !containsEnv(spec.Env, "VEV_ANCHOR=old") | |
| 122 | + | })).Return([]byte("\x1b[31mok\x1b[0m\nignored"), nil) | |
| 123 | + | runner := barScriptRunner{runner: portRunner, timeout: 50 * time.Millisecond, baseEnv: []string{"PATH=/bin", "VEV_ANCHOR=old"}} | |
| 124 | + | ||
| 125 | + | got, err := runner.run(context.Background(), "printf 'ok\\nignored'", barScriptContext{Anchor: "top-right", Session: "work", Tab: "tab-1", Pane: "pane-1", PaneCWD: "/repo", Cols: 120}) | |
| 126 | + | if err != nil { | |
| 127 | + | t.Fatalf("run err = %v, want nil", err) | |
| 128 | + | } | |
| 129 | + | if got != "ok" { | |
| 130 | + | t.Fatalf("run output = %q, want %q", got, "ok") | |
| 131 | + | } | |
| 132 | + | }) | |
| 133 | + | ||
| 134 | + | t.Run("port failure returns sanitized partial stdout and error", func(t *testing.T) { | |
| 135 | + | wantErr := errors.New("exit 1") | |
| 136 | + | portRunner := portsmocks.NewMockShellCommandRunner(t) | |
| 137 | + | portRunner.EXPECT().Run(mock.Anything, mock.Anything).Return([]byte("partial\x00 output\nignored"), wantErr) | |
| 138 | + | runner := barScriptRunner{runner: portRunner, timeout: time.Second} | |
| 139 | + | ||
| 140 | + | got, err := runner.run(context.Background(), "false", barScriptContext{}) | |
| 141 | + | if !errors.Is(err, wantErr) { | |
| 142 | + | t.Fatalf("run err = %v, want %v", err, wantErr) | |
| 143 | + | } | |
| 144 | + | if got != "partial output" { | |
| 145 | + | t.Fatalf("run output = %q, want %q", got, "partial output") | |
| 146 | + | } | |
| 147 | + | }) | |
| 148 | + | ||
| 149 | + | t.Run("timeout propagates deadline", func(t *testing.T) { | |
| 150 | + | portRunner := portsmocks.NewMockShellCommandRunner(t) | |
| 151 | + | portRunner.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ ports.CommandSpec) ([]byte, error) { | |
| 152 | + | <-ctx.Done() | |
| 153 | + | return nil, ctx.Err() | |
| 154 | + | }) | |
| 155 | + | runner := barScriptRunner{runner: portRunner, timeout: time.Nanosecond} | |
| 156 | + | ||
| 157 | + | _, err := runner.run(context.Background(), "sleep 1", barScriptContext{}) | |
| 158 | + | if !errors.Is(err, context.DeadlineExceeded) { | |
| 159 | + | t.Fatalf("run err = %v, want %v", err, context.DeadlineExceeded) | |
| 160 | + | } | |
| 161 | + | }) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) { | |
| 165 | + | portRunner := portsmocks.NewMockShellCommandRunner(t) | |
| 166 | + | portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool { | |
| 167 | + | return spec.StdoutLimit == barScriptOutputLimit | |
| 168 | + | })).Return([]byte(strings.Repeat("a", 4096)), nil) | |
| 169 | + | runner := barScriptRunner{runner: portRunner, timeout: time.Second} | |
| 170 | + | ||
| 171 | + | got, err := runner.run(context.Background(), "long-output", barScriptContext{}) | |
| 172 | + | if err != nil { | |
| 173 | + | t.Fatalf("run bounded output: %v", err) | |
| 174 | + | } | |
| 175 | + | if len(got) > barScriptDisplayLimit { | |
| 176 | + | t.Fatalf("output length = %d, want <= %d", len(got), barScriptDisplayLimit) | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func containsEnv(env []string, entry string) bool { | |
| 181 | + | return slices.Contains(env, entry) | |
| 182 | + | } | |
| @@ -0,0 +1,182 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "slices" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/vev/internal/ports" | |||
| 12 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 13 | + | "github.com/stretchr/testify/mock" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func TestBarScriptSanitizeOutput(t *testing.T) { | |||
| 17 | + | tests := []struct { | |||
| 18 | + | name string | |||
| 19 | + | raw string | |||
| 20 | + | limit int | |||
| 21 | + | want string | |||
| 22 | + | }{ | |||
| 23 | + | {name: "first line only", raw: "one\ntwo\n", limit: barScriptOutputLimit, want: "one"}, | |||
| 24 | + | {name: "ansi stripped", raw: "\x1b[31mred\x1b[0m", limit: barScriptOutputLimit, want: "red"}, | |||
| 25 | + | {name: "controls stripped", raw: "a\x00b\x07c", limit: barScriptOutputLimit, want: "abc"}, | |||
| 26 | + | {name: "osc stripped", raw: "a\x1b]0;bad\ab", limit: barScriptOutputLimit, want: "ab"}, | |||
| 27 | + | {name: "charset escape stripped", raw: "a\x1b(Bb\x1b)0c", limit: barScriptOutputLimit, want: "abc"}, | |||
| 28 | + | {name: "non csi escape stripped", raw: "a\x1b7b\x1b8c", limit: barScriptOutputLimit, want: "abc"}, | |||
| 29 | + | {name: "c1 controls stripped", raw: "a\u009b31mb\u009d0;bad\ac", limit: barScriptOutputLimit, want: "abc"}, | |||
| 30 | + | {name: "utf8 and nerd font preserved", raw: " 14:32 ↑3", limit: barScriptOutputLimit, want: " 14:32 ↑3"}, | |||
| 31 | + | {name: "display capped", raw: strings.Repeat("a", barScriptDisplayLimit+10), limit: barScriptOutputLimit, want: strings.Repeat("a", barScriptDisplayLimit)}, | |||
| 32 | + | {name: "capture capped before first line", raw: strings.Repeat("a", 8) + "\nignored", limit: 4, want: "aaaa"}, | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | for _, tt := range tests { | |||
| 36 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 37 | + | if got := sanitizeBarScriptOutput([]byte(tt.raw), tt.limit); got != tt.want { | |||
| 38 | + | t.Fatalf("sanitizeBarScriptOutput() = %q, want %q", got, tt.want) | |||
| 39 | + | } | |||
| 40 | + | }) | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestBarScriptContextEnv(t *testing.T) { | |||
| 45 | + | base := []string{ | |||
| 46 | + | "PATH=/bin", | |||
| 47 | + | "VEV_ANCHOR=old", | |||
| 48 | + | "VEV_SESSION=old", | |||
| 49 | + | "VEV_TAB=old", | |||
| 50 | + | "VEV_PANE=old", | |||
| 51 | + | "VEV_PANE_CWD=/old", | |||
| 52 | + | "VEV_COLS=1", | |||
| 53 | + | } | |||
| 54 | + | ctx := barScriptContext{Anchor: "top-right", Session: "work", Tab: "2", Pane: "pane-3", PaneCWD: "/repo", Cols: 120} | |||
| 55 | + | got := ctx.env(base) | |||
| 56 | + | ||||
| 57 | + | want := map[string]string{ | |||
| 58 | + | "VEV_ANCHOR": "top-right", | |||
| 59 | + | "VEV_SESSION": "work", | |||
| 60 | + | "VEV_TAB": "2", | |||
| 61 | + | "VEV_PANE": "pane-3", | |||
| 62 | + | "VEV_PANE_CWD": "/repo", | |||
| 63 | + | "VEV_COLS": "120", | |||
| 64 | + | } | |||
| 65 | + | seen := map[string]int{} | |||
| 66 | + | for _, entry := range got { | |||
| 67 | + | key, val, ok := strings.Cut(entry, "=") | |||
| 68 | + | if !ok { | |||
| 69 | + | continue | |||
| 70 | + | } | |||
| 71 | + | if _, isVEV := want[key]; isVEV { | |||
| 72 | + | seen[key]++ | |||
| 73 | + | if val != want[key] { | |||
| 74 | + | t.Fatalf("%s = %q, want %q in env %v", key, val, want[key], got) | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | for key := range want { | |||
| 79 | + | if seen[key] != 1 { | |||
| 80 | + | t.Fatalf("%s appears %d times, want 1 in env %v", key, seen[key], got) | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestBarScriptRunner(t *testing.T) { | |||
| 86 | + | t.Run("nil runner returns explicit error for command", func(t *testing.T) { | |||
| 87 | + | runner := barScriptRunner{} | |||
| 88 | + | got, err := runner.run(context.Background(), "date", barScriptContext{}) | |||
| 89 | + | if err == nil || err.Error() != "bar script command runner is nil" { | |||
| 90 | + | t.Fatalf("run err = %v, want explicit nil runner error", err) | |||
| 91 | + | } | |||
| 92 | + | if got != "" { | |||
| 93 | + | t.Fatalf("run output = %q, want empty", got) | |||
| 94 | + | } | |||
| 95 | + | }) | |||
| 96 | + | ||||
| 97 | + | t.Run("empty command skips port runner", func(t *testing.T) { | |||
| 98 | + | runner := barScriptRunner{} | |||
| 99 | + | got, err := runner.run(context.Background(), " \t\n ", barScriptContext{}) | |||
| 100 | + | if err != nil { | |||
| 101 | + | t.Fatalf("run err = %v, want nil", err) | |||
| 102 | + | } | |||
| 103 | + | if got != "" { | |||
| 104 | + | t.Fatalf("run output = %q, want empty", got) | |||
| 105 | + | } | |||
| 106 | + | }) | |||
| 107 | + | ||||
| 108 | + | t.Run("success sanitizes first line and passes command spec", func(t *testing.T) { | |||
| 109 | + | portRunner := portsmocks.NewMockShellCommandRunner(t) | |||
| 110 | + | portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool { | |||
| 111 | + | return spec.Command == "printf 'ok\\nignored'" && | |||
| 112 | + | spec.Timeout == 50*time.Millisecond && | |||
| 113 | + | spec.StdoutLimit == barScriptOutputLimit && | |||
| 114 | + | containsEnv(spec.Env, "PATH=/bin") && | |||
| 115 | + | containsEnv(spec.Env, "VEV_ANCHOR=top-right") && | |||
| 116 | + | containsEnv(spec.Env, "VEV_SESSION=work") && | |||
| 117 | + | containsEnv(spec.Env, "VEV_TAB=tab-1") && | |||
| 118 | + | containsEnv(spec.Env, "VEV_PANE=pane-1") && | |||
| 119 | + | containsEnv(spec.Env, "VEV_PANE_CWD=/repo") && | |||
| 120 | + | containsEnv(spec.Env, "VEV_COLS=120") && | |||
| 121 | + | !containsEnv(spec.Env, "VEV_ANCHOR=old") | |||
| 122 | + | })).Return([]byte("\x1b[31mok\x1b[0m\nignored"), nil) | |||
| 123 | + | runner := barScriptRunner{runner: portRunner, timeout: 50 * time.Millisecond, baseEnv: []string{"PATH=/bin", "VEV_ANCHOR=old"}} | |||
| 124 | + | ||||
| 125 | + | got, err := runner.run(context.Background(), "printf 'ok\\nignored'", barScriptContext{Anchor: "top-right", Session: "work", Tab: "tab-1", Pane: "pane-1", PaneCWD: "/repo", Cols: 120}) | |||
| 126 | + | if err != nil { | |||
| 127 | + | t.Fatalf("run err = %v, want nil", err) | |||
| 128 | + | } | |||
| 129 | + | if got != "ok" { | |||
| 130 | + | t.Fatalf("run output = %q, want %q", got, "ok") | |||
| 131 | + | } | |||
| 132 | + | }) | |||
| 133 | + | ||||
| 134 | + | t.Run("port failure returns sanitized partial stdout and error", func(t *testing.T) { | |||
| 135 | + | wantErr := errors.New("exit 1") | |||
| 136 | + | portRunner := portsmocks.NewMockShellCommandRunner(t) | |||
| 137 | + | portRunner.EXPECT().Run(mock.Anything, mock.Anything).Return([]byte("partial\x00 output\nignored"), wantErr) | |||
| 138 | + | runner := barScriptRunner{runner: portRunner, timeout: time.Second} | |||
| 139 | + | ||||
| 140 | + | got, err := runner.run(context.Background(), "false", barScriptContext{}) | |||
| 141 | + | if !errors.Is(err, wantErr) { | |||
| 142 | + | t.Fatalf("run err = %v, want %v", err, wantErr) | |||
| 143 | + | } | |||
| 144 | + | if got != "partial output" { | |||
| 145 | + | t.Fatalf("run output = %q, want %q", got, "partial output") | |||
| 146 | + | } | |||
| 147 | + | }) | |||
| 148 | + | ||||
| 149 | + | t.Run("timeout propagates deadline", func(t *testing.T) { | |||
| 150 | + | portRunner := portsmocks.NewMockShellCommandRunner(t) | |||
| 151 | + | portRunner.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ ports.CommandSpec) ([]byte, error) { | |||
| 152 | + | <-ctx.Done() | |||
| 153 | + | return nil, ctx.Err() | |||
| 154 | + | }) | |||
| 155 | + | runner := barScriptRunner{runner: portRunner, timeout: time.Nanosecond} | |||
| 156 | + | ||||
| 157 | + | _, err := runner.run(context.Background(), "sleep 1", barScriptContext{}) | |||
| 158 | + | if !errors.Is(err, context.DeadlineExceeded) { | |||
| 159 | + | t.Fatalf("run err = %v, want %v", err, context.DeadlineExceeded) | |||
| 160 | + | } | |||
| 161 | + | }) | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) { | |||
| 165 | + | portRunner := portsmocks.NewMockShellCommandRunner(t) | |||
| 166 | + | portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool { | |||
| 167 | + | return spec.StdoutLimit == barScriptOutputLimit | |||
| 168 | + | })).Return([]byte(strings.Repeat("a", 4096)), nil) | |||
| 169 | + | runner := barScriptRunner{runner: portRunner, timeout: time.Second} | |||
| 170 | + | ||||
| 171 | + | got, err := runner.run(context.Background(), "long-output", barScriptContext{}) | |||
| 172 | + | if err != nil { | |||
| 173 | + | t.Fatalf("run bounded output: %v", err) | |||
| 174 | + | } | |||
| 175 | + | if len(got) > barScriptDisplayLimit { | |||
| 176 | + | t.Fatalf("output length = %d, want <= %d", len(got), barScriptDisplayLimit) | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | func containsEnv(env []string, entry string) bool { | |||
| 181 | + | return slices.Contains(env, entry) | |||
| 182 | + | } | |||
M internal/usecase/daemon/client.go +2 -0
| @@ -389,6 +389,7 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain | |||
| 389 | 389 | d.resize(sess, ac, clientSize) | |
| 390 | 390 | return | |
| 391 | 391 | } | |
| 392 | + | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | |
| 392 | 393 | d.paint(sess, ac, true) | |
| 393 | 394 | } | |
| 394 | 395 | ||
| @@ -539,6 +540,7 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||
| 539 | 540 | } | |
| 540 | 541 | markSnapshotDirty(sess) | |
| 541 | 542 | if ac != nil { | |
| 543 | + | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | |
| 542 | 544 | d.paint(sess, ac, true) | |
| 543 | 545 | } | |
| 544 | 546 | } | |
| @@ -389,6 +389,7 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain | |||||
| 389 | d.resize(sess, ac, clientSize) | 389 | d.resize(sess, ac, clientSize) | ||
| 390 | return | 390 | return | ||
| 391 | } | 391 | } | ||
| 392 | + | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | |||
| 392 | d.paint(sess, ac, true) | 393 | d.paint(sess, ac, true) | ||
| 393 | } | 394 | } | ||
| 394 | 395 | ||||
| @@ -539,6 +540,7 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||||
| 539 | } | 540 | } | ||
| 540 | markSnapshotDirty(sess) | 541 | markSnapshotDirty(sess) | ||
| 541 | if ac != nil { | 542 | if ac != nil { | ||
| 543 | + | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | |||
| 542 | d.paint(sess, ac, true) | 544 | d.paint(sess, ac, true) | ||
| 543 | } | 545 | } | ||
| 544 | } | 546 | } | ||
M internal/usecase/daemon/config.go +15 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "regexp" | |
| 7 | 7 | "sort" | |
| 8 | 8 | "strings" | |
| 9 | + | "time" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/vev/internal/domain" | |
| 11 | 12 | "github.com/bnema/vev/internal/usecase/command" | |
| @@ -27,6 +28,20 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) { | |||
| 27 | 28 | d.codeOverrides.Store(&overrides) | |
| 28 | 29 | d.restoreProcessAllowlist.Store(&allowlist) | |
| 29 | 30 | d.themeMode.Store(uint32(cfg.Theme)) | |
| 31 | + | if d.barScripts != nil { | |
| 32 | + | d.barScripts.mu.Lock() | |
| 33 | + | d.barScripts.initLocked() | |
| 34 | + | barCfg := barConfigFromDomain(cfg.Bar) | |
| 35 | + | if d.barScripts.cfg != barCfg { | |
| 36 | + | d.barScripts.cfg = barCfg | |
| 37 | + | d.barScripts.outputs = make(map[domain.SessionID]barScriptOutputs) | |
| 38 | + | d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time) | |
| 39 | + | d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext) | |
| 40 | + | d.barScripts.pending = make(map[domain.SessionID]bool) | |
| 41 | + | d.barScripts.version++ | |
| 42 | + | } | |
| 43 | + | d.barScripts.mu.Unlock() | |
| 44 | + | } | |
| 30 | 45 | d.reapplyThemeAllSessions() | |
| 31 | 46 | d.repaintAllAttachedClients() | |
| 32 | 47 | } | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "regexp" | 6 | "regexp" | ||
| 7 | "sort" | 7 | "sort" | ||
| 8 | "strings" | 8 | "strings" | ||
| 9 | + | "time" | |||
| 9 | 10 | ||||
| 10 | "github.com/bnema/vev/internal/domain" | 11 | "github.com/bnema/vev/internal/domain" | ||
| 11 | "github.com/bnema/vev/internal/usecase/command" | 12 | "github.com/bnema/vev/internal/usecase/command" | ||
| @@ -27,6 +28,20 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) { | |||||
| 27 | d.codeOverrides.Store(&overrides) | 28 | d.codeOverrides.Store(&overrides) | ||
| 28 | d.restoreProcessAllowlist.Store(&allowlist) | 29 | d.restoreProcessAllowlist.Store(&allowlist) | ||
| 29 | d.themeMode.Store(uint32(cfg.Theme)) | 30 | d.themeMode.Store(uint32(cfg.Theme)) | ||
| 31 | + | if d.barScripts != nil { | |||
| 32 | + | d.barScripts.mu.Lock() | |||
| 33 | + | d.barScripts.initLocked() | |||
| 34 | + | barCfg := barConfigFromDomain(cfg.Bar) | |||
| 35 | + | if d.barScripts.cfg != barCfg { | |||
| 36 | + | d.barScripts.cfg = barCfg | |||
| 37 | + | d.barScripts.outputs = make(map[domain.SessionID]barScriptOutputs) | |||
| 38 | + | d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time) | |||
| 39 | + | d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext) | |||
| 40 | + | d.barScripts.pending = make(map[domain.SessionID]bool) | |||
| 41 | + | d.barScripts.version++ | |||
| 42 | + | } | |||
| 43 | + | d.barScripts.mu.Unlock() | |||
| 44 | + | } | |||
| 30 | d.reapplyThemeAllSessions() | 45 | d.reapplyThemeAllSessions() | ||
| 31 | d.repaintAllAttachedClients() | 46 | d.repaintAllAttachedClients() | ||
| 32 | } | 47 | } | ||
M internal/usecase/daemon/copymode.go +1 -1
| @@ -441,7 +441,7 @@ func composeCopyClientFrame(mode *scopy.Mode, tb *tab, bars barState) (renderer. | |||
| 441 | 441 | width, screenRows = tb.size.Cols, tb.size.Rows | |
| 442 | 442 | } | |
| 443 | 443 | frame := renderer.NewFrame(width, screenRows+2) | |
| 444 | - | drawTopBarSnapshot(frame.Row(0), bars.status, bars.attentionFrame, styles) | |
| 444 | + | drawTopBarSnapshot(frame.Row(0), bars.status, bars.attentionFrame, bars.topRight, styles) | |
| 445 | 445 | base, _ := composeTabFrame(tb, domain.Rect{Width: width, Height: screenRows}, bars.theme) | |
| 446 | 446 | for y := range screenRows { | |
| 447 | 447 | copy(frame.Row(y+1), base.Row(y)) | |
| @@ -441,7 +441,7 @@ func composeCopyClientFrame(mode *scopy.Mode, tb *tab, bars barState) (renderer. | |||||
| 441 | width, screenRows = tb.size.Cols, tb.size.Rows | 441 | width, screenRows = tb.size.Cols, tb.size.Rows | ||
| 442 | } | 442 | } | ||
| 443 | frame := renderer.NewFrame(width, screenRows+2) | 443 | frame := renderer.NewFrame(width, screenRows+2) | ||
| 444 | - | drawTopBarSnapshot(frame.Row(0), bars.status, bars.attentionFrame, styles) | 444 | + | drawTopBarSnapshot(frame.Row(0), bars.status, bars.attentionFrame, bars.topRight, styles) |
| 445 | base, _ := composeTabFrame(tb, domain.Rect{Width: width, Height: screenRows}, bars.theme) | 445 | base, _ := composeTabFrame(tb, domain.Rect{Width: width, Height: screenRows}, bars.theme) | ||
| 446 | for y := range screenRows { | 446 | for y := range screenRows { | ||
| 447 | copy(frame.Row(y+1), base.Row(y)) | 447 | copy(frame.Row(y+1), base.Row(y)) | ||
M internal/usecase/daemon/daemon.go +20 -0
| @@ -116,6 +116,7 @@ type Daemon struct { | |||
| 116 | 116 | codeOverrides atomic.Pointer[map[string]string] | |
| 117 | 117 | restoreProcessAllowlist atomic.Pointer[map[string]struct{}] | |
| 118 | 118 | themeMode atomic.Uint32 | |
| 119 | + | barScripts *barScriptState | |
| 119 | 120 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | |
| 120 | 121 | // (see clipboard.go); empty means use os.TempDir(). | |
| 121 | 122 | tempDir string | |
| @@ -224,6 +225,15 @@ func WithTempDir(dir string) Option { | |||
| 224 | 225 | } | |
| 225 | 226 | } | |
| 226 | 227 | ||
| 228 | + | // WithBarScriptCommandRunner installs the shell command runner used by bar scripts. | |
| 229 | + | func WithBarScriptCommandRunner(runner ports.ShellCommandRunner) Option { | |
| 230 | + | return func(d *Daemon) { | |
| 231 | + | if runner != nil { | |
| 232 | + | d.barScripts.runner = barScriptRunner{runner: runner, baseEnv: d.baseEnv} | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | ||
| 227 | 237 | // WithConfig applies the initial user configuration. | |
| 228 | 238 | func WithConfig(cfg domain.Config) Option { | |
| 229 | 239 | return func(d *Daemon) { | |
| @@ -255,6 +265,13 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 255 | 265 | done: make(chan struct{}), | |
| 256 | 266 | restoreDone: make(chan struct{}), | |
| 257 | 267 | animWake: make(chan struct{}, 1), | |
| 268 | + | barScripts: &barScriptState{ | |
| 269 | + | cfg: barConfigFromDomain(domain.Defaults().Bar), | |
| 270 | + | outputs: make(map[domain.SessionID]barScriptOutputs), | |
| 271 | + | lastRefresh: make(map[domain.SessionID]time.Time), | |
| 272 | + | lastContext: make(map[domain.SessionID]barScriptContext), | |
| 273 | + | running: make(map[domain.SessionID]bool), | |
| 274 | + | }, | |
| 258 | 275 | } | |
| 259 | 276 | for _, o := range opts { | |
| 260 | 277 | o(d) | |
| @@ -303,6 +320,9 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error { | |||
| 303 | 320 | d.sessWg.Go(func() { | |
| 304 | 321 | d.attentionAnimator(d.serveCtx) | |
| 305 | 322 | }) | |
| 323 | + | d.sessWg.Go(func() { | |
| 324 | + | d.barScriptPoller(d.serveCtx) | |
| 325 | + | }) | |
| 306 | 326 | if d.persistEnabled && d.procCwd != nil { | |
| 307 | 327 | d.sessWg.Go(func() { | |
| 308 | 328 | d.cwdSampler(d.serveCtx) | |
| @@ -116,6 +116,7 @@ type Daemon struct { | |||||
| 116 | codeOverrides atomic.Pointer[map[string]string] | 116 | codeOverrides atomic.Pointer[map[string]string] | ||
| 117 | restoreProcessAllowlist atomic.Pointer[map[string]struct{}] | 117 | restoreProcessAllowlist atomic.Pointer[map[string]struct{}] | ||
| 118 | themeMode atomic.Uint32 | 118 | themeMode atomic.Uint32 | ||
| 119 | + | barScripts *barScriptState | |||
| 119 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | 120 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | ||
| 120 | // (see clipboard.go); empty means use os.TempDir(). | 121 | // (see clipboard.go); empty means use os.TempDir(). | ||
| 121 | tempDir string | 122 | tempDir string | ||
| @@ -224,6 +225,15 @@ func WithTempDir(dir string) Option { | |||||
| 224 | } | 225 | } | ||
| 225 | } | 226 | } | ||
| 226 | 227 | ||||
| 228 | + | // WithBarScriptCommandRunner installs the shell command runner used by bar scripts. | |||
| 229 | + | func WithBarScriptCommandRunner(runner ports.ShellCommandRunner) Option { | |||
| 230 | + | return func(d *Daemon) { | |||
| 231 | + | if runner != nil { | |||
| 232 | + | d.barScripts.runner = barScriptRunner{runner: runner, baseEnv: d.baseEnv} | |||
| 233 | + | } | |||
| 234 | + | } | |||
| 235 | + | } | |||
| 236 | + | ||||
| 227 | // WithConfig applies the initial user configuration. | 237 | // WithConfig applies the initial user configuration. | ||
| 228 | func WithConfig(cfg domain.Config) Option { | 238 | func WithConfig(cfg domain.Config) Option { | ||
| 229 | return func(d *Daemon) { | 239 | return func(d *Daemon) { | ||
| @@ -255,6 +265,13 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||||
| 255 | done: make(chan struct{}), | 265 | done: make(chan struct{}), | ||
| 256 | restoreDone: make(chan struct{}), | 266 | restoreDone: make(chan struct{}), | ||
| 257 | animWake: make(chan struct{}, 1), | 267 | animWake: make(chan struct{}, 1), | ||
| 268 | + | barScripts: &barScriptState{ | |||
| 269 | + | cfg: barConfigFromDomain(domain.Defaults().Bar), | |||
| 270 | + | outputs: make(map[domain.SessionID]barScriptOutputs), | |||
| 271 | + | lastRefresh: make(map[domain.SessionID]time.Time), | |||
| 272 | + | lastContext: make(map[domain.SessionID]barScriptContext), | |||
| 273 | + | running: make(map[domain.SessionID]bool), | |||
| 274 | + | }, | |||
| 258 | } | 275 | } | ||
| 259 | for _, o := range opts { | 276 | for _, o := range opts { | ||
| 260 | o(d) | 277 | o(d) | ||
| @@ -303,6 +320,9 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error { | |||||
| 303 | d.sessWg.Go(func() { | 320 | d.sessWg.Go(func() { | ||
| 304 | d.attentionAnimator(d.serveCtx) | 321 | d.attentionAnimator(d.serveCtx) | ||
| 305 | }) | 322 | }) | ||
| 323 | + | d.sessWg.Go(func() { | |||
| 324 | + | d.barScriptPoller(d.serveCtx) | |||
| 325 | + | }) | |||
| 306 | if d.persistEnabled && d.procCwd != nil { | 326 | if d.persistEnabled && d.procCwd != nil { | ||
| 307 | d.sessWg.Go(func() { | 327 | d.sessWg.Go(func() { | ||
| 308 | d.cwdSampler(d.serveCtx) | 328 | d.cwdSampler(d.serveCtx) | ||
M internal/usecase/daemon/daemon_test.go +4 -0
| @@ -306,6 +306,10 @@ func waitGroupDone(wg *sync.WaitGroup) <-chan struct{} { | |||
| 306 | 306 | func rowText(row []renderer.Cell) string { | |
| 307 | 307 | runes := make([]rune, len(row)) | |
| 308 | 308 | for i, c := range row { | |
| 309 | + | if c.Continuation { | |
| 310 | + | runes[i] = ' ' | |
| 311 | + | continue | |
| 312 | + | } | |
| 309 | 313 | runes[i] = c.Rune | |
| 310 | 314 | } | |
| 311 | 315 | return string(runes) | |
| @@ -306,6 +306,10 @@ func waitGroupDone(wg *sync.WaitGroup) <-chan struct{} { | |||||
| 306 | func rowText(row []renderer.Cell) string { | 306 | func rowText(row []renderer.Cell) string { | ||
| 307 | runes := make([]rune, len(row)) | 307 | runes := make([]rune, len(row)) | ||
| 308 | for i, c := range row { | 308 | for i, c := range row { | ||
| 309 | + | if c.Continuation { | |||
| 310 | + | runes[i] = ' ' | |||
| 311 | + | continue | |||
| 312 | + | } | |||
| 309 | runes[i] = c.Rune | 313 | runes[i] = c.Rune | ||
| 310 | } | 314 | } | ||
| 311 | return string(runes) | 315 | return string(runes) | ||
M internal/usecase/daemon/render.go +2 -1
| @@ -252,6 +252,7 @@ func (d *Daemon) render(sess *session, tb *tab, p *pane) { | |||
| 252 | 252 | p.mu.Unlock() | |
| 253 | 253 | return | |
| 254 | 254 | } | |
| 255 | + | d.refreshBarScriptsIfDue(sess, d.clock.Now(), false) | |
| 255 | 256 | d.paint(sess, ac, false) | |
| 256 | 257 | } | |
| 257 | 258 | ||
| @@ -502,7 +503,7 @@ func composeClientFrameWithLayout(bars barState, tb *tab, full bool, layoutSnap | |||
| 502 | 503 | } | |
| 503 | 504 | frame := renderer.NewFrame(width, screenRows+2) | |
| 504 | 505 | topBar := frame.Row(0) | |
| 505 | - | drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, styles) | |
| 506 | + | drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, bars.topRight, styles) | |
| 506 | 507 | contentFrame, contentDamage := composeTabFrameWithLayout(tb, domain.Rect{Width: width, Height: screenRows}, bars.theme, layoutSnap) | |
| 507 | 508 | for y := range screenRows { | |
| 508 | 509 | copy(frame.Row(y+1), contentFrame.Row(y)) | |
| @@ -252,6 +252,7 @@ func (d *Daemon) render(sess *session, tb *tab, p *pane) { | |||||
| 252 | p.mu.Unlock() | 252 | p.mu.Unlock() | ||
| 253 | return | 253 | return | ||
| 254 | } | 254 | } | ||
| 255 | + | d.refreshBarScriptsIfDue(sess, d.clock.Now(), false) | |||
| 255 | d.paint(sess, ac, false) | 256 | d.paint(sess, ac, false) | ||
| 256 | } | 257 | } | ||
| 257 | 258 | ||||
| @@ -502,7 +503,7 @@ func composeClientFrameWithLayout(bars barState, tb *tab, full bool, layoutSnap | |||||
| 502 | } | 503 | } | ||
| 503 | frame := renderer.NewFrame(width, screenRows+2) | 504 | frame := renderer.NewFrame(width, screenRows+2) | ||
| 504 | topBar := frame.Row(0) | 505 | topBar := frame.Row(0) | ||
| 505 | - | drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, styles) | 506 | + | drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, bars.topRight, styles) |
| 506 | contentFrame, contentDamage := composeTabFrameWithLayout(tb, domain.Rect{Width: width, Height: screenRows}, bars.theme, layoutSnap) | 507 | contentFrame, contentDamage := composeTabFrameWithLayout(tb, domain.Rect{Width: width, Height: screenRows}, bars.theme, layoutSnap) | ||
| 507 | for y := range screenRows { | 508 | for y := range screenRows { | ||
| 508 | copy(frame.Row(y+1), contentFrame.Row(y)) | 509 | copy(frame.Row(y+1), contentFrame.Row(y)) | ||
M internal/usecase/daemon/session.go +1 -0
| @@ -538,6 +538,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||
| 538 | 538 | return nil | |
| 539 | 539 | } | |
| 540 | 540 | delete(d.sessions, sess.id) | |
| 541 | + | d.clearBarScriptsForSession(sess.id) | |
| 541 | 542 | d.purgeParkedForSessionLocked(sess) | |
| 542 | 543 | sess.mu.Lock() | |
| 543 | 544 | stoppedName := sess.name | |
| @@ -538,6 +538,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||||
| 538 | return nil | 538 | return nil | ||
| 539 | } | 539 | } | ||
| 540 | delete(d.sessions, sess.id) | 540 | delete(d.sessions, sess.id) | ||
| 541 | + | d.clearBarScriptsForSession(sess.id) | |||
| 541 | d.purgeParkedForSessionLocked(sess) | 542 | d.purgeParkedForSessionLocked(sess) | ||
| 542 | sess.mu.Lock() | 543 | sess.mu.Lock() | ||
| 543 | stoppedName := sess.name | 544 | stoppedName := sess.name | ||
M internal/usecase/daemon/status.go +45 -10
| @@ -58,7 +58,7 @@ func pulseStyle(frame int) (renderer.Style, bool) { | |||
| 58 | 58 | return style, true | |
| 59 | 59 | } | |
| 60 | 60 | ||
| 61 | - | func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, styles themeStyles) { | |
| 61 | + | func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, topRight string, styles themeStyles) { | |
| 62 | 62 | clearStatusRow(row) | |
| 63 | 63 | x := 0 | |
| 64 | 64 | for _, w := range status.tabs { | |
| @@ -73,6 +73,7 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, s | |||
| 73 | 73 | } | |
| 74 | 74 | writeStatusText(row, &x, " ", style) | |
| 75 | 75 | } | |
| 76 | + | drawRightPlainText(row, topRight, x, styles.statusBar) | |
| 76 | 77 | } | |
| 77 | 78 | ||
| 78 | 79 | func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) { | |
| @@ -80,7 +81,8 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) | |||
| 80 | 81 | x := 0 | |
| 81 | 82 | writeStatusText(row, &x, " "+state.status.session+" ", styles.accent) | |
| 82 | 83 | ||
| 83 | - | fittedMRU := fitMRU(state.mru, len(row), x, state.copyFeedback) | |
| 84 | + | rightText := composeBottomRightText(state.bottomRight, state.copyFeedback) | |
| 85 | + | fittedMRU := fitMRU(state.mru, len(row), x, rightText) | |
| 84 | 86 | for i, sess := range fittedMRU { | |
| 85 | 87 | style := mruStyle(styles.statusBar, state.theme, i, len(fittedMRU)) | |
| 86 | 88 | name := sess.name | |
| @@ -94,17 +96,36 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) | |||
| 94 | 96 | } | |
| 95 | 97 | writeStatusText(row, &x, " ", style) | |
| 96 | 98 | } | |
| 97 | - | drawRightPlainText(row, state.copyFeedback, x, styles.statusBar) | |
| 99 | + | drawRightPlainText(row, rightText, x, styles.statusBar) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func composeBottomRightText(scriptText, copyFeedback string) string { | |
| 103 | + | if scriptText == "" { | |
| 104 | + | return copyFeedback | |
| 105 | + | } | |
| 106 | + | if copyFeedback == "" { | |
| 107 | + | return scriptText | |
| 108 | + | } | |
| 109 | + | return scriptText + " " + copyFeedback | |
| 98 | 110 | } | |
| 99 | 111 | ||
| 100 | 112 | func drawRightPlainText(row []renderer.Cell, text string, reservedLeft int, style renderer.Style) { | |
| 101 | - | if text == "" || len([]rune(text))+1+reservedLeft > len(row) { | |
| 113 | + | textWidth := statusTextWidth(text) | |
| 114 | + | if text == "" || textWidth+1+reservedLeft > len(row) { | |
| 102 | 115 | return | |
| 103 | 116 | } | |
| 104 | - | x := len(row) - len([]rune(text)) - 1 | |
| 117 | + | x := len(row) - textWidth - 1 | |
| 105 | 118 | writeStatusText(row, &x, " "+text, style) | |
| 106 | 119 | } | |
| 107 | 120 | ||
| 121 | + | func statusTextWidth(text string) int { | |
| 122 | + | width := 0 | |
| 123 | + | for _, r := range text { | |
| 124 | + | width += renderer.RuneWidth(r) | |
| 125 | + | } | |
| 126 | + | return width | |
| 127 | + | } | |
| 128 | + | ||
| 108 | 129 | func clearStatusRow(row []renderer.Cell) { | |
| 109 | 130 | for i := range row { | |
| 110 | 131 | row[i] = renderer.BlankCell() | |
| @@ -113,6 +134,8 @@ func clearStatusRow(row []renderer.Cell) { | |||
| 113 | 134 | ||
| 114 | 135 | type barState struct { | |
| 115 | 136 | status statusSnapshot | |
| 137 | + | topRight string | |
| 138 | + | bottomRight string | |
| 116 | 139 | copyFeedback string | |
| 117 | 140 | mru []mruSession | |
| 118 | 141 | attentionFrame int | |
| @@ -165,6 +188,9 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState { | |||
| 165 | 188 | if cur != nil { | |
| 166 | 189 | state.status = cur.statusSegments() | |
| 167 | 190 | } | |
| 191 | + | if d != nil { | |
| 192 | + | state.topRight, state.bottomRight = d.barScriptSnapshot(cur) | |
| 193 | + | } | |
| 168 | 194 | if d == nil { | |
| 169 | 195 | return state | |
| 170 | 196 | } | |
| @@ -205,19 +231,20 @@ func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSe | |||
| 205 | 231 | // its " text" width plus a one-cell gap so drawRightPlainText always fits. | |
| 206 | 232 | copyReserve := 1 | |
| 207 | 233 | if feedback != "" { | |
| 208 | - | copyReserve = len([]rune(feedback)) + 2 | |
| 234 | + | copyReserve = statusTextWidth(feedback) + 2 | |
| 209 | 235 | } | |
| 210 | 236 | physicalBudget := rowLen - leftUsed - copyReserve | |
| 211 | 237 | if physicalBudget <= 0 || len(entries) == 0 { | |
| 212 | 238 | return nil | |
| 213 | 239 | } | |
| 214 | 240 | cost := func(e mruSession) int { | |
| 215 | - | n := 2 + len([]rune(e.name)) | |
| 241 | + | name := e.name | |
| 216 | 242 | if e.ephemeral { | |
| 217 | - | n++ | |
| 243 | + | name += "*" | |
| 218 | 244 | } | |
| 245 | + | n := 2 + statusTextWidth(name) | |
| 219 | 246 | if e.attention { | |
| 220 | - | n += 2 | |
| 247 | + | n += 1 + renderer.RuneWidth(attentionGlyph) | |
| 221 | 248 | } | |
| 222 | 249 | return n | |
| 223 | 250 | } | |
| @@ -286,10 +313,18 @@ func writeBell(row []renderer.Cell, x *int, frame int) { | |||
| 286 | 313 | ||
| 287 | 314 | func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) { | |
| 288 | 315 | for _, r := range text { | |
| 289 | - | if *x >= len(row) { | |
| 316 | + | width := renderer.RuneWidth(r) | |
| 317 | + | if width == 0 { | |
| 318 | + | continue | |
| 319 | + | } | |
| 320 | + | if *x >= len(row) || *x+width > len(row) { | |
| 290 | 321 | return | |
| 291 | 322 | } | |
| 292 | 323 | row[*x] = renderer.Cell{Rune: r, Style: style} | |
| 293 | 324 | (*x)++ | |
| 325 | + | if width == 2 { | |
| 326 | + | row[*x] = renderer.Cell{Style: style, Continuation: true} | |
| 327 | + | (*x)++ | |
| 328 | + | } | |
| 294 | 329 | } | |
| 295 | 330 | } | |
| @@ -58,7 +58,7 @@ func pulseStyle(frame int) (renderer.Style, bool) { | |||||
| 58 | return style, true | 58 | return style, true | ||
| 59 | } | 59 | } | ||
| 60 | 60 | ||||
| 61 | - | func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, styles themeStyles) { | 61 | + | func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, topRight string, styles themeStyles) { |
| 62 | clearStatusRow(row) | 62 | clearStatusRow(row) | ||
| 63 | x := 0 | 63 | x := 0 | ||
| 64 | for _, w := range status.tabs { | 64 | for _, w := range status.tabs { | ||
| @@ -73,6 +73,7 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, s | |||||
| 73 | } | 73 | } | ||
| 74 | writeStatusText(row, &x, " ", style) | 74 | writeStatusText(row, &x, " ", style) | ||
| 75 | } | 75 | } | ||
| 76 | + | drawRightPlainText(row, topRight, x, styles.statusBar) | |||
| 76 | } | 77 | } | ||
| 77 | 78 | ||||
| 78 | func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) { | 79 | func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) { | ||
| @@ -80,7 +81,8 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) | |||||
| 80 | x := 0 | 81 | x := 0 | ||
| 81 | writeStatusText(row, &x, " "+state.status.session+" ", styles.accent) | 82 | writeStatusText(row, &x, " "+state.status.session+" ", styles.accent) | ||
| 82 | 83 | ||||
| 83 | - | fittedMRU := fitMRU(state.mru, len(row), x, state.copyFeedback) | 84 | + | rightText := composeBottomRightText(state.bottomRight, state.copyFeedback) |
| 85 | + | fittedMRU := fitMRU(state.mru, len(row), x, rightText) | |||
| 84 | for i, sess := range fittedMRU { | 86 | for i, sess := range fittedMRU { | ||
| 85 | style := mruStyle(styles.statusBar, state.theme, i, len(fittedMRU)) | 87 | style := mruStyle(styles.statusBar, state.theme, i, len(fittedMRU)) | ||
| 86 | name := sess.name | 88 | name := sess.name | ||
| @@ -94,17 +96,36 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeStyles) | |||||
| 94 | } | 96 | } | ||
| 95 | writeStatusText(row, &x, " ", style) | 97 | writeStatusText(row, &x, " ", style) | ||
| 96 | } | 98 | } | ||
| 97 | - | drawRightPlainText(row, state.copyFeedback, x, styles.statusBar) | 99 | + | drawRightPlainText(row, rightText, x, styles.statusBar) |
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func composeBottomRightText(scriptText, copyFeedback string) string { | |||
| 103 | + | if scriptText == "" { | |||
| 104 | + | return copyFeedback | |||
| 105 | + | } | |||
| 106 | + | if copyFeedback == "" { | |||
| 107 | + | return scriptText | |||
| 108 | + | } | |||
| 109 | + | return scriptText + " " + copyFeedback | |||
| 98 | } | 110 | } | ||
| 99 | 111 | ||||
| 100 | func drawRightPlainText(row []renderer.Cell, text string, reservedLeft int, style renderer.Style) { | 112 | func drawRightPlainText(row []renderer.Cell, text string, reservedLeft int, style renderer.Style) { | ||
| 101 | - | if text == "" || len([]rune(text))+1+reservedLeft > len(row) { | 113 | + | textWidth := statusTextWidth(text) |
| 114 | + | if text == "" || textWidth+1+reservedLeft > len(row) { | |||
| 102 | return | 115 | return | ||
| 103 | } | 116 | } | ||
| 104 | - | x := len(row) - len([]rune(text)) - 1 | 117 | + | x := len(row) - textWidth - 1 |
| 105 | writeStatusText(row, &x, " "+text, style) | 118 | writeStatusText(row, &x, " "+text, style) | ||
| 106 | } | 119 | } | ||
| 107 | 120 | ||||
| 121 | + | func statusTextWidth(text string) int { | |||
| 122 | + | width := 0 | |||
| 123 | + | for _, r := range text { | |||
| 124 | + | width += renderer.RuneWidth(r) | |||
| 125 | + | } | |||
| 126 | + | return width | |||
| 127 | + | } | |||
| 128 | + | ||||
| 108 | func clearStatusRow(row []renderer.Cell) { | 129 | func clearStatusRow(row []renderer.Cell) { | ||
| 109 | for i := range row { | 130 | for i := range row { | ||
| 110 | row[i] = renderer.BlankCell() | 131 | row[i] = renderer.BlankCell() | ||
| @@ -113,6 +134,8 @@ func clearStatusRow(row []renderer.Cell) { | |||||
| 113 | 134 | ||||
| 114 | type barState struct { | 135 | type barState struct { | ||
| 115 | status statusSnapshot | 136 | status statusSnapshot | ||
| 137 | + | topRight string | |||
| 138 | + | bottomRight string | |||
| 116 | copyFeedback string | 139 | copyFeedback string | ||
| 117 | mru []mruSession | 140 | mru []mruSession | ||
| 118 | attentionFrame int | 141 | attentionFrame int | ||
| @@ -165,6 +188,9 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState { | |||||
| 165 | if cur != nil { | 188 | if cur != nil { | ||
| 166 | state.status = cur.statusSegments() | 189 | state.status = cur.statusSegments() | ||
| 167 | } | 190 | } | ||
| 191 | + | if d != nil { | |||
| 192 | + | state.topRight, state.bottomRight = d.barScriptSnapshot(cur) | |||
| 193 | + | } | |||
| 168 | if d == nil { | 194 | if d == nil { | ||
| 169 | return state | 195 | return state | ||
| 170 | } | 196 | } | ||
| @@ -205,19 +231,20 @@ func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSe | |||||
| 205 | // its " text" width plus a one-cell gap so drawRightPlainText always fits. | 231 | // its " text" width plus a one-cell gap so drawRightPlainText always fits. | ||
| 206 | copyReserve := 1 | 232 | copyReserve := 1 | ||
| 207 | if feedback != "" { | 233 | if feedback != "" { | ||
| 208 | - | copyReserve = len([]rune(feedback)) + 2 | 234 | + | copyReserve = statusTextWidth(feedback) + 2 |
| 209 | } | 235 | } | ||
| 210 | physicalBudget := rowLen - leftUsed - copyReserve | 236 | physicalBudget := rowLen - leftUsed - copyReserve | ||
| 211 | if physicalBudget <= 0 || len(entries) == 0 { | 237 | if physicalBudget <= 0 || len(entries) == 0 { | ||
| 212 | return nil | 238 | return nil | ||
| 213 | } | 239 | } | ||
| 214 | cost := func(e mruSession) int { | 240 | cost := func(e mruSession) int { | ||
| 215 | - | n := 2 + len([]rune(e.name)) | 241 | + | name := e.name |
| 216 | if e.ephemeral { | 242 | if e.ephemeral { | ||
| 217 | - | n++ | 243 | + | name += "*" |
| 218 | } | 244 | } | ||
| 245 | + | n := 2 + statusTextWidth(name) | |||
| 219 | if e.attention { | 246 | if e.attention { | ||
| 220 | - | n += 2 | 247 | + | n += 1 + renderer.RuneWidth(attentionGlyph) |
| 221 | } | 248 | } | ||
| 222 | return n | 249 | return n | ||
| 223 | } | 250 | } | ||
| @@ -286,10 +313,18 @@ func writeBell(row []renderer.Cell, x *int, frame int) { | |||||
| 286 | 313 | ||||
| 287 | func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) { | 314 | func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) { | ||
| 288 | for _, r := range text { | 315 | for _, r := range text { | ||
| 289 | - | if *x >= len(row) { | 316 | + | width := renderer.RuneWidth(r) |
| 317 | + | if width == 0 { | |||
| 318 | + | continue | |||
| 319 | + | } | |||
| 320 | + | if *x >= len(row) || *x+width > len(row) { | |||
| 290 | return | 321 | return | ||
| 291 | } | 322 | } | ||
| 292 | row[*x] = renderer.Cell{Rune: r, Style: style} | 323 | row[*x] = renderer.Cell{Rune: r, Style: style} | ||
| 293 | (*x)++ | 324 | (*x)++ | ||
| 325 | + | if width == 2 { | |||
| 326 | + | row[*x] = renderer.Cell{Style: style, Continuation: true} | |||
| 327 | + | (*x)++ | |||
| 328 | + | } | |||
| 294 | } | 329 | } | ||
| 295 | } | 330 | } | ||
M internal/usecase/daemon/status_test.go +128 -0
| @@ -398,6 +398,52 @@ func TestStatusMarksEphemeralSession(t *testing.T) { | |||
| 398 | 398 | require.Equal(t, " 0* ", rowText(frame.Row(3))) | |
| 399 | 399 | } | |
| 400 | 400 | ||
| 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) | |
| 436 | + | ||
| 437 | + | drawTopBarSnapshot(row, tt.status, 0, tt.topRight, resolveThemeStyles(nil)) | |
| 438 | + | ||
| 439 | + | require.Equal(t, tt.want, rowText(row)) | |
| 440 | + | if tt.continuationCell > 0 { | |
| 441 | + | require.True(t, row[tt.continuationCell].Continuation) | |
| 442 | + | } | |
| 443 | + | }) | |
| 444 | + | } | |
| 445 | + | } | |
| 446 | + | ||
| 401 | 447 | func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) { | |
| 402 | 448 | p, releasePTY := newBlockingPTY(t) | |
| 403 | 449 | _, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| @@ -644,6 +690,88 @@ func TestStatusBarMRUWidthAwareBudget(t *testing.T) { | |||
| 644 | 690 | } | |
| 645 | 691 | } | |
| 646 | 692 | ||
| 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) | |
| 752 | + | ||
| 753 | + | drawStatusBarState(row, tt.state, resolveThemeStyles(nil)) | |
| 754 | + | ||
| 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 | + | } | |
| 773 | + | } | |
| 774 | + | ||
| 647 | 775 | func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) { | |
| 648 | 776 | state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []mruSession{{name: "a"}, {name: "b"}, {name: "c"}}} | |
| 649 | 777 | row := make([]renderer.Cell, 20) | |
| @@ -398,6 +398,52 @@ 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 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) | |||
| 436 | + | ||||
| 437 | + | drawTopBarSnapshot(row, tt.status, 0, tt.topRight, resolveThemeStyles(nil)) | |||
| 438 | + | ||||
| 439 | + | require.Equal(t, tt.want, rowText(row)) | |||
| 440 | + | if tt.continuationCell > 0 { | |||
| 441 | + | require.True(t, row[tt.continuationCell].Continuation) | |||
| 442 | + | } | |||
| 443 | + | }) | |||
| 444 | + | } | |||
| 445 | + | } | |||
| 446 | + | ||||
| 401 | func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) { | 447 | func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) { | ||
| 402 | p, releasePTY := newBlockingPTY(t) | 448 | p, releasePTY := newBlockingPTY(t) | ||
| 403 | _, sess, _, _ := newManualSessionWithPTYs(t, p) | 449 | _, sess, _, _ := newManualSessionWithPTYs(t, p) | ||
| @@ -644,6 +690,88 @@ func TestStatusBarMRUWidthAwareBudget(t *testing.T) { | |||||
| 644 | } | 690 | } | ||
| 645 | } | 691 | } | ||
| 646 | 692 | ||||
| 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) | |||
| 752 | + | ||||
| 753 | + | drawStatusBarState(row, tt.state, resolveThemeStyles(nil)) | |||
| 754 | + | ||||
| 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 | + | } | |||
| 773 | + | } | |||
| 774 | + | ||||
| 647 | func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) { | 775 | func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) { | ||
| 648 | state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []mruSession{{name: "a"}, {name: "b"}, {name: "c"}}} | 776 | state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []mruSession{{name: "a"}, {name: "b"}, {name: "c"}}} | ||
| 649 | row := make([]renderer.Cell, 20) | 777 | row := make([]renderer.Cell, 20) | ||
A scripts/vev-bar-bottom-right +49 -0
| @@ -0,0 +1,49 @@ | |||
| 1 | + | #!/bin/sh | |
| 2 | + | ||
| 3 | + | # Print compact git state for vev's bottom-right bar anchor. | |
| 4 | + | # Always inspect VEV_PANE_CWD, never the daemon's current working directory. | |
| 5 | + | ||
| 6 | + | if [ -z "${VEV_PANE_CWD:-}" ]; then | |
| 7 | + | printf '\n' | |
| 8 | + | exit 0 | |
| 9 | + | fi | |
| 10 | + | ||
| 11 | + | if ! git -C "$VEV_PANE_CWD" rev-parse --is-inside-work-tree >/dev/null 2>&1; then | |
| 12 | + | printf '\n' | |
| 13 | + | exit 0 | |
| 14 | + | fi | |
| 15 | + | ||
| 16 | + | name=$(git -C "$VEV_PANE_CWD" branch --show-current 2>/dev/null || true) | |
| 17 | + | if [ -z "$name" ]; then | |
| 18 | + | name=$(git -C "$VEV_PANE_CWD" rev-parse --short HEAD 2>/dev/null || true) | |
| 19 | + | fi | |
| 20 | + | if [ -z "$name" ]; then | |
| 21 | + | printf '\n' | |
| 22 | + | exit 0 | |
| 23 | + | fi | |
| 24 | + | ||
| 25 | + | line=$name | |
| 26 | + | ||
| 27 | + | counts=$(git -C "$VEV_PANE_CWD" rev-list --left-right --count '@{upstream}...HEAD' 2>/dev/null || true) | |
| 28 | + | if [ -n "$counts" ]; then | |
| 29 | + | behind=$(printf '%s\n' "$counts" | awk '{ print $1 }') | |
| 30 | + | ahead=$(printf '%s\n' "$counts" | awk '{ print $2 }') | |
| 31 | + | case $ahead in | |
| 32 | + | ''|*[!0-9]*) ahead=0 ;; | |
| 33 | + | esac | |
| 34 | + | case $behind in | |
| 35 | + | ''|*[!0-9]*) behind=0 ;; | |
| 36 | + | esac | |
| 37 | + | if [ "$ahead" -gt 0 ] 2>/dev/null; then | |
| 38 | + | line="$line ↑$ahead" | |
| 39 | + | fi | |
| 40 | + | if [ "$behind" -gt 0 ] 2>/dev/null; then | |
| 41 | + | line="$line ↓$behind" | |
| 42 | + | fi | |
| 43 | + | fi | |
| 44 | + | ||
| 45 | + | if [ -n "$(git -C "$VEV_PANE_CWD" status --porcelain 2>/dev/null || true)" ]; then | |
| 46 | + | line="$line *" | |
| 47 | + | fi | |
| 48 | + | ||
| 49 | + | printf '%s\n' "$line" | |
| @@ -0,0 +1,49 @@ | |||||
| 1 | + | #!/bin/sh | |||
| 2 | + | ||||
| 3 | + | # Print compact git state for vev's bottom-right bar anchor. | |||
| 4 | + | # Always inspect VEV_PANE_CWD, never the daemon's current working directory. | |||
| 5 | + | ||||
| 6 | + | if [ -z "${VEV_PANE_CWD:-}" ]; then | |||
| 7 | + | printf '\n' | |||
| 8 | + | exit 0 | |||
| 9 | + | fi | |||
| 10 | + | ||||
| 11 | + | if ! git -C "$VEV_PANE_CWD" rev-parse --is-inside-work-tree >/dev/null 2>&1; then | |||
| 12 | + | printf '\n' | |||
| 13 | + | exit 0 | |||
| 14 | + | fi | |||
| 15 | + | ||||
| 16 | + | name=$(git -C "$VEV_PANE_CWD" branch --show-current 2>/dev/null || true) | |||
| 17 | + | if [ -z "$name" ]; then | |||
| 18 | + | name=$(git -C "$VEV_PANE_CWD" rev-parse --short HEAD 2>/dev/null || true) | |||
| 19 | + | fi | |||
| 20 | + | if [ -z "$name" ]; then | |||
| 21 | + | printf '\n' | |||
| 22 | + | exit 0 | |||
| 23 | + | fi | |||
| 24 | + | ||||
| 25 | + | line=$name | |||
| 26 | + | ||||
| 27 | + | counts=$(git -C "$VEV_PANE_CWD" rev-list --left-right --count '@{upstream}...HEAD' 2>/dev/null || true) | |||
| 28 | + | if [ -n "$counts" ]; then | |||
| 29 | + | behind=$(printf '%s\n' "$counts" | awk '{ print $1 }') | |||
| 30 | + | ahead=$(printf '%s\n' "$counts" | awk '{ print $2 }') | |||
| 31 | + | case $ahead in | |||
| 32 | + | ''|*[!0-9]*) ahead=0 ;; | |||
| 33 | + | esac | |||
| 34 | + | case $behind in | |||
| 35 | + | ''|*[!0-9]*) behind=0 ;; | |||
| 36 | + | esac | |||
| 37 | + | if [ "$ahead" -gt 0 ] 2>/dev/null; then | |||
| 38 | + | line="$line ↑$ahead" | |||
| 39 | + | fi | |||
| 40 | + | if [ "$behind" -gt 0 ] 2>/dev/null; then | |||
| 41 | + | line="$line ↓$behind" | |||
| 42 | + | fi | |||
| 43 | + | fi | |||
| 44 | + | ||||
| 45 | + | if [ -n "$(git -C "$VEV_PANE_CWD" status --porcelain 2>/dev/null || true)" ]; then | |||
| 46 | + | line="$line *" | |||
| 47 | + | fi | |||
| 48 | + | ||||
| 49 | + | printf '%s\n' "$line" | |||
A scripts/vev-bar-top-right +57 -0
| @@ -0,0 +1,57 @@ | |||
| 1 | + | #!/bin/sh | |
| 2 | + | ||
| 3 | + | # Print a compact, single-line system summary for vev's top-right bar anchor. | |
| 4 | + | # Segments that cannot be read are omitted without producing stderr. | |
| 5 | + | ||
| 6 | + | now=$(date +%H:%M 2>/dev/null || true) | |
| 7 | + | ||
| 8 | + | cpu_pct= | |
| 9 | + | if [ -r /proc/stat ]; then | |
| 10 | + | first=$(awk '$1 == "cpu" { idle = $5 + $6; total = 0; for (i = 2; i <= NF; i++) total += $i; print idle, total; exit }' /proc/stat 2>/dev/null || true) | |
| 11 | + | sleep 0.1 2>/dev/null || true | |
| 12 | + | second=$(awk '$1 == "cpu" { idle = $5 + $6; total = 0; for (i = 2; i <= NF; i++) total += $i; print idle, total; exit }' /proc/stat 2>/dev/null || true) | |
| 13 | + | if [ -n "$first" ] && [ -n "$second" ]; then | |
| 14 | + | cpu_pct=$( | |
| 15 | + | awk -v first="$first" -v second="$second" ' | |
| 16 | + | BEGIN { | |
| 17 | + | split(first, a, " ") | |
| 18 | + | split(second, b, " ") | |
| 19 | + | idle = b[1] - a[1] | |
| 20 | + | total = b[2] - a[2] | |
| 21 | + | if (total > 0) printf "%d", ((total - idle) * 100) / total | |
| 22 | + | } | |
| 23 | + | ' 2>/dev/null || true | |
| 24 | + | ) | |
| 25 | + | fi | |
| 26 | + | fi | |
| 27 | + | ||
| 28 | + | mem_pct= | |
| 29 | + | if [ -r /proc/meminfo ]; then | |
| 30 | + | mem_pct=$( | |
| 31 | + | awk ' | |
| 32 | + | $1 == "MemTotal:" { total = $2 } | |
| 33 | + | $1 == "MemAvailable:" { available = $2 } | |
| 34 | + | END { | |
| 35 | + | if (total > 0 && available >= 0) printf "%d", ((total - available) * 100) / total | |
| 36 | + | } | |
| 37 | + | ' /proc/meminfo 2>/dev/null || true | |
| 38 | + | ) | |
| 39 | + | fi | |
| 40 | + | ||
| 41 | + | line=$now | |
| 42 | + | if [ -n "$cpu_pct" ]; then | |
| 43 | + | if [ -n "$line" ]; then | |
| 44 | + | line="$line cpu $cpu_pct%" | |
| 45 | + | else | |
| 46 | + | line="cpu $cpu_pct%" | |
| 47 | + | fi | |
| 48 | + | fi | |
| 49 | + | if [ -n "$mem_pct" ]; then | |
| 50 | + | if [ -n "$line" ]; then | |
| 51 | + | line="$line mem $mem_pct%" | |
| 52 | + | else | |
| 53 | + | line="mem $mem_pct%" | |
| 54 | + | fi | |
| 55 | + | fi | |
| 56 | + | ||
| 57 | + | printf '%s\n' "$line" | |
| @@ -0,0 +1,57 @@ | |||||
| 1 | + | #!/bin/sh | |||
| 2 | + | ||||
| 3 | + | # Print a compact, single-line system summary for vev's top-right bar anchor. | |||
| 4 | + | # Segments that cannot be read are omitted without producing stderr. | |||
| 5 | + | ||||
| 6 | + | now=$(date +%H:%M 2>/dev/null || true) | |||
| 7 | + | ||||
| 8 | + | cpu_pct= | |||
| 9 | + | if [ -r /proc/stat ]; then | |||
| 10 | + | first=$(awk '$1 == "cpu" { idle = $5 + $6; total = 0; for (i = 2; i <= NF; i++) total += $i; print idle, total; exit }' /proc/stat 2>/dev/null || true) | |||
| 11 | + | sleep 0.1 2>/dev/null || true | |||
| 12 | + | second=$(awk '$1 == "cpu" { idle = $5 + $6; total = 0; for (i = 2; i <= NF; i++) total += $i; print idle, total; exit }' /proc/stat 2>/dev/null || true) | |||
| 13 | + | if [ -n "$first" ] && [ -n "$second" ]; then | |||
| 14 | + | cpu_pct=$( | |||
| 15 | + | awk -v first="$first" -v second="$second" ' | |||
| 16 | + | BEGIN { | |||
| 17 | + | split(first, a, " ") | |||
| 18 | + | split(second, b, " ") | |||
| 19 | + | idle = b[1] - a[1] | |||
| 20 | + | total = b[2] - a[2] | |||
| 21 | + | if (total > 0) printf "%d", ((total - idle) * 100) / total | |||
| 22 | + | } | |||
| 23 | + | ' 2>/dev/null || true | |||
| 24 | + | ) | |||
| 25 | + | fi | |||
| 26 | + | fi | |||
| 27 | + | ||||
| 28 | + | mem_pct= | |||
| 29 | + | if [ -r /proc/meminfo ]; then | |||
| 30 | + | mem_pct=$( | |||
| 31 | + | awk ' | |||
| 32 | + | $1 == "MemTotal:" { total = $2 } | |||
| 33 | + | $1 == "MemAvailable:" { available = $2 } | |||
| 34 | + | END { | |||
| 35 | + | if (total > 0 && available >= 0) printf "%d", ((total - available) * 100) / total | |||
| 36 | + | } | |||
| 37 | + | ' /proc/meminfo 2>/dev/null || true | |||
| 38 | + | ) | |||
| 39 | + | fi | |||
| 40 | + | ||||
| 41 | + | line=$now | |||
| 42 | + | if [ -n "$cpu_pct" ]; then | |||
| 43 | + | if [ -n "$line" ]; then | |||
| 44 | + | line="$line cpu $cpu_pct%" | |||
| 45 | + | else | |||
| 46 | + | line="cpu $cpu_pct%" | |||
| 47 | + | fi | |||
| 48 | + | fi | |||
| 49 | + | if [ -n "$mem_pct" ]; then | |||
| 50 | + | if [ -n "$line" ]; then | |||
| 51 | + | line="$line mem $mem_pct%" | |||
| 52 | + | else | |||
| 53 | + | line="mem $mem_pct%" | |||
| 54 | + | fi | |||
| 55 | + | fi | |||
| 56 | + | ||||
| 57 | + | printf '%s\n' "$line" | |||