Add explicit job output panel

This commit is contained in:
slhx agent
2026-06-21 04:12:54 +02:00
parent 871f3b4917
commit caba99fb40
4 changed files with 342 additions and 1 deletions
+125
View File
@@ -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);
}