Add read-only git workbench panels

This commit is contained in:
slhx agent
2026-06-21 03:16:10 +02:00
parent fc3b6a7d08
commit 871f3b4917
2 changed files with 344 additions and 1 deletions
+208 -1
View File
@@ -12,6 +12,7 @@ pub const Error = error{
InvalidPattern,
InvalidQuery,
InvalidSearchRow,
InvalidStatusRow,
FileNotFound,
};
@@ -199,6 +200,129 @@ fn byteOffsetForLineColumn(content_bytes: []const u8, line_no: usize, column: us
return Error.InvalidSearchRow;
}
pub fn gitStatusRowsAlloc(allocator: std.mem.Allocator, io: std.Io, cwd: []const u8) ![][]const u8 {
const argv = [_][]const u8{ "git", "-C", cwd, "status", "--short" };
const result = std.process.run(allocator, io, .{
.argv = &argv,
.stdout_limit = .limited(64 * 1024),
.stderr_limit = .limited(16 * 1024),
}) catch |err| return singleOwnedRow(allocator, try std.fmt.allocPrint(allocator, "git_error:{s}", .{@errorName(err)}));
defer allocator.free(result.stdout);
defer allocator.free(result.stderr);
if (!termSucceeded(result.term)) return singleOwnedRow(allocator, try gitErrorRowAlloc(allocator, result.stderr));
return parseStatusRowsAlloc(allocator, result.stdout);
}
pub fn gitDiffRowsAlloc(allocator: std.mem.Allocator, io: std.Io, cwd: []const u8, path: []const u8) ![][]const u8 {
try validatePath(path);
const argv = [_][]const u8{ "git", "-C", cwd, "diff", "--unified=0", "--", path };
const result = std.process.run(allocator, io, .{
.argv = &argv,
.stdout_limit = .limited(256 * 1024),
.stderr_limit = .limited(16 * 1024),
}) catch |err| return singleOwnedRow(allocator, try std.fmt.allocPrint(allocator, "git_error:{s}", .{@errorName(err)}));
defer allocator.free(result.stdout);
defer allocator.free(result.stderr);
if (!termSucceeded(result.term)) return singleOwnedRow(allocator, try gitErrorRowAlloc(allocator, result.stderr));
return parseDiffRowsAlloc(allocator, result.stdout);
}
pub fn gitPathFromStatusRow(row: []const u8) ![]const u8 {
const colon = std.mem.indexOfScalar(u8, row, ':') orelse return Error.InvalidStatusRow;
const path = row[colon + 1 ..];
if (path.len == 0 or std.mem.startsWith(u8, row, "git_error:") or std.mem.eql(u8, row, "clean")) return Error.InvalidStatusRow;
try validatePath(path);
return path;
}
pub fn readRepoFileAlloc(allocator: std.mem.Allocator, io: std.Io, cwd: []const u8, path: []const u8) ![]u8 {
try validatePath(path);
const full_path = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ cwd, path });
defer allocator.free(full_path);
return std.Io.Dir.cwd().readFileAlloc(io, full_path, allocator, .limited(1024 * 1024));
}
fn termSucceeded(term: std.process.Child.Term) bool {
return switch (term) {
.exited => |code| code == 0,
else => false,
};
}
fn parseStatusRowsAlloc(allocator: std.mem.Allocator, stdout: []const u8) ![][]const u8 {
var rows = std.ArrayList([]const u8).empty;
errdefer {
for (rows.items) |row| allocator.free(row);
rows.deinit(allocator);
}
var lines = std.mem.splitScalar(u8, stdout, '\n');
while (lines.next()) |raw_line| {
const line = std.mem.trim(u8, raw_line, "\r");
if (line.len == 0) continue;
if (line.len < 4) continue;
const code = line[0..2];
var path = std.mem.trim(u8, line[3..], " ");
if (std.mem.indexOf(u8, path, " -> ")) |rename_arrow| path = path[rename_arrow + 4 ..];
try validatePath(path);
try rows.append(allocator, try std.fmt.allocPrint(allocator, "{s}:{s}", .{ statusName(code), path }));
}
if (rows.items.len == 0) try rows.append(allocator, try allocator.dupe(u8, "clean"));
return rows.toOwnedSlice(allocator);
}
fn statusName(code: []const u8) []const u8 {
if (std.mem.indexOfScalar(u8, code, '?') != null) return "untracked";
if (std.mem.indexOfScalar(u8, code, 'D') != null) return "deleted";
if (std.mem.indexOfScalar(u8, code, 'A') != null) return "added";
if (std.mem.indexOfScalar(u8, code, 'M') != null) return "modified";
if (std.mem.indexOfScalar(u8, code, 'R') != null) return "renamed";
return "changed";
}
fn parseDiffRowsAlloc(allocator: std.mem.Allocator, stdout: []const u8) ![][]const u8 {
var rows = std.ArrayList([]const u8).empty;
errdefer {
for (rows.items) |row| allocator.free(row);
rows.deinit(allocator);
}
var lines = std.mem.splitScalar(u8, stdout, '\n');
while (lines.next()) |raw_line| {
const line = std.mem.trim(u8, raw_line, "\r");
if (line.len == 0) continue;
if (std.mem.startsWith(u8, line, "@@")) {
const preview = try previewAlloc(allocator, line);
defer allocator.free(preview);
try rows.append(allocator, try std.fmt.allocPrint(allocator, "hunk:{s}", .{preview}));
} else if (std.mem.startsWith(u8, line, "+") and !std.mem.startsWith(u8, line, "+++")) {
const preview = try previewAlloc(allocator, line[1..]);
defer allocator.free(preview);
try rows.append(allocator, try std.fmt.allocPrint(allocator, "add:{s}", .{preview}));
} else if (std.mem.startsWith(u8, line, "-") and !std.mem.startsWith(u8, line, "---")) {
const preview = try previewAlloc(allocator, line[1..]);
defer allocator.free(preview);
try rows.append(allocator, try std.fmt.allocPrint(allocator, "del:{s}", .{preview}));
}
}
if (rows.items.len == 0) try rows.append(allocator, try allocator.dupe(u8, "no diff"));
return rows.toOwnedSlice(allocator);
}
fn gitErrorRowAlloc(allocator: std.mem.Allocator, stderr: []const u8) ![]u8 {
const trimmed = std.mem.trim(u8, stderr, " \t\r\n");
if (trimmed.len == 0) return allocator.dupe(u8, "git_error:command_failed");
const limit = @min(trimmed.len, 120);
const preview = try previewAlloc(allocator, trimmed[0..limit]);
defer allocator.free(preview);
return std.fmt.allocPrint(allocator, "git_error:{s}", .{preview});
}
fn singleOwnedRow(allocator: std.mem.Allocator, row: []u8) ![][]const u8 {
errdefer allocator.free(row);
const rows = try allocator.alloc([]const u8, 1);
rows[0] = row;
return rows;
}
fn matchesPattern(pattern: []const u8, path: []const u8) bool {
if (std.mem.endsWith(u8, pattern, "/")) return std.mem.startsWith(u8, path, pattern);
if (std.mem.startsWith(u8, pattern, "*.")) return std.mem.endsWith(u8, path, pattern[1..]);
@@ -214,7 +338,7 @@ fn freeItems(allocator: std.mem.Allocator, items: [][]const u8) void {
allocator.free(items);
}
fn freeOwnedItems(allocator: std.mem.Allocator, items: [][]const u8) void {
fn freeOwnedItems(allocator: std.mem.Allocator, items: []const []const u8) void {
for (items) |item| allocator.free(item);
allocator.free(items);
}
@@ -324,3 +448,86 @@ test "adversarial: project search rejects bad queries and rows" {
try std.testing.expectError(Error.InvalidSearchRow, repo.searchOffsetFromRow("bad-row"));
try std.testing.expectError(Error.FileNotFound, repo.searchOffsetFromRow("missing.zig:1:1:needle"));
}
fn makeGitFixture(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 = "const old = 1;\n" });
const cwd = try std.fmt.allocPrint(allocator, ".zig-cache/tmp/{s}", .{&tmp.sub_path});
errdefer allocator.free(cwd);
try runGitTest(allocator, cwd, &.{ "init", "--quiet" });
try runGitTest(allocator, cwd, &.{ "add", "src/main.zig" });
try runGitTest(allocator, cwd, &.{ "-c", "user.email=test@example.invalid", "-c", "user.name=Test", "commit", "--quiet", "-m", "init" });
try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "src/main.zig", .data = "const new = 2;\n" });
return .{ .tmp = tmp, .cwd = cwd };
}
fn runGitTest(allocator: std.mem.Allocator, cwd: []const u8, args: []const []const u8) !void {
var argv = try allocator.alloc([]const u8, args.len + 3);
defer allocator.free(argv);
argv[0] = "git";
argv[1] = "-C";
argv[2] = cwd;
@memcpy(argv[3..], args);
const result = try std.process.run(allocator, std.testing.io, .{
.argv = argv,
.stdout_limit = .limited(64 * 1024),
.stderr_limit = .limited(64 * 1024),
});
defer allocator.free(result.stdout);
defer allocator.free(result.stderr);
try std.testing.expect(termSucceeded(result.term));
}
test "regular: git status rows expose changed files" {
var fixture = try makeGitFixture(std.testing.allocator);
defer {
std.testing.allocator.free(fixture.cwd);
fixture.tmp.cleanup();
}
const rows = try gitStatusRowsAlloc(std.testing.allocator, std.testing.io, fixture.cwd);
defer freeOwnedItems(std.testing.allocator, rows);
try std.testing.expectEqual(@as(usize, 1), rows.len);
try std.testing.expectEqualStrings("modified:src/main.zig", rows[0]);
try std.testing.expectEqualStrings("src/main.zig", try gitPathFromStatusRow(rows[0]));
}
test "regular: git diff rows expose hunks and changed lines" {
var fixture = try makeGitFixture(std.testing.allocator);
defer {
std.testing.allocator.free(fixture.cwd);
fixture.tmp.cleanup();
}
const rows = try gitDiffRowsAlloc(std.testing.allocator, std.testing.io, fixture.cwd, "src/main.zig");
defer freeOwnedItems(std.testing.allocator, rows);
try std.testing.expect(rows.len >= 3);
try std.testing.expect(std.mem.startsWith(u8, rows[0], "hunk:@@"));
try std.testing.expect(std.mem.indexOf(u8, rows[1], "const_old_=_1;") != null);
try std.testing.expect(std.mem.indexOf(u8, rows[2], "const_new_=_2;") != null);
}
test "regular: changed git file can be read for editor navigation" {
var fixture = try makeGitFixture(std.testing.allocator);
defer {
std.testing.allocator.free(fixture.cwd);
fixture.tmp.cleanup();
}
const content = try readRepoFileAlloc(std.testing.allocator, std.testing.io, fixture.cwd, "src/main.zig");
defer std.testing.allocator.free(content);
try std.testing.expectEqualStrings("const new = 2;\n", content);
}
test "adversarial: git errors are visible rows and status rows are validated" {
const rows = try gitStatusRowsAlloc(std.testing.allocator, std.testing.io, "/definitely/not/a/mim/repo");
defer freeOwnedItems(std.testing.allocator, rows);
try std.testing.expectEqual(@as(usize, 1), rows.len);
try std.testing.expect(std.mem.startsWith(u8, rows[0], "git_error:"));
try std.testing.expectError(Error.InvalidStatusRow, gitPathFromStatusRow(rows[0]));
try std.testing.expectError(Error.InvalidStatusRow, gitPathFromStatusRow("clean"));
try std.testing.expectError(Error.InvalidPath, gitPathFromStatusRow("modified:../secret"));
}
+136
View File
@@ -85,18 +85,24 @@ pub const Client = struct {
session: session_mod.Session,
leader: leader_mod.Leader,
repo: repo_mod.Index,
io: ?std.Io,
viewport: Viewport,
saved_bytes: ?[]u8 = null,
message: ?[]const u8 = null,
quit: bool = false,
pub fn init(allocator: std.mem.Allocator, viewport: Viewport) !Client {
return initWithIo(allocator, viewport, null);
}
pub fn initWithIo(allocator: std.mem.Allocator, viewport: Viewport, io: ?std.Io) !Client {
try viewport.validate();
return .{
.allocator = allocator,
.session = session_mod.Session.init(allocator),
.leader = leader_mod.Leader.init(allocator),
.repo = repo_mod.Index.init(allocator),
.io = io,
.viewport = viewport,
};
}
@@ -128,6 +134,9 @@ pub const Client = struct {
if (std.mem.startsWith(u8, line, "search_text all ")) return self.openSearchList(line[16..], true);
if (std.mem.startsWith(u8, line, "search_text ")) return self.openSearchList(line[12..], false);
if (std.mem.eql(u8, line, "search_open_selected")) return self.openSelectedSearchResult();
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, "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);
@@ -329,6 +338,35 @@ pub const Client = struct {
self.message = null;
}
fn openGitStatus(self: *Client, cwd: []const u8) !void {
const io = self.io orelse return Error.ProtocolRejected;
const rows = repo_mod.gitStatusRowsAlloc(self.allocator, io, cwd) catch return Error.ProtocolRejected;
defer freeOwnedRows(self.allocator, rows);
try self.session.openListPanel("git-status", rows);
self.message = null;
}
fn openSelectedGitDiff(self: *Client, cwd: []const u8) !void {
const io = self.io orelse return Error.ProtocolRejected;
const selected = self.session.selectListPanel() catch return Error.ProtocolRejected;
const path = repo_mod.gitPathFromStatusRow(selected) catch return Error.ProtocolRejected;
const rows = repo_mod.gitDiffRowsAlloc(self.allocator, io, cwd, path) catch return Error.ProtocolRejected;
defer freeOwnedRows(self.allocator, rows);
try self.session.openListPanel("git-diff", rows);
self.message = null;
}
fn openSelectedGitFile(self: *Client, cwd: []const u8) !void {
const io = self.io orelse return Error.ProtocolRejected;
const selected = self.session.selectListPanel() catch return Error.ProtocolRejected;
const path = repo_mod.gitPathFromStatusRow(selected) catch return Error.ProtocolRejected;
const content = repo_mod.readRepoFileAlloc(self.allocator, io, cwd, path) catch return Error.ProtocolRejected;
defer self.allocator.free(content);
try self.session.openFixture(content);
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");
@@ -552,6 +590,11 @@ fn assertLinesFit(frame: []const u8, width: usize) !void {
}
}
fn freeOwnedRows(allocator: std.mem.Allocator, rows: []const []const u8) void {
for (rows) |row| allocator.free(row);
allocator.free(rows);
}
test "regular: scripted narrow terminal trace edits saves exits and replays saved bytes" {
const trace =
\\open abc
@@ -1118,3 +1161,96 @@ test "adversarial: project text search failures do not corrupt current buffer" {
const snap = try client.session.snapshot();
try std.testing.expectEqualStrings("safe", snap.bytes);
}
fn makeTuiGitFixture(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 = "const old = 1;\n" });
const cwd = try std.fmt.allocPrint(allocator, ".zig-cache/tmp/{s}", .{&tmp.sub_path});
errdefer allocator.free(cwd);
try runGitTuiTest(allocator, cwd, &.{ "init", "--quiet" });
try runGitTuiTest(allocator, cwd, &.{ "add", "src/main.zig" });
try runGitTuiTest(allocator, cwd, &.{ "-c", "user.email=test@example.invalid", "-c", "user.name=Test", "commit", "--quiet", "-m", "init" });
try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "src/main.zig", .data = "const new = 2;\n" });
return .{ .tmp = tmp, .cwd = cwd };
}
fn runGitTuiTest(allocator: std.mem.Allocator, cwd: []const u8, args: []const []const u8) !void {
var argv = try allocator.alloc([]const u8, args.len + 3);
defer allocator.free(argv);
argv[0] = "git";
argv[1] = "-C";
argv[2] = cwd;
@memcpy(argv[3..], args);
const result = try std.process.run(allocator, std.testing.io, .{
.argv = argv,
.stdout_limit = .limited(64 * 1024),
.stderr_limit = .limited(64 * 1024),
});
defer allocator.free(result.stdout);
defer allocator.free(result.stderr);
try std.testing.expect(switch (result.term) {
.exited => |code| code == 0,
else => false,
});
}
test "regular: git status panel opens changed file into editor" {
var fixture = try makeTuiGitFixture(std.testing.allocator);
defer {
std.testing.allocator.free(fixture.cwd);
fixture.tmp.cleanup();
}
var client = try Client.initWithIo(std.testing.allocator, .{ .width = 64, .height = 6 }, std.testing.io);
defer client.deinit();
const status = try std.fmt.allocPrint(std.testing.allocator, "git_status {s}", .{fixture.cwd});
defer std.testing.allocator.free(status);
try client.handleTraceLine(status);
{
const frame = try client.render(std.testing.allocator);
defer std.testing.allocator.free(frame);
try std.testing.expect(std.mem.indexOf(u8, frame, "git-status") != null);
try std.testing.expect(std.mem.indexOf(u8, frame, "modified:src/main.zig") != null);
}
const open_changed = try std.fmt.allocPrint(std.testing.allocator, "git_open_changed_selected {s}", .{fixture.cwd});
defer std.testing.allocator.free(open_changed);
try client.handleTraceLine(open_changed);
const snap = try client.session.snapshot();
try std.testing.expectEqualStrings("const new = 2;\n", snap.bytes);
}
test "regular: git diff panel renders hunks for selected changed file" {
var fixture = try makeTuiGitFixture(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 status = try std.fmt.allocPrint(std.testing.allocator, "git_status {s}", .{fixture.cwd});
defer std.testing.allocator.free(status);
try client.handleTraceLine(status);
const diff = try std.fmt.allocPrint(std.testing.allocator, "git_diff_selected {s}", .{fixture.cwd});
defer std.testing.allocator.free(diff);
try client.handleTraceLine(diff);
const frame = try client.render(std.testing.allocator);
defer std.testing.allocator.free(frame);
try std.testing.expect(std.mem.indexOf(u8, frame, "git-diff") != null);
try std.testing.expect(std.mem.indexOf(u8, frame, "hunk:@@") != null);
try std.testing.expect(std.mem.indexOf(u8, frame, "add:const_new_=_2;") != null);
}
test "adversarial: git status errors are visible in panel" {
var client = try Client.initWithIo(std.testing.allocator, .{ .width = 64, .height = 5 }, std.testing.io);
defer client.deinit();
try client.handleTraceLine("git_status /definitely/not/a/mim/repo");
const frame = try client.render(std.testing.allocator);
defer std.testing.allocator.free(frame);
try std.testing.expect(std.mem.indexOf(u8, frame, "git_error:") != null);
}