From caba99fb401dc5f535d31178e160b93f7407531b Mon Sep 17 00:00:00 2001 From: slhx agent Date: Sun, 21 Jun 2026 04:12:54 +0200 Subject: [PATCH] Add explicit job output panel --- src/job.zig | 198 ++++++++++++++++++++++++++++++++++++++++++++++++ src/main.zig | 2 + src/session.zig | 18 ++++- src/tui.zig | 125 ++++++++++++++++++++++++++++++ 4 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 src/job.zig diff --git a/src/job.zig b/src/job.zig new file mode 100644 index 0000000..fe35f4f --- /dev/null +++ b/src/job.zig @@ -0,0 +1,198 @@ +const std = @import("std"); + +// Explicit local build/test job execution for the shared panel model. +// req: repo/002, ui/002, testing/001, testing/002, testing/003, testing/004 + +test { + _ = runRowsAlloc; +} + +pub const Error = error{ + InvalidCwd, + InvalidCommand, + InvalidJobRow, + InvalidPath, +}; + +pub const Location = struct { + path: []const u8, + line: usize, + column: usize, +}; + +pub fn runRowsAlloc(allocator: std.mem.Allocator, io: std.Io, cwd: []const u8, argv: []const []const u8) ![][]const u8 { + try validateCwd(cwd); + try validateArgv(argv); + + var rows = std.ArrayList([]const u8).empty; + errdefer { + for (rows.items) |row| allocator.free(row); + rows.deinit(allocator); + } + + const command_preview = try commandPreviewAlloc(allocator, argv); + defer allocator.free(command_preview); + try rows.append(allocator, try std.fmt.allocPrint(allocator, "job:spawned:{s}", .{command_preview})); + + const result = std.process.run(allocator, io, .{ + .argv = argv, + .cwd = .{ .path = cwd }, + .stdout_limit = .limited(256 * 1024), + .stderr_limit = .limited(256 * 1024), + }) catch |err| { + try rows.append(allocator, try std.fmt.allocPrint(allocator, "job:status:spawn_error_{s}", .{@errorName(err)})); + return rows.toOwnedSlice(allocator); + }; + defer allocator.free(result.stdout); + defer allocator.free(result.stderr); + + try appendTermRow(allocator, &rows, result.term); + try appendOutputRows(allocator, &rows, "stdout", result.stdout); + try appendOutputRows(allocator, &rows, "stderr", result.stderr); + if (rows.items.len == 2) try rows.append(allocator, try allocator.dupe(u8, "job:output_empty")); + return rows.toOwnedSlice(allocator); +} + +pub fn locationFromRow(row: []const u8) !Location { + const prefix_end = std.mem.indexOfScalar(u8, row, ':') orelse return Error.InvalidJobRow; + const body = row[prefix_end + 1 ..]; + if (!std.mem.eql(u8, row[0..prefix_end], "stdout") and !std.mem.eql(u8, row[0..prefix_end], "stderr")) return Error.InvalidJobRow; + + const first = std.mem.indexOfScalar(u8, body, ':') orelse return Error.InvalidJobRow; + const path = body[0..first]; + try validatePath(path); + const rest = body[first + 1 ..]; + const second = std.mem.indexOfScalar(u8, rest, ':') orelse return Error.InvalidJobRow; + const line = std.fmt.parseUnsigned(usize, rest[0..second], 10) catch return Error.InvalidJobRow; + if (line == 0) return Error.InvalidJobRow; + + const after_line = rest[second + 1 ..]; + const maybe_column_token = if (std.mem.indexOfScalar(u8, after_line, ':')) |third| after_line[0..third] else after_line; + const column = std.fmt.parseUnsigned(usize, maybe_column_token, 10) catch 1; + return .{ .path = path, .line = line, .column = if (column == 0) 1 else column }; +} + +pub fn byteOffsetForLineColumn(content: []const u8, line: usize, column: usize) !usize { + if (line == 0 or column == 0) return Error.InvalidJobRow; + var current_line: usize = 1; + var line_start: usize = 0; + var i: usize = 0; + while (i <= content.len) : (i += 1) { + if (i == content.len or content[i] == '\n') { + if (current_line == line) { + const line_bytes = content[line_start..i]; + if (column - 1 > line_bytes.len) return Error.InvalidJobRow; + return line_start + column - 1; + } + current_line += 1; + line_start = i + 1; + } + } + return Error.InvalidJobRow; +} + +fn validateCwd(cwd: []const u8) !void { + if (cwd.len == 0 or !std.unicode.utf8ValidateSlice(cwd)) return Error.InvalidCwd; + for (cwd) |byte| { + if (byte == 0 or byte == '\n' or byte == '\r' or byte == '|') return Error.InvalidCwd; + } +} + +fn validateArgv(argv: []const []const u8) !void { + if (argv.len == 0) return Error.InvalidCommand; + for (argv) |arg| { + if (arg.len == 0 or !std.unicode.utf8ValidateSlice(arg)) return Error.InvalidCommand; + for (arg) |byte| { + if (byte == 0 or byte == '\n' or byte == '\r') return Error.InvalidCommand; + } + } +} + +fn validatePath(path: []const u8) !void { + if (path.len == 0 or !std.unicode.utf8ValidateSlice(path)) return Error.InvalidPath; + if (std.mem.startsWith(u8, path, "/") or std.mem.indexOf(u8, path, "..") != null) return Error.InvalidPath; + for (path) |byte| { + if (byte <= 0x20 or byte == '|') return Error.InvalidPath; + } +} + +fn appendTermRow(allocator: std.mem.Allocator, rows: *std.ArrayList([]const u8), term: std.process.Child.Term) !void { + const row = switch (term) { + .exited => |code| try std.fmt.allocPrint(allocator, "job:status:exit_{d}", .{code}), + .signal => |sig| try std.fmt.allocPrint(allocator, "job:status:signal_{d}", .{@intFromEnum(sig)}), + .stopped => |sig| try std.fmt.allocPrint(allocator, "job:status:stopped_{d}", .{@intFromEnum(sig)}), + .unknown => |code| try std.fmt.allocPrint(allocator, "job:status:unknown_{d}", .{code}), + }; + try rows.append(allocator, row); +} + +fn appendOutputRows(allocator: std.mem.Allocator, rows: *std.ArrayList([]const u8), prefix: []const u8, output: []const u8) !void { + var lines = std.mem.splitScalar(u8, output, '\n'); + while (lines.next()) |raw_line| { + const trimmed = std.mem.trim(u8, raw_line, "\r"); + if (trimmed.len == 0) continue; + const sanitized = try sanitizeLineAlloc(allocator, trimmed); + defer allocator.free(sanitized); + try rows.append(allocator, try std.fmt.allocPrint(allocator, "{s}:{s}", .{ prefix, sanitized })); + } +} + +fn sanitizeLineAlloc(allocator: std.mem.Allocator, line: []const u8) ![]u8 { + var out = std.ArrayList(u8).empty; + errdefer out.deinit(allocator); + var i: usize = 0; + while (i < line.len and out.items.len < 160) { + const len = std.unicode.utf8ByteSequenceLength(line[i]) catch 1; + const end = @min(line.len, i + len); + const slice = line[i..end]; + if (slice.len == 1 and (slice[0] <= 0x20 or slice[0] == '|')) { + try out.append(allocator, '_'); + } else { + try out.appendSlice(allocator, slice); + } + i = end; + } + if (out.items.len == 0) try out.append(allocator, '_'); + return out.toOwnedSlice(allocator); +} + +fn commandPreviewAlloc(allocator: std.mem.Allocator, argv: []const []const u8) ![]u8 { + var out = std.ArrayList(u8).empty; + errdefer out.deinit(allocator); + for (argv, 0..) |arg, index| { + if (index != 0) try out.append(allocator, '_'); + const sanitized = try sanitizeLineAlloc(allocator, arg); + defer allocator.free(sanitized); + try out.appendSlice(allocator, sanitized); + if (out.items.len >= 120) break; + } + return out.toOwnedSlice(allocator); +} + +fn freeOwnedRows(allocator: std.mem.Allocator, rows: []const []const u8) void { + for (rows) |row| allocator.free(row); + allocator.free(rows); +} + +test "regular: job rows capture failing command output and status" { + const rows = try runRowsAlloc(std.testing.allocator, std.testing.io, ".", &.{ "sh", "-c", "printf 'src/main.zig:2:5:error:bad\\n'; exit 1" }); + defer freeOwnedRows(std.testing.allocator, rows); + try std.testing.expect(rows.len >= 3); + try std.testing.expectEqualStrings("job:status:exit_1", rows[1]); + try std.testing.expectEqualStrings("stdout:src/main.zig:2:5:error:bad", rows[2]); + const loc = try locationFromRow(rows[2]); + try std.testing.expectEqualStrings("src/main.zig", loc.path); + try std.testing.expectEqual(@as(usize, 2), loc.line); + try std.testing.expectEqual(@as(usize, 5), loc.column); +} + +test "regular: job row location converts to byte offset" { + try std.testing.expectEqual(@as(usize, 6), try byteOffsetForLineColumn("one\ntwo needle\n", 2, 3)); +} + +test "adversarial: invalid commands rows and unparseable output are rejected" { + try std.testing.expectError(Error.InvalidCommand, runRowsAlloc(std.testing.allocator, std.testing.io, ".", &.{})); + try std.testing.expectError(Error.InvalidCwd, runRowsAlloc(std.testing.allocator, std.testing.io, "bad\nwd", &.{"true"})); + try std.testing.expectError(Error.InvalidJobRow, locationFromRow("job:status:exit_1")); + try std.testing.expectError(Error.InvalidPath, locationFromRow("stderr:../secret:1:1:nope")); +} diff --git a/src/main.zig b/src/main.zig index 20ff2ce..9d0bdbb 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,5 +1,6 @@ const std = @import("std"); const input = @import("input.zig"); +const job = @import("job.zig"); const layout = @import("layout.zig"); const leader = @import("leader.zig"); const mobile_acceptance = @import("mobile_acceptance.zig"); @@ -124,6 +125,7 @@ fn collectRemainingArgs(allocator: std.mem.Allocator, args: *std.process.Args.It test { _ = input; + _ = job; _ = layout; _ = leader; _ = mobile_acceptance; diff --git a/src/session.zig b/src/session.zig index c451f68..7e78fe5 100644 --- a/src/session.zig +++ b/src/session.zig @@ -144,7 +144,7 @@ pub const Session = struct { pub fn openFixtureAt(self: *Session, fixture: []const u8, cursor_byte: usize) !void { if (self.buffer) |*buffer| buffer.deinit(); var buffer = try Buffer.openFromBytes(self.allocator, fixture); - buffer.cursor.byte = previousBoundary(buffer.bytes.items, @min(cursor_byte, buffer.bytes.items.len)); + buffer.cursor.byte = boundaryAtOrBefore(buffer.bytes.items, @min(cursor_byte, buffer.bytes.items.len)); buffer.refreshCell(); self.buffer = buffer; } @@ -227,6 +227,13 @@ pub const Session = struct { } }; +pub fn boundaryAtOrBefore(bytes: []const u8, cursor: usize) usize { + var i = @min(cursor, bytes.len); + if (i == bytes.len) return i; + while (i > 0 and isContinuation(bytes[i])) : (i -= 1) {} + return i; +} + pub fn previousBoundary(bytes: []const u8, cursor: usize) usize { if (cursor == 0) return 0; var i = @min(cursor, bytes.len) - 1; @@ -298,6 +305,15 @@ fn isWide(cp: u21) bool { (cp >= 0x20000 and cp <= 0x3fffd); } +test "regular: opening a fixture at a cursor byte keeps exact utf8 boundary" { + var session = Session.init(std.testing.allocator); + defer session.deinit(); + + try session.openFixtureAt("one\nabcdTARGET\n", 8); + const snap = try session.snapshot(); + try std.testing.expectEqual(@as(usize, 8), snap.cursor_byte); +} + test "regular: session opens fixture and dispatches multibyte edits" { var session = Session.init(std.testing.allocator); defer session.deinit(); diff --git a/src/tui.zig b/src/tui.zig index f7ed981..4d33dbb 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -1,5 +1,6 @@ const std = @import("std"); const input = @import("input.zig"); +const job_mod = @import("job.zig"); const leader_mod = @import("leader.zig"); const protocol = @import("protocol.zig"); const replay = @import("replay.zig"); @@ -137,6 +138,10 @@ pub const Client = struct { if (std.mem.startsWith(u8, line, "git_status ")) return self.openGitStatus(line[11..]); if (std.mem.startsWith(u8, line, "git_diff_selected ")) return self.openSelectedGitDiff(line[18..]); if (std.mem.startsWith(u8, line, "git_open_changed_selected ")) return self.openSelectedGitFile(line[26..]); + if (std.mem.startsWith(u8, line, "job_run ")) return self.openJobRun(line[8..]); + if (std.mem.eql(u8, line, "job_status")) return self.openStaticJobRow("job-status", "job_status:idle"); + if (std.mem.eql(u8, line, "job_cancel")) return self.openStaticJobRow("job-status", "job_cancel:no_running_job"); + if (std.mem.startsWith(u8, line, "job_open_selected ")) return self.openSelectedJobLocation(line[18..]); if (std.mem.startsWith(u8, line, "panel_open ")) return self.applyProtocolCommand(line); if (std.mem.startsWith(u8, line, "list_open ")) return self.applyProtocolCommand(line); if (std.mem.startsWith(u8, line, "list_filter ")) return self.applyProtocolCommand(line); @@ -367,6 +372,42 @@ pub const Client = struct { self.message = null; } + fn openJobRun(self: *Client, cwd_and_command: []const u8) !void { + const io = self.io orelse return Error.ProtocolRejected; + const first_space = std.mem.indexOfScalar(u8, cwd_and_command, ' ') orelse return Error.ProtocolRejected; + const cwd = cwd_and_command[0..first_space]; + const command_line = std.mem.trim(u8, cwd_and_command[first_space + 1 ..], " "); + if (command_line.len == 0) return Error.ProtocolRejected; + var argv = std.ArrayList([]const u8).empty; + defer argv.deinit(self.allocator); + var parts = std.mem.splitScalar(u8, command_line, ' '); + while (parts.next()) |part| { + if (part.len == 0) continue; + try argv.append(self.allocator, part); + } + const rows = job_mod.runRowsAlloc(self.allocator, io, cwd, argv.items) catch return Error.ProtocolRejected; + defer freeOwnedRows(self.allocator, rows); + try self.session.openListPanel("job-output", rows); + self.message = null; + } + + fn openStaticJobRow(self: *Client, title: []const u8, row: []const u8) !void { + try self.session.openListPanel(title, &.{row}); + self.message = null; + } + + fn openSelectedJobLocation(self: *Client, cwd: []const u8) !void { + const io = self.io orelse return Error.ProtocolRejected; + const selected = self.session.selectListPanel() catch return Error.ProtocolRejected; + const location = job_mod.locationFromRow(selected) catch return Error.ProtocolRejected; + const content = repo_mod.readRepoFileAlloc(self.allocator, io, cwd, location.path) catch return Error.ProtocolRejected; + defer self.allocator.free(content); + const offset = job_mod.byteOffsetForLineColumn(content, location.line, location.column) catch return Error.ProtocolRejected; + try self.session.openFixtureAt(content, offset); + try self.session.closePanel(); + self.message = null; + } + fn handleTraceKey(self: *Client, key_name: []const u8) !void { if (std.mem.eql(u8, key_name, "space")) return self.handleInput(" "); if (std.mem.eql(u8, key_name, "enter")) return self.handleInput("\r"); @@ -1254,3 +1295,87 @@ test "adversarial: git status errors are visible in panel" { defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "git_error:") != null); } + +fn makeTuiJobFixture(allocator: std.mem.Allocator) !struct { tmp: std.testing.TmpDir, cwd: []u8 } { + var tmp = std.testing.tmpDir(.{}); + errdefer tmp.cleanup(); + var src = try tmp.dir.createDirPathOpen(std.testing.io, "src", .{}); + src.close(std.testing.io); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "src/main.zig", .data = "one\nabcdTARGET\n" }); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "fail.sh", .data = "printf 'src/main.zig:2:5:error:bad\\n'\nexit 1\n" }); + const cwd = try std.fmt.allocPrint(allocator, ".zig-cache/tmp/{s}", .{&tmp.sub_path}); + errdefer allocator.free(cwd); + return .{ .tmp = tmp, .cwd = cwd }; +} + +test "regular: job panel captures failing command output and jumps to file line" { + var fixture = try makeTuiJobFixture(std.testing.allocator); + defer { + std.testing.allocator.free(fixture.cwd); + fixture.tmp.cleanup(); + } + var client = try Client.initWithIo(std.testing.allocator, .{ .width = 72, .height = 7 }, std.testing.io); + defer client.deinit(); + + const run = try std.fmt.allocPrint(std.testing.allocator, "job_run {s} sh fail.sh", .{fixture.cwd}); + defer std.testing.allocator.free(run); + try client.handleTraceLine(run); + { + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, "job-output") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, "job:status:exit_1") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, "stdout:src/main.zig:2:5:error:bad") != null); + } + + try client.handleTraceLine("list_filter src/main.zig"); + const open = try std.fmt.allocPrint(std.testing.allocator, "job_open_selected {s}", .{fixture.cwd}); + defer std.testing.allocator.free(open); + try client.handleTraceLine(open); + const snap = try client.session.snapshot(); + try std.testing.expectEqualStrings("one\nabcdTARGET\n", snap.bytes); + try std.testing.expectEqual(@as(usize, 8), snap.cursor_byte); +} + +test "regular: job status and cancel expose honest foreground state rows" { + var client = try Client.initWithIo(std.testing.allocator, .{ .width = 56, .height = 5 }, std.testing.io); + defer client.deinit(); + + try client.handleTraceLine("job_status"); + { + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, "job_status:idle") != null); + } + + try client.handleTraceLine("job_cancel"); + { + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, "job_cancel:no_running_job") != null); + } +} + +test "adversarial: bad job command is visible and failed job open does not corrupt buffer" { + var fixture = try makeTuiJobFixture(std.testing.allocator); + defer { + std.testing.allocator.free(fixture.cwd); + fixture.tmp.cleanup(); + } + var client = try Client.initWithIo(std.testing.allocator, .{ .width = 72, .height = 6 }, std.testing.io); + defer client.deinit(); + + try client.handleTraceLine("open safe"); + const run = try std.fmt.allocPrint(std.testing.allocator, "job_run {s} definitely-not-a-mim-command", .{fixture.cwd}); + defer std.testing.allocator.free(run); + try client.handleTraceLine(run); + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, "job:status:spawn_error_") != null); + + const open = try std.fmt.allocPrint(std.testing.allocator, "job_open_selected {s}", .{fixture.cwd}); + defer std.testing.allocator.free(open); + try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine(open)); + const snap = try client.session.snapshot(); + try std.testing.expectEqualStrings("safe", snap.bytes); +}