Add project text search panel

This commit is contained in:
slhx agent
2026-06-21 03:06:12 +02:00
parent 233a5245f6
commit fc3b6a7d08
3 changed files with 239 additions and 1 deletions
+145
View File
@@ -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"));
}
+8 -1
View File
@@ -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 {
+86
View File
@@ -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);
}