From 871f3b4917c112df958b64926f755d72e37d1e08 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Sun, 21 Jun 2026 03:16:10 +0200 Subject: [PATCH] Add read-only git workbench panels --- src/repo.zig | 209 ++++++++++++++++++++++++++++++++++++++++++++++++++- src/tui.zig | 136 +++++++++++++++++++++++++++++++++ 2 files changed, 344 insertions(+), 1 deletion(-) diff --git a/src/repo.zig b/src/repo.zig index 57b4f3a..eb7a013 100644 --- a/src/repo.zig +++ b/src/repo.zig @@ -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")); +} diff --git a/src/tui.zig b/src/tui.zig index 4e6f8fc..f7ed981 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -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); +}