Add gitignore-aware file picker
This commit is contained in:
+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"));
|
||||
}
|
||||
Reference in New Issue
Block a user