feat(daemon): add scriptable bar right anchors
Signed-off-by: brice <b@bnema.dev>
- commit
- 5e8267317d9a58c41b796a3b560322e05de653e9
- author
- brice · · 2026-07-06 14:29 UTC
- parent
- 14f582a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
19 files · +968 -8
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. | |
| 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. | |||
| 115 | + | ||||
| 101 | ## Remote attach | 116 | ## Remote attach | ||
| 102 | 117 | ||||
| 103 | ```sh | 118 | ```sh | ||
M internal/adapters/config/config.go +19 -1
| @@ -15,7 +15,10 @@ import ( | |||
| 15 | 15 | "github.com/bnema/vev/internal/ports" | |
| 16 | 16 | ) | |
| 17 | 17 | ||
| 18 | - | const pollInterval = 2 * time.Second | |
| 18 | + | const ( | |
| 19 | + | pollInterval = 2 * time.Second | |
| 20 | + | minimumBarInterval = time.Second | |
| 21 | + | ) | |
| 19 | 22 | ||
| 20 | 23 | var processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`) | |
| 21 | 24 | ||
| @@ -60,6 +63,21 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 60 | 63 | continue | |
| 61 | 64 | } | |
| 62 | 65 | cfg.Theme = mode | |
| 66 | + | case key == "bar.top-right": | |
| 67 | + | cfg.Bar.TopRight = value | |
| 68 | + | case key == "bar.bottom-right": | |
| 69 | + | cfg.Bar.BottomRight = value | |
| 70 | + | case key == "bar.interval": | |
| 71 | + | interval, err := time.ParseDuration(value) | |
| 72 | + | if err != nil { | |
| 73 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.interval %q", value)}) | |
| 74 | + | continue | |
| 75 | + | } | |
| 76 | + | if interval < minimumBarInterval { | |
| 77 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "bar.interval below minimum \"1s\""}) | |
| 78 | + | interval = minimumBarInterval | |
| 79 | + | } | |
| 80 | + | cfg.Bar.Interval = interval | |
| 63 | 81 | case strings.HasPrefix(key, "code."): | |
| 64 | 82 | codeKey := strings.TrimPrefix(key, "code.") | |
| 65 | 83 | if codeKey == "" { | |
| @@ -15,7 +15,10 @@ import ( | |||||
| 15 | "github.com/bnema/vev/internal/ports" | 15 | "github.com/bnema/vev/internal/ports" | ||
| 16 | ) | 16 | ) | ||
| 17 | 17 | ||||
| 18 | - | const pollInterval = 2 * time.Second | 18 | + | const ( |
| 19 | + | pollInterval = 2 * time.Second | |||
| 20 | + | minimumBarInterval = time.Second | |||
| 21 | + | ) | |||
| 19 | 22 | ||||
| 20 | var processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`) | 23 | var processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`) | ||
| 21 | 24 | ||||
| @@ -60,6 +63,21 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||||
| 60 | continue | 63 | continue | ||
| 61 | } | 64 | } | ||
| 62 | cfg.Theme = mode | 65 | cfg.Theme = mode | ||
| 66 | + | case key == "bar.top-right": | |||
| 67 | + | cfg.Bar.TopRight = value | |||
| 68 | + | case key == "bar.bottom-right": | |||
| 69 | + | cfg.Bar.BottomRight = value | |||
| 70 | + | case key == "bar.interval": | |||
| 71 | + | interval, err := time.ParseDuration(value) | |||
| 72 | + | if err != nil { | |||
| 73 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid bar.interval %q", value)}) | |||
| 74 | + | continue | |||
| 75 | + | } | |||
| 76 | + | if interval < minimumBarInterval { | |||
| 77 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: "bar.interval below minimum \"1s\""}) | |||
| 78 | + | interval = minimumBarInterval | |||
| 79 | + | } | |||
| 80 | + | cfg.Bar.Interval = interval | |||
| 63 | case strings.HasPrefix(key, "code."): | 81 | case strings.HasPrefix(key, "code."): | ||
| 64 | codeKey := strings.TrimPrefix(key, "code.") | 82 | codeKey := strings.TrimPrefix(key, "code.") | ||
| 65 | if codeKey == "" { | 83 | if codeKey == "" { | ||
M internal/adapters/config/config_test.go +61 -0
| @@ -91,6 +91,64 @@ 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 empty commands disable anchors", | |
| 110 | + | input: "bar.top-right =\nbar.bottom-right =\nbar.interval = 5s\n", | |
| 111 | + | want: domain.Config{ | |
| 112 | + | Theme: domain.ThemeAuto, | |
| 113 | + | Bar: domain.BarConfig{ | |
| 114 | + | TopRight: "", | |
| 115 | + | BottomRight: "", | |
| 116 | + | Interval: 5 * time.Second, | |
| 117 | + | }, | |
| 118 | + | BindingEntries: []domain.ConfigEntry{}, | |
| 119 | + | Codes: map[string]string{}, | |
| 120 | + | }, | |
| 121 | + | }, | |
| 122 | + | { | |
| 123 | + | name: "bar interval invalid warns and keeps default", | |
| 124 | + | input: "bar.interval = nope\n", | |
| 125 | + | want: domain.Config{ | |
| 126 | + | Theme: domain.ThemeAuto, | |
| 127 | + | Bar: domain.BarConfig{ | |
| 128 | + | TopRight: "vev-bar-top-right", | |
| 129 | + | BottomRight: "vev-bar-bottom-right", | |
| 130 | + | Interval: 5 * time.Second, | |
| 131 | + | }, | |
| 132 | + | BindingEntries: []domain.ConfigEntry{}, | |
| 133 | + | Codes: map[string]string{}, | |
| 134 | + | }, | |
| 135 | + | wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid bar.interval \"nope\""}}, | |
| 136 | + | }, | |
| 137 | + | { | |
| 138 | + | name: "bar interval below minimum clamps and warns", | |
| 139 | + | input: "bar.interval = 100ms\n", | |
| 140 | + | want: domain.Config{ | |
| 141 | + | Theme: domain.ThemeAuto, | |
| 142 | + | Bar: domain.BarConfig{ | |
| 143 | + | TopRight: "vev-bar-top-right", | |
| 144 | + | BottomRight: "vev-bar-bottom-right", | |
| 145 | + | Interval: time.Second, | |
| 146 | + | }, | |
| 147 | + | BindingEntries: []domain.ConfigEntry{}, | |
| 148 | + | Codes: map[string]string{}, | |
| 149 | + | }, | |
| 150 | + | wantWarnings: []domain.Warning{{Line: 1, Msg: "bar.interval below minimum \"1s\""}}, | |
| 151 | + | }, | |
| 94 | 152 | { | |
| 95 | 153 | name: "snapshot restore processes", | |
| 96 | 154 | input: "snapshot.restore_processes = claude, codex, pi, opencode, btop\n", | |
| @@ -140,6 +198,9 @@ func TestParse(t *testing.T) { | |||
| 140 | 198 | t.Run(tt.name, func(t *testing.T) { | |
| 141 | 199 | t.Parallel() | |
| 142 | 200 | ||
| 201 | + | if tt.want.Bar.Interval == 0 { | |
| 202 | + | tt.want.Bar = domain.Defaults().Bar | |
| 203 | + | } | |
| 143 | 204 | if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet { | |
| 144 | 205 | tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...) | |
| 145 | 206 | } | |
| @@ -91,6 +91,64 @@ 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 empty commands disable anchors", | |||
| 110 | + | input: "bar.top-right =\nbar.bottom-right =\nbar.interval = 5s\n", | |||
| 111 | + | want: domain.Config{ | |||
| 112 | + | Theme: domain.ThemeAuto, | |||
| 113 | + | Bar: domain.BarConfig{ | |||
| 114 | + | TopRight: "", | |||
| 115 | + | BottomRight: "", | |||
| 116 | + | Interval: 5 * time.Second, | |||
| 117 | + | }, | |||
| 118 | + | BindingEntries: []domain.ConfigEntry{}, | |||
| 119 | + | Codes: map[string]string{}, | |||
| 120 | + | }, | |||
| 121 | + | }, | |||
| 122 | + | { | |||
| 123 | + | name: "bar interval invalid warns and keeps default", | |||
| 124 | + | input: "bar.interval = nope\n", | |||
| 125 | + | want: domain.Config{ | |||
| 126 | + | Theme: domain.ThemeAuto, | |||
| 127 | + | Bar: domain.BarConfig{ | |||
| 128 | + | TopRight: "vev-bar-top-right", | |||
| 129 | + | BottomRight: "vev-bar-bottom-right", | |||
| 130 | + | Interval: 5 * time.Second, | |||
| 131 | + | }, | |||
| 132 | + | BindingEntries: []domain.ConfigEntry{}, | |||
| 133 | + | Codes: map[string]string{}, | |||
| 134 | + | }, | |||
| 135 | + | wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid bar.interval \"nope\""}}, | |||
| 136 | + | }, | |||
| 137 | + | { | |||
| 138 | + | name: "bar interval below minimum clamps and warns", | |||
| 139 | + | input: "bar.interval = 100ms\n", | |||
| 140 | + | want: domain.Config{ | |||
| 141 | + | Theme: domain.ThemeAuto, | |||
| 142 | + | Bar: domain.BarConfig{ | |||
| 143 | + | TopRight: "vev-bar-top-right", | |||
| 144 | + | BottomRight: "vev-bar-bottom-right", | |||
| 145 | + | Interval: time.Second, | |||
| 146 | + | }, | |||
| 147 | + | BindingEntries: []domain.ConfigEntry{}, | |||
| 148 | + | Codes: map[string]string{}, | |||
| 149 | + | }, | |||
| 150 | + | wantWarnings: []domain.Warning{{Line: 1, Msg: "bar.interval below minimum \"1s\""}}, | |||
| 151 | + | }, | |||
| 94 | { | 152 | { | ||
| 95 | name: "snapshot restore processes", | 153 | name: "snapshot restore processes", | ||
| 96 | input: "snapshot.restore_processes = claude, codex, pi, opencode, btop\n", | 154 | input: "snapshot.restore_processes = claude, codex, pi, opencode, btop\n", | ||
| @@ -140,6 +198,9 @@ func TestParse(t *testing.T) { | |||||
| 140 | t.Run(tt.name, func(t *testing.T) { | 198 | t.Run(tt.name, func(t *testing.T) { | ||
| 141 | t.Parallel() | 199 | t.Parallel() | ||
| 142 | 200 | ||||
| 201 | + | if tt.want.Bar.Interval == 0 { | |||
| 202 | + | tt.want.Bar = domain.Defaults().Bar | |||
| 203 | + | } | |||
| 143 | if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet { | 204 | if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet { | ||
| 144 | tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...) | 205 | tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...) | ||
| 145 | } | 206 | } | ||
M internal/domain/config.go +16 -1
| @@ -1,5 +1,7 @@ | |||
| 1 | 1 | package domain | |
| 2 | 2 | ||
| 3 | + | import "time" | |
| 4 | + | ||
| 3 | 5 | // ThemeMode selects how vev derives its color scheme. | |
| 4 | 6 | type ThemeMode int | |
| 5 | 7 | ||
| @@ -35,11 +37,19 @@ func DefaultSnapshotRestoreProcesses() []string { | |||
| 35 | 37 | return append([]string(nil), defaultSnapshotRestoreProcesses...) | |
| 36 | 38 | } | |
| 37 | 39 | ||
| 40 | + | // BarConfig contains user-configurable bar right-anchor settings. | |
| 41 | + | type BarConfig struct { | |
| 42 | + | TopRight string | |
| 43 | + | BottomRight string | |
| 44 | + | Interval time.Duration | |
| 45 | + | } | |
| 46 | + | ||
| 38 | 47 | // Config is the user-editable vev configuration after parsing. Unknown binding | |
| 39 | 48 | // keys are preserved here (in BindingEntries, in file order) so the usecase | |
| 40 | 49 | // layer can decide which actions it understands. | |
| 41 | 50 | type Config struct { | |
| 42 | 51 | Theme ThemeMode | |
| 52 | + | Bar BarConfig | |
| 43 | 53 | BindingEntries []ConfigEntry | |
| 44 | 54 | Codes map[string]string | |
| 45 | 55 | Snapshot SnapshotConfig | |
| @@ -55,7 +65,12 @@ type Warning struct { | |||
| 55 | 65 | // Defaults returns vev's default configuration. | |
| 56 | 66 | func Defaults() Config { | |
| 57 | 67 | return Config{ | |
| 58 | - | Theme: ThemeAuto, | |
| 68 | + | Theme: ThemeAuto, | |
| 69 | + | Bar: BarConfig{ | |
| 70 | + | TopRight: "vev-bar-top-right", | |
| 71 | + | BottomRight: "vev-bar-bottom-right", | |
| 72 | + | Interval: 5 * time.Second, | |
| 73 | + | }, | |
| 59 | 74 | BindingEntries: []ConfigEntry{}, | |
| 60 | 75 | Codes: map[string]string{}, | |
| 61 | 76 | Snapshot: SnapshotConfig{ | |
| @@ -1,5 +1,7 @@ | |||||
| 1 | package domain | 1 | package domain | ||
| 2 | 2 | ||||
| 3 | + | import "time" | |||
| 4 | + | ||||
| 3 | // ThemeMode selects how vev derives its color scheme. | 5 | // ThemeMode selects how vev derives its color scheme. | ||
| 4 | type ThemeMode int | 6 | type ThemeMode int | ||
| 5 | 7 | ||||
| @@ -35,11 +37,19 @@ func DefaultSnapshotRestoreProcesses() []string { | |||||
| 35 | return append([]string(nil), defaultSnapshotRestoreProcesses...) | 37 | return append([]string(nil), defaultSnapshotRestoreProcesses...) | ||
| 36 | } | 38 | } | ||
| 37 | 39 | ||||
| 40 | + | // BarConfig contains user-configurable bar right-anchor settings. | |||
| 41 | + | type BarConfig struct { | |||
| 42 | + | TopRight string | |||
| 43 | + | BottomRight string | |||
| 44 | + | Interval time.Duration | |||
| 45 | + | } | |||
| 46 | + | ||||
| 38 | // Config is the user-editable vev configuration after parsing. Unknown binding | 47 | // Config is the user-editable vev configuration after parsing. Unknown binding | ||
| 39 | // keys are preserved here (in BindingEntries, in file order) so the usecase | 48 | // keys are preserved here (in BindingEntries, in file order) so the usecase | ||
| 40 | // layer can decide which actions it understands. | 49 | // layer can decide which actions it understands. | ||
| 41 | type Config struct { | 50 | type Config struct { | ||
| 42 | Theme ThemeMode | 51 | Theme ThemeMode | ||
| 52 | + | Bar BarConfig | |||
| 43 | BindingEntries []ConfigEntry | 53 | BindingEntries []ConfigEntry | ||
| 44 | Codes map[string]string | 54 | Codes map[string]string | ||
| 45 | Snapshot SnapshotConfig | 55 | Snapshot SnapshotConfig | ||
| @@ -55,7 +65,12 @@ type Warning struct { | |||||
| 55 | // Defaults returns vev's default configuration. | 65 | // Defaults returns vev's default configuration. | ||
| 56 | func Defaults() Config { | 66 | func Defaults() Config { | ||
| 57 | return Config{ | 67 | return Config{ | ||
| 58 | - | Theme: ThemeAuto, | 68 | + | Theme: ThemeAuto, |
| 69 | + | Bar: BarConfig{ | |||
| 70 | + | TopRight: "vev-bar-top-right", | |||
| 71 | + | BottomRight: "vev-bar-bottom-right", | |||
| 72 | + | Interval: 5 * time.Second, | |||
| 73 | + | }, | |||
| 59 | BindingEntries: []ConfigEntry{}, | 74 | BindingEntries: []ConfigEntry{}, | ||
| 60 | Codes: map[string]string{}, | 75 | Codes: map[string]string{}, | ||
| 61 | Snapshot: SnapshotConfig{ | 76 | Snapshot: SnapshotConfig{ | ||
A internal/usecase/daemon/bar_refresh.go +221 -0
| @@ -0,0 +1,221 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "sync" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | const minBarScriptInterval = time.Second | |
| 12 | + | ||
| 13 | + | type barScriptExecutor interface { | |
| 14 | + | run(context.Context, string, barScriptContext) (string, error) | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | type barScriptConfig struct { | |
| 18 | + | topRight string | |
| 19 | + | bottomRight string | |
| 20 | + | interval time.Duration | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | type barScriptOutputs struct { | |
| 24 | + | topRight string | |
| 25 | + | bottomRight string | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | type barScriptState struct { | |
| 29 | + | mu sync.Mutex | |
| 30 | + | cfg barScriptConfig | |
| 31 | + | runner barScriptExecutor | |
| 32 | + | outputs map[domain.SessionID]barScriptOutputs | |
| 33 | + | lastRefresh map[domain.SessionID]time.Time | |
| 34 | + | running map[domain.SessionID]bool | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func effectiveBarInterval(d time.Duration) time.Duration { | |
| 38 | + | if d <= 0 { | |
| 39 | + | return 5 * time.Second | |
| 40 | + | } | |
| 41 | + | if d < minBarScriptInterval { | |
| 42 | + | return minBarScriptInterval | |
| 43 | + | } | |
| 44 | + | return d | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func barConfigFromDomain(cfg domain.BarConfig) barScriptConfig { | |
| 48 | + | return barScriptConfig{topRight: cfg.TopRight, bottomRight: cfg.BottomRight, interval: effectiveBarInterval(cfg.Interval)} | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func (s *barScriptState) initLocked() { | |
| 52 | + | if s.outputs == nil { | |
| 53 | + | s.outputs = make(map[domain.SessionID]barScriptOutputs) | |
| 54 | + | } | |
| 55 | + | if s.lastRefresh == nil { | |
| 56 | + | s.lastRefresh = make(map[domain.SessionID]time.Time) | |
| 57 | + | } | |
| 58 | + | if s.running == nil { | |
| 59 | + | s.running = make(map[domain.SessionID]bool) | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func (d *Daemon) barScriptSnapshot(sess *session) (string, string) { | |
| 64 | + | if d == nil || d.barScripts == nil || sess == nil { | |
| 65 | + | return "", "" | |
| 66 | + | } | |
| 67 | + | d.barScripts.mu.Lock() | |
| 68 | + | defer d.barScripts.mu.Unlock() | |
| 69 | + | out := d.barScripts.outputs[sess.id] | |
| 70 | + | return out.topRight, out.bottomRight | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func (d *Daemon) barScriptInterval() time.Duration { | |
| 74 | + | if d == nil || d.barScripts == nil { | |
| 75 | + | return 5 * time.Second | |
| 76 | + | } | |
| 77 | + | d.barScripts.mu.Lock() | |
| 78 | + | defer d.barScripts.mu.Unlock() | |
| 79 | + | return effectiveBarInterval(d.barScripts.cfg.interval) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScriptContext, bool) { | |
| 83 | + | ctx := barScriptContext{Anchor: anchor} | |
| 84 | + | if sess == nil { | |
| 85 | + | return ctx, false | |
| 86 | + | } | |
| 87 | + | sess.mu.Lock() | |
| 88 | + | ctx.Session = sess.name | |
| 89 | + | active := sess.active | |
| 90 | + | var tb *tab | |
| 91 | + | if active >= 0 && active < len(sess.tabs) { | |
| 92 | + | tb = sess.tabs[active] | |
| 93 | + | } | |
| 94 | + | ac := sess.client | |
| 95 | + | ctx.PaneCWD = sess.cwd | |
| 96 | + | sess.mu.Unlock() | |
| 97 | + | if ac == nil || tb == nil { | |
| 98 | + | return ctx, false | |
| 99 | + | } | |
| 100 | + | tb.mu.Lock() | |
| 101 | + | ctx.Tab = tb.stableID | |
| 102 | + | ctx.Cols = tb.size.Cols | |
| 103 | + | if p := tb.focusedPane(); p != nil { | |
| 104 | + | ctx.Pane = p.stableID | |
| 105 | + | } | |
| 106 | + | tb.mu.Unlock() | |
| 107 | + | return ctx, true | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func (d *Daemon) barScriptPoller(ctx context.Context) { | |
| 111 | + | timer := d.clock.NewTimer(d.barScriptInterval()) | |
| 112 | + | defer timer.Stop() | |
| 113 | + | for { | |
| 114 | + | select { | |
| 115 | + | case <-ctx.Done(): | |
| 116 | + | return | |
| 117 | + | case now := <-timer.C(): | |
| 118 | + | for _, sess := range d.sessionsSnapshot() { | |
| 119 | + | d.refreshBarScriptsIfDue(sess, now, false) | |
| 120 | + | } | |
| 121 | + | timer.Reset(d.barScriptInterval()) | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func (d *Daemon) sessionsSnapshot() []*session { | |
| 127 | + | d.mu.Lock() | |
| 128 | + | defer d.mu.Unlock() | |
| 129 | + | sessions := make([]*session, 0, len(d.sessions)) | |
| 130 | + | for _, sess := range d.sessions { | |
| 131 | + | sessions = append(sessions, sess) | |
| 132 | + | } | |
| 133 | + | return sessions | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) { | |
| 137 | + | if d == nil || d.barScripts == nil { | |
| 138 | + | return | |
| 139 | + | } | |
| 140 | + | d.barScripts.mu.Lock() | |
| 141 | + | defer d.barScripts.mu.Unlock() | |
| 142 | + | delete(d.barScripts.outputs, id) | |
| 143 | + | delete(d.barScripts.lastRefresh, id) | |
| 144 | + | delete(d.barScripts.running, id) | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool) bool { | |
| 148 | + | if d == nil || d.barScripts == nil || sess == nil { | |
| 149 | + | return false | |
| 150 | + | } | |
| 151 | + | baseCtx, ok := d.collectBarScriptContext(sess, "") | |
| 152 | + | if !ok { | |
| 153 | + | return false | |
| 154 | + | } | |
| 155 | + | d.barScripts.mu.Lock() | |
| 156 | + | d.barScripts.initLocked() | |
| 157 | + | if d.barScripts.running[sess.id] || (!force && !d.barScripts.lastRefresh[sess.id].IsZero() && now.Sub(d.barScripts.lastRefresh[sess.id]) < d.barScripts.cfg.interval) { | |
| 158 | + | d.barScripts.mu.Unlock() | |
| 159 | + | return false | |
| 160 | + | } | |
| 161 | + | d.barScripts.running[sess.id] = true | |
| 162 | + | d.barScripts.lastRefresh[sess.id] = now | |
| 163 | + | cfg := d.barScripts.cfg | |
| 164 | + | runner := d.barScripts.runner | |
| 165 | + | d.barScripts.mu.Unlock() | |
| 166 | + | go d.runBarScripts(sess, runner, cfg, baseCtx) | |
| 167 | + | return true | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barScriptConfig, base barScriptContext) { | |
| 171 | + | if runner == nil { | |
| 172 | + | runner = barScriptRunner{baseEnv: d.baseEnv} | |
| 173 | + | } | |
| 174 | + | ctx := sess.ctx | |
| 175 | + | if ctx == nil { | |
| 176 | + | ctx = context.Background() | |
| 177 | + | } | |
| 178 | + | top, topOK := runOneBarScript(ctx, runner, cfg.topRight, base, "top-right") | |
| 179 | + | bottom, bottomOK := runOneBarScript(ctx, runner, cfg.bottomRight, base, "bottom-right") | |
| 180 | + | d.barScripts.mu.Lock() | |
| 181 | + | d.barScripts.initLocked() | |
| 182 | + | current := d.barScripts.outputs[sess.id] | |
| 183 | + | changed := false | |
| 184 | + | if topOK && current.topRight != top { | |
| 185 | + | current.topRight = top | |
| 186 | + | changed = true | |
| 187 | + | } | |
| 188 | + | if bottomOK && current.bottomRight != bottom { | |
| 189 | + | current.bottomRight = bottom | |
| 190 | + | changed = true | |
| 191 | + | } | |
| 192 | + | d.barScripts.outputs[sess.id] = current | |
| 193 | + | delete(d.barScripts.running, sess.id) | |
| 194 | + | d.barScripts.mu.Unlock() | |
| 195 | + | if changed { | |
| 196 | + | d.pokeSessionRender(sess) | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, base barScriptContext, anchor string) (string, bool) { | |
| 201 | + | if command == "" { | |
| 202 | + | return "", true | |
| 203 | + | } | |
| 204 | + | base.Anchor = anchor | |
| 205 | + | out, err := runner.run(ctx, command, base) | |
| 206 | + | return out, err == nil | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func (d *Daemon) pokeSessionRender(sess *session) { | |
| 210 | + | if sess == nil { | |
| 211 | + | return | |
| 212 | + | } | |
| 213 | + | if tb := sess.activeTab(); tb != nil { | |
| 214 | + | tb.mu.Lock() | |
| 215 | + | p := tb.focusedPane() | |
| 216 | + | tb.mu.Unlock() | |
| 217 | + | if p != nil { | |
| 218 | + | signal(p.dirty) | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | } | |
| @@ -0,0 +1,221 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "sync" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | const minBarScriptInterval = time.Second | |||
| 12 | + | ||||
| 13 | + | type barScriptExecutor interface { | |||
| 14 | + | run(context.Context, string, barScriptContext) (string, error) | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | type barScriptConfig struct { | |||
| 18 | + | topRight string | |||
| 19 | + | bottomRight string | |||
| 20 | + | interval time.Duration | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | type barScriptOutputs struct { | |||
| 24 | + | topRight string | |||
| 25 | + | bottomRight string | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | type barScriptState struct { | |||
| 29 | + | mu sync.Mutex | |||
| 30 | + | cfg barScriptConfig | |||
| 31 | + | runner barScriptExecutor | |||
| 32 | + | outputs map[domain.SessionID]barScriptOutputs | |||
| 33 | + | lastRefresh map[domain.SessionID]time.Time | |||
| 34 | + | running map[domain.SessionID]bool | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func effectiveBarInterval(d time.Duration) time.Duration { | |||
| 38 | + | if d <= 0 { | |||
| 39 | + | return 5 * time.Second | |||
| 40 | + | } | |||
| 41 | + | if d < minBarScriptInterval { | |||
| 42 | + | return minBarScriptInterval | |||
| 43 | + | } | |||
| 44 | + | return d | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func barConfigFromDomain(cfg domain.BarConfig) barScriptConfig { | |||
| 48 | + | return barScriptConfig{topRight: cfg.TopRight, bottomRight: cfg.BottomRight, interval: effectiveBarInterval(cfg.Interval)} | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func (s *barScriptState) initLocked() { | |||
| 52 | + | if s.outputs == nil { | |||
| 53 | + | s.outputs = make(map[domain.SessionID]barScriptOutputs) | |||
| 54 | + | } | |||
| 55 | + | if s.lastRefresh == nil { | |||
| 56 | + | s.lastRefresh = make(map[domain.SessionID]time.Time) | |||
| 57 | + | } | |||
| 58 | + | if s.running == nil { | |||
| 59 | + | s.running = make(map[domain.SessionID]bool) | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func (d *Daemon) barScriptSnapshot(sess *session) (string, string) { | |||
| 64 | + | if d == nil || d.barScripts == nil || sess == nil { | |||
| 65 | + | return "", "" | |||
| 66 | + | } | |||
| 67 | + | d.barScripts.mu.Lock() | |||
| 68 | + | defer d.barScripts.mu.Unlock() | |||
| 69 | + | out := d.barScripts.outputs[sess.id] | |||
| 70 | + | return out.topRight, out.bottomRight | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func (d *Daemon) barScriptInterval() time.Duration { | |||
| 74 | + | if d == nil || d.barScripts == nil { | |||
| 75 | + | return 5 * time.Second | |||
| 76 | + | } | |||
| 77 | + | d.barScripts.mu.Lock() | |||
| 78 | + | defer d.barScripts.mu.Unlock() | |||
| 79 | + | return effectiveBarInterval(d.barScripts.cfg.interval) | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScriptContext, bool) { | |||
| 83 | + | ctx := barScriptContext{Anchor: anchor} | |||
| 84 | + | if sess == nil { | |||
| 85 | + | return ctx, false | |||
| 86 | + | } | |||
| 87 | + | sess.mu.Lock() | |||
| 88 | + | ctx.Session = sess.name | |||
| 89 | + | active := sess.active | |||
| 90 | + | var tb *tab | |||
| 91 | + | if active >= 0 && active < len(sess.tabs) { | |||
| 92 | + | tb = sess.tabs[active] | |||
| 93 | + | } | |||
| 94 | + | ac := sess.client | |||
| 95 | + | ctx.PaneCWD = sess.cwd | |||
| 96 | + | sess.mu.Unlock() | |||
| 97 | + | if ac == nil || tb == nil { | |||
| 98 | + | return ctx, false | |||
| 99 | + | } | |||
| 100 | + | tb.mu.Lock() | |||
| 101 | + | ctx.Tab = tb.stableID | |||
| 102 | + | ctx.Cols = tb.size.Cols | |||
| 103 | + | if p := tb.focusedPane(); p != nil { | |||
| 104 | + | ctx.Pane = p.stableID | |||
| 105 | + | } | |||
| 106 | + | tb.mu.Unlock() | |||
| 107 | + | return ctx, true | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func (d *Daemon) barScriptPoller(ctx context.Context) { | |||
| 111 | + | timer := d.clock.NewTimer(d.barScriptInterval()) | |||
| 112 | + | defer timer.Stop() | |||
| 113 | + | for { | |||
| 114 | + | select { | |||
| 115 | + | case <-ctx.Done(): | |||
| 116 | + | return | |||
| 117 | + | case now := <-timer.C(): | |||
| 118 | + | for _, sess := range d.sessionsSnapshot() { | |||
| 119 | + | d.refreshBarScriptsIfDue(sess, now, false) | |||
| 120 | + | } | |||
| 121 | + | timer.Reset(d.barScriptInterval()) | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func (d *Daemon) sessionsSnapshot() []*session { | |||
| 127 | + | d.mu.Lock() | |||
| 128 | + | defer d.mu.Unlock() | |||
| 129 | + | sessions := make([]*session, 0, len(d.sessions)) | |||
| 130 | + | for _, sess := range d.sessions { | |||
| 131 | + | sessions = append(sessions, sess) | |||
| 132 | + | } | |||
| 133 | + | return sessions | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) { | |||
| 137 | + | if d == nil || d.barScripts == nil { | |||
| 138 | + | return | |||
| 139 | + | } | |||
| 140 | + | d.barScripts.mu.Lock() | |||
| 141 | + | defer d.barScripts.mu.Unlock() | |||
| 142 | + | delete(d.barScripts.outputs, id) | |||
| 143 | + | delete(d.barScripts.lastRefresh, id) | |||
| 144 | + | delete(d.barScripts.running, id) | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func (d *Daemon) refreshBarScriptsIfDue(sess *session, now time.Time, force bool) bool { | |||
| 148 | + | if d == nil || d.barScripts == nil || sess == nil { | |||
| 149 | + | return false | |||
| 150 | + | } | |||
| 151 | + | baseCtx, ok := d.collectBarScriptContext(sess, "") | |||
| 152 | + | if !ok { | |||
| 153 | + | return false | |||
| 154 | + | } | |||
| 155 | + | d.barScripts.mu.Lock() | |||
| 156 | + | d.barScripts.initLocked() | |||
| 157 | + | if d.barScripts.running[sess.id] || (!force && !d.barScripts.lastRefresh[sess.id].IsZero() && now.Sub(d.barScripts.lastRefresh[sess.id]) < d.barScripts.cfg.interval) { | |||
| 158 | + | d.barScripts.mu.Unlock() | |||
| 159 | + | return false | |||
| 160 | + | } | |||
| 161 | + | d.barScripts.running[sess.id] = true | |||
| 162 | + | d.barScripts.lastRefresh[sess.id] = now | |||
| 163 | + | cfg := d.barScripts.cfg | |||
| 164 | + | runner := d.barScripts.runner | |||
| 165 | + | d.barScripts.mu.Unlock() | |||
| 166 | + | go d.runBarScripts(sess, runner, cfg, baseCtx) | |||
| 167 | + | return true | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | func (d *Daemon) runBarScripts(sess *session, runner barScriptExecutor, cfg barScriptConfig, base barScriptContext) { | |||
| 171 | + | if runner == nil { | |||
| 172 | + | runner = barScriptRunner{baseEnv: d.baseEnv} | |||
| 173 | + | } | |||
| 174 | + | ctx := sess.ctx | |||
| 175 | + | if ctx == nil { | |||
| 176 | + | ctx = context.Background() | |||
| 177 | + | } | |||
| 178 | + | top, topOK := runOneBarScript(ctx, runner, cfg.topRight, base, "top-right") | |||
| 179 | + | bottom, bottomOK := runOneBarScript(ctx, runner, cfg.bottomRight, base, "bottom-right") | |||
| 180 | + | d.barScripts.mu.Lock() | |||
| 181 | + | d.barScripts.initLocked() | |||
| 182 | + | current := d.barScripts.outputs[sess.id] | |||
| 183 | + | changed := false | |||
| 184 | + | if topOK && current.topRight != top { | |||
| 185 | + | current.topRight = top | |||
| 186 | + | changed = true | |||
| 187 | + | } | |||
| 188 | + | if bottomOK && current.bottomRight != bottom { | |||
| 189 | + | current.bottomRight = bottom | |||
| 190 | + | changed = true | |||
| 191 | + | } | |||
| 192 | + | d.barScripts.outputs[sess.id] = current | |||
| 193 | + | delete(d.barScripts.running, sess.id) | |||
| 194 | + | d.barScripts.mu.Unlock() | |||
| 195 | + | if changed { | |||
| 196 | + | d.pokeSessionRender(sess) | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func runOneBarScript(ctx context.Context, runner barScriptExecutor, command string, base barScriptContext, anchor string) (string, bool) { | |||
| 201 | + | if command == "" { | |||
| 202 | + | return "", true | |||
| 203 | + | } | |||
| 204 | + | base.Anchor = anchor | |||
| 205 | + | out, err := runner.run(ctx, command, base) | |||
| 206 | + | return out, err == nil | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | func (d *Daemon) pokeSessionRender(sess *session) { | |||
| 210 | + | if sess == nil { | |||
| 211 | + | return | |||
| 212 | + | } | |||
| 213 | + | if tb := sess.activeTab(); tb != nil { | |||
| 214 | + | tb.mu.Lock() | |||
| 215 | + | p := tb.focusedPane() | |||
| 216 | + | tb.mu.Unlock() | |||
| 217 | + | if p != nil { | |||
| 218 | + | signal(p.dirty) | |||
| 219 | + | } | |||
| 220 | + | } | |||
| 221 | + | } | |||
A internal/usecase/daemon/bar_refresh_test.go +146 -0
| @@ -0,0 +1,146 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "sync" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/domain" | |
| 11 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | type fakeBarRunner struct { | |
| 16 | + | mu sync.Mutex | |
| 17 | + | calls []barScriptContext | |
| 18 | + | outs []string | |
| 19 | + | errs []error | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func (r *fakeBarRunner) run(_ context.Context, _ string, ctx barScriptContext) (string, error) { | |
| 23 | + | r.mu.Lock() | |
| 24 | + | defer r.mu.Unlock() | |
| 25 | + | r.calls = append(r.calls, ctx) | |
| 26 | + | i := len(r.calls) - 1 | |
| 27 | + | if i < len(r.errs) && r.errs[i] != nil { | |
| 28 | + | return "", r.errs[i] | |
| 29 | + | } | |
| 30 | + | if i < len(r.outs) { | |
| 31 | + | return r.outs[i], nil | |
| 32 | + | } | |
| 33 | + | return "", nil | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func TestBarScriptRefreshIntervalClampingTickAndLastGoodOnFailure(t *testing.T) { | |
| 37 | + | r := &fakeBarRunner{outs: []string{"top1", "bot1", "top2", "bot2"}} | |
| 38 | + | d := newBarRefreshTestDaemon(r, 100*time.Millisecond) | |
| 39 | + | sess := newBarRefreshTestSession() | |
| 40 | + | sess.client = &attachedClient{size: domain.Size{Cols: 120, Rows: 24}} | |
| 41 | + | ||
| 42 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false)) | |
| 43 | + | waitBarRefreshIdle(t, d) | |
| 44 | + | require.Equal(t, 2, len(r.calls)) | |
| 45 | + | require.Equal(t, "top1", d.barStateFor(sess, "").topRight) | |
| 46 | + | require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, int64(500*time.Millisecond)), false), "clamped interval should skip sub-second tick") | |
| 47 | + | ||
| 48 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(1, 0), false)) | |
| 49 | + | waitBarRefreshIdle(t, d) | |
| 50 | + | require.Equal(t, 4, len(r.calls)) | |
| 51 | + | require.Equal(t, "top2", d.barStateFor(sess, "").topRight) | |
| 52 | + | ||
| 53 | + | r.errs = []error{nil, nil, nil, nil, errors.New("boom"), errors.New("boom")} | |
| 54 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(2, 0), false)) | |
| 55 | + | waitBarRefreshIdle(t, d) | |
| 56 | + | state := d.barStateFor(sess, "") | |
| 57 | + | require.Equal(t, "top2", state.topRight) | |
| 58 | + | require.Equal(t, "bot2", state.bottomRight) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestBarScriptContextUsesActivePaneSessionAndClientCols(t *testing.T) { | |
| 62 | + | r := &fakeBarRunner{outs: []string{"top", "bottom"}} | |
| 63 | + | d := newBarRefreshTestDaemon(r, time.Second) | |
| 64 | + | sess := newBarRefreshTestSession() | |
| 65 | + | sess.name = "work" | |
| 66 | + | sess.cwd = "/repo" | |
| 67 | + | sess.client = &attachedClient{size: domain.Size{Cols: 132, Rows: 40}} | |
| 68 | + | sess.active = 1 | |
| 69 | + | sess.tabs[1].size = domain.Size{Cols: 132, Rows: 38} | |
| 70 | + | active := sess.tabs[1].focusedPane() | |
| 71 | + | active.stableID = "pane-active" | |
| 72 | + | ||
| 73 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true)) | |
| 74 | + | waitBarRefreshIdle(t, d) | |
| 75 | + | require.Len(t, r.calls, 2) | |
| 76 | + | for _, call := range r.calls { | |
| 77 | + | require.Equal(t, "work", call.Session) | |
| 78 | + | require.Equal(t, "tab-active", call.Tab) | |
| 79 | + | require.Equal(t, "pane-active", call.Pane) | |
| 80 | + | require.Equal(t, "/repo", call.PaneCWD) | |
| 81 | + | require.Equal(t, 132, call.Cols) | |
| 82 | + | } | |
| 83 | + | require.Equal(t, "top-right", r.calls[0].Anchor) | |
| 84 | + | require.Equal(t, "bottom-right", r.calls[1].Anchor) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestBarScriptRefreshSkipsWithoutAttachedClient(t *testing.T) { | |
| 88 | + | r := &fakeBarRunner{} | |
| 89 | + | d := newBarRefreshTestDaemon(r, time.Second) | |
| 90 | + | sess := newBarRefreshTestSession() | |
| 91 | + | ||
| 92 | + | require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true)) | |
| 93 | + | require.Empty(t, r.calls) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func TestBarScriptRefreshIsPerSession(t *testing.T) { | |
| 97 | + | r := &fakeBarRunner{outs: []string{"top-a", "bottom-a", "top-b", "bottom-b"}} | |
| 98 | + | d := newBarRefreshTestDaemon(r, time.Second) | |
| 99 | + | a := newBarRefreshTestSession() | |
| 100 | + | a.id = "a" | |
| 101 | + | a.name = "a" | |
| 102 | + | a.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 103 | + | b := newBarRefreshTestSession() | |
| 104 | + | b.id = "b" | |
| 105 | + | b.name = "b" | |
| 106 | + | b.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 107 | + | ||
| 108 | + | require.True(t, d.refreshBarScriptsIfDue(a, time.Unix(0, 0), false)) | |
| 109 | + | waitBarRefreshIdle(t, d) | |
| 110 | + | require.True(t, d.refreshBarScriptsIfDue(b, time.Unix(0, 0), false)) | |
| 111 | + | waitBarRefreshIdle(t, d) | |
| 112 | + | ||
| 113 | + | stateA := d.barStateFor(a, "") | |
| 114 | + | stateB := d.barStateFor(b, "") | |
| 115 | + | require.Equal(t, "top-a", stateA.topRight) | |
| 116 | + | require.Equal(t, "bottom-a", stateA.bottomRight) | |
| 117 | + | require.Equal(t, "top-b", stateB.topRight) | |
| 118 | + | require.Equal(t, "bottom-b", stateB.bottomRight) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func newBarRefreshTestDaemon(r *fakeBarRunner, interval time.Duration) *Daemon { | |
| 122 | + | d := &Daemon{barScripts: &barScriptState{ | |
| 123 | + | cfg: barScriptConfig{topRight: "top", bottomRight: "bottom", interval: effectiveBarInterval(interval)}, | |
| 124 | + | runner: r, | |
| 125 | + | outputs: make(map[domain.SessionID]barScriptOutputs), | |
| 126 | + | lastRefresh: make(map[domain.SessionID]time.Time), | |
| 127 | + | running: make(map[domain.SessionID]bool), | |
| 128 | + | }} | |
| 129 | + | return d | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func newBarRefreshTestSession() *session { | |
| 133 | + | inactive := newTabWithStableID("tab-inactive", "pane-inactive", nil, domain.Size{Cols: 80, Rows: 22}) | |
| 134 | + | active := newTabWithStableID("tab-active", "pane-active", nil, domain.Size{Cols: 80, Rows: 22}) | |
| 135 | + | active.tree.Focus = layout.PaneID("pane-1") | |
| 136 | + | return &session{id: "s", name: "s", cwd: "/tmp", tabs: []*tab{inactive, active}, active: 0, ctx: context.Background()} | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func waitBarRefreshIdle(t *testing.T, d *Daemon) { | |
| 140 | + | t.Helper() | |
| 141 | + | require.Eventually(t, func() bool { | |
| 142 | + | d.barScripts.mu.Lock() | |
| 143 | + | defer d.barScripts.mu.Unlock() | |
| 144 | + | return len(d.barScripts.running) == 0 | |
| 145 | + | }, time.Second, time.Millisecond) | |
| 146 | + | } | |
| @@ -0,0 +1,146 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "sync" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/internal/domain" | |||
| 11 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | type fakeBarRunner struct { | |||
| 16 | + | mu sync.Mutex | |||
| 17 | + | calls []barScriptContext | |||
| 18 | + | outs []string | |||
| 19 | + | errs []error | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func (r *fakeBarRunner) run(_ context.Context, _ string, ctx barScriptContext) (string, error) { | |||
| 23 | + | r.mu.Lock() | |||
| 24 | + | defer r.mu.Unlock() | |||
| 25 | + | r.calls = append(r.calls, ctx) | |||
| 26 | + | i := len(r.calls) - 1 | |||
| 27 | + | if i < len(r.errs) && r.errs[i] != nil { | |||
| 28 | + | return "", r.errs[i] | |||
| 29 | + | } | |||
| 30 | + | if i < len(r.outs) { | |||
| 31 | + | return r.outs[i], nil | |||
| 32 | + | } | |||
| 33 | + | return "", nil | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func TestBarScriptRefreshIntervalClampingTickAndLastGoodOnFailure(t *testing.T) { | |||
| 37 | + | r := &fakeBarRunner{outs: []string{"top1", "bot1", "top2", "bot2"}} | |||
| 38 | + | d := newBarRefreshTestDaemon(r, 100*time.Millisecond) | |||
| 39 | + | sess := newBarRefreshTestSession() | |||
| 40 | + | sess.client = &attachedClient{size: domain.Size{Cols: 120, Rows: 24}} | |||
| 41 | + | ||||
| 42 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), false)) | |||
| 43 | + | waitBarRefreshIdle(t, d) | |||
| 44 | + | require.Equal(t, 2, len(r.calls)) | |||
| 45 | + | require.Equal(t, "top1", d.barStateFor(sess, "").topRight) | |||
| 46 | + | require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, int64(500*time.Millisecond)), false), "clamped interval should skip sub-second tick") | |||
| 47 | + | ||||
| 48 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(1, 0), false)) | |||
| 49 | + | waitBarRefreshIdle(t, d) | |||
| 50 | + | require.Equal(t, 4, len(r.calls)) | |||
| 51 | + | require.Equal(t, "top2", d.barStateFor(sess, "").topRight) | |||
| 52 | + | ||||
| 53 | + | r.errs = []error{nil, nil, nil, nil, errors.New("boom"), errors.New("boom")} | |||
| 54 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(2, 0), false)) | |||
| 55 | + | waitBarRefreshIdle(t, d) | |||
| 56 | + | state := d.barStateFor(sess, "") | |||
| 57 | + | require.Equal(t, "top2", state.topRight) | |||
| 58 | + | require.Equal(t, "bot2", state.bottomRight) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestBarScriptContextUsesActivePaneSessionAndClientCols(t *testing.T) { | |||
| 62 | + | r := &fakeBarRunner{outs: []string{"top", "bottom"}} | |||
| 63 | + | d := newBarRefreshTestDaemon(r, time.Second) | |||
| 64 | + | sess := newBarRefreshTestSession() | |||
| 65 | + | sess.name = "work" | |||
| 66 | + | sess.cwd = "/repo" | |||
| 67 | + | sess.client = &attachedClient{size: domain.Size{Cols: 132, Rows: 40}} | |||
| 68 | + | sess.active = 1 | |||
| 69 | + | sess.tabs[1].size = domain.Size{Cols: 132, Rows: 38} | |||
| 70 | + | active := sess.tabs[1].focusedPane() | |||
| 71 | + | active.stableID = "pane-active" | |||
| 72 | + | ||||
| 73 | + | require.True(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true)) | |||
| 74 | + | waitBarRefreshIdle(t, d) | |||
| 75 | + | require.Len(t, r.calls, 2) | |||
| 76 | + | for _, call := range r.calls { | |||
| 77 | + | require.Equal(t, "work", call.Session) | |||
| 78 | + | require.Equal(t, "tab-active", call.Tab) | |||
| 79 | + | require.Equal(t, "pane-active", call.Pane) | |||
| 80 | + | require.Equal(t, "/repo", call.PaneCWD) | |||
| 81 | + | require.Equal(t, 132, call.Cols) | |||
| 82 | + | } | |||
| 83 | + | require.Equal(t, "top-right", r.calls[0].Anchor) | |||
| 84 | + | require.Equal(t, "bottom-right", r.calls[1].Anchor) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func TestBarScriptRefreshSkipsWithoutAttachedClient(t *testing.T) { | |||
| 88 | + | r := &fakeBarRunner{} | |||
| 89 | + | d := newBarRefreshTestDaemon(r, time.Second) | |||
| 90 | + | sess := newBarRefreshTestSession() | |||
| 91 | + | ||||
| 92 | + | require.False(t, d.refreshBarScriptsIfDue(sess, time.Unix(0, 0), true)) | |||
| 93 | + | require.Empty(t, r.calls) | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func TestBarScriptRefreshIsPerSession(t *testing.T) { | |||
| 97 | + | r := &fakeBarRunner{outs: []string{"top-a", "bottom-a", "top-b", "bottom-b"}} | |||
| 98 | + | d := newBarRefreshTestDaemon(r, time.Second) | |||
| 99 | + | a := newBarRefreshTestSession() | |||
| 100 | + | a.id = "a" | |||
| 101 | + | a.name = "a" | |||
| 102 | + | a.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |||
| 103 | + | b := newBarRefreshTestSession() | |||
| 104 | + | b.id = "b" | |||
| 105 | + | b.name = "b" | |||
| 106 | + | b.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |||
| 107 | + | ||||
| 108 | + | require.True(t, d.refreshBarScriptsIfDue(a, time.Unix(0, 0), false)) | |||
| 109 | + | waitBarRefreshIdle(t, d) | |||
| 110 | + | require.True(t, d.refreshBarScriptsIfDue(b, time.Unix(0, 0), false)) | |||
| 111 | + | waitBarRefreshIdle(t, d) | |||
| 112 | + | ||||
| 113 | + | stateA := d.barStateFor(a, "") | |||
| 114 | + | stateB := d.barStateFor(b, "") | |||
| 115 | + | require.Equal(t, "top-a", stateA.topRight) | |||
| 116 | + | require.Equal(t, "bottom-a", stateA.bottomRight) | |||
| 117 | + | require.Equal(t, "top-b", stateB.topRight) | |||
| 118 | + | require.Equal(t, "bottom-b", stateB.bottomRight) | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func newBarRefreshTestDaemon(r *fakeBarRunner, interval time.Duration) *Daemon { | |||
| 122 | + | d := &Daemon{barScripts: &barScriptState{ | |||
| 123 | + | cfg: barScriptConfig{topRight: "top", bottomRight: "bottom", interval: effectiveBarInterval(interval)}, | |||
| 124 | + | runner: r, | |||
| 125 | + | outputs: make(map[domain.SessionID]barScriptOutputs), | |||
| 126 | + | lastRefresh: make(map[domain.SessionID]time.Time), | |||
| 127 | + | running: make(map[domain.SessionID]bool), | |||
| 128 | + | }} | |||
| 129 | + | return d | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func newBarRefreshTestSession() *session { | |||
| 133 | + | inactive := newTabWithStableID("tab-inactive", "pane-inactive", nil, domain.Size{Cols: 80, Rows: 22}) | |||
| 134 | + | active := newTabWithStableID("tab-active", "pane-active", nil, domain.Size{Cols: 80, Rows: 22}) | |||
| 135 | + | active.tree.Focus = layout.PaneID("pane-1") | |||
| 136 | + | return &session{id: "s", name: "s", cwd: "/tmp", tabs: []*tab{inactive, active}, active: 0, ctx: context.Background()} | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func waitBarRefreshIdle(t *testing.T, d *Daemon) { | |||
| 140 | + | t.Helper() | |||
| 141 | + | require.Eventually(t, func() bool { | |||
| 142 | + | d.barScripts.mu.Lock() | |||
| 143 | + | defer d.barScripts.mu.Unlock() | |||
| 144 | + | return len(d.barScripts.running) == 0 | |||
| 145 | + | }, time.Second, time.Millisecond) | |||
| 146 | + | } | |||
A internal/usecase/daemon/bar_script.go +165 -0
| @@ -0,0 +1,165 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "io" | |
| 8 | + | "os/exec" | |
| 9 | + | "strconv" | |
| 10 | + | "strings" | |
| 11 | + | "time" | |
| 12 | + | "unicode/utf8" | |
| 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 | + | timeout time.Duration | |
| 57 | + | baseEnv []string | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func (r barScriptRunner) run(ctx context.Context, command string, scriptCtx barScriptContext) (string, error) { | |
| 61 | + | if strings.TrimSpace(command) == "" { | |
| 62 | + | return "", nil | |
| 63 | + | } | |
| 64 | + | timeout := r.timeout | |
| 65 | + | if timeout <= 0 { | |
| 66 | + | timeout = barScriptTimeout | |
| 67 | + | } | |
| 68 | + | ctx, cancel := context.WithTimeout(ctx, timeout) | |
| 69 | + | defer cancel() | |
| 70 | + | ||
| 71 | + | cmd := exec.CommandContext(ctx, "sh", "-c", command) | |
| 72 | + | cmd.Env = scriptCtx.env(r.baseEnv) | |
| 73 | + | var stdout boundedBuffer | |
| 74 | + | stdout.limit = barScriptOutputLimit | |
| 75 | + | var stderr bytes.Buffer | |
| 76 | + | cmd.Stdout = &stdout | |
| 77 | + | cmd.Stderr = &stderr | |
| 78 | + | ||
| 79 | + | err := cmd.Run() | |
| 80 | + | if errors.Is(ctx.Err(), context.DeadlineExceeded) { | |
| 81 | + | return "", context.DeadlineExceeded | |
| 82 | + | } | |
| 83 | + | text := sanitizeBarScriptOutput(stdout.Bytes(), barScriptOutputLimit) | |
| 84 | + | if err != nil { | |
| 85 | + | return text, err | |
| 86 | + | } | |
| 87 | + | return text, nil | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | type boundedBuffer struct { | |
| 91 | + | buf bytes.Buffer | |
| 92 | + | limit int | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func (b *boundedBuffer) Write(p []byte) (int, error) { | |
| 96 | + | if b.limit <= 0 { | |
| 97 | + | return len(p), nil | |
| 98 | + | } | |
| 99 | + | remaining := b.limit - b.buf.Len() | |
| 100 | + | if remaining > 0 { | |
| 101 | + | if len(p) < remaining { | |
| 102 | + | remaining = len(p) | |
| 103 | + | } | |
| 104 | + | _, _ = b.buf.Write(p[:remaining]) | |
| 105 | + | } | |
| 106 | + | return len(p), nil | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func (b *boundedBuffer) Bytes() []byte { return b.buf.Bytes() } | |
| 110 | + | ||
| 111 | + | func sanitizeBarScriptOutput(raw []byte, limit int) string { | |
| 112 | + | if limit <= 0 || limit > len(raw) { | |
| 113 | + | limit = len(raw) | |
| 114 | + | } | |
| 115 | + | raw = raw[:limit] | |
| 116 | + | if i := bytes.IndexAny(raw, "\r\n"); i >= 0 { | |
| 117 | + | raw = raw[:i] | |
| 118 | + | } | |
| 119 | + | text := strings.ToValidUTF8(string(raw), "") | |
| 120 | + | text = stripANSIAndControls(text) | |
| 121 | + | if len(text) <= barScriptDisplayLimit { | |
| 122 | + | return text | |
| 123 | + | } | |
| 124 | + | return trimUTF8Bytes(text, barScriptDisplayLimit) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func stripANSIAndControls(s string) string { | |
| 128 | + | var out strings.Builder | |
| 129 | + | for i := 0; i < len(s); { | |
| 130 | + | r, size := utf8.DecodeRuneInString(s[i:]) | |
| 131 | + | if r == '\x1b' { | |
| 132 | + | i += size | |
| 133 | + | if i < len(s) && s[i] == '[' { | |
| 134 | + | i++ | |
| 135 | + | for i < len(s) { | |
| 136 | + | c := s[i] | |
| 137 | + | i++ | |
| 138 | + | if c >= 0x40 && c <= 0x7e { | |
| 139 | + | break | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | continue | |
| 144 | + | } | |
| 145 | + | if r < 0x20 || r == 0x7f { | |
| 146 | + | i += size | |
| 147 | + | continue | |
| 148 | + | } | |
| 149 | + | out.WriteRune(r) | |
| 150 | + | i += size | |
| 151 | + | } | |
| 152 | + | return out.String() | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func trimUTF8Bytes(s string, limit int) string { | |
| 156 | + | if len(s) <= limit { | |
| 157 | + | return s | |
| 158 | + | } | |
| 159 | + | for limit > 0 && !utf8.ValidString(s[:limit]) { | |
| 160 | + | limit-- | |
| 161 | + | } | |
| 162 | + | return s[:limit] | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | var _ io.Writer = (*boundedBuffer)(nil) | |
| @@ -0,0 +1,165 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "errors" | |||
| 7 | + | "io" | |||
| 8 | + | "os/exec" | |||
| 9 | + | "strconv" | |||
| 10 | + | "strings" | |||
| 11 | + | "time" | |||
| 12 | + | "unicode/utf8" | |||
| 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 | + | timeout time.Duration | |||
| 57 | + | baseEnv []string | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func (r barScriptRunner) run(ctx context.Context, command string, scriptCtx barScriptContext) (string, error) { | |||
| 61 | + | if strings.TrimSpace(command) == "" { | |||
| 62 | + | return "", nil | |||
| 63 | + | } | |||
| 64 | + | timeout := r.timeout | |||
| 65 | + | if timeout <= 0 { | |||
| 66 | + | timeout = barScriptTimeout | |||
| 67 | + | } | |||
| 68 | + | ctx, cancel := context.WithTimeout(ctx, timeout) | |||
| 69 | + | defer cancel() | |||
| 70 | + | ||||
| 71 | + | cmd := exec.CommandContext(ctx, "sh", "-c", command) | |||
| 72 | + | cmd.Env = scriptCtx.env(r.baseEnv) | |||
| 73 | + | var stdout boundedBuffer | |||
| 74 | + | stdout.limit = barScriptOutputLimit | |||
| 75 | + | var stderr bytes.Buffer | |||
| 76 | + | cmd.Stdout = &stdout | |||
| 77 | + | cmd.Stderr = &stderr | |||
| 78 | + | ||||
| 79 | + | err := cmd.Run() | |||
| 80 | + | if errors.Is(ctx.Err(), context.DeadlineExceeded) { | |||
| 81 | + | return "", context.DeadlineExceeded | |||
| 82 | + | } | |||
| 83 | + | text := sanitizeBarScriptOutput(stdout.Bytes(), barScriptOutputLimit) | |||
| 84 | + | if err != nil { | |||
| 85 | + | return text, err | |||
| 86 | + | } | |||
| 87 | + | return text, nil | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | type boundedBuffer struct { | |||
| 91 | + | buf bytes.Buffer | |||
| 92 | + | limit int | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func (b *boundedBuffer) Write(p []byte) (int, error) { | |||
| 96 | + | if b.limit <= 0 { | |||
| 97 | + | return len(p), nil | |||
| 98 | + | } | |||
| 99 | + | remaining := b.limit - b.buf.Len() | |||
| 100 | + | if remaining > 0 { | |||
| 101 | + | if len(p) < remaining { | |||
| 102 | + | remaining = len(p) | |||
| 103 | + | } | |||
| 104 | + | _, _ = b.buf.Write(p[:remaining]) | |||
| 105 | + | } | |||
| 106 | + | return len(p), nil | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func (b *boundedBuffer) Bytes() []byte { return b.buf.Bytes() } | |||
| 110 | + | ||||
| 111 | + | func sanitizeBarScriptOutput(raw []byte, limit int) string { | |||
| 112 | + | if limit <= 0 || limit > len(raw) { | |||
| 113 | + | limit = len(raw) | |||
| 114 | + | } | |||
| 115 | + | raw = raw[:limit] | |||
| 116 | + | if i := bytes.IndexAny(raw, "\r\n"); i >= 0 { | |||
| 117 | + | raw = raw[:i] | |||
| 118 | + | } | |||
| 119 | + | text := strings.ToValidUTF8(string(raw), "") | |||
| 120 | + | text = stripANSIAndControls(text) | |||
| 121 | + | if len(text) <= barScriptDisplayLimit { | |||
| 122 | + | return text | |||
| 123 | + | } | |||
| 124 | + | return trimUTF8Bytes(text, barScriptDisplayLimit) | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func stripANSIAndControls(s string) string { | |||
| 128 | + | var out strings.Builder | |||
| 129 | + | for i := 0; i < len(s); { | |||
| 130 | + | r, size := utf8.DecodeRuneInString(s[i:]) | |||
| 131 | + | if r == '\x1b' { | |||
| 132 | + | i += size | |||
| 133 | + | if i < len(s) && s[i] == '[' { | |||
| 134 | + | i++ | |||
| 135 | + | for i < len(s) { | |||
| 136 | + | c := s[i] | |||
| 137 | + | i++ | |||
| 138 | + | if c >= 0x40 && c <= 0x7e { | |||
| 139 | + | break | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 143 | + | continue | |||
| 144 | + | } | |||
| 145 | + | if r < 0x20 || r == 0x7f { | |||
| 146 | + | i += size | |||
| 147 | + | continue | |||
| 148 | + | } | |||
| 149 | + | out.WriteRune(r) | |||
| 150 | + | i += size | |||
| 151 | + | } | |||
| 152 | + | return out.String() | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | func trimUTF8Bytes(s string, limit int) string { | |||
| 156 | + | if len(s) <= limit { | |||
| 157 | + | return s | |||
| 158 | + | } | |||
| 159 | + | for limit > 0 && !utf8.ValidString(s[:limit]) { | |||
| 160 | + | limit-- | |||
| 161 | + | } | |||
| 162 | + | return s[:limit] | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | var _ io.Writer = (*boundedBuffer)(nil) | |||
A internal/usecase/daemon/bar_script_test.go +110 -0
| @@ -0,0 +1,110 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "os" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestBarScriptSanitizeOutput(t *testing.T) { | |
| 13 | + | tests := []struct { | |
| 14 | + | name string | |
| 15 | + | raw string | |
| 16 | + | limit int | |
| 17 | + | want string | |
| 18 | + | }{ | |
| 19 | + | {name: "first line only", raw: "one\ntwo\n", limit: barScriptOutputLimit, want: "one"}, | |
| 20 | + | {name: "ansi stripped", raw: "\x1b[31mred\x1b[0m", limit: barScriptOutputLimit, want: "red"}, | |
| 21 | + | {name: "controls stripped", raw: "a\x00b\x07c", limit: barScriptOutputLimit, want: "abc"}, | |
| 22 | + | {name: "utf8 and nerd font preserved", raw: " 14:32 ↑3", limit: barScriptOutputLimit, want: " 14:32 ↑3"}, | |
| 23 | + | {name: "display capped", raw: strings.Repeat("a", barScriptDisplayLimit+10), limit: barScriptOutputLimit, want: strings.Repeat("a", barScriptDisplayLimit)}, | |
| 24 | + | {name: "capture capped before first line", raw: strings.Repeat("a", 8) + "\nignored", limit: 4, want: "aaaa"}, | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | for _, tt := range tests { | |
| 28 | + | t.Run(tt.name, func(t *testing.T) { | |
| 29 | + | if got := sanitizeBarScriptOutput([]byte(tt.raw), tt.limit); got != tt.want { | |
| 30 | + | t.Fatalf("sanitizeBarScriptOutput() = %q, want %q", got, tt.want) | |
| 31 | + | } | |
| 32 | + | }) | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func TestBarScriptContextEnv(t *testing.T) { | |
| 37 | + | base := []string{ | |
| 38 | + | "PATH=/bin", | |
| 39 | + | "VEV_ANCHOR=old", | |
| 40 | + | "VEV_SESSION=old", | |
| 41 | + | "VEV_TAB=old", | |
| 42 | + | "VEV_PANE=old", | |
| 43 | + | "VEV_PANE_CWD=/old", | |
| 44 | + | "VEV_COLS=1", | |
| 45 | + | } | |
| 46 | + | ctx := barScriptContext{Anchor: "top-right", Session: "work", Tab: "2", Pane: "pane-3", PaneCWD: "/repo", Cols: 120} | |
| 47 | + | got := ctx.env(base) | |
| 48 | + | ||
| 49 | + | want := map[string]string{ | |
| 50 | + | "VEV_ANCHOR": "top-right", | |
| 51 | + | "VEV_SESSION": "work", | |
| 52 | + | "VEV_TAB": "2", | |
| 53 | + | "VEV_PANE": "pane-3", | |
| 54 | + | "VEV_PANE_CWD": "/repo", | |
| 55 | + | "VEV_COLS": "120", | |
| 56 | + | } | |
| 57 | + | seen := map[string]int{} | |
| 58 | + | for _, entry := range got { | |
| 59 | + | key, val, ok := strings.Cut(entry, "=") | |
| 60 | + | if !ok { | |
| 61 | + | continue | |
| 62 | + | } | |
| 63 | + | if _, isVEV := want[key]; isVEV { | |
| 64 | + | seen[key]++ | |
| 65 | + | if val != want[key] { | |
| 66 | + | t.Fatalf("%s = %q, want %q in env %v", key, val, want[key], got) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | for key := range want { | |
| 71 | + | if seen[key] != 1 { | |
| 72 | + | t.Fatalf("%s appears %d times, want 1 in env %v", key, seen[key], got) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestBarScriptRunner(t *testing.T) { | |
| 78 | + | runner := barScriptRunner{timeout: 50 * time.Millisecond, baseEnv: os.Environ()} | |
| 79 | + | got, err := runner.run(context.Background(), "", barScriptContext{}) | |
| 80 | + | if err != nil || got != "" { | |
| 81 | + | t.Fatalf("empty command = %q, %v; want empty nil", got, err) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | got, err = runner.run(context.Background(), "printf 'ok\\nignored'", barScriptContext{}) | |
| 85 | + | if err != nil { | |
| 86 | + | t.Fatalf("run command: %v", err) | |
| 87 | + | } | |
| 88 | + | if got != "ok" { | |
| 89 | + | t.Fatalf("run command = %q, want ok", got) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | got, err = runner.run(context.Background(), "sleep 1; printf late", barScriptContext{}) | |
| 93 | + | if !errors.Is(err, context.DeadlineExceeded) { | |
| 94 | + | t.Fatalf("timeout err = %v, want DeadlineExceeded", err) | |
| 95 | + | } | |
| 96 | + | if got != "" { | |
| 97 | + | t.Fatalf("timeout output = %q, want empty", got) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) { | |
| 102 | + | runner := barScriptRunner{timeout: time.Second, baseEnv: os.Environ()} | |
| 103 | + | got, err := runner.run(context.Background(), "yes a | head -c 4096", barScriptContext{}) | |
| 104 | + | if err != nil { | |
| 105 | + | t.Fatalf("run bounded output: %v", err) | |
| 106 | + | } | |
| 107 | + | if len(got) > barScriptDisplayLimit { | |
| 108 | + | t.Fatalf("output length = %d, want <= %d", len(got), barScriptDisplayLimit) | |
| 109 | + | } | |
| 110 | + | } | |
| @@ -0,0 +1,110 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "os" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestBarScriptSanitizeOutput(t *testing.T) { | |||
| 13 | + | tests := []struct { | |||
| 14 | + | name string | |||
| 15 | + | raw string | |||
| 16 | + | limit int | |||
| 17 | + | want string | |||
| 18 | + | }{ | |||
| 19 | + | {name: "first line only", raw: "one\ntwo\n", limit: barScriptOutputLimit, want: "one"}, | |||
| 20 | + | {name: "ansi stripped", raw: "\x1b[31mred\x1b[0m", limit: barScriptOutputLimit, want: "red"}, | |||
| 21 | + | {name: "controls stripped", raw: "a\x00b\x07c", limit: barScriptOutputLimit, want: "abc"}, | |||
| 22 | + | {name: "utf8 and nerd font preserved", raw: " 14:32 ↑3", limit: barScriptOutputLimit, want: " 14:32 ↑3"}, | |||
| 23 | + | {name: "display capped", raw: strings.Repeat("a", barScriptDisplayLimit+10), limit: barScriptOutputLimit, want: strings.Repeat("a", barScriptDisplayLimit)}, | |||
| 24 | + | {name: "capture capped before first line", raw: strings.Repeat("a", 8) + "\nignored", limit: 4, want: "aaaa"}, | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | for _, tt := range tests { | |||
| 28 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 29 | + | if got := sanitizeBarScriptOutput([]byte(tt.raw), tt.limit); got != tt.want { | |||
| 30 | + | t.Fatalf("sanitizeBarScriptOutput() = %q, want %q", got, tt.want) | |||
| 31 | + | } | |||
| 32 | + | }) | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func TestBarScriptContextEnv(t *testing.T) { | |||
| 37 | + | base := []string{ | |||
| 38 | + | "PATH=/bin", | |||
| 39 | + | "VEV_ANCHOR=old", | |||
| 40 | + | "VEV_SESSION=old", | |||
| 41 | + | "VEV_TAB=old", | |||
| 42 | + | "VEV_PANE=old", | |||
| 43 | + | "VEV_PANE_CWD=/old", | |||
| 44 | + | "VEV_COLS=1", | |||
| 45 | + | } | |||
| 46 | + | ctx := barScriptContext{Anchor: "top-right", Session: "work", Tab: "2", Pane: "pane-3", PaneCWD: "/repo", Cols: 120} | |||
| 47 | + | got := ctx.env(base) | |||
| 48 | + | ||||
| 49 | + | want := map[string]string{ | |||
| 50 | + | "VEV_ANCHOR": "top-right", | |||
| 51 | + | "VEV_SESSION": "work", | |||
| 52 | + | "VEV_TAB": "2", | |||
| 53 | + | "VEV_PANE": "pane-3", | |||
| 54 | + | "VEV_PANE_CWD": "/repo", | |||
| 55 | + | "VEV_COLS": "120", | |||
| 56 | + | } | |||
| 57 | + | seen := map[string]int{} | |||
| 58 | + | for _, entry := range got { | |||
| 59 | + | key, val, ok := strings.Cut(entry, "=") | |||
| 60 | + | if !ok { | |||
| 61 | + | continue | |||
| 62 | + | } | |||
| 63 | + | if _, isVEV := want[key]; isVEV { | |||
| 64 | + | seen[key]++ | |||
| 65 | + | if val != want[key] { | |||
| 66 | + | t.Fatalf("%s = %q, want %q in env %v", key, val, want[key], got) | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | for key := range want { | |||
| 71 | + | if seen[key] != 1 { | |||
| 72 | + | t.Fatalf("%s appears %d times, want 1 in env %v", key, seen[key], got) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func TestBarScriptRunner(t *testing.T) { | |||
| 78 | + | runner := barScriptRunner{timeout: 50 * time.Millisecond, baseEnv: os.Environ()} | |||
| 79 | + | got, err := runner.run(context.Background(), "", barScriptContext{}) | |||
| 80 | + | if err != nil || got != "" { | |||
| 81 | + | t.Fatalf("empty command = %q, %v; want empty nil", got, err) | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | got, err = runner.run(context.Background(), "printf 'ok\\nignored'", barScriptContext{}) | |||
| 85 | + | if err != nil { | |||
| 86 | + | t.Fatalf("run command: %v", err) | |||
| 87 | + | } | |||
| 88 | + | if got != "ok" { | |||
| 89 | + | t.Fatalf("run command = %q, want ok", got) | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | got, err = runner.run(context.Background(), "sleep 1; printf late", barScriptContext{}) | |||
| 93 | + | if !errors.Is(err, context.DeadlineExceeded) { | |||
| 94 | + | t.Fatalf("timeout err = %v, want DeadlineExceeded", err) | |||
| 95 | + | } | |||
| 96 | + | if got != "" { | |||
| 97 | + | t.Fatalf("timeout output = %q, want empty", got) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) { | |||
| 102 | + | runner := barScriptRunner{timeout: time.Second, baseEnv: os.Environ()} | |||
| 103 | + | got, err := runner.run(context.Background(), "yes a | head -c 4096", barScriptContext{}) | |||
| 104 | + | if err != nil { | |||
| 105 | + | t.Fatalf("run bounded output: %v", err) | |||
| 106 | + | } | |||
| 107 | + | if len(got) > barScriptDisplayLimit { | |||
| 108 | + | t.Fatalf("output length = %d, want <= %d", len(got), barScriptDisplayLimit) | |||
| 109 | + | } | |||
| 110 | + | } | |||
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 +9 -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,14 @@ 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.cfg = barConfigFromDomain(cfg.Bar) | |
| 34 | + | d.barScripts.outputs = make(map[domain.SessionID]barScriptOutputs) | |
| 35 | + | d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time) | |
| 36 | + | d.barScripts.running = make(map[domain.SessionID]bool) | |
| 37 | + | d.barScripts.mu.Unlock() | |
| 38 | + | } | |
| 30 | 39 | d.reapplyThemeAllSessions() | |
| 31 | 40 | d.repaintAllAttachedClients() | |
| 32 | 41 | } | |
| @@ -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,14 @@ 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.cfg = barConfigFromDomain(cfg.Bar) | |||
| 34 | + | d.barScripts.outputs = make(map[domain.SessionID]barScriptOutputs) | |||
| 35 | + | d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time) | |||
| 36 | + | d.barScripts.running = make(map[domain.SessionID]bool) | |||
| 37 | + | d.barScripts.mu.Unlock() | |||
| 38 | + | } | |||
| 30 | d.reapplyThemeAllSessions() | 39 | d.reapplyThemeAllSessions() | ||
| 31 | d.repaintAllAttachedClients() | 40 | d.repaintAllAttachedClients() | ||
| 32 | } | 41 | } | ||
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 +11 -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 | |
| @@ -255,6 +256,13 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 255 | 256 | done: make(chan struct{}), | |
| 256 | 257 | restoreDone: make(chan struct{}), | |
| 257 | 258 | animWake: make(chan struct{}, 1), | |
| 259 | + | barScripts: &barScriptState{ | |
| 260 | + | cfg: barConfigFromDomain(domain.Defaults().Bar), | |
| 261 | + | runner: barScriptRunner{baseEnv: os.Environ()}, | |
| 262 | + | outputs: make(map[domain.SessionID]barScriptOutputs), | |
| 263 | + | lastRefresh: make(map[domain.SessionID]time.Time), | |
| 264 | + | running: make(map[domain.SessionID]bool), | |
| 265 | + | }, | |
| 258 | 266 | } | |
| 259 | 267 | for _, o := range opts { | |
| 260 | 268 | o(d) | |
| @@ -303,6 +311,9 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error { | |||
| 303 | 311 | d.sessWg.Go(func() { | |
| 304 | 312 | d.attentionAnimator(d.serveCtx) | |
| 305 | 313 | }) | |
| 314 | + | d.sessWg.Go(func() { | |
| 315 | + | d.barScriptPoller(d.serveCtx) | |
| 316 | + | }) | |
| 306 | 317 | if d.persistEnabled && d.procCwd != nil { | |
| 307 | 318 | d.sessWg.Go(func() { | |
| 308 | 319 | 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 | ||
| @@ -255,6 +256,13 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||||
| 255 | done: make(chan struct{}), | 256 | done: make(chan struct{}), | ||
| 256 | restoreDone: make(chan struct{}), | 257 | restoreDone: make(chan struct{}), | ||
| 257 | animWake: make(chan struct{}, 1), | 258 | animWake: make(chan struct{}, 1), | ||
| 259 | + | barScripts: &barScriptState{ | |||
| 260 | + | cfg: barConfigFromDomain(domain.Defaults().Bar), | |||
| 261 | + | runner: barScriptRunner{baseEnv: os.Environ()}, | |||
| 262 | + | outputs: make(map[domain.SessionID]barScriptOutputs), | |||
| 263 | + | lastRefresh: make(map[domain.SessionID]time.Time), | |||
| 264 | + | running: make(map[domain.SessionID]bool), | |||
| 265 | + | }, | |||
| 258 | } | 266 | } | ||
| 259 | for _, o := range opts { | 267 | for _, o := range opts { | ||
| 260 | o(d) | 268 | o(d) | ||
| @@ -303,6 +311,9 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error { | |||||
| 303 | d.sessWg.Go(func() { | 311 | d.sessWg.Go(func() { | ||
| 304 | d.attentionAnimator(d.serveCtx) | 312 | d.attentionAnimator(d.serveCtx) | ||
| 305 | }) | 313 | }) | ||
| 314 | + | d.sessWg.Go(func() { | |||
| 315 | + | d.barScriptPoller(d.serveCtx) | |||
| 316 | + | }) | |||
| 306 | if d.persistEnabled && d.procCwd != nil { | 317 | if d.persistEnabled && d.procCwd != nil { | ||
| 307 | d.sessWg.Go(func() { | 318 | d.sessWg.Go(func() { | ||
| 308 | d.cwdSampler(d.serveCtx) | 319 | d.cwdSampler(d.serveCtx) | ||
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 +20 -3
| @@ -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,7 +96,17 @@ 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) { | |
| @@ -113,6 +125,8 @@ func clearStatusRow(row []renderer.Cell) { | |||
| 113 | 125 | ||
| 114 | 126 | type barState struct { | |
| 115 | 127 | status statusSnapshot | |
| 128 | + | topRight string | |
| 129 | + | bottomRight string | |
| 116 | 130 | copyFeedback string | |
| 117 | 131 | mru []mruSession | |
| 118 | 132 | attentionFrame int | |
| @@ -165,6 +179,9 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState { | |||
| 165 | 179 | if cur != nil { | |
| 166 | 180 | state.status = cur.statusSegments() | |
| 167 | 181 | } | |
| 182 | + | if d != nil { | |
| 183 | + | state.topRight, state.bottomRight = d.barScriptSnapshot(cur) | |
| 184 | + | } | |
| 168 | 185 | if d == nil { | |
| 169 | 186 | return state | |
| 170 | 187 | } | |
| @@ -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,7 +96,17 @@ 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) { | ||
| @@ -113,6 +125,8 @@ func clearStatusRow(row []renderer.Cell) { | |||||
| 113 | 125 | ||||
| 114 | type barState struct { | 126 | type barState struct { | ||
| 115 | status statusSnapshot | 127 | status statusSnapshot | ||
| 128 | + | topRight string | |||
| 129 | + | bottomRight string | |||
| 116 | copyFeedback string | 130 | copyFeedback string | ||
| 117 | mru []mruSession | 131 | mru []mruSession | ||
| 118 | attentionFrame int | 132 | attentionFrame int | ||
| @@ -165,6 +179,9 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState { | |||||
| 165 | if cur != nil { | 179 | if cur != nil { | ||
| 166 | state.status = cur.statusSegments() | 180 | state.status = cur.statusSegments() | ||
| 167 | } | 181 | } | ||
| 182 | + | if d != nil { | |||
| 183 | + | state.topRight, state.bottomRight = d.barScriptSnapshot(cur) | |||
| 184 | + | } | |||
| 168 | if d == nil { | 185 | if d == nil { | ||
| 169 | return state | 186 | return state | ||
| 170 | } | 187 | } | ||
M internal/usecase/daemon/status_test.go +62 -0
| @@ -398,6 +398,24 @@ func TestStatusMarksEphemeralSession(t *testing.T) { | |||
| 398 | 398 | require.Equal(t, " 0* ", rowText(frame.Row(3))) | |
| 399 | 399 | } | |
| 400 | 400 | ||
| 401 | + | func TestTopBarRightAnchorRendersFlushRightWhenFullyFits(t *testing.T) { | |
| 402 | + | row := make([]renderer.Cell, 20) | |
| 403 | + | status := statusSnapshot{tabs: []statusTab{{name: "1", active: true}}} | |
| 404 | + | ||
| 405 | + | drawTopBarSnapshot(row, status, 0, "14:32", resolveThemeStyles(nil)) | |
| 406 | + | ||
| 407 | + | require.Equal(t, " 1 14:32", rowText(row)) | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | func TestTopBarRightAnchorHidesOnOverlap(t *testing.T) { | |
| 411 | + | row := make([]renderer.Cell, 10) | |
| 412 | + | status := statusSnapshot{tabs: []statusTab{{name: "1"}, {name: "2"}}} | |
| 413 | + | ||
| 414 | + | drawTopBarSnapshot(row, status, 0, "12345", resolveThemeStyles(nil)) | |
| 415 | + | ||
| 416 | + | require.Equal(t, " 1 2 ", rowText(row)) | |
| 417 | + | } | |
| 418 | + | ||
| 401 | 419 | func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) { | |
| 402 | 420 | p, releasePTY := newBlockingPTY(t) | |
| 403 | 421 | _, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| @@ -644,6 +662,50 @@ func TestStatusBarMRUWidthAwareBudget(t *testing.T) { | |||
| 644 | 662 | } | |
| 645 | 663 | } | |
| 646 | 664 | ||
| 665 | + | func TestStatusBarBottomRightScriptTextAndCopyFeedback(t *testing.T) { | |
| 666 | + | state := barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []mruSession{{name: "a"}}} | |
| 667 | + | row := make([]renderer.Cell, 32) | |
| 668 | + | ||
| 669 | + | drawStatusBarState(row, state, resolveThemeStyles(nil)) | |
| 670 | + | ||
| 671 | + | text := rowText(row) | |
| 672 | + | require.Contains(t, text, " a") | |
| 673 | + | require.True(t, strings.HasSuffix(text, " main ↑3 * copied"), text) | |
| 674 | + | } | |
| 675 | + | ||
| 676 | + | func TestStatusBarBottomRightHidesOnOverlap(t *testing.T) { | |
| 677 | + | state := barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []mruSession{{name: "fresh"}}} | |
| 678 | + | row := make([]renderer.Cell, 16) | |
| 679 | + | ||
| 680 | + | drawStatusBarState(row, state, resolveThemeStyles(nil)) | |
| 681 | + | ||
| 682 | + | text := rowText(row) | |
| 683 | + | require.Equal(t, " cur ", text) | |
| 684 | + | require.NotContains(t, text, "main") | |
| 685 | + | require.NotContains(t, text, "copied") | |
| 686 | + | } | |
| 687 | + | ||
| 688 | + | func TestStatusBarEmptyBottomRightScriptKeepsCopyFeedbackBehavior(t *testing.T) { | |
| 689 | + | state := barState{status: statusSnapshot{session: "cur"}, bottomRight: "", copyFeedback: "copied", mru: []mruSession{{name: "fresh"}}} | |
| 690 | + | row := make([]renderer.Cell, 12) | |
| 691 | + | ||
| 692 | + | drawStatusBarState(row, state, resolveThemeStyles(nil)) | |
| 693 | + | ||
| 694 | + | require.Equal(t, " cur copied", rowText(row)) | |
| 695 | + | } | |
| 696 | + | ||
| 697 | + | func TestStatusBarMRUWholeEntryFittingWithBottomRightScript(t *testing.T) { | |
| 698 | + | state := barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []mruSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}} | |
| 699 | + | row := make([]renderer.Cell, 24) | |
| 700 | + | ||
| 701 | + | drawStatusBarState(row, state, resolveThemeStyles(nil)) | |
| 702 | + | ||
| 703 | + | text := rowText(row) | |
| 704 | + | require.Contains(t, text, " fresh") | |
| 705 | + | require.NotContains(t, text, "middle") | |
| 706 | + | require.True(t, strings.HasSuffix(text, " git"), text) | |
| 707 | + | } | |
| 708 | + | ||
| 647 | 709 | func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) { | |
| 648 | 710 | state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []mruSession{{name: "a"}, {name: "b"}, {name: "c"}}} | |
| 649 | 711 | row := make([]renderer.Cell, 20) | |
| @@ -398,6 +398,24 @@ func TestStatusMarksEphemeralSession(t *testing.T) { | |||||
| 398 | require.Equal(t, " 0* ", rowText(frame.Row(3))) | 398 | require.Equal(t, " 0* ", rowText(frame.Row(3))) | ||
| 399 | } | 399 | } | ||
| 400 | 400 | ||||
| 401 | + | func TestTopBarRightAnchorRendersFlushRightWhenFullyFits(t *testing.T) { | |||
| 402 | + | row := make([]renderer.Cell, 20) | |||
| 403 | + | status := statusSnapshot{tabs: []statusTab{{name: "1", active: true}}} | |||
| 404 | + | ||||
| 405 | + | drawTopBarSnapshot(row, status, 0, "14:32", resolveThemeStyles(nil)) | |||
| 406 | + | ||||
| 407 | + | require.Equal(t, " 1 14:32", rowText(row)) | |||
| 408 | + | } | |||
| 409 | + | ||||
| 410 | + | func TestTopBarRightAnchorHidesOnOverlap(t *testing.T) { | |||
| 411 | + | row := make([]renderer.Cell, 10) | |||
| 412 | + | status := statusSnapshot{tabs: []statusTab{{name: "1"}, {name: "2"}}} | |||
| 413 | + | ||||
| 414 | + | drawTopBarSnapshot(row, status, 0, "12345", resolveThemeStyles(nil)) | |||
| 415 | + | ||||
| 416 | + | require.Equal(t, " 1 2 ", rowText(row)) | |||
| 417 | + | } | |||
| 418 | + | ||||
| 401 | func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) { | 419 | func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) { | ||
| 402 | p, releasePTY := newBlockingPTY(t) | 420 | p, releasePTY := newBlockingPTY(t) | ||
| 403 | _, sess, _, _ := newManualSessionWithPTYs(t, p) | 421 | _, sess, _, _ := newManualSessionWithPTYs(t, p) | ||
| @@ -644,6 +662,50 @@ func TestStatusBarMRUWidthAwareBudget(t *testing.T) { | |||||
| 644 | } | 662 | } | ||
| 645 | } | 663 | } | ||
| 646 | 664 | ||||
| 665 | + | func TestStatusBarBottomRightScriptTextAndCopyFeedback(t *testing.T) { | |||
| 666 | + | state := barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []mruSession{{name: "a"}}} | |||
| 667 | + | row := make([]renderer.Cell, 32) | |||
| 668 | + | ||||
| 669 | + | drawStatusBarState(row, state, resolveThemeStyles(nil)) | |||
| 670 | + | ||||
| 671 | + | text := rowText(row) | |||
| 672 | + | require.Contains(t, text, " a") | |||
| 673 | + | require.True(t, strings.HasSuffix(text, " main ↑3 * copied"), text) | |||
| 674 | + | } | |||
| 675 | + | ||||
| 676 | + | func TestStatusBarBottomRightHidesOnOverlap(t *testing.T) { | |||
| 677 | + | state := barState{status: statusSnapshot{session: "cur"}, bottomRight: "main ↑3 *", copyFeedback: "copied", mru: []mruSession{{name: "fresh"}}} | |||
| 678 | + | row := make([]renderer.Cell, 16) | |||
| 679 | + | ||||
| 680 | + | drawStatusBarState(row, state, resolveThemeStyles(nil)) | |||
| 681 | + | ||||
| 682 | + | text := rowText(row) | |||
| 683 | + | require.Equal(t, " cur ", text) | |||
| 684 | + | require.NotContains(t, text, "main") | |||
| 685 | + | require.NotContains(t, text, "copied") | |||
| 686 | + | } | |||
| 687 | + | ||||
| 688 | + | func TestStatusBarEmptyBottomRightScriptKeepsCopyFeedbackBehavior(t *testing.T) { | |||
| 689 | + | state := barState{status: statusSnapshot{session: "cur"}, bottomRight: "", copyFeedback: "copied", mru: []mruSession{{name: "fresh"}}} | |||
| 690 | + | row := make([]renderer.Cell, 12) | |||
| 691 | + | ||||
| 692 | + | drawStatusBarState(row, state, resolveThemeStyles(nil)) | |||
| 693 | + | ||||
| 694 | + | require.Equal(t, " cur copied", rowText(row)) | |||
| 695 | + | } | |||
| 696 | + | ||||
| 697 | + | func TestStatusBarMRUWholeEntryFittingWithBottomRightScript(t *testing.T) { | |||
| 698 | + | state := barState{status: statusSnapshot{session: "cur"}, bottomRight: "git", mru: []mruSession{{name: "fresh"}, {name: "middle"}, {name: "old"}}} | |||
| 699 | + | row := make([]renderer.Cell, 24) | |||
| 700 | + | ||||
| 701 | + | drawStatusBarState(row, state, resolveThemeStyles(nil)) | |||
| 702 | + | ||||
| 703 | + | text := rowText(row) | |||
| 704 | + | require.Contains(t, text, " fresh") | |||
| 705 | + | require.NotContains(t, text, "middle") | |||
| 706 | + | require.True(t, strings.HasSuffix(text, " git"), text) | |||
| 707 | + | } | |||
| 708 | + | ||||
| 647 | func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) { | 709 | func TestStatusBarCopyFeedbackFullyRenderedAlongsideMRU(t *testing.T) { | ||
| 648 | state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []mruSession{{name: "a"}, {name: "b"}, {name: "c"}}} | 710 | state := barState{status: statusSnapshot{session: "cur"}, copyFeedback: "copied", mru: []mruSession{{name: "a"}, {name: "b"}, {name: "c"}}} | ||
| 649 | row := make([]renderer.Cell, 20) | 711 | row := make([]renderer.Cell, 20) | ||
A scripts/vev-bar-bottom-right +50 -0
| @@ -0,0 +1,50 @@ | |||
| 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 | + | set -- $counts | |
| 30 | + | behind=${1:-0} | |
| 31 | + | ahead=${2:-0} | |
| 32 | + | case $ahead in | |
| 33 | + | ''|*[!0-9]*) ahead=0 ;; | |
| 34 | + | esac | |
| 35 | + | case $behind in | |
| 36 | + | ''|*[!0-9]*) behind=0 ;; | |
| 37 | + | esac | |
| 38 | + | if [ "$ahead" -gt 0 ] 2>/dev/null; then | |
| 39 | + | line="$line ↑$ahead" | |
| 40 | + | fi | |
| 41 | + | if [ "$behind" -gt 0 ] 2>/dev/null; then | |
| 42 | + | line="$line ↓$behind" | |
| 43 | + | fi | |
| 44 | + | fi | |
| 45 | + | ||
| 46 | + | if [ -n "$(git -C "$VEV_PANE_CWD" status --porcelain 2>/dev/null || true)" ]; then | |
| 47 | + | line="$line *" | |
| 48 | + | fi | |
| 49 | + | ||
| 50 | + | printf '%s\n' "$line" | |
| @@ -0,0 +1,50 @@ | |||||
| 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 | + | set -- $counts | |||
| 30 | + | behind=${1:-0} | |||
| 31 | + | ahead=${2:-0} | |||
| 32 | + | case $ahead in | |||
| 33 | + | ''|*[!0-9]*) ahead=0 ;; | |||
| 34 | + | esac | |||
| 35 | + | case $behind in | |||
| 36 | + | ''|*[!0-9]*) behind=0 ;; | |||
| 37 | + | esac | |||
| 38 | + | if [ "$ahead" -gt 0 ] 2>/dev/null; then | |||
| 39 | + | line="$line ↑$ahead" | |||
| 40 | + | fi | |||
| 41 | + | if [ "$behind" -gt 0 ] 2>/dev/null; then | |||
| 42 | + | line="$line ↓$behind" | |||
| 43 | + | fi | |||
| 44 | + | fi | |||
| 45 | + | ||||
| 46 | + | if [ -n "$(git -C "$VEV_PANE_CWD" status --porcelain 2>/dev/null || true)" ]; then | |||
| 47 | + | line="$line *" | |||
| 48 | + | fi | |||
| 49 | + | ||||
| 50 | + | printf '%s\n' "$line" | |||
A scripts/vev-bar-top-right +52 -0
| @@ -0,0 +1,52 @@ | |||
| 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 | + | cpu_pct=$( | |
| 11 | + | awk ' | |
| 12 | + | $1 == "cpu" { | |
| 13 | + | idle = $5 + $6 | |
| 14 | + | total = 0 | |
| 15 | + | for (i = 2; i <= NF; i++) total += $i | |
| 16 | + | if (total > 0) printf "%d", ((total - idle) * 100) / total | |
| 17 | + | exit | |
| 18 | + | } | |
| 19 | + | ' /proc/stat 2>/dev/null || true | |
| 20 | + | ) | |
| 21 | + | fi | |
| 22 | + | ||
| 23 | + | mem_pct= | |
| 24 | + | if [ -r /proc/meminfo ]; then | |
| 25 | + | mem_pct=$( | |
| 26 | + | awk ' | |
| 27 | + | $1 == "MemTotal:" { total = $2 } | |
| 28 | + | $1 == "MemAvailable:" { available = $2 } | |
| 29 | + | END { | |
| 30 | + | if (total > 0 && available >= 0) printf "%d", ((total - available) * 100) / total | |
| 31 | + | } | |
| 32 | + | ' /proc/meminfo 2>/dev/null || true | |
| 33 | + | ) | |
| 34 | + | fi | |
| 35 | + | ||
| 36 | + | line=$now | |
| 37 | + | if [ -n "$cpu_pct" ]; then | |
| 38 | + | if [ -n "$line" ]; then | |
| 39 | + | line="$line cpu $cpu_pct%" | |
| 40 | + | else | |
| 41 | + | line="cpu $cpu_pct%" | |
| 42 | + | fi | |
| 43 | + | fi | |
| 44 | + | if [ -n "$mem_pct" ]; then | |
| 45 | + | if [ -n "$line" ]; then | |
| 46 | + | line="$line mem $mem_pct%" | |
| 47 | + | else | |
| 48 | + | line="mem $mem_pct%" | |
| 49 | + | fi | |
| 50 | + | fi | |
| 51 | + | ||
| 52 | + | printf '%s\n' "$line" | |
| @@ -0,0 +1,52 @@ | |||||
| 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 | + | cpu_pct=$( | |||
| 11 | + | awk ' | |||
| 12 | + | $1 == "cpu" { | |||
| 13 | + | idle = $5 + $6 | |||
| 14 | + | total = 0 | |||
| 15 | + | for (i = 2; i <= NF; i++) total += $i | |||
| 16 | + | if (total > 0) printf "%d", ((total - idle) * 100) / total | |||
| 17 | + | exit | |||
| 18 | + | } | |||
| 19 | + | ' /proc/stat 2>/dev/null || true | |||
| 20 | + | ) | |||
| 21 | + | fi | |||
| 22 | + | ||||
| 23 | + | mem_pct= | |||
| 24 | + | if [ -r /proc/meminfo ]; then | |||
| 25 | + | mem_pct=$( | |||
| 26 | + | awk ' | |||
| 27 | + | $1 == "MemTotal:" { total = $2 } | |||
| 28 | + | $1 == "MemAvailable:" { available = $2 } | |||
| 29 | + | END { | |||
| 30 | + | if (total > 0 && available >= 0) printf "%d", ((total - available) * 100) / total | |||
| 31 | + | } | |||
| 32 | + | ' /proc/meminfo 2>/dev/null || true | |||
| 33 | + | ) | |||
| 34 | + | fi | |||
| 35 | + | ||||
| 36 | + | line=$now | |||
| 37 | + | if [ -n "$cpu_pct" ]; then | |||
| 38 | + | if [ -n "$line" ]; then | |||
| 39 | + | line="$line cpu $cpu_pct%" | |||
| 40 | + | else | |||
| 41 | + | line="cpu $cpu_pct%" | |||
| 42 | + | fi | |||
| 43 | + | fi | |||
| 44 | + | if [ -n "$mem_pct" ]; then | |||
| 45 | + | if [ -n "$line" ]; then | |||
| 46 | + | line="$line mem $mem_pct%" | |||
| 47 | + | else | |||
| 48 | + | line="mem $mem_pct%" | |||
| 49 | + | fi | |||
| 50 | + | fi | |||
| 51 | + | ||||
| 52 | + | printf '%s\n' "$line" | |||