diff --git a/src/main.zig b/src/main.zig index 4532c97..20ff2ce 100644 --- a/src/main.zig +++ b/src/main.zig @@ -6,6 +6,7 @@ const mobile_acceptance = @import("mobile_acceptance.zig"); const panel = @import("panel.zig"); const protocol = @import("protocol.zig"); const replay = @import("replay.zig"); +const repo = @import("repo.zig"); const session = @import("session.zig"); const socket = @import("socket.zig"); const symbol = @import("symbol.zig"); @@ -129,6 +130,7 @@ test { _ = panel; _ = protocol; _ = replay; + _ = repo; _ = session; _ = socket; _ = symbol; diff --git a/src/repo.zig b/src/repo.zig new file mode 100644 index 0000000..c82fbb0 --- /dev/null +++ b/src/repo.zig @@ -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")); +} diff --git a/src/tui.zig b/src/tui.zig index 579e65d..bc69b52 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -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); +}