From fc3b6a7d085de583cec5fdd3a7253bc1fabef881 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Sun, 21 Jun 2026 03:06:12 +0200 Subject: [PATCH] Add project text search panel --- src/repo.zig | 145 ++++++++++++++++++++++++++++++++++++++++++++++++ src/session.zig | 9 ++- src/tui.zig | 86 ++++++++++++++++++++++++++++ 3 files changed, 239 insertions(+), 1 deletion(-) diff --git a/src/repo.zig b/src/repo.zig index c82fbb0..57b4f3a 100644 --- a/src/repo.zig +++ b/src/repo.zig @@ -10,6 +10,8 @@ test { pub const Error = error{ InvalidPath, InvalidPattern, + InvalidQuery, + InvalidSearchRow, FileNotFound, }; @@ -84,6 +86,33 @@ pub const Index = struct { return Error.FileNotFound; } + pub fn searchRowsAlloc(self: *const Index, allocator: std.mem.Allocator, query: []const u8, include_ignored: bool) ![][]const u8 { + try validateQuery(query); + var rows = std.ArrayList([]const u8).empty; + errdefer { + for (rows.items) |row| allocator.free(row); + rows.deinit(allocator); + } + for (self.entries.items) |entry| { + if (!include_ignored and self.isIgnored(entry.path)) continue; + try appendSearchRowsForEntry(allocator, &rows, entry, query); + } + return rows.toOwnedSlice(allocator); + } + + pub fn searchOffsetFromRow(self: *const Index, row: []const u8) !struct { path: []const u8, offset: usize } { + const first = std.mem.indexOfScalar(u8, row, ':') orelse return Error.InvalidSearchRow; + const rest = row[first + 1 ..]; + const second_rel = std.mem.indexOfScalar(u8, rest, ':') orelse return Error.InvalidSearchRow; + const third_rel = std.mem.indexOfScalar(u8, rest[second_rel + 1 ..], ':') orelse return Error.InvalidSearchRow; + const path = row[0..first]; + const line_no = std.fmt.parseUnsigned(usize, rest[0..second_rel], 10) catch return Error.InvalidSearchRow; + const column = std.fmt.parseUnsigned(usize, rest[second_rel + 1 .. second_rel + 1 + third_rel], 10) catch return Error.InvalidSearchRow; + if (line_no == 0 or column == 0) return Error.InvalidSearchRow; + const file_content = try self.content(path); + return .{ .path = path, .offset = try byteOffsetForLineColumn(file_content, line_no, column) }; + } + pub fn isIgnored(self: *const Index, path: []const u8) bool { for (self.ignore_patterns.items) |pattern| { if (matchesPattern(pattern, path)) return true; @@ -109,6 +138,67 @@ fn validatePattern(pattern: []const u8) !void { } } +fn validateQuery(query: []const u8) !void { + if (query.len == 0) return Error.InvalidQuery; + if (!std.unicode.utf8ValidateSlice(query)) return Error.InvalidQuery; + for (query) |byte| { + if (byte <= 0x20 or byte == '|') return Error.InvalidQuery; + } +} + +fn appendSearchRowsForEntry(allocator: std.mem.Allocator, rows: *std.ArrayList([]const u8), entry: Entry, query: []const u8) !void { + var line_no: usize = 1; + var lines = std.mem.splitScalar(u8, entry.content, '\n'); + while (lines.next()) |line| : (line_no += 1) { + if (std.mem.indexOf(u8, line, query)) |column_zero| { + const preview = try previewAlloc(allocator, line); + defer allocator.free(preview); + try rows.append(allocator, try std.fmt.allocPrint( + allocator, + "{s}:{d}:{d}:{s}", + .{ entry.path, line_no, column_zero + 1, preview }, + )); + } + } +} + +fn previewAlloc(allocator: std.mem.Allocator, line: []const u8) ![]u8 { + var out = std.ArrayList(u8).empty; + errdefer out.deinit(allocator); + var i: usize = 0; + while (i < line.len and out.items.len < 48) { + const len = std.unicode.utf8ByteSequenceLength(line[i]) catch 1; + const end = @min(line.len, i + len); + const slice = line[i..end]; + if (slice.len == 1 and (slice[0] <= 0x20 or slice[0] == ':' or slice[0] == '|')) { + try out.append(allocator, '_'); + } else { + try out.appendSlice(allocator, slice); + } + i = end; + } + if (out.items.len == 0) try out.append(allocator, '_'); + return out.toOwnedSlice(allocator); +} + +fn byteOffsetForLineColumn(content_bytes: []const u8, line_no: usize, column: usize) !usize { + var current_line: usize = 1; + var line_start: usize = 0; + var i: usize = 0; + while (i <= content_bytes.len) : (i += 1) { + if (i == content_bytes.len or content_bytes[i] == '\n') { + if (current_line == line_no) { + const line = content_bytes[line_start..i]; + if (column - 1 > line.len) return Error.InvalidSearchRow; + return line_start + column - 1; + } + current_line += 1; + line_start = i + 1; + } + } + return Error.InvalidSearchRow; +} + 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..]); @@ -124,6 +214,11 @@ fn freeItems(allocator: std.mem.Allocator, items: [][]const u8) void { allocator.free(items); } +fn freeOwnedItems(allocator: std.mem.Allocator, items: [][]const u8) void { + for (items) |item| allocator.free(item); + allocator.free(items); +} + test "regular: picker respects gitignore by default and can include ignored" { var repo = Index.init(std.testing.allocator); defer repo.deinit(); @@ -179,3 +274,53 @@ test "adversarial: invalid paths and patterns are rejected" { try std.testing.expectError(Error.InvalidPattern, repo.addIgnorePattern("bad pattern")); try std.testing.expectError(Error.FileNotFound, repo.content("missing.zig")); } + +test "regular: project search returns file line column and preview rows" { + var repo = Index.init(std.testing.allocator); + defer repo.deinit(); + try repo.addIgnorePattern("zig-out/"); + try repo.addFile("src/main.zig", "pub fn main() void {}\nconst needle = 1;"); + try repo.addFile("src/lib.zig", "needle here"); + try repo.addFile("zig-out/log.txt", "needle ignored"); + + const rows = try repo.searchRowsAlloc(std.testing.allocator, "needle", false); + defer freeOwnedItems(std.testing.allocator, rows); + try std.testing.expectEqual(@as(usize, 2), rows.len); + try std.testing.expectEqualStrings("src/main.zig:2:7:const_needle_=_1;", rows[0]); + try std.testing.expectEqualStrings("src/lib.zig:1:1:needle_here", rows[1]); +} + +test "regular: project search can include ignored files explicitly" { + var repo = Index.init(std.testing.allocator); + defer repo.deinit(); + try repo.addIgnorePattern("zig-out/"); + try repo.addFile("src/main.zig", "needle"); + try repo.addFile("zig-out/log.txt", "needle"); + + const rows = try repo.searchRowsAlloc(std.testing.allocator, "needle", true); + defer freeOwnedItems(std.testing.allocator, rows); + try std.testing.expectEqual(@as(usize, 2), rows.len); +} + +test "regular: search row resolves back to file and byte offset" { + var repo = Index.init(std.testing.allocator); + defer repo.deinit(); + try repo.addFile("src/main.zig", "abc\nlet needle = 1;\n"); + + const rows = try repo.searchRowsAlloc(std.testing.allocator, "needle", false); + defer freeOwnedItems(std.testing.allocator, rows); + const resolved = try repo.searchOffsetFromRow(rows[0]); + try std.testing.expectEqualStrings("src/main.zig", resolved.path); + try std.testing.expectEqual(@as(usize, 8), resolved.offset); +} + +test "adversarial: project search rejects bad queries and rows" { + var repo = Index.init(std.testing.allocator); + defer repo.deinit(); + try repo.addFile("src/main.zig", "needle"); + + try std.testing.expectError(Error.InvalidQuery, repo.searchRowsAlloc(std.testing.allocator, "", false)); + try std.testing.expectError(Error.InvalidQuery, repo.searchRowsAlloc(std.testing.allocator, "two words", false)); + try std.testing.expectError(Error.InvalidSearchRow, repo.searchOffsetFromRow("bad-row")); + try std.testing.expectError(Error.FileNotFound, repo.searchOffsetFromRow("missing.zig:1:1:needle")); +} diff --git a/src/session.zig b/src/session.zig index 9bb26da..c451f68 100644 --- a/src/session.zig +++ b/src/session.zig @@ -138,8 +138,15 @@ pub const Session = struct { } pub fn openFixture(self: *Session, fixture: []const u8) !void { + try self.openFixtureAt(fixture, 0); + } + + pub fn openFixtureAt(self: *Session, fixture: []const u8, cursor_byte: usize) !void { if (self.buffer) |*buffer| buffer.deinit(); - self.buffer = try Buffer.openFromBytes(self.allocator, fixture); + var buffer = try Buffer.openFromBytes(self.allocator, fixture); + buffer.cursor.byte = previousBoundary(buffer.bytes.items, @min(cursor_byte, buffer.bytes.items.len)); + buffer.refreshCell(); + self.buffer = buffer; } pub fn dispatch(self: *Session, command: Command) !void { diff --git a/src/tui.zig b/src/tui.zig index bc69b52..4e6f8fc 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -125,6 +125,9 @@ pub const Client = struct { 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, "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, "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); @@ -307,6 +310,25 @@ pub const Client = struct { self.message = null; } + fn openSearchList(self: *Client, query: []const u8, include_ignored: bool) !void { + const rows = self.repo.searchRowsAlloc(self.allocator, query, include_ignored) catch return Error.ProtocolRejected; + defer { + for (rows) |row| self.allocator.free(row); + self.allocator.free(rows); + } + try self.session.openListPanel("search", rows); + self.message = null; + } + + fn openSelectedSearchResult(self: *Client) !void { + const selected = self.session.selectListPanel() catch return Error.ProtocolRejected; + const match = self.repo.searchOffsetFromRow(selected) catch return Error.ProtocolRejected; + const content = self.repo.content(match.path) catch return Error.ProtocolRejected; + try self.session.openFixtureAt(content, match.offset); + 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"); @@ -1032,3 +1054,67 @@ test "adversarial: invalid repo paths and missing selections fail without corrup defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "safe") != null); } + +test "regular: project text search lists matches filters results and jumps to match" { + var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 }); + defer client.deinit(); + + try client.handleTraceLine("repo_file src/main.zig=abc_needle"); + try client.handleTraceLine("repo_file src/lib.zig=needle_lib"); + try client.handleTraceLine("search_text needle"); + { + 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:1:5") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, "src/lib.zig:1:1") != null); + } + + try client.handleTraceLine("list_filter lib"); + { + 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:1:1") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, "src/main.zig") == null); + } + + try client.handleTraceLine("search_open_selected"); + const snap = try client.session.snapshot(); + try std.testing.expectEqualStrings("needle_lib", snap.bytes); + try std.testing.expectEqual(@as(usize, 0), snap.cursor_byte); +} + +test "regular: project text search respects ignored files unless include ignored is explicit" { + var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 5 }); + defer client.deinit(); + + try client.handleTraceLine("repo_gitignore zig-out/"); + try client.handleTraceLine("repo_file src/main.zig=needle"); + try client.handleTraceLine("repo_file zig-out/log.txt=needle"); + try client.handleTraceLine("search_text needle"); + { + 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 client.handleTraceLine("panel_close"); + try client.handleTraceLine("search_text all needle"); + { + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, "zig-out/log.txt") != null); + } +} + +test "adversarial: project text search failures do not corrupt current buffer" { + var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 5 }); + defer client.deinit(); + + try client.handleTraceLine("open safe"); + try client.handleTraceLine("repo_file src/main.zig=needle"); + try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("search_text two words")); + try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("search_open_selected")); + const snap = try client.session.snapshot(); + try std.testing.expectEqualStrings("safe", snap.bytes); +}