Add gitignore-aware file picker
This commit is contained in:
@@ -6,6 +6,7 @@ const mobile_acceptance = @import("mobile_acceptance.zig");
|
|||||||
const panel = @import("panel.zig");
|
const panel = @import("panel.zig");
|
||||||
const protocol = @import("protocol.zig");
|
const protocol = @import("protocol.zig");
|
||||||
const replay = @import("replay.zig");
|
const replay = @import("replay.zig");
|
||||||
|
const repo = @import("repo.zig");
|
||||||
const session = @import("session.zig");
|
const session = @import("session.zig");
|
||||||
const socket = @import("socket.zig");
|
const socket = @import("socket.zig");
|
||||||
const symbol = @import("symbol.zig");
|
const symbol = @import("symbol.zig");
|
||||||
@@ -129,6 +130,7 @@ test {
|
|||||||
_ = panel;
|
_ = panel;
|
||||||
_ = protocol;
|
_ = protocol;
|
||||||
_ = replay;
|
_ = replay;
|
||||||
|
_ = repo;
|
||||||
_ = session;
|
_ = session;
|
||||||
_ = socket;
|
_ = socket;
|
||||||
_ = symbol;
|
_ = symbol;
|
||||||
|
|||||||
+181
@@ -0,0 +1,181 @@
|
|||||||
|
const std = @import("std");
|
||||||
|
|
||||||
|
// Minimal repo index for file picker/tree behavior.
|
||||||
|
// req: repo/001, ui/002, testing/001, testing/002, testing/003, testing/004
|
||||||
|
|
||||||
|
test {
|
||||||
|
_ = Index;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const Error = error{
|
||||||
|
InvalidPath,
|
||||||
|
InvalidPattern,
|
||||||
|
FileNotFound,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const Entry = struct {
|
||||||
|
path: []u8,
|
||||||
|
content: []u8,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const Index = struct {
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
entries: std.ArrayList(Entry) = .empty,
|
||||||
|
ignore_patterns: std.ArrayList([]u8) = .empty,
|
||||||
|
|
||||||
|
pub fn init(allocator: std.mem.Allocator) Index {
|
||||||
|
return .{ .allocator = allocator };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deinit(self: *Index) void {
|
||||||
|
for (self.entries.items) |entry| {
|
||||||
|
self.allocator.free(entry.path);
|
||||||
|
self.allocator.free(entry.content);
|
||||||
|
}
|
||||||
|
self.entries.deinit(self.allocator);
|
||||||
|
for (self.ignore_patterns.items) |pattern| self.allocator.free(pattern);
|
||||||
|
self.ignore_patterns.deinit(self.allocator);
|
||||||
|
self.* = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(self: *Index) void {
|
||||||
|
for (self.entries.items) |entry| {
|
||||||
|
self.allocator.free(entry.path);
|
||||||
|
self.allocator.free(entry.content);
|
||||||
|
}
|
||||||
|
self.entries.clearRetainingCapacity();
|
||||||
|
for (self.ignore_patterns.items) |pattern| self.allocator.free(pattern);
|
||||||
|
self.ignore_patterns.clearRetainingCapacity();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn addIgnorePattern(self: *Index, pattern: []const u8) !void {
|
||||||
|
try validatePattern(pattern);
|
||||||
|
try self.ignore_patterns.append(self.allocator, try self.allocator.dupe(u8, pattern));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn addFile(self: *Index, path: []const u8, file_content: []const u8) !void {
|
||||||
|
try validatePath(path);
|
||||||
|
if (!std.unicode.utf8ValidateSlice(file_content)) return Error.InvalidPath;
|
||||||
|
try self.entries.append(self.allocator, .{
|
||||||
|
.path = try self.allocator.dupe(u8, path),
|
||||||
|
.content = try self.allocator.dupe(u8, file_content),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pickerItemsAlloc(self: *const Index, allocator: std.mem.Allocator, include_ignored: bool) ![][]const u8 {
|
||||||
|
var items = std.ArrayList([]const u8).empty;
|
||||||
|
errdefer items.deinit(allocator);
|
||||||
|
for (self.entries.items) |entry| {
|
||||||
|
if (!include_ignored and self.isIgnored(entry.path)) continue;
|
||||||
|
try items.append(allocator, entry.path);
|
||||||
|
}
|
||||||
|
return items.toOwnedSlice(allocator);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn treeItemsAlloc(self: *const Index, allocator: std.mem.Allocator, include_ignored: bool) ![][]const u8 {
|
||||||
|
// Tree rows are path-shaped for now; rendering is still through the shared list primitive.
|
||||||
|
return self.pickerItemsAlloc(allocator, include_ignored);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn content(self: *const Index, path: []const u8) ![]const u8 {
|
||||||
|
for (self.entries.items) |entry| {
|
||||||
|
if (std.mem.eql(u8, entry.path, path)) return entry.content;
|
||||||
|
}
|
||||||
|
return Error.FileNotFound;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn isIgnored(self: *const Index, path: []const u8) bool {
|
||||||
|
for (self.ignore_patterns.items) |pattern| {
|
||||||
|
if (matchesPattern(pattern, path)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fn validatePath(path: []const u8) !void {
|
||||||
|
if (path.len == 0) return Error.InvalidPath;
|
||||||
|
if (!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 validatePattern(pattern: []const u8) !void {
|
||||||
|
if (pattern.len == 0) return Error.InvalidPattern;
|
||||||
|
if (!std.unicode.utf8ValidateSlice(pattern)) return Error.InvalidPattern;
|
||||||
|
for (pattern) |byte| {
|
||||||
|
if (byte <= 0x20 or byte == '|') return Error.InvalidPattern;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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..]);
|
||||||
|
if (std.mem.indexOfScalar(u8, pattern, '/') != null) return std.mem.eql(u8, pattern, path) or std.mem.startsWith(u8, path, pattern);
|
||||||
|
var parts = std.mem.splitScalar(u8, path, '/');
|
||||||
|
while (parts.next()) |part| {
|
||||||
|
if (std.mem.eql(u8, part, pattern)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn freeItems(allocator: std.mem.Allocator, items: [][]const u8) void {
|
||||||
|
allocator.free(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "regular: picker respects gitignore by default and can include ignored" {
|
||||||
|
var repo = Index.init(std.testing.allocator);
|
||||||
|
defer repo.deinit();
|
||||||
|
try repo.addIgnorePattern("zig-out/");
|
||||||
|
try repo.addIgnorePattern("*.o");
|
||||||
|
try repo.addIgnorePattern("secret.txt");
|
||||||
|
try repo.addFile("src/main.zig", "pub fn main() void {}");
|
||||||
|
try repo.addFile("zig-out/bin/mim", "binary");
|
||||||
|
try repo.addFile("build.o", "object");
|
||||||
|
try repo.addFile("secret.txt", "hidden");
|
||||||
|
|
||||||
|
{
|
||||||
|
const items = try repo.pickerItemsAlloc(std.testing.allocator, false);
|
||||||
|
defer freeItems(std.testing.allocator, items);
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), items.len);
|
||||||
|
try std.testing.expectEqualStrings("src/main.zig", items[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const items = try repo.pickerItemsAlloc(std.testing.allocator, true);
|
||||||
|
defer freeItems(std.testing.allocator, items);
|
||||||
|
try std.testing.expectEqual(@as(usize, 4), items.len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test "regular: tree rows use the same gitignore boundary as picker rows" {
|
||||||
|
var repo = Index.init(std.testing.allocator);
|
||||||
|
defer repo.deinit();
|
||||||
|
try repo.addIgnorePattern("tmp/");
|
||||||
|
try repo.addFile("src/main.zig", "main");
|
||||||
|
try repo.addFile("tmp/log.txt", "log");
|
||||||
|
|
||||||
|
const rows = try repo.treeItemsAlloc(std.testing.allocator, false);
|
||||||
|
defer freeItems(std.testing.allocator, rows);
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), rows.len);
|
||||||
|
try std.testing.expectEqualStrings("src/main.zig", rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "regular: selected file content is retrievable" {
|
||||||
|
var repo = Index.init(std.testing.allocator);
|
||||||
|
defer repo.deinit();
|
||||||
|
try repo.addFile("src/main.zig", "const std = @import(\"std\");");
|
||||||
|
|
||||||
|
try std.testing.expectEqualStrings("const std = @import(\"std\");", try repo.content("src/main.zig"));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "adversarial: invalid paths and patterns are rejected" {
|
||||||
|
var repo = Index.init(std.testing.allocator);
|
||||||
|
defer repo.deinit();
|
||||||
|
|
||||||
|
try std.testing.expectError(Error.InvalidPath, repo.addFile("../secret", "x"));
|
||||||
|
try std.testing.expectError(Error.InvalidPath, repo.addFile("two words", "x"));
|
||||||
|
try std.testing.expectError(Error.InvalidPattern, repo.addIgnorePattern("bad pattern"));
|
||||||
|
try std.testing.expectError(Error.FileNotFound, repo.content("missing.zig"));
|
||||||
|
}
|
||||||
+110
@@ -3,6 +3,7 @@ const input = @import("input.zig");
|
|||||||
const leader_mod = @import("leader.zig");
|
const leader_mod = @import("leader.zig");
|
||||||
const protocol = @import("protocol.zig");
|
const protocol = @import("protocol.zig");
|
||||||
const replay = @import("replay.zig");
|
const replay = @import("replay.zig");
|
||||||
|
const repo_mod = @import("repo.zig");
|
||||||
const session_mod = @import("session.zig");
|
const session_mod = @import("session.zig");
|
||||||
const symbol_mod = @import("symbol.zig");
|
const symbol_mod = @import("symbol.zig");
|
||||||
|
|
||||||
@@ -83,6 +84,7 @@ pub const Client = struct {
|
|||||||
allocator: std.mem.Allocator,
|
allocator: std.mem.Allocator,
|
||||||
session: session_mod.Session,
|
session: session_mod.Session,
|
||||||
leader: leader_mod.Leader,
|
leader: leader_mod.Leader,
|
||||||
|
repo: repo_mod.Index,
|
||||||
viewport: Viewport,
|
viewport: Viewport,
|
||||||
saved_bytes: ?[]u8 = null,
|
saved_bytes: ?[]u8 = null,
|
||||||
message: ?[]const u8 = null,
|
message: ?[]const u8 = null,
|
||||||
@@ -94,12 +96,14 @@ pub const Client = struct {
|
|||||||
.allocator = allocator,
|
.allocator = allocator,
|
||||||
.session = session_mod.Session.init(allocator),
|
.session = session_mod.Session.init(allocator),
|
||||||
.leader = leader_mod.Leader.init(allocator),
|
.leader = leader_mod.Leader.init(allocator),
|
||||||
|
.repo = repo_mod.Index.init(allocator),
|
||||||
.viewport = viewport,
|
.viewport = viewport,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn deinit(self: *Client) void {
|
pub fn deinit(self: *Client) void {
|
||||||
if (self.saved_bytes) |bytes| self.allocator.free(bytes);
|
if (self.saved_bytes) |bytes| self.allocator.free(bytes);
|
||||||
|
self.repo.deinit();
|
||||||
self.leader.deinit();
|
self.leader.deinit();
|
||||||
self.session.deinit();
|
self.session.deinit();
|
||||||
self.* = undefined;
|
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, "key ")) return self.handleTraceKey(line[4..]);
|
||||||
if (std.mem.startsWith(u8, line, "type ")) return self.handleInput(line[5..]);
|
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, "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_open ")) return self.applyProtocolCommand(line);
|
||||||
if (std.mem.startsWith(u8, line, "list_filter ")) 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;
|
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 {
|
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, "space")) return self.handleInput(" ");
|
||||||
if (std.mem.eql(u8, key_name, "enter")) return self.handleInput("\r");
|
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(result.diagnostics.clipped_sources >= 1);
|
||||||
try std.testing.expect(std.mem.indexOf(u8, result.warning, "clipped") != null);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user