Add gitignore-aware file picker

This commit is contained in:
slhx agent
2026-06-21 03:01:20 +02:00
parent fddfd1a5e4
commit 233a5245f6
3 changed files with 293 additions and 0 deletions
+110
View File
@@ -3,6 +3,7 @@ const input = @import("input.zig");
const leader_mod = @import("leader.zig");
const protocol = @import("protocol.zig");
const replay = @import("replay.zig");
const repo_mod = @import("repo.zig");
const session_mod = @import("session.zig");
const symbol_mod = @import("symbol.zig");
@@ -83,6 +84,7 @@ pub const Client = struct {
allocator: std.mem.Allocator,
session: session_mod.Session,
leader: leader_mod.Leader,
repo: repo_mod.Index,
viewport: Viewport,
saved_bytes: ?[]u8 = null,
message: ?[]const u8 = null,
@@ -94,12 +96,14 @@ pub const Client = struct {
.allocator = allocator,
.session = session_mod.Session.init(allocator),
.leader = leader_mod.Leader.init(allocator),
.repo = repo_mod.Index.init(allocator),
.viewport = viewport,
};
}
pub fn deinit(self: *Client) void {
if (self.saved_bytes) |bytes| self.allocator.free(bytes);
self.repo.deinit();
self.leader.deinit();
self.session.deinit();
self.* = undefined;
@@ -114,6 +118,13 @@ pub const Client = struct {
if (std.mem.startsWith(u8, line, "key ")) return self.handleTraceKey(line[4..]);
if (std.mem.startsWith(u8, line, "type ")) return self.handleInput(line[5..]);
if (std.mem.startsWith(u8, line, "repo_gitignore ")) return self.repo.addIgnorePattern(line[15..]);
if (std.mem.startsWith(u8, line, "repo_file ")) return self.addRepoFile(line[10..]);
if (std.mem.eql(u8, line, "file_picker")) return self.openRepoList("files", false, false);
if (std.mem.eql(u8, line, "file_picker all")) return self.openRepoList("files", false, true);
if (std.mem.eql(u8, line, "file_tree")) return self.openRepoList("tree", true, false);
if (std.mem.eql(u8, line, "file_tree all")) return self.openRepoList("tree", true, true);
if (std.mem.eql(u8, line, "file_open_selected")) return self.openSelectedRepoFile();
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);
@@ -273,6 +284,29 @@ pub const Client = struct {
return self.saved_bytes orelse Error.NothingSaved;
}
fn addRepoFile(self: *Client, payload: []const u8) !void {
const separator = std.mem.indexOfScalar(u8, payload, '=') orelse return repo_mod.Error.InvalidPath;
try self.repo.addFile(payload[0..separator], payload[separator + 1 ..]);
}
fn openRepoList(self: *Client, title: []const u8, tree: bool, include_ignored: bool) !void {
const items = if (tree)
try self.repo.treeItemsAlloc(self.allocator, include_ignored)
else
try self.repo.pickerItemsAlloc(self.allocator, include_ignored);
defer self.allocator.free(items);
try self.session.openListPanel(title, items);
self.message = null;
}
fn openSelectedRepoFile(self: *Client) !void {
const selected = self.session.selectListPanel() catch return Error.ProtocolRejected;
const content = self.repo.content(selected) catch return Error.ProtocolRejected;
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");
@@ -922,3 +956,79 @@ test "adversarial: render diagnostics name clipped panel sources" {
try std.testing.expect(result.diagnostics.clipped_sources >= 1);
try std.testing.expect(std.mem.indexOf(u8, result.warning, "clipped") != null);
}
test "regular: file picker respects gitignore by default and opens selected file" {
var client = try Client.init(std.testing.allocator, .{ .width = 40, .height = 6 });
defer client.deinit();
try client.handleTraceLine("repo_gitignore zig-out/");
try client.handleTraceLine("repo_gitignore *.o");
try client.handleTraceLine("repo_file src/main.zig=pubfnmain");
try client.handleTraceLine("repo_file zig-out/bin/mim=binary");
try client.handleTraceLine("repo_file build.o=object");
try client.handleTraceLine("file_picker");
{
const frame = try client.render(std.testing.allocator);
defer std.testing.allocator.free(frame);
try std.testing.expect(std.mem.indexOf(u8, frame, "src/main.zig") != null);
try std.testing.expect(std.mem.indexOf(u8, frame, "zig-out") == null);
try std.testing.expect(std.mem.indexOf(u8, frame, "build.o") == null);
}
try client.handleTraceLine("file_open_selected");
{
const frame = try client.render(std.testing.allocator);
defer std.testing.allocator.free(frame);
try std.testing.expect(std.mem.indexOf(u8, frame, "pubfnmain") != null);
}
}
test "regular: file picker include ignored toggle exposes ignored files" {
var client = try Client.init(std.testing.allocator, .{ .width = 40, .height = 6 });
defer client.deinit();
try client.handleTraceLine("repo_gitignore zig-out/");
try client.handleTraceLine("repo_file src/main.zig=main");
try client.handleTraceLine("repo_file zig-out/bin/mim=binary");
try client.handleTraceLine("file_picker all");
const frame = try client.render(std.testing.allocator);
defer std.testing.allocator.free(frame);
try std.testing.expect(std.mem.indexOf(u8, frame, "src/main.zig") != null);
try std.testing.expect(std.mem.indexOf(u8, frame, "zig-out/bin/mim") != null);
}
test "regular: file tree uses the same panel primitive and include ignored boundary" {
var client = try Client.init(std.testing.allocator, .{ .width = 40, .height = 6 });
defer client.deinit();
try client.handleTraceLine("repo_gitignore tmp/");
try client.handleTraceLine("repo_file src/lib.zig=lib");
try client.handleTraceLine("repo_file tmp/log.txt=log");
try client.handleTraceLine("file_tree");
{
const frame = try client.render(std.testing.allocator);
defer std.testing.allocator.free(frame);
try std.testing.expect(std.mem.indexOf(u8, frame, "src/lib.zig") != null);
try std.testing.expect(std.mem.indexOf(u8, frame, "tmp/log") == null);
}
try client.handleTraceLine("panel_close");
try client.handleTraceLine("file_tree all");
{
const frame = try client.render(std.testing.allocator);
defer std.testing.allocator.free(frame);
try std.testing.expect(std.mem.indexOf(u8, frame, "tmp/log.txt") != null);
}
}
test "adversarial: invalid repo paths and missing selections fail without corrupting editor" {
var client = try Client.init(std.testing.allocator, .{ .width = 32, .height = 5 });
defer client.deinit();
try client.handleTraceLine("open safe");
try std.testing.expectError(repo_mod.Error.InvalidPath, client.handleTraceLine("repo_file ../secret=x"));
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("file_open_selected"));
const frame = try client.render(std.testing.allocator);
defer std.testing.allocator.free(frame);
try std.testing.expect(std.mem.indexOf(u8, frame, "safe") != null);
}