Commit 8d5e827
Max Rydahl Andersen
·
2026-06-29 02:43:28 -0400 EDT
parent ce52eb7
feat: session labels Now you can apply ephemeral key-value pairs to a zmx session and see them when running `zmx list`. This allows users to tag sessions with metadata instead of only relying on the session name.
11 files changed,
+674,
-31
+10,
-0
| ... | ... | @@ -4,6 +4,16 @@ Use spec: https://common-changelog.org/ | |
| 4 | 4 | ||
| 5 | 5 | ## Staged | |
| 6 | 6 | ||
| 7 | + | ### Added | |
| 8 | + | ||
| 9 | + | - Label system for sessions: | |
| 10 | + | - `zmx set <name> k=v ...` to attach key=value labels to live sessions | |
| 11 | + | - `zmx set <name> key=` to remove a specific label (empty value = delete) | |
| 12 | + | - `zmx get <name>` to read labels from a session | |
| 13 | + | - `zmx get <name> key` to print a single value | |
| 14 | + | - `zmx clear <name>` to remove all labels | |
| 15 | + | - `zmx list` now shows labels by default as tab-separated fields | |
| 16 | + | ||
| 7 | 17 | ### Fixed | |
| 8 | 18 | ||
| 9 | 19 | - `zmx run` will now detect heredocs and add the completion marker to a newline |
+24,
-1
| ... | ... | @@ -83,7 +83,11 @@ Commands: | |
| 83 | 83 | [p]rint <name> <text...> Inject text into session display | |
| 84 | 84 | [wr]ite <name> <file_path> Write stdin to file_path through the session | |
| 85 | 85 | [d]etach Detach all clients (ctrl+\\ for current client) | |
| 86 | - | [l]ist|ls [--short] List active sessions | |
| 86 | + | [l]ist|ls [--short|--where k=v] List active sessions | |
| 87 | + | [g]et <name> Get session labels | |
| 88 | + | set <name> k=v ... Set session labels | |
| 89 | + | [un]set <name> key ... Remove session labels | |
| 90 | + | [cl]ear <name> Clear all session labels | |
| 87 | 91 | [k]ill <name>... [--force] Kill session and all attached clients | |
| 88 | 92 | [hi]story <name> [--vt|--html] Output session scrollback | |
| 89 | 93 | [w]ait <name>... Wait for session tasks to complete |
| ... | ... | @@ -286,6 +290,25 @@ zmx k tests # kills d.tests | |
| 286 | 290 | zmx wait # suspends until all tasks prefixed with "d." are complete | |
| 287 | 291 | ``` | |
| 288 | 292 | ||
| 293 | + | ## label inheritance | |
| 294 | + | ||
| 295 | + | When creating a new session from inside an existing one (`$ZMX_SESSION` is set), labels are automatically inherited from the parent session. This means if you set `project=zmx` on a session, any child sessions created from within it will also have `project=zmx`. | |
| 296 | + | ||
| 297 | + | To control which labels are inherited, set `ZMX_INHERIT_LABELS`: | |
| 298 | + | ||
| 299 | + | ```bash | |
| 300 | + | # Inherit all labels (default) | |
| 301 | + | zmx a child | |
| 302 | + | ||
| 303 | + | # Inherit no labels | |
| 304 | + | export ZMX_INHERIT_LABELS= | |
| 305 | + | zmx a child | |
| 306 | + | ||
| 307 | + | # Inherit only specific labels | |
| 308 | + | export ZMX_INHERIT_LABELS=project,team | |
| 309 | + | zmx a child | |
| 310 | + | ``` | |
| 311 | + | ||
| 289 | 312 | ## philosophy | |
| 290 | 313 | ||
| 291 | 314 | The entire argument for `zmx` instead of something like `tmux` that has windows, panes, splits, etc. is that job should be handled by your os window manager. By using something like `tmux` you now have redundant functionality in your dev stack: a window manager for your os and a window manager for your terminal. Further, in order to use modern terminal features, your terminal emulator **and** `tmux` need to have support for them. This holds back the terminal enthusiast community and feature development. |
+3,
-0
| ... | ... | @@ -37,6 +37,7 @@ pub fn build(b: *std.Build) void { | |
| 37 | 37 | // on PATH" (true even via the CLT stub), which pulls in the iOS SDK at | |
| 38 | 38 | // configure time and breaks builds without full Xcode. | |
| 39 | 39 | .@"emit-xcframework" = false, | |
| 40 | + | .@"emit-macos-app" = false, | |
| 40 | 41 | }); | |
| 41 | 42 | exe_mod.addImport( | |
| 42 | 43 | "ghostty-vt", |
| ... | ... | @@ -73,6 +74,7 @@ pub fn build(b: *std.Build) void { | |
| 73 | 74 | .optimize = optimize, | |
| 74 | 75 | .@"emit-lib-vt" = true, | |
| 75 | 76 | .@"emit-xcframework" = false, | |
| 77 | + | .@"emit-macos-app" = false, | |
| 76 | 78 | }); | |
| 77 | 79 | test_module.addImport( | |
| 78 | 80 | "ghostty-vt", |
| ... | ... | @@ -126,6 +128,7 @@ pub fn build(b: *std.Build) void { | |
| 126 | 128 | .optimize = .ReleaseSafe, | |
| 127 | 129 | .@"emit-lib-vt" = true, | |
| 128 | 130 | .@"emit-xcframework" = false, | |
| 131 | + | .@"emit-macos-app" = false, | |
| 129 | 132 | })) |release_dep| { | |
| 130 | 133 | release_mod.addImport("ghostty-vt", release_dep.module("ghostty-vt")); | |
| 131 | 134 | } |
M
pico.sh
+1,
-0
| ... | ... | @@ -3,6 +3,7 @@ set -euo pipefail | |
| 3 | 3 | ||
| 4 | 4 | export ZMX_SESSION_PREFIX="${ZMX_SESSION_PREFIX:-ci.zmx.}" | |
| 5 | 5 | EVENT="${PICI_EVENT:-manual}" | |
| 6 | + | BRANCH="${PICI_BRANCH:-tmp}" | |
| 6 | 7 | ||
| 7 | 8 | echo "running ci event=${EVENT} session=${ZMX_SESSION_PREFIX}" | |
| 8 | 9 |
+24,
-4
| ... | ... | @@ -32,7 +32,7 @@ const bash_completions = | |
| 32 | 32 | \\ cur="${COMP_WORDS[COMP_CWORD]}" | |
| 33 | 33 | \\ prev="${COMP_WORDS[COMP_CWORD-1]}" | |
| 34 | 34 | \\ | |
| 35 | - | \\ local commands="attach run send print write detach list kill history wait tail completions version help" | |
| 35 | + | \\ local commands="attach run send print write detach list kill history get set clear wait tail completions version help" | |
| 36 | 36 | \\ | |
| 37 | 37 | \\ if [[ $COMP_CWORD -eq 1 ]]; then | |
| 38 | 38 | \\ COMPREPLY=($(compgen -W "$commands" -- "$cur")) |
| ... | ... | @@ -40,7 +40,7 @@ const bash_completions = | |
| 40 | 40 | \\ fi | |
| 41 | 41 | \\ | |
| 42 | 42 | \\ case "$prev" in | |
| 43 | - | \\ attach|run|send|print|write|kill|history|wait|tail) | |
| 43 | + | \\ attach|run|send|print|write|kill|history|get|set|clear|wait|tail) | |
| 44 | 44 | \\ local sessions=$(zmx list --short 2>/dev/null | tr '\n' ' ') | |
| 45 | 45 | \\ COMPREPLY=($(compgen -W "$sessions" -- "$cur")) | |
| 46 | 46 | \\ ;; |
| ... | ... | @@ -86,6 +86,9 @@ const zsh_completions = | |
| 86 | 86 | \\ 'wait:Wait for session tasks to complete' | |
| 87 | 87 | \\ 'tail:Follow session output' | |
| 88 | 88 | \\ 'completions:Shell completion scripts' | |
| 89 | + | \\ 'get:Get session labels' | |
| 90 | + | \\ 'set:Set session labels' | |
| 91 | + | \\ 'clear:Clear all session labels' | |
| 89 | 92 | \\ 'version:Show version' | |
| 90 | 93 | \\ 'help:Show help message' | |
| 91 | 94 | \\ ) |
| ... | ... | @@ -93,7 +96,7 @@ const zsh_completions = | |
| 93 | 96 | \\ ;; | |
| 94 | 97 | \\ args) | |
| 95 | 98 | \\ case $words[2] in | |
| 96 | - | \\ attach|a|kill|k|run|r|send|s|print|p|write|wr|history|hi|wait|w|tail|t) | |
| 99 | + | \\ attach|a|kill|k|run|r|send|s|print|p|write|wr|history|get|g|set|clear|hi|wait|w|tail|t) | |
| 97 | 100 | \\ _zmx_sessions | |
| 98 | 101 | \\ ;; | |
| 99 | 102 | \\ completions|c) |
| ... | ... | @@ -145,10 +148,13 @@ const fish_completions = | |
| 145 | 148 | \\complete -c zmx -n "__fish_is_nth_token 1" -a tail -d 'Follow session output' | |
| 146 | 149 | \\complete -c zmx -n "__fish_is_nth_token 1" -a completions -d 'Shell completions (bash, zsh, fish, nu)' | |
| 147 | 150 | \\complete -c zmx -n "__fish_is_nth_token 1" -a version -d 'Show version' | |
| 151 | + | \\complete -c zmx -n "__fish_is_nth_token 1" -a get -d 'Get session labels' | |
| 152 | + | \\complete -c zmx -n "__fish_is_nth_token 1" -a set -d 'Set session labels' | |
| 153 | + | \\complete -c zmx -n "__fish_is_nth_token 1" -a clear -d 'Clear all session labels' | |
| 148 | 154 | \\complete -c zmx -n "__fish_is_nth_token 1" -a help -d 'Show help message' | |
| 149 | 155 | \\ | |
| 150 | 156 | \\# Complete session names and shells | |
| 151 | - | \\complete -c zmx -n "__fish_is_nth_token 2; and __fish_seen_subcommand_from a attach r run s send p print wr write hi history" -a '(zmx list --short 2>/dev/null)' -d 'Session name' | |
| 157 | + | \\complete -c zmx -n "__fish_is_nth_token 2; and __fish_seen_subcommand_from a attach r run s send p print wr write hi history g get se set cl clear" -a '(zmx list --short 2>/dev/null)' -d 'Session name' | |
| 152 | 158 | \\complete -c zmx -n "not __fish_is_nth_token 1; and __fish_seen_subcommand_from k kill w wait t tail" -a '(zmx list --short 2>/dev/null)' -d 'Session name' | |
| 153 | 159 | \\ | |
| 154 | 160 | \\complete -c zmx -n "__fish_is_nth_token 2; and __fish_seen_subcommand_from c completions" -a 'bash zsh fish nu' -d Shell |
| ... | ... | @@ -157,6 +163,7 @@ const fish_completions = | |
| 157 | 163 | \\complete -c zmx -n "__fish_seen_subcommand_from r run" -s d -d 'Detach from the calling terminal; use `wait` to track its status' | |
| 158 | 164 | \\complete -c zmx -n "__fish_seen_subcommand_from r run" -l fish -d 'Required when the session runs fish shell' | |
| 159 | 165 | \\complete -c zmx -n "__fish_seen_subcommand_from l list" -l short -d 'Short output' | |
| 166 | + | \\complete -c zmx -n "__fish_seen_subcommand_from l list" -l where -d 'Filter by label (key=value)' -r | |
| 160 | 167 | \\complete -c zmx -n "__fish_seen_subcommand_from k kill" -l force -d 'Force kill' | |
| 161 | 168 | \\complete -c zmx -n "__fish_seen_subcommand_from hi history" -l vt -d 'History format for escape sequences' | |
| 162 | 169 | \\complete -c zmx -n "__fish_seen_subcommand_from hi history" -l html -d 'History format for escape sequences' |
| ... | ... | @@ -210,5 +217,18 @@ const nu_completions = | |
| 210 | 217 | \\export extern "zmx tail" [...sessions: string@"nu-complete zmx sessions"] | |
| 211 | 218 | \\export extern "zmx version" [] | |
| 212 | 219 | \\export extern "completions" [shell: string@"nu-complete zmx complete"] | |
| 220 | + | \\export extern "zmx get" [ | |
| 221 | + | \\ name?: string@"nu-complete zmx sessions" | |
| 222 | + | \\] | |
| 223 | + | \\ | |
| 224 | + | \\export extern "zmx set" [ | |
| 225 | + | \\ name?: string@"nu-complete zmx sessions" | |
| 226 | + | \\ ...pairs: string | |
| 227 | + | \\] | |
| 228 | + | \\ | |
| 229 | + | \\export extern "zmx clear" [ | |
| 230 | + | \\ name?: string@"nu-complete zmx sessions" | |
| 231 | + | \\] | |
| 232 | + | \\ | |
| 213 | 233 | \\export extern "zmx help" [] | |
| 214 | 234 | ; |
+81,
-13
| ... | ... | @@ -18,6 +18,10 @@ pub const Tag = enum(u8) { | |
| 18 | 18 | Switch = 11, | |
| 19 | 19 | Write = 12, | |
| 20 | 20 | TaskComplete = 13, | |
| 21 | + | LabelGet = 14, | |
| 22 | + | LabelSet = 15, | |
| 23 | + | LabelClear = 16, | |
| 24 | + | LabelData = 17, | |
| 21 | 25 | // Non-exhaustive: this enum comes off the wire via bytesToValue and | |
| 22 | 26 | // @enumFromInt, so out-of-range values (14-255) are representable | |
| 23 | 27 | // rather than UB. Switches must handle `_` (unknown tag). |
| ... | ... | @@ -213,6 +217,13 @@ const SessionProbeError = error{ | |
| 213 | 217 | const SessionProbeResult = struct { | |
| 214 | 218 | fd: i32, | |
| 215 | 219 | info: Info, | |
| 220 | + | labels: ?[]const u8, | |
| 221 | + | alloc: std.mem.Allocator, | |
| 222 | + | ||
| 223 | + | pub fn deinit(self: *const SessionProbeResult) void { | |
| 224 | + | if (self.labels) |lbl| self.alloc.free(lbl); | |
| 225 | + | posix.close(self.fd); | |
| 226 | + | } | |
| 216 | 227 | }; | |
| 217 | 228 | ||
| 218 | 229 | pub fn probeSession( |
| ... | ... | @@ -224,6 +235,7 @@ pub fn probeSession( | |
| 224 | 235 | errdefer posix.close(fd); | |
| 225 | 236 | ||
| 226 | 237 | send(fd, .Info, "") catch return error.Unexpected; | |
| 238 | + | send(fd, .LabelGet, "") catch {}; | |
| 227 | 239 | ||
| 228 | 240 | var poll_fds = [_]posix.pollfd{.{ .fd = fd, .events = posix.POLL.IN, .revents = 0 }}; | |
| 229 | 241 | const poll_result = posix.poll(&poll_fds, timeout_ms) catch return error.Unexpected; |
| ... | ... | @@ -237,19 +249,43 @@ pub fn probeSession( | |
| 237 | 249 | const n = sb.read(fd) catch return error.Unexpected; | |
| 238 | 250 | if (n == 0) return error.Unexpected; | |
| 239 | 251 | ||
| 240 | - | while (sb.next()) |msg| { | |
| 241 | - | if (msg.header.tag == .Info) { | |
| 242 | - | if (msg.payload.len != @sizeOf(Info)) return error.InfoSizeMismatch; | |
| 243 | - | return .{ | |
| 244 | - | .fd = fd, | |
| 245 | - | .info = std.mem.bytesToValue(Info, msg.payload[0..@sizeOf(Info)]), | |
| 246 | - | }; | |
| 252 | + | var info_result: ?Info = null; | |
| 253 | + | var labels: ?[]const u8 = null; | |
| 254 | + | errdefer if (labels) |lbl| alloc.free(lbl); | |
| 255 | + | ||
| 256 | + | while (true) { | |
| 257 | + | if (sb.next()) |msg| { | |
| 258 | + | if (msg.header.tag == .Info) { | |
| 259 | + | if (msg.payload.len != @sizeOf(Info)) return error.InfoSizeMismatch; | |
| 260 | + | info_result = std.mem.bytesToValue(Info, msg.payload[0..@sizeOf(Info)]); | |
| 261 | + | } | |
| 262 | + | if (msg.header.tag == .LabelData) { | |
| 263 | + | labels = alloc.dupe(u8, msg.payload) catch null; | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | if (info_result != null and labels != null) break; | |
| 267 | + | continue; | |
| 247 | 268 | } | |
| 269 | + | ||
| 270 | + | // No complete message available, wait for more data | |
| 271 | + | const more = posix.poll(&poll_fds, 50) catch break; | |
| 272 | + | if (more == 0) break; | |
| 273 | + | const n_read = sb.read(fd) catch break; | |
| 274 | + | if (n_read == 0) break; | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | if (info_result) |info| { | |
| 278 | + | return .{ | |
| 279 | + | .fd = fd, | |
| 280 | + | .info = info, | |
| 281 | + | .labels = labels, | |
| 282 | + | .alloc = alloc, | |
| 283 | + | }; | |
| 248 | 284 | } | |
| 249 | 285 | return error.Unexpected; | |
| 250 | 286 | } | |
| 251 | 287 | ||
| 252 | - | // WIRE PROTOCOL FREEZE — read before "fixing" any test below. | |
| 288 | + | // WIRE PROTOCOL FREEZE: read before "fixing" any test below. | |
| 253 | 289 | // | |
| 254 | 290 | // Changing these constants does not fix the test; it breaks every | |
| 255 | 291 | // running daemon for every user until they `pkill -f zmx`. |
| ... | ... | @@ -264,14 +300,46 @@ test "Info wire size is frozen" { | |
| 264 | 300 | ||
| 265 | 301 | test "Tag wire values are frozen" { | |
| 266 | 302 | inline for (.{ | |
| 267 | - | .{ Tag.Input, 0 }, .{ Tag.Output, 1 }, .{ Tag.Resize, 2 }, | |
| 268 | - | .{ Tag.Detach, 3 }, .{ Tag.DetachAll, 4 }, .{ Tag.Kill, 5 }, | |
| 269 | - | .{ Tag.Info, 6 }, .{ Tag.Init, 7 }, .{ Tag.History, 8 }, | |
| 270 | - | .{ Tag.Run, 9 }, .{ Tag.Ack, 10 }, .{ Tag.Switch, 11 }, | |
| 271 | - | .{ Tag.Write, 12 }, .{ Tag.TaskComplete, 13 }, | |
| 303 | + | .{ Tag.Input, 0 }, .{ Tag.Output, 1 }, .{ Tag.Resize, 2 }, | |
| 304 | + | .{ Tag.Detach, 3 }, .{ Tag.DetachAll, 4 }, .{ Tag.Kill, 5 }, | |
| 305 | + | .{ Tag.Info, 6 }, .{ Tag.Init, 7 }, .{ Tag.History, 8 }, | |
| 306 | + | .{ Tag.Run, 9 }, .{ Tag.Ack, 10 }, .{ Tag.Switch, 11 }, | |
| 307 | + | .{ Tag.Write, 12 }, .{ Tag.TaskComplete, 13 }, .{ Tag.LabelGet, 14 }, | |
| 308 | + | .{ Tag.LabelSet, 15 }, .{ Tag.LabelClear, 16 }, .{ Tag.LabelData, 17 }, | |
| 272 | 309 | }) |p| try std.testing.expectEqual(@as(u8, p[1]), @intFromEnum(p[0])); | |
| 273 | 310 | } | |
| 274 | 311 | ||
| 312 | + | pub fn roundTripForTag( | |
| 313 | + | alloc: std.mem.Allocator, | |
| 314 | + | socket_path: []const u8, | |
| 315 | + | request_tag: Tag, | |
| 316 | + | payload: []const u8, | |
| 317 | + | expected_tag: Tag, | |
| 318 | + | ) SessionProbeError![]u8 { | |
| 319 | + | const timeout_ms = 1000; | |
| 320 | + | const fd = try connectSession(socket_path); | |
| 321 | + | defer posix.close(fd); | |
| 322 | + | ||
| 323 | + | send(fd, request_tag, payload) catch return error.Unexpected; | |
| 324 | + | ||
| 325 | + | var poll_fds = [_]posix.pollfd{.{ .fd = fd, .events = posix.POLL.IN, .revents = 0 }}; | |
| 326 | + | const poll_result = posix.poll(&poll_fds, timeout_ms) catch return error.Unexpected; | |
| 327 | + | if (poll_result == 0) return error.Timeout; | |
| 328 | + | ||
| 329 | + | var sb = SocketBuffer.init(alloc) catch return error.Unexpected; | |
| 330 | + | defer sb.deinit(); | |
| 331 | + | ||
| 332 | + | const n = sb.read(fd) catch return error.Unexpected; | |
| 333 | + | if (n == 0) return error.Unexpected; | |
| 334 | + | ||
| 335 | + | while (sb.next()) |msg| { | |
| 336 | + | if (msg.header.tag == expected_tag) { | |
| 337 | + | return alloc.dupe(u8, msg.payload) catch return error.Unexpected; | |
| 338 | + | } | |
| 339 | + | } | |
| 340 | + | return error.Unexpected; | |
| 341 | + | } | |
| 342 | + | ||
| 275 | 343 | test "zeroed Info has no stack garbage in wire bytes" { | |
| 276 | 344 | var info = std.mem.zeroes(Info); | |
| 277 | 345 | info.clients_len = 3; |
+145,
-0
| ... | ... | @@ -0,0 +1,145 @@ | |
| 1 | + | const std = @import("std"); | |
| 2 | + | ||
| 3 | + | pub const LabelError = error{ | |
| 4 | + | LabelKeyEmpty, | |
| 5 | + | LabelKeyInvalidChar, | |
| 6 | + | LabelValueInvalidChar, | |
| 7 | + | LabelKeyReservedName, | |
| 8 | + | }; | |
| 9 | + | ||
| 10 | + | const reserved_keys = [_][]const u8{ "name", "start_dir", "cmd" }; | |
| 11 | + | ||
| 12 | + | fn isAlnum(c: u8) bool { | |
| 13 | + | return (c >= 'a' and c <= 'z') or | |
| 14 | + | (c >= 'A' and c <= 'Z') or | |
| 15 | + | (c >= '0' and c <= '9'); | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | pub fn assertLabel(key: []const u8, value: []const u8) LabelError!void { | |
| 19 | + | if (key.len == 0) { | |
| 20 | + | return LabelError.LabelKeyEmpty; | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | for (reserved_keys) |rk| { | |
| 24 | + | if (std.mem.eql(u8, key, rk)) return error.LabelKeyReservedName; | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | for (key) |ch| { | |
| 28 | + | if (!isAlnum(ch) and ch != '-' and ch != '_' and ch != '.') { | |
| 29 | + | return LabelError.LabelKeyInvalidChar; | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | for (value) |ch| { | |
| 34 | + | if (!isAlnum(ch) and ch != '-' and ch != '_' and ch != '.') { | |
| 35 | + | return LabelError.LabelValueInvalidChar; | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | pub fn labelsToU8(alloc: std.mem.Allocator, labels: std.StringHashMapUnmanaged([]u8)) ![]u8 { | |
| 41 | + | var out = std.ArrayList(u8).empty; | |
| 42 | + | var keys = std.ArrayList([]const u8).empty; | |
| 43 | + | defer keys.deinit(alloc); | |
| 44 | + | ||
| 45 | + | var it = labels.iterator(); | |
| 46 | + | while (it.next()) |entry| { | |
| 47 | + | try keys.append(alloc, entry.key_ptr.*); | |
| 48 | + | } | |
| 49 | + | std.mem.sort([]const u8, keys.items, {}, struct { | |
| 50 | + | fn lessThan(_: void, a: []const u8, b: []const u8) bool { | |
| 51 | + | return std.mem.order(u8, a, b) == .lt; | |
| 52 | + | } | |
| 53 | + | }.lessThan); | |
| 54 | + | ||
| 55 | + | var idx: usize = 1; | |
| 56 | + | for (keys.items) |key| { | |
| 57 | + | defer idx += 1; | |
| 58 | + | const value = labels.get(key).?; | |
| 59 | + | try out.appendSlice(alloc, key); | |
| 60 | + | try out.append(alloc, '='); | |
| 61 | + | try out.appendSlice(alloc, value); | |
| 62 | + | if (idx < keys.items.len) { | |
| 63 | + | try out.append(alloc, ' '); | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | return out.toOwnedSlice(alloc); | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | pub const LabelIterator = struct { | |
| 70 | + | labels: []const u8, | |
| 71 | + | idx: usize = 0, | |
| 72 | + | ||
| 73 | + | const LabelKeyValue = struct { | |
| 74 | + | key: []const u8, | |
| 75 | + | value: []const u8, | |
| 76 | + | }; | |
| 77 | + | ||
| 78 | + | pub fn init(labels: []const u8) LabelIterator { | |
| 79 | + | return .{ | |
| 80 | + | .labels = labels, | |
| 81 | + | }; | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | pub fn next(self: *LabelIterator) ?LabelKeyValue { | |
| 85 | + | const labels = self.labels; | |
| 86 | + | while (self.idx < labels.len) { | |
| 87 | + | var eql_idx = self.idx; | |
| 88 | + | // scan to '=' char | |
| 89 | + | while (eql_idx < labels.len and labels[eql_idx] != '=') eql_idx += 1; | |
| 90 | + | if (eql_idx == labels.len) break; | |
| 91 | + | ||
| 92 | + | var space_idx = eql_idx + 1; | |
| 93 | + | // scan to ' ' char | |
| 94 | + | while (space_idx < labels.len and labels[space_idx] != ' ') space_idx += 1; | |
| 95 | + | ||
| 96 | + | const kv = LabelKeyValue{ | |
| 97 | + | .key = labels[self.idx..eql_idx], | |
| 98 | + | .value = labels[eql_idx + 1 .. space_idx], | |
| 99 | + | }; | |
| 100 | + | // move the pointer so next() will start where it left off | |
| 101 | + | self.idx = if (space_idx < labels.len) space_idx + 1 else labels.len; | |
| 102 | + | return kv; | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | return null; | |
| 106 | + | } | |
| 107 | + | }; | |
| 108 | + | ||
| 109 | + | pub fn getLabelValueFromPairs(single_kv: []const u8, labels: []const u8) error{LabelKeyNotFound}![]const u8 { | |
| 110 | + | var iter = LabelIterator.init(labels); | |
| 111 | + | while (iter.next()) |kv| { | |
| 112 | + | if (std.mem.eql(u8, single_kv, kv.key)) { | |
| 113 | + | return kv.value; | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | return error.LabelKeyNotFound; | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | test "getLabelValueFromPairs" { | |
| 120 | + | try std.testing.expect(std.mem.eql(u8, "zmx", try getLabelValueFromPairs("project", "project=zmx env=prd"))); | |
| 121 | + | try std.testing.expect(std.mem.eql(u8, "zmx", try getLabelValueFromPairs("project", "env=prd status=done project=zmx"))); | |
| 122 | + | try std.testing.expectError(error.LabelKeyNotFound, getLabelValueFromPairs("sha", "env=prd status=done project=zmx")); | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | test "assertLabel" { | |
| 126 | + | try assertLabel("key", ""); | |
| 127 | + | try assertLabel("1337", ""); | |
| 128 | + | try assertLabel("key.key_key-key", ""); | |
| 129 | + | try std.testing.expectError(error.LabelKeyEmpty, assertLabel("", "value")); | |
| 130 | + | try std.testing.expectError(error.LabelKeyInvalidChar, assertLabel("key key", "")); | |
| 131 | + | try std.testing.expectError(error.LabelKeyInvalidChar, assertLabel("key:key", "")); | |
| 132 | + | try std.testing.expectError(error.LabelKeyInvalidChar, assertLabel("key/key", "")); | |
| 133 | + | ||
| 134 | + | try assertLabel("key", ""); | |
| 135 | + | try assertLabel("key", "1337"); | |
| 136 | + | try assertLabel("key", "value"); | |
| 137 | + | try assertLabel("key", "value.value_value-value"); | |
| 138 | + | try std.testing.expectError(error.LabelValueInvalidChar, assertLabel("key", "value value")); | |
| 139 | + | try std.testing.expectError(error.LabelValueInvalidChar, assertLabel("key", "value:value")); | |
| 140 | + | try std.testing.expectError(error.LabelValueInvalidChar, assertLabel("key", "value/value")); | |
| 141 | + | ||
| 142 | + | try std.testing.expectError(error.LabelKeyReservedName, assertLabel("name", "dev")); | |
| 143 | + | try std.testing.expectError(error.LabelKeyReservedName, assertLabel("start_dir", "dev")); | |
| 144 | + | try std.testing.expectError(error.LabelKeyReservedName, assertLabel("cmd", "dev")); | |
| 145 | + | } |
+267,
-12
| ... | ... | @@ -8,6 +8,7 @@ const completions = @import("completions.zig"); | |
| 8 | 8 | const util = @import("util.zig"); | |
| 9 | 9 | const cross = @import("cross.zig"); | |
| 10 | 10 | const socket = @import("socket.zig"); | |
| 11 | + | const label = @import("label.zig"); | |
| 11 | 12 | ||
| 12 | 13 | pub const version = build_options.version; | |
| 13 | 14 | pub const ghostty_version = build_options.ghostty_version; |
| ... | ... | @@ -46,6 +47,25 @@ const SessionMatch = struct { | |
| 46 | 47 | } | |
| 47 | 48 | }; | |
| 48 | 49 | ||
| 50 | + | fn resolveSessionOrEnv(alloc: std.mem.Allocator, session_name: ?[]const u8) ![]const u8 { | |
| 51 | + | const sesh_env = socket.getSeshNameFromEnv(); | |
| 52 | + | const raw = if (session_name) |name| | |
| 53 | + | if (std.mem.eql(u8, name, ".")) blk: { | |
| 54 | + | if (sesh_env.len > 0) break :blk sesh_env; | |
| 55 | + | var buf: [4096]u8 = undefined; | |
| 56 | + | var w = std.fs.File.stderr().writer(&buf); | |
| 57 | + | w.interface.print("error: \".\" requires ZMX_SESSION (are you inside a zmx session?)\n", .{}) catch {}; | |
| 58 | + | w.interface.flush() catch {}; | |
| 59 | + | return error.SessionNameRequired; | |
| 60 | + | } else name | |
| 61 | + | else if (sesh_env.len > 0) | |
| 62 | + | sesh_env | |
| 63 | + | else { | |
| 64 | + | return error.SessionNameRequired; | |
| 65 | + | }; | |
| 66 | + | return socket.getSeshName(alloc, raw); | |
| 67 | + | } | |
| 68 | + | ||
| 49 | 69 | fn parseSessionArg(alloc: std.mem.Allocator, raw: []const u8) !SessionMatch { | |
| 50 | 70 | if (raw.len > 0 and raw[raw.len - 1] == '*') { | |
| 51 | 71 | const name = try socket.getSeshName(alloc, raw[0 .. raw.len - 1]); |
| ... | ... | @@ -67,6 +87,10 @@ fn drainSignalPipe() void { | |
| 67 | 87 | } | |
| 68 | 88 | } | |
| 69 | 89 | ||
| 90 | + | fn detectHelp(arg: []const u8) bool { | |
| 91 | + | return (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")); | |
| 92 | + | } | |
| 93 | + | ||
| 70 | 94 | pub fn main() !void { | |
| 71 | 95 | // use c_allocator to avoid "reached unreachable code" panic in DebugAllocator when forking | |
| 72 | 96 | const alloc = std.heap.c_allocator; |
| ... | ... | @@ -99,13 +123,39 @@ pub fn main() !void { | |
| 99 | 123 | return help(); | |
| 100 | 124 | } else if (std.mem.eql(u8, cmd, "list") or std.mem.eql(u8, cmd, "l") or std.mem.eql(u8, cmd, "ls")) { | |
| 101 | 125 | var short = false; | |
| 102 | - | if (args.next()) |arg| { | |
| 103 | - | if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) { | |
| 104 | - | return help(); | |
| 105 | - | } | |
| 106 | - | short = std.mem.eql(u8, arg, "--short"); | |
| 126 | + | while (args.next()) |arg| { | |
| 127 | + | if (detectHelp(arg)) return help(); | |
| 128 | + | if (std.mem.eql(u8, arg, "--short")) short = true; | |
| 107 | 129 | } | |
| 108 | 130 | return list(&cfg, short); | |
| 131 | + | } else if (std.mem.eql(u8, cmd, "get") or std.mem.eql(u8, cmd, "g")) { | |
| 132 | + | const sesh_name = args.next() orelse return error.SessionNameRequired; | |
| 133 | + | if (detectHelp(sesh_name)) return help(); | |
| 134 | + | const sesh = try resolveSessionOrEnv(alloc, sesh_name); | |
| 135 | + | defer alloc.free(sesh); | |
| 136 | + | const single_kv = args.next() orelse ""; | |
| 137 | + | return labelGet(&cfg, sesh, single_kv); | |
| 138 | + | } else if (std.mem.eql(u8, cmd, "set")) { | |
| 139 | + | const sesh_name = args.next() orelse return error.SessionNameRequired; | |
| 140 | + | if (detectHelp(sesh_name)) return help(); | |
| 141 | + | const sesh = try resolveSessionOrEnv(alloc, sesh_name); | |
| 142 | + | defer alloc.free(sesh); | |
| 143 | + | ||
| 144 | + | var kvs = std.ArrayList(u8).empty; | |
| 145 | + | defer kvs.deinit(alloc); | |
| 146 | + | var first = true; | |
| 147 | + | while (args.next()) |arg| { | |
| 148 | + | if (!first) try kvs.append(alloc, ' '); | |
| 149 | + | try kvs.appendSlice(alloc, arg); | |
| 150 | + | first = false; | |
| 151 | + | } | |
| 152 | + | return labelSet(&cfg, sesh, kvs.items); | |
| 153 | + | } else if (std.mem.eql(u8, cmd, "clear")) { | |
| 154 | + | const sesh_name = args.next() orelse return error.SessionNameRequired; | |
| 155 | + | if (detectHelp(sesh_name)) return help(); | |
| 156 | + | const sesh = try resolveSessionOrEnv(alloc, sesh_name); | |
| 157 | + | defer alloc.free(sesh); | |
| 158 | + | return labelClear(&cfg, sesh); | |
| 109 | 159 | } else if (std.mem.eql(u8, cmd, "completions") or std.mem.eql(u8, cmd, "c")) { | |
| 110 | 160 | const arg = args.next() orelse return; | |
| 111 | 161 | if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) { |
| ... | ... | @@ -187,9 +237,9 @@ pub fn main() !void { | |
| 187 | 237 | while (args.next()) |arg| { | |
| 188 | 238 | if (std.mem.startsWith(u8, arg, "-d")) { | |
| 189 | 239 | detached = true; | |
| 190 | - | continue; | |
| 240 | + | } else { | |
| 241 | + | try cmd_args_raw.append(alloc, arg); | |
| 191 | 242 | } | |
| 192 | - | try cmd_args_raw.append(alloc, arg); | |
| 193 | 243 | } | |
| 194 | 244 | const clients = try std.ArrayList(*Client).initCapacity(alloc, 10); | |
| 195 | 245 |
| ... | ... | @@ -604,6 +654,7 @@ const Daemon = struct { | |
| 604 | 654 | cfg: *Cfg, | |
| 605 | 655 | alloc: std.mem.Allocator, | |
| 606 | 656 | clients: std.ArrayList(*Client), | |
| 657 | + | labels: std.StringHashMapUnmanaged([]u8) = .empty, | |
| 607 | 658 | // This control which client is the leader. The leader controls terminal state and | |
| 608 | 659 | // cols/rows of session. | |
| 609 | 660 | leader_client_fd: ?i32, |
| ... | ... | @@ -630,10 +681,64 @@ const Daemon = struct { | |
| 630 | 681 | ||
| 631 | 682 | pub fn deinit(self: *Daemon) void { | |
| 632 | 683 | self.clients.deinit(self.alloc); | |
| 684 | + | var it = self.labels.iterator(); | |
| 685 | + | while (it.next()) |entry| { | |
| 686 | + | self.alloc.free(entry.key_ptr.*); | |
| 687 | + | self.alloc.free(entry.value_ptr.*); | |
| 688 | + | } | |
| 689 | + | self.labels.deinit(self.alloc); | |
| 633 | 690 | self.pty_write_buf.deinit(self.alloc); | |
| 634 | 691 | self.alloc.free(self.socket_path); | |
| 635 | 692 | } | |
| 636 | 693 | ||
| 694 | + | fn handleLabelGet(self: *Daemon, client: *Client) !void { | |
| 695 | + | const out = try label.labelsToU8(self.alloc, self.labels); | |
| 696 | + | defer self.alloc.free(out); | |
| 697 | + | try ipc.appendMessage(self.alloc, &client.write_buf, .LabelData, out); | |
| 698 | + | client.has_pending_output = true; | |
| 699 | + | } | |
| 700 | + | ||
| 701 | + | fn handleLabelSet(self: *Daemon, client: *Client, labels: []const u8) !void { | |
| 702 | + | std.log.info("handle label set payload={s}", .{labels}); | |
| 703 | + | ||
| 704 | + | var kvs = label.LabelIterator.init(labels); | |
| 705 | + | while (kvs.next()) |kv| { | |
| 706 | + | if (kv.value.len == 0) { | |
| 707 | + | if (self.labels.fetchRemove(kv.key)) |existing| { | |
| 708 | + | self.alloc.free(existing.key); | |
| 709 | + | self.alloc.free(existing.value); | |
| 710 | + | } | |
| 711 | + | continue; | |
| 712 | + | } | |
| 713 | + | ||
| 714 | + | const owned_key = try self.alloc.dupe(u8, kv.key); | |
| 715 | + | errdefer self.alloc.free(owned_key); | |
| 716 | + | const owned_value = try self.alloc.dupe(u8, kv.value); | |
| 717 | + | errdefer self.alloc.free(owned_value); | |
| 718 | + | if (try self.labels.fetchPut(self.alloc, owned_key, owned_value)) |existing| { | |
| 719 | + | // fetchPut does NOT replace the key in the map, the old | |
| 720 | + | // key pointer stays. So free the new (unused) key and the | |
| 721 | + | // old value. | |
| 722 | + | self.alloc.free(owned_key); | |
| 723 | + | self.alloc.free(existing.value); | |
| 724 | + | } | |
| 725 | + | } | |
| 726 | + | ||
| 727 | + | try ipc.appendMessage(self.alloc, &client.write_buf, .Ack, ""); | |
| 728 | + | client.has_pending_output = true; | |
| 729 | + | } | |
| 730 | + | ||
| 731 | + | fn handleLabelClear(self: *Daemon, client: *Client) !void { | |
| 732 | + | var it = self.labels.iterator(); | |
| 733 | + | while (it.next()) |entry| { | |
| 734 | + | self.alloc.free(entry.key_ptr.*); | |
| 735 | + | self.alloc.free(entry.value_ptr.*); | |
| 736 | + | } | |
| 737 | + | self.labels.clearRetainingCapacity(); | |
| 738 | + | try ipc.appendMessage(self.alloc, &client.write_buf, .Ack, ""); | |
| 739 | + | client.has_pending_output = true; | |
| 740 | + | } | |
| 741 | + | ||
| 637 | 742 | pub fn shutdown(self: *Daemon) void { | |
| 638 | 743 | std.log.info("shutting down daemon session={s}", .{self.session_name}); | |
| 639 | 744 | self.running = false; |
| ... | ... | @@ -762,6 +867,7 @@ const Daemon = struct { | |
| 762 | 867 | /// ensureSession "upserts" a session by checking if the unix socket exists already. | |
| 763 | 868 | /// If not it creates one and spawns the daemon. | |
| 764 | 869 | fn ensureSession(self: *Daemon) !EnsureSessionResult { | |
| 870 | + | std.log.info("ensure session session={s}", .{self.session_name}); | |
| 765 | 871 | var dir = try std.fs.openDirAbsolute(self.cfg.socket_dir, .{}); | |
| 766 | 872 | defer dir.close(); | |
| 767 | 873 |
| ... | ... | @@ -889,6 +995,7 @@ const Daemon = struct { | |
| 889 | 995 | } | |
| 890 | 996 | ||
| 891 | 997 | try daemonLoop(self, server_sock_fd, pty_fd); | |
| 998 | + | std.log.info("daemon loop shutdown", .{}); | |
| 892 | 999 | return .{ .created = true, .is_daemon = true }; | |
| 893 | 1000 | } | |
| 894 | 1001 | posix.close(server_sock_fd); |
| ... | ... | @@ -1287,7 +1394,10 @@ fn help() !void { | |
| 1287 | 1394 | \\ [p]rint <name> <text...> Inject text into session display | |
| 1288 | 1395 | \\ [wr]ite <name> <file_path> Write stdin to file_path through the session | |
| 1289 | 1396 | \\ [d]etach Detach all clients (ctrl+\\ for current client) | |
| 1290 | - | \\ [l]ist|ls [--short] List active sessions | |
| 1397 | + | \\ [l]ist|ls [--short|--where k=v] List active sessions | |
| 1398 | + | \\ [g]et <name> Get session labels | |
| 1399 | + | \\ set <name> k=v ... Set session labels (k= to remove) | |
| 1400 | + | \\ [cl]ear <name> Clear all session labels | |
| 1291 | 1401 | \\ [k]ill <name>... [--force] Kill session and all attached clients | |
| 1292 | 1402 | \\ [hi]story <name> [--vt|--html] Output session scrollback | |
| 1293 | 1403 | \\ [w]ait <name>... Wait for session tasks to complete |
| ... | ... | @@ -1383,6 +1493,20 @@ fn help() !void { | |
| 1383 | 1493 | \\ zmx wait dev | |
| 1384 | 1494 | \\ zmx wait dev other | |
| 1385 | 1495 | \\ | |
| 1496 | + | \\Labels: | |
| 1497 | + | \\ Attach key=value labels to live sessions for discovery and | |
| 1498 | + | \\ filtering. Labels are in-memory and scoped to session lifetime. | |
| 1499 | + | \\ | |
| 1500 | + | \\ Examples: | |
| 1501 | + | \\ zmx set dev project=zmx env=dev | |
| 1502 | + | \\ zmx set dev project= # unset a label | |
| 1503 | + | \\ zmx set . status=fail # "." resolves to current session | |
| 1504 | + | \\ zmx get dev | |
| 1505 | + | \\ zmx get dev project | |
| 1506 | + | \\ zmx set next "$(zmx get prev)" # set labels from other session | |
| 1507 | + | \\ zmx list | grep project=zmx | |
| 1508 | + | \\ zmx clear dev | |
| 1509 | + | \\ | |
| 1386 | 1510 | \\Environment variables: | |
| 1387 | 1511 | \\ SHELL Default shell for new sessions | |
| 1388 | 1512 | \\ ZMX_DIR Socket directory (priority 1) |
| ... | ... | @@ -1747,7 +1871,6 @@ fn list(cfg: *Cfg, short: bool) !void { | |
| 1747 | 1871 | const current_session = socket.getSeshNameFromEnv(); | |
| 1748 | 1872 | var buf: [4096]u8 = undefined; | |
| 1749 | 1873 | var stdout = std.fs.File.stdout().writer(&buf); | |
| 1750 | - | ||
| 1751 | 1874 | var sessions = try util.get_session_entries(alloc, cfg.socket_dir); | |
| 1752 | 1875 | defer { | |
| 1753 | 1876 | for (sessions.items) |session| { |
| ... | ... | @@ -1768,6 +1891,12 @@ fn list(cfg: *Cfg, short: bool) !void { | |
| 1768 | 1891 | std.mem.sort(util.SessionEntry, sessions.items, {}, util.SessionEntry.lessThan); | |
| 1769 | 1892 | ||
| 1770 | 1893 | for (sessions.items) |session| { | |
| 1894 | + | if (session.is_error) { | |
| 1895 | + | try util.writeSessionLine(&stdout.interface, session, short, current_session); | |
| 1896 | + | try stdout.interface.flush(); | |
| 1897 | + | continue; | |
| 1898 | + | } | |
| 1899 | + | ||
| 1771 | 1900 | try util.writeSessionLine(&stdout.interface, session, short, current_session); | |
| 1772 | 1901 | try stdout.interface.flush(); | |
| 1773 | 1902 | } |
| ... | ... | @@ -1782,6 +1911,7 @@ fn detachAll(cfg: *Cfg) !void { | |
| 1782 | 1911 | std.log.err("ZMX_SESSION env var not found: are you inside a zmx session?", .{}); | |
| 1783 | 1912 | return; | |
| 1784 | 1913 | } | |
| 1914 | + | std.log.info("detach all session={s}", .{session_name}); | |
| 1785 | 1915 | ||
| 1786 | 1916 | var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{}); | |
| 1787 | 1917 | defer dir.close(); |
| ... | ... | @@ -1804,6 +1934,7 @@ fn detachAll(cfg: *Cfg) !void { | |
| 1804 | 1934 | } | |
| 1805 | 1935 | ||
| 1806 | 1936 | fn kill(cfg: *Cfg, session_name: []const u8, force: bool) !void { | |
| 1937 | + | std.log.info("kill session={s}", .{session_name}); | |
| 1807 | 1938 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; | |
| 1808 | 1939 | defer _ = gpa.deinit(); | |
| 1809 | 1940 | const alloc = gpa.allocator(); |
| ... | ... | @@ -1865,6 +1996,116 @@ fn kill(cfg: *Cfg, session_name: []const u8, force: bool) !void { | |
| 1865 | 1996 | try w.interface.flush(); | |
| 1866 | 1997 | } | |
| 1867 | 1998 | ||
| 1999 | + | fn printLabelError(session_name: []const u8, err: anyerror) noreturn { | |
| 2000 | + | var buf: [4096]u8 = undefined; | |
| 2001 | + | var w = std.fs.File.stderr().writer(&buf); | |
| 2002 | + | switch (err) { | |
| 2003 | + | error.Timeout => w.interface.print( | |
| 2004 | + | "error: session \"{s}\" does not support labels (daemon too old?)\n", | |
| 2005 | + | .{session_name}, | |
| 2006 | + | ) catch {}, | |
| 2007 | + | error.ConnectionRefused, error.Unexpected => w.interface.print( | |
| 2008 | + | "error: session \"{s}\" not found or unresponsive\n", | |
| 2009 | + | .{session_name}, | |
| 2010 | + | ) catch {}, | |
| 2011 | + | else => w.interface.print( | |
| 2012 | + | "error: {s}\n", | |
| 2013 | + | .{@errorName(err)}, | |
| 2014 | + | ) catch {}, | |
| 2015 | + | } | |
| 2016 | + | w.interface.flush() catch {}; | |
| 2017 | + | std.process.exit(1); | |
| 2018 | + | } | |
| 2019 | + | ||
| 2020 | + | fn labelGet(cfg: *Cfg, session_name: []const u8, single_kv: []const u8) !void { | |
| 2021 | + | std.log.info("label get session={s}", .{session_name}); | |
| 2022 | + | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; | |
| 2023 | + | defer _ = gpa.deinit(); | |
| 2024 | + | const alloc = gpa.allocator(); | |
| 2025 | + | ||
| 2026 | + | const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) { | |
| 2027 | + | error.NameTooLong => return socket.printSessionNameTooLong(session_name, cfg.socket_dir), | |
| 2028 | + | error.OutOfMemory => return err, | |
| 2029 | + | }; | |
| 2030 | + | defer alloc.free(socket_path); | |
| 2031 | + | ||
| 2032 | + | const payload = ipc.roundTripForTag(alloc, socket_path, .LabelGet, "", .LabelData) catch |err| { | |
| 2033 | + | printLabelError(session_name, err); | |
| 2034 | + | }; | |
| 2035 | + | defer alloc.free(payload); | |
| 2036 | + | ||
| 2037 | + | var buf: [4096]u8 = undefined; | |
| 2038 | + | var stdout = std.fs.File.stdout().writer(&buf); | |
| 2039 | + | if (single_kv.len == 0) { | |
| 2040 | + | try stdout.interface.print("{s}", .{payload}); | |
| 2041 | + | try stdout.interface.flush(); | |
| 2042 | + | return; | |
| 2043 | + | } | |
| 2044 | + | ||
| 2045 | + | const val = try label.getLabelValueFromPairs(single_kv, payload); | |
| 2046 | + | try stdout.interface.print("{s}", .{val}); | |
| 2047 | + | try stdout.interface.flush(); | |
| 2048 | + | } | |
| 2049 | + | ||
| 2050 | + | fn labelSet(cfg: *Cfg, session_name: []const u8, labels: []const u8) !void { | |
| 2051 | + | std.log.info("label set session={s}", .{session_name}); | |
| 2052 | + | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; | |
| 2053 | + | defer _ = gpa.deinit(); | |
| 2054 | + | const alloc = gpa.allocator(); | |
| 2055 | + | ||
| 2056 | + | var kvs = label.LabelIterator.init(labels); | |
| 2057 | + | while (kvs.next()) |kv| { | |
| 2058 | + | label.assertLabel(kv.key, kv.value) catch |err| { | |
| 2059 | + | var buf: [4096]u8 = undefined; | |
| 2060 | + | var w = std.fs.File.stderr().writer(&buf); | |
| 2061 | + | const msg = "error: key-value kvs can only contain [a-z, A-Z, 0-9, -_.] characters"; | |
| 2062 | + | switch (err) { | |
| 2063 | + | error.LabelKeyEmpty => { | |
| 2064 | + | w.interface.print("error: label key cannot be empty\n", .{}) catch {}; | |
| 2065 | + | }, | |
| 2066 | + | error.LabelKeyReservedName => { | |
| 2067 | + | w.interface.print("error: \"{s}\" is a read-only built-in field\n", .{kv.key}) catch {}; | |
| 2068 | + | }, | |
| 2069 | + | error.LabelKeyInvalidChar => { | |
| 2070 | + | w.interface.print("{s}: key=[{s}]\n", .{ msg, kv.key }) catch {}; | |
| 2071 | + | }, | |
| 2072 | + | error.LabelValueInvalidChar => { | |
| 2073 | + | w.interface.print("{s}: value=[{s}]\n", .{ msg, kv.value }) catch {}; | |
| 2074 | + | }, | |
| 2075 | + | } | |
| 2076 | + | w.interface.flush() catch {}; | |
| 2077 | + | std.process.exit(1); | |
| 2078 | + | }; | |
| 2079 | + | } | |
| 2080 | + | ||
| 2081 | + | const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) { | |
| 2082 | + | error.NameTooLong => return socket.printSessionNameTooLong(session_name, cfg.socket_dir), | |
| 2083 | + | error.OutOfMemory => return err, | |
| 2084 | + | }; | |
| 2085 | + | defer alloc.free(socket_path); | |
| 2086 | + | ||
| 2087 | + | _ = ipc.roundTripForTag(alloc, socket_path, .LabelSet, labels, .Ack) catch |err| { | |
| 2088 | + | printLabelError(session_name, err); | |
| 2089 | + | }; | |
| 2090 | + | } | |
| 2091 | + | ||
| 2092 | + | fn labelClear(cfg: *Cfg, session_name: []const u8) !void { | |
| 2093 | + | std.log.info("label clear session={s}", .{session_name}); | |
| 2094 | + | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; | |
| 2095 | + | defer _ = gpa.deinit(); | |
| 2096 | + | const alloc = gpa.allocator(); | |
| 2097 | + | ||
| 2098 | + | const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) { | |
| 2099 | + | error.NameTooLong => return socket.printSessionNameTooLong(session_name, cfg.socket_dir), | |
| 2100 | + | error.OutOfMemory => return err, | |
| 2101 | + | }; | |
| 2102 | + | defer alloc.free(socket_path); | |
| 2103 | + | ||
| 2104 | + | _ = ipc.roundTripForTag(alloc, socket_path, .LabelClear, "", .Ack) catch |err| { | |
| 2105 | + | printLabelError(session_name, err); | |
| 2106 | + | }; | |
| 2107 | + | } | |
| 2108 | + | ||
| 1868 | 2109 | /// Fetch terminal history from a session socket, returning it as an allocated | |
| 1869 | 2110 | /// string. Caller owns the returned memory and must free it. | |
| 1870 | 2111 | fn fetchHistory( |
| ... | ... | @@ -1872,6 +2113,7 @@ fn fetchHistory( | |
| 1872 | 2113 | cfg: *Cfg, | |
| 1873 | 2114 | session_name: []const u8, | |
| 1874 | 2115 | ) ![]const u8 { | |
| 2116 | + | std.log.info("fetch history session={s}", .{session_name}); | |
| 1875 | 2117 | const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) { | |
| 1876 | 2118 | error.NameTooLong => { | |
| 1877 | 2119 | socket.printSessionNameTooLong(session_name, cfg.socket_dir); |
| ... | ... | @@ -1930,6 +2172,7 @@ fn fetchHistory( | |
| 1930 | 2172 | } | |
| 1931 | 2173 | ||
| 1932 | 2174 | fn history(cfg: *Cfg, session_name: []const u8, format: util.HistoryFormat) !void { | |
| 2175 | + | std.log.info("history session={s}", .{session_name}); | |
| 1933 | 2176 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; | |
| 1934 | 2177 | defer _ = gpa.deinit(); | |
| 1935 | 2178 | const alloc = gpa.allocator(); |
| ... | ... | @@ -1991,6 +2234,7 @@ fn switchSesh(daemon: *Daemon, current_sesh: []const u8) !void { | |
| 1991 | 2234 | // we want daemon.session_name because that's the session name the user provided during zmx attach | |
| 1992 | 2235 | // instead of the name of the session they are currently inside of. | |
| 1993 | 2236 | const next_session = daemon.session_name; | |
| 2237 | + | std.log.info("switch session cur={s} next={s}", .{ current_sesh, next_session }); | |
| 1994 | 2238 | ||
| 1995 | 2239 | const socket_path = socket.getSocketPath(daemon.alloc, daemon.cfg.socket_dir, current_sesh) catch |err| switch (err) { | |
| 1996 | 2240 | error.NameTooLong => return socket.printSessionNameTooLong(current_sesh, daemon.cfg.socket_dir), |
| ... | ... | @@ -2170,7 +2414,7 @@ fn writeFile(daemon: *Daemon, file_path: []const u8) !void { | |
| 2170 | 2414 | return; | |
| 2171 | 2415 | }; | |
| 2172 | 2416 | ||
| 2173 | - | defer posix.close(result.fd); | |
| 2417 | + | defer result.deinit(); | |
| 2174 | 2418 | ||
| 2175 | 2419 | // Build wire payload: [u32 path len][path bytes][file content] | |
| 2176 | 2420 | var wire_buf = try std.ArrayList(u8).initCapacity( |
| ... | ... | @@ -2206,6 +2450,7 @@ fn writeFile(daemon: *Daemon, file_path: []const u8) !void { | |
| 2206 | 2450 | } | |
| 2207 | 2451 | ||
| 2208 | 2452 | fn send(cfg: *Cfg, session_name: []const u8, socket_path: []const u8, text_parts: [][]const u8, tag: ipc.Tag) !void { | |
| 2453 | + | std.log.info("send session={s}", .{session_name}); | |
| 2209 | 2454 | const alloc = std.heap.c_allocator; | |
| 2210 | 2455 | var buf: [4096]u8 = undefined; | |
| 2211 | 2456 | var w = std.fs.File.stdout().writer(&buf); |
| ... | ... | @@ -2259,7 +2504,7 @@ fn send(cfg: *Cfg, session_name: []const u8, socket_path: []const u8, text_parts | |
| 2259 | 2504 | try w.interface.flush(); | |
| 2260 | 2505 | return; | |
| 2261 | 2506 | }; | |
| 2262 | - | defer posix.close(probe_result.fd); | |
| 2507 | + | defer probe_result.deinit(); | |
| 2263 | 2508 | ||
| 2264 | 2509 | ipc.send(probe_result.fd, tag, payload.items) catch |err| switch (err) { | |
| 2265 | 2510 | error.ConnectionResetByPeer, error.BrokenPipe => return, |
| ... | ... | @@ -2372,6 +2617,7 @@ const ClientResult = struct { | |
| 2372 | 2617 | /// clientLoop sends ipc commands to its corresponding daemon. It uses poll() as its non-blocking | |
| 2373 | 2618 | /// mechanism. It will send stdin to the daemon and receive stdout from the daemon. | |
| 2374 | 2619 | fn clientLoop(client_sock_fd: i32) !ClientResult { | |
| 2620 | + | std.log.info("client loop fd={d}", .{client_sock_fd}); | |
| 2375 | 2621 | // use c_allocator to avoid "reached unreachable code" panic in DebugAllocator when forking | |
| 2376 | 2622 | const alloc = std.heap.c_allocator; | |
| 2377 | 2623 | defer posix.close(client_sock_fd); |
| ... | ... | @@ -2461,11 +2707,13 @@ fn clientLoop(client_sock_fd: i32) !ClientResult { | |
| 2461 | 2707 | if (n > 0) { | |
| 2462 | 2708 | // Check for detach sequences (ctrl+\ as first byte or Kitty escape sequence) | |
| 2463 | 2709 | if (util.isCtrlBackslash(buf[0..n])) { | |
| 2710 | + | std.log.info("detach key detected", .{}); | |
| 2464 | 2711 | try ipc.appendMessage(alloc, &sock_write_buf, .Detach, ""); | |
| 2465 | 2712 | } else { | |
| 2466 | 2713 | try ipc.appendMessage(alloc, &sock_write_buf, .Input, buf[0..n]); | |
| 2467 | 2714 | } | |
| 2468 | 2715 | } else { | |
| 2716 | + | std.log.info("eof stdin", .{}); | |
| 2469 | 2717 | // EOF on stdin | |
| 2470 | 2718 | return ClientResult{ .kind = .detach, .session_name = null }; | |
| 2471 | 2719 | } |
| ... | ... | @@ -2483,6 +2731,7 @@ fn clientLoop(client_sock_fd: i32) !ClientResult { | |
| 2483 | 2731 | return err; | |
| 2484 | 2732 | }; | |
| 2485 | 2733 | if (n == 0) { | |
| 2734 | + | std.log.info("server closed connection", .{}); | |
| 2486 | 2735 | // Server closed connection | |
| 2487 | 2736 | return ClientResult{ .kind = .detach, .session_name = null }; | |
| 2488 | 2737 | } |
| ... | ... | @@ -2506,6 +2755,7 @@ fn clientLoop(client_sock_fd: i32) !ClientResult { | |
| 2506 | 2755 | ); | |
| 2507 | 2756 | }, | |
| 2508 | 2757 | .Switch => { | |
| 2758 | + | std.log.info("switch session", .{}); | |
| 2509 | 2759 | return ClientResult{ .kind = .switch_session, .session_name = try alloc.dupe(u8, msg.payload) }; | |
| 2510 | 2760 | }, | |
| 2511 | 2761 | else => {}, |
| ... | ... | @@ -2519,6 +2769,7 @@ fn clientLoop(client_sock_fd: i32) !ClientResult { | |
| 2519 | 2769 | const n = posix.write(client_sock_fd, sock_write_buf.items) catch |err| blk: { | |
| 2520 | 2770 | if (err == error.WouldBlock) break :blk 0; | |
| 2521 | 2771 | if (err == error.ConnectionResetByPeer or err == error.BrokenPipe) { | |
| 2772 | + | std.log.info("connection reset or broken pipe", .{}); | |
| 2522 | 2773 | return ClientResult{ .kind = .detach, .session_name = null }; | |
| 2523 | 2774 | } | |
| 2524 | 2775 | return err; |
| ... | ... | @@ -2540,6 +2791,7 @@ fn clientLoop(client_sock_fd: i32) !ClientResult { | |
| 2540 | 2791 | } | |
| 2541 | 2792 | ||
| 2542 | 2793 | if (poll_fds.items[1].revents & (posix.POLL.HUP | posix.POLL.ERR | posix.POLL.NVAL) != 0) { | |
| 2794 | + | std.log.info("poll hup|err|nval", .{}); | |
| 2543 | 2795 | return ClientResult{ .kind = .detach, .session_name = null }; | |
| 2544 | 2796 | } | |
| 2545 | 2797 | } |
| ... | ... | @@ -2798,9 +3050,12 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void { | |
| 2798 | 3050 | break :daemon_loop; | |
| 2799 | 3051 | }, | |
| 2800 | 3052 | .Info => try daemon.handleInfo(client), | |
| 3053 | + | .LabelGet => try daemon.handleLabelGet(client), | |
| 3054 | + | .LabelSet => try daemon.handleLabelSet(client, msg.payload), | |
| 3055 | + | .LabelClear => try daemon.handleLabelClear(client), | |
| 2801 | 3056 | .History => try daemon.handleHistory(client, &term, msg.payload), | |
| 2802 | 3057 | .Run => try daemon.handleRun(client, msg.payload), | |
| 2803 | - | .Ack, .TaskComplete => {}, | |
| 3058 | + | .Ack, .TaskComplete, .LabelData => {}, | |
| 2804 | 3059 | .Write => try daemon.handleWrite(client, msg.payload), | |
| 2805 | 3060 | _ => std.log.warn( | |
| 2806 | 3061 | "ignoring unknown IPC tag={d}", |
+1,
-0
| ... | ... | @@ -3,4 +3,5 @@ comptime { | |
| 3 | 3 | _ = @import("util.zig"); | |
| 4 | 4 | _ = @import("socket.zig"); | |
| 5 | 5 | _ = @import("ipc.zig"); | |
| 6 | + | _ = @import("label.zig"); | |
| 6 | 7 | } |
+19,
-1
| ... | ... | @@ -3,6 +3,7 @@ const posix = std.posix; | |
| 3 | 3 | const ghostty_vt = @import("ghostty-vt"); | |
| 4 | 4 | const ipc = @import("ipc.zig"); | |
| 5 | 5 | const socket = @import("socket.zig"); | |
| 6 | + | const label = @import("label.zig"); | |
| 6 | 7 | const testing = std.testing; | |
| 7 | 8 | ||
| 8 | 9 | pub const SessionEntry = struct { |
| ... | ... | @@ -13,6 +14,7 @@ pub const SessionEntry = struct { | |
| 13 | 14 | error_name: ?[]const u8, | |
| 14 | 15 | cmd: ?[]const u8 = null, | |
| 15 | 16 | cwd: ?[]const u8 = null, | |
| 17 | + | labels: ?[]const u8 = null, | |
| 16 | 18 | created_at: u64, | |
| 17 | 19 | task_ended_at: ?u64, | |
| 18 | 20 | task_exit_code: ?u8, |
| ... | ... | @@ -21,6 +23,7 @@ pub const SessionEntry = struct { | |
| 21 | 23 | alloc.free(self.name); | |
| 22 | 24 | if (self.cmd) |cmd| alloc.free(cmd); | |
| 23 | 25 | if (self.cwd) |cwd| alloc.free(cwd); | |
| 26 | + | if (self.labels) |l| alloc.free(l); | |
| 24 | 27 | } | |
| 25 | 28 | ||
| 26 | 29 | pub fn lessThan(_: void, a: SessionEntry, b: SessionEntry) bool { |
| ... | ... | @@ -32,6 +35,7 @@ pub fn get_session_entries( | |
| 32 | 35 | alloc: std.mem.Allocator, | |
| 33 | 36 | socket_dir: []const u8, | |
| 34 | 37 | ) !std.ArrayList(SessionEntry) { | |
| 38 | + | std.log.info("get session entries socket_dir={s}", .{socket_dir}); | |
| 35 | 39 | var dir = try std.fs.openDirAbsolute(socket_dir, .{ .iterate = true }); | |
| 36 | 40 | defer dir.close(); | |
| 37 | 41 | var iter = dir.iterate(); |
| ... | ... | @@ -60,6 +64,7 @@ pub fn get_session_entries( | |
| 60 | 64 | .created_at = 0, | |
| 61 | 65 | .task_exit_code = 1, | |
| 62 | 66 | .task_ended_at = 0, | |
| 67 | + | .labels = "", | |
| 63 | 68 | }); | |
| 64 | 69 | // Only clean up when the daemon is definitively gone. A busy | |
| 65 | 70 | // daemon can miss the probe timeout; deleting its socket |
| ... | ... | @@ -69,7 +74,7 @@ pub fn get_session_entries( | |
| 69 | 74 | } | |
| 70 | 75 | continue; | |
| 71 | 76 | }; | |
| 72 | - | posix.close(result.fd); | |
| 77 | + | defer result.deinit(); | |
| 73 | 78 | ||
| 74 | 79 | // Extract cmd and cwd from the fixed-size arrays. Lengths come | |
| 75 | 80 | // off the wire (u16 range), so clamp to the actual array size. |
| ... | ... | @@ -79,11 +84,17 @@ pub fn get_session_entries( | |
| 79 | 84 | alloc.dupe(u8, result.info.cmd[0..cmd_len]) catch null | |
| 80 | 85 | else | |
| 81 | 86 | null; | |
| 87 | + | ||
| 82 | 88 | const cwd: ?[]const u8 = if (cwd_len > 0) | |
| 83 | 89 | alloc.dupe(u8, result.info.cwd[0..cwd_len]) catch null | |
| 84 | 90 | else | |
| 85 | 91 | null; | |
| 86 | 92 | ||
| 93 | + | const labels = if (result.labels) |lbl| | |
| 94 | + | alloc.dupe(u8, lbl) catch null | |
| 95 | + | else | |
| 96 | + | null; | |
| 97 | + | ||
| 87 | 98 | try sessions.append(alloc, .{ | |
| 88 | 99 | .name = name, | |
| 89 | 100 | .pid = result.info.pid, |
| ... | ... | @@ -92,6 +103,7 @@ pub fn get_session_entries( | |
| 92 | 103 | .error_name = null, | |
| 93 | 104 | .cmd = cmd, | |
| 94 | 105 | .cwd = cwd, | |
| 106 | + | .labels = labels, | |
| 95 | 107 | .created_at = result.info.created_at, | |
| 96 | 108 | .task_ended_at = result.info.task_ended_at, | |
| 97 | 109 | .task_exit_code = result.info.task_exit_code, |
| ... | ... | @@ -739,6 +751,12 @@ pub fn writeSessionLine( | |
| 739 | 751 | } | |
| 740 | 752 | } | |
| 741 | 753 | } | |
| 754 | + | if (session.labels) |labels| { | |
| 755 | + | var kvs = label.LabelIterator.init(labels); | |
| 756 | + | while (kvs.next()) |kv| { | |
| 757 | + | try writer.print("\t{s}={s}", .{ kv.key, kv.value }); | |
| 758 | + | } | |
| 759 | + | } | |
| 742 | 760 | try writer.print("\n", .{}); | |
| 743 | 761 | } | |
| 744 | 762 |
+99,
-0
| ... | ... | @@ -0,0 +1,99 @@ | |
| 1 | + | #!/usr/bin/env bats | |
| 2 | + | # Label tests for zmx. | |
| 3 | + | ||
| 4 | + | load test_helper | |
| 5 | + | ||
| 6 | + | # ============================================================================ | |
| 7 | + | # Label CRUD | |
| 8 | + | # ============================================================================ | |
| 9 | + | ||
| 10 | + | @test "set/get: round-trips labels" { | |
| 11 | + | "$ZMX" run test-labels -d sleep 30 | |
| 12 | + | wait_for_session test-labels | |
| 13 | + | ||
| 14 | + | run "$ZMX" set test-labels project=zmx env=dev | |
| 15 | + | [ "$status" -eq 0 ] | |
| 16 | + | ||
| 17 | + | run "$ZMX" get test-labels | |
| 18 | + | [ "$status" -eq 0 ] | |
| 19 | + | [[ "$output" == *"env=dev"* ]] | |
| 20 | + | [[ "$output" == *"project=zmx"* ]] | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | @test "set: updates existing label" { | |
| 24 | + | "$ZMX" run test-update -d sleep 30 | |
| 25 | + | wait_for_session test-update | |
| 26 | + | ||
| 27 | + | run "$ZMX" set test-update status=busy | |
| 28 | + | [ "$status" -eq 0 ] | |
| 29 | + | run "$ZMX" set test-update status=done | |
| 30 | + | [ "$status" -eq 0 ] | |
| 31 | + | ||
| 32 | + | run "$ZMX" get test-update | |
| 33 | + | [ "$status" -eq 0 ] | |
| 34 | + | [[ "$output" == *"status=done"* ]] | |
| 35 | + | [[ "$output" != *"status=busy"* ]] | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | @test "set: rejects reserved key 'name'" { | |
| 39 | + | "$ZMX" run test-reserved -d sleep 30 | |
| 40 | + | wait_for_session test-reserved | |
| 41 | + | ||
| 42 | + | run "$ZMX" set test-reserved name=bad | |
| 43 | + | [ "$status" -ne 0 ] | |
| 44 | + | [[ "$output" == *"read-only built-in field"* ]] | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | @test "set: rejects reserved key 'start_dir'" { | |
| 48 | + | "$ZMX" run test-reserved2 -d sleep 30 | |
| 49 | + | wait_for_session test-reserved2 | |
| 50 | + | ||
| 51 | + | run "$ZMX" set test-reserved2 start_dir=/tmp | |
| 52 | + | [ "$status" -ne 0 ] | |
| 53 | + | [[ "$output" == *"read-only built-in field"* ]] | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | @test "set: rejects reserved key 'cmd'" { | |
| 57 | + | "$ZMX" run test-reserved3 -d sleep 30 | |
| 58 | + | wait_for_session test-reserved3 | |
| 59 | + | ||
| 60 | + | run "$ZMX" set test-reserved3 cmd=bad | |
| 61 | + | [ "$status" -ne 0 ] | |
| 62 | + | [[ "$output" == *"read-only built-in field"* ]] | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | @test "set with empty value removes label" { | |
| 66 | + | "$ZMX" run test-unset -d sleep 30 | |
| 67 | + | wait_for_session test-unset | |
| 68 | + | ||
| 69 | + | run "$ZMX" set test-unset a=1 b=2 | |
| 70 | + | run "$ZMX" set test-unset a= | |
| 71 | + | ||
| 72 | + | run "$ZMX" get test-unset | |
| 73 | + | [ "$status" -eq 0 ] | |
| 74 | + | [[ "$output" != *"a=1"* ]] | |
| 75 | + | [[ "$output" == *"b=2"* ]] | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | @test "clear: removes all labels" { | |
| 79 | + | "$ZMX" run test-clear -d sleep 30 | |
| 80 | + | wait_for_session test-clear | |
| 81 | + | ||
| 82 | + | run "$ZMX" set test-clear x=1 y=2 | |
| 83 | + | run "$ZMX" clear test-clear | |
| 84 | + | ||
| 85 | + | run "$ZMX" get test-clear | |
| 86 | + | [ "$status" -eq 0 ] | |
| 87 | + | [ -z "$output" ] | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | @test "get: no session prints error" { | |
| 91 | + | run "$ZMX" get nonexistent | |
| 92 | + | [ "$status" -ne 0 ] | |
| 93 | + | [[ "$output" == *"not found"* ]] | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | @test "get: no args prints error" { | |
| 97 | + | run env -u ZMX_SESSION "$ZMX" get | |
| 98 | + | [[ "$output" == *"SessionNameRequired"* ]] | |
| 99 | + | } |