Add current file search

This commit is contained in:
slhx agent
2026-06-21 12:53:17 +02:00
parent 30af1e97bc
commit 70ef989c2a
4 changed files with 249 additions and 28 deletions
+51 -13
View File
@@ -21,6 +21,7 @@ pub const Action = union(enum) {
quit,
open: []u8,
symbol: symbol_mod.Symbol,
search_file,
repeat_rail,
restore_last,
not_built: Feature,
@@ -37,6 +38,7 @@ const Mode = enum {
idle,
rail,
symbol_rail,
search_rail,
open_prompt,
};
@@ -60,6 +62,7 @@ pub const Leader = struct {
.idle => return self.handleIdle(event),
.rail => return self.handleRail(event),
.symbol_rail => return self.handleSymbolRail(event),
.search_rail => return self.handleSearchRail(event),
.open_prompt => return self.handleOpenPrompt(event),
}
}
@@ -68,8 +71,9 @@ pub const Leader = struct {
if (self.message) |message| return message;
return switch (self.mode) {
.idle => "",
.rail => "leader: s save q quit o open p symbols r repeat / search x close",
.rail => "leader: w save q quit o open p symbols s search r repeat x close",
.symbol_rail => symbol_mod.rail_status,
.search_rail => "search: f current file p project s symbols",
.open_prompt => "open: type path, Enter opens, Esc cancels",
};
}
@@ -106,10 +110,14 @@ pub const Leader = struct {
self.message = null;
switch (event) {
.text => |text| {
if (std.mem.eql(u8, text, "s")) {
if (std.mem.eql(u8, text, "w")) {
self.mode = .idle;
return .save;
}
if (std.mem.eql(u8, text, "s")) {
self.mode = .search_rail;
return .none;
}
if (std.mem.eql(u8, text, "q")) {
self.mode = .idle;
return .quit;
@@ -127,11 +135,6 @@ pub const Leader = struct {
self.mode = .idle;
return .repeat_rail;
}
if (std.mem.eql(u8, text, "/")) {
self.mode = .idle;
self.message = "search is not built in this profile yet";
return .{ .not_built = .search };
}
if (std.mem.eql(u8, text, "x")) {
self.mode = .idle;
self.message = "panel close is not built in this profile yet";
@@ -165,6 +168,35 @@ pub const Leader = struct {
}
}
fn handleSearchRail(self: *Leader, event: input.Event) Action {
self.message = null;
switch (event) {
.text => |text| {
self.mode = .idle;
if (std.mem.eql(u8, text, "f")) return .search_file;
self.message = "search target is not built in this profile yet";
return .{ .not_built = .search };
},
.key => |key| switch (key) {
.escape, .backspace => {
self.mode = .idle;
self.message = "search cancelled";
return .none;
},
else => {
self.mode = .idle;
self.message = "unknown search key";
return .none;
},
},
.unknown => {
self.mode = .idle;
self.message = "unknown search key";
return .none;
},
}
}
fn handleSymbolRail(self: *Leader, event: input.Event) Action {
self.message = null;
switch (event) {
@@ -241,14 +273,14 @@ fn expectActionTag(expected: std.meta.Tag(Action), action: Action) !void {
try std.testing.expectEqual(expected, std.meta.activeTag(action));
}
test "regular: space opens a visible leader rail and save dispatches" {
test "regular: space opens a visible leader rail and write dispatches" {
var leader = Leader.init(std.testing.allocator);
defer leader.deinit();
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
try std.testing.expectEqualStrings("leader: s save q quit o open p symbols r repeat / search x close", leader.status());
try std.testing.expectEqualStrings("leader: w save q quit o open p symbols s search r repeat x close", leader.status());
try expectActionTag(.save, try leader.handleEvent(input.normalize("s")));
try expectActionTag(.save, try leader.handleEvent(input.normalize("w")));
try std.testing.expect(!leader.isActive());
}
@@ -282,17 +314,23 @@ test "regular: leader open prompt collects UTF-8 path and backspace respects cod
}
}
test "regular: not-yet-built leader entries are explicit recoverable actions" {
test "regular: search rail opens current-file search and rejects other targets" {
var leader = Leader.init(std.testing.allocator);
defer leader.deinit();
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
const action = try leader.handleEvent(input.normalize("/"));
try expectActionTag(.none, try leader.handleEvent(input.normalize("s")));
try std.testing.expectEqualStrings("search: f current file p project s symbols", leader.status());
try expectActionTag(.search_file, try leader.handleEvent(input.normalize("f")));
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
try expectActionTag(.none, try leader.handleEvent(input.normalize("s")));
const action = try leader.handleEvent(input.normalize("p"));
switch (action) {
.not_built => |feature| try std.testing.expectEqual(Feature.search, feature),
else => return error.ExpectedNotBuiltAction,
}
try std.testing.expectEqualStrings("search is not built in this profile yet", leader.status());
try std.testing.expectEqualStrings("search target is not built in this profile yet", leader.status());
}
test "adversarial: unknown leader key does not dispatch and recovers to idle" {
+3 -3
View File
@@ -260,7 +260,7 @@ fn runLocalEditor(
if (client.requestedQuit()) {
if (dirty) {
client.clearQuit();
client.setStatusMessage("dirty buffer: Space s saves, quit blocked");
client.setStatusMessage("dirty buffer: Space w saves, quit blocked");
continue;
}
return;
@@ -325,7 +325,7 @@ fn renderLocalFrame(allocator: std.mem.Allocator, io: std.Io, stdout: std.Io.Fil
try stdout.writeStreamingAll(io, if (is_dir)
"\nDirectory browser. Space opens commands; o/Enter opens panel items where available; q quits when clean.\n"
else
"\nEditor. Type to insert; arrows move; Space shows commands; Space s saves; Space q quits when clean.\n");
"\nEditor. Type to insert; arrows move; Space shows commands; Space w saves; Space q quits when clean.\n");
}
fn printLocalContext(allocator: std.mem.Allocator, io: std.Io, args: *std.process.Args.Iterator, stdout: std.Io.File) !void {
@@ -506,7 +506,7 @@ test "regular: local editor client exposes save and dirty quit guards" {
try client.handleInput("n");
try std.testing.expectEqualStrings("normal", client.modeName());
try client.handleInput(" ");
try client.handleInput("s");
try client.handleInput("w");
try std.testing.expectEqualStrings("hello", try client.saved());
try client.handleInput("i");
+4 -4
View File
@@ -86,7 +86,7 @@ test "regular: mobile trace performs code edit with leader save and quit" {
\\key p
\\key s
\\key space
\\key s
\\key w
\\key space
\\key q
\\
@@ -111,7 +111,7 @@ test "regular: mobile trace edits UTF-8 and hard-to-reach braces" {
\\key space
\\type n
\\key space
\\key s
\\key w
\\key space
\\key q
\\
@@ -123,7 +123,7 @@ test "regular: mobile search entry is reachable and recoverable without desktop
const trace =
\\open abc
\\key space
\\key /
\\key s
\\
;
try assertMobileOnlyTrace(trace);
@@ -135,7 +135,7 @@ test "regular: mobile search entry is reachable and recoverable without desktop
}
const frame = try client.render(std.testing.allocator);
defer std.testing.allocator.free(frame);
try std.testing.expect(std.mem.indexOf(u8, frame, "search is not built") != null);
try std.testing.expect(std.mem.indexOf(u8, frame, "search: f current file") != null);
try std.testing.expect(!client.quit);
try std.testing.expectError(tui.Error.NothingSaved, client.saved());
}
+191 -8
View File
@@ -135,6 +135,11 @@ pub const Client = struct {
prefix: PrefixRail = .none,
pending_count: usize = 0,
last_rail: PrefixRail = .none,
search_prompt_active: bool = false,
search_prompt: std.ArrayList(u8) = .empty,
search_query: []u8 = &.{},
search_matches: std.ArrayList(usize) = .empty,
search_index: usize = 0,
undo_stack: std.ArrayList(EditSnapshot) = .empty,
redo_stack: std.ArrayList(EditSnapshot) = .empty,
yank_bytes: ?[]u8 = null,
@@ -158,6 +163,9 @@ pub const Client = struct {
pub fn deinit(self: *Client) void {
if (self.saved_bytes) |bytes| self.allocator.free(bytes);
if (self.yank_bytes) |bytes| self.allocator.free(bytes);
self.search_prompt.deinit(self.allocator);
self.allocator.free(self.search_query);
self.search_matches.deinit(self.allocator);
self.freeSnapshotStack(&self.undo_stack);
self.freeSnapshotStack(&self.redo_stack);
self.repo.deinit();
@@ -283,8 +291,11 @@ pub const Client = struct {
const leader_status = self.leader.status();
const prefix_status = self.prefixStatus();
const search_status = self.searchStatus();
const status = if (self.message) |message|
try std.fmt.allocPrint(allocator, "{s}", .{message})
else if (search_status.len != 0)
try std.fmt.allocPrint(allocator, "mode:{s} {s}", .{ @tagName(self.effectiveMode()), search_status })
else if (leader_status.len != 0)
try std.fmt.allocPrint(
allocator,
@@ -362,6 +373,8 @@ pub const Client = struct {
const event = input.normalize(raw);
self.message = null;
if (self.search_prompt_active) return self.applySearchPromptInput(event);
const leader_trigger = isLeaderTrigger(event) and self.effectiveMode() != .insert;
if (self.leader.capturesInput() or leader_trigger) {
if (self.prefix == .insert_space and isLeaderTrigger(event)) {
@@ -412,12 +425,18 @@ pub const Client = struct {
}
fn effectiveMode(self: *const Client) EditorMode {
if (self.leader.isPromptActive()) return .prompt;
if (self.search_prompt_active or self.leader.isPromptActive()) return .prompt;
const snap = self.session.snapshot() catch return self.mode;
if (snap.active_panel_title != null) return .panel;
return self.mode;
}
fn searchStatus(self: *const Client) []const u8 {
if (self.search_prompt_active) return "search: type query, Enter accept, Esc cancel";
if (self.search_query.len != 0) return "search active: n next N previous";
return "";
}
fn prefixStatus(self: *const Client) []const u8 {
return switch (self.prefix) {
.none => if (self.pending_count != 0) "count pending" else "",
@@ -795,6 +814,9 @@ pub const Client = struct {
if (std.mem.eql(u8, text, "c")) return self.openPrefix(.change);
if (std.mem.eql(u8, text, "y")) return self.openPrefix(.yank);
if (std.mem.eql(u8, text, "r")) return self.openPrefix(.replace);
if (std.mem.eql(u8, text, "/")) return self.openSearchPrompt();
if (std.mem.eql(u8, text, "n")) return self.nextSearchMatch();
if (std.mem.eql(u8, text, "N")) return self.previousSearchMatch();
if (std.mem.eql(u8, text, "p")) return self.pasteRegister();
if (std.mem.eql(u8, text, "u")) return self.undoEdit();
if (std.mem.eql(u8, text, "U")) return self.redoEdit();
@@ -973,6 +995,83 @@ pub const Client = struct {
self.message = null;
}
fn openSearchPrompt(self: *Client) void {
self.search_prompt_active = true;
self.search_prompt.clearRetainingCapacity();
self.message = null;
self.prefix = .none;
}
fn applySearchPromptInput(self: *Client, event: input.Event) !void {
switch (event) {
.text => |text| try self.search_prompt.appendSlice(self.allocator, text),
.key => |key| switch (key) {
.enter => try self.commitSearchPrompt(),
.escape => {
self.search_prompt_active = false;
self.message = "search cancelled";
},
.backspace => {
if (self.search_prompt.items.len > 0) _ = self.search_prompt.pop();
},
.space => try self.search_prompt.append(self.allocator, ' '),
else => {},
},
.unknown => {},
}
}
fn commitSearchPrompt(self: *Client) !void {
self.search_prompt_active = false;
self.allocator.free(self.search_query);
self.search_query = try self.allocator.dupe(u8, self.search_prompt.items);
try self.rebuildSearchMatches();
if (self.search_matches.items.len == 0) {
self.message = "no search matches";
return;
}
self.search_index = 0;
try self.gotoSearchMatch();
}
fn rebuildSearchMatches(self: *Client) !void {
self.search_matches.clearRetainingCapacity();
if (self.search_query.len == 0) return;
const snap = try self.session.snapshot();
var offset: usize = 0;
while (offset <= snap.bytes.len) {
const found = std.mem.indexOf(u8, snap.bytes[offset..], self.search_query) orelse break;
const at = offset + found;
try self.search_matches.append(self.allocator, at);
offset = at + @max(self.search_query.len, 1);
}
}
fn gotoSearchMatch(self: *Client) !void {
if (self.search_matches.items.len == 0) return;
const at = self.search_matches.items[self.search_index];
try self.session.moveToByte(at);
try self.session.selectRange(at, at + self.search_query.len);
}
fn nextSearchMatch(self: *Client) !void {
if (self.search_matches.items.len == 0) {
self.message = "no active search";
return;
}
self.search_index = (self.search_index + 1) % self.search_matches.items.len;
try self.gotoSearchMatch();
}
fn previousSearchMatch(self: *Client) !void {
if (self.search_matches.items.len == 0) {
self.message = "no active search";
return;
}
self.search_index = if (self.search_index == 0) self.search_matches.items.len - 1 else self.search_index - 1;
try self.gotoSearchMatch();
}
fn applyMatchRail(self: *Client, event: input.Event) !void {
const text = eventText(event) orelse return self.unknownPrefixOrInput("normal");
if (std.mem.eql(u8, text, "m")) return self.jumpToMatch(null);
@@ -1343,6 +1442,7 @@ pub const Client = struct {
try self.applyProtocol(line);
},
.symbol => |symbol| try self.applyProtocol(symbol_mod.protocolCommand(symbol)),
.search_file => self.openSearchPrompt(),
.repeat_rail => self.openPrefix(.repeat),
.restore_last => self.restoreLastRail(),
.not_built => {},
@@ -1655,9 +1755,9 @@ test "regular: leader rail is visible in terminal render and dispatches save" {
try client.handleTraceLine("key space");
const frame = try client.render(std.testing.allocator);
defer std.testing.allocator.free(frame);
try std.testing.expect(std.mem.indexOf(u8, frame, "leader: s save") != null);
try std.testing.expect(std.mem.indexOf(u8, frame, "leader: w save") != null);
try client.handleTraceLine("key s");
try client.handleTraceLine("key w");
try std.testing.expectEqualStrings("abc", try client.saved());
}
@@ -1686,20 +1786,22 @@ test "regular: leader open prompt opens typed UTF-8 payload" {
try client.handleTraceLine("key enter");
try client.handleTraceLine("key space");
try client.handleTraceLine("key s");
try client.handleTraceLine("key w");
try std.testing.expectEqualStrings("café.zig", try client.saved());
}
test "regular: leader search and panel-close entries recover as not-built messages" {
test "regular: leader search rail opens file search and panel-close recovers as not-built" {
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 4 });
defer client.deinit();
try client.handleTraceLine("open abc");
try client.handleTraceLine("key space");
try client.handleTraceLine("key /");
try client.handleTraceLine("key s");
try client.handleTraceLine("key f");
const search_frame = try client.render(std.testing.allocator);
defer std.testing.allocator.free(search_frame);
try std.testing.expect(std.mem.indexOf(u8, search_frame, "search is not built") != null);
try std.testing.expect(std.mem.indexOf(u8, search_frame, "search: type query") != null);
try client.handleTraceLine("key escape");
try client.handleTraceLine("key space");
try client.handleTraceLine("key x");
@@ -1879,7 +1981,7 @@ test "regular: save status appears near editor status line" {
try client.handleTraceLine("open abc");
try client.handleTraceLine("key space");
try client.handleTraceLine("key s");
try client.handleTraceLine("key w");
const frame = try client.render(std.testing.allocator);
defer std.testing.allocator.free(frame);
try std.testing.expect(std.mem.indexOf(u8, frame, "saved") != null);
@@ -3170,3 +3272,84 @@ test "regular: quote matching ignores escaped quote" {
const snap = try client.session.snapshot();
try std.testing.expectEqual(@as(usize, 4), snap.cursor_byte);
}
test "regular: slash search selects first match and navigates wrap" {
var client = try Client.init(std.testing.allocator, .{ .width = 60, .height = 8 });
defer client.deinit();
try client.handleTraceLine("open alpha beta alpha");
try client.handleInput("/");
try std.testing.expectEqualStrings("prompt", client.modeName());
try client.handleInput("a");
try client.handleInput("l");
try client.handleInput("p");
try client.handleInput("h");
try client.handleInput("a");
try client.handleInput("\n");
var snap = try client.session.snapshot();
try std.testing.expect(snap.selection != null);
try std.testing.expectEqual(@as(usize, 0), snap.selection.?.anchor);
try std.testing.expectEqualStrings("alpha", snap.bytes[snap.selection.?.anchor..snap.selection.?.cursor]);
try client.handleInput("n");
snap = try client.session.snapshot();
try std.testing.expectEqual(@as(usize, 11), snap.selection.?.anchor);
try client.handleInput("n");
snap = try client.session.snapshot();
try std.testing.expectEqual(@as(usize, 0), snap.selection.?.anchor);
try client.handleInput("N");
snap = try client.session.snapshot();
try std.testing.expectEqual(@as(usize, 11), snap.selection.?.anchor);
}
test "regular: space s f opens current-file search prompt without losing save path" {
var client = try Client.init(std.testing.allocator, .{ .width = 60, .height = 8 });
defer client.deinit();
try client.handleTraceLine("open one two one");
try client.handleInput(" ");
try client.handleInput("s");
try client.handleInput("f");
try std.testing.expectEqualStrings("prompt", client.modeName());
try client.handleInput("t");
try client.handleInput("w");
try client.handleInput("o");
try client.handleInput("\n");
const snap = try client.session.snapshot();
try std.testing.expectEqualStrings("two", snap.bytes[snap.selection.?.anchor..snap.selection.?.cursor]);
}
test "adversarial: search no match cancel utf8 and long line remain safe" {
var client = try Client.init(std.testing.allocator, .{ .width = 30, .height = 6 });
defer client.deinit();
try client.handleTraceLine("open short élan veryveryveryverylongline élan");
try client.handleInput("/");
try client.handleInput("z");
try client.handleInput("\n");
const no_match_frame = try client.render(std.testing.allocator);
defer std.testing.allocator.free(no_match_frame);
try std.testing.expect(std.mem.indexOf(u8, no_match_frame, "no search matches") != null);
try client.handleInput("/");
try client.handleInput("x");
try client.handleInput("\x1b");
const cancel_frame = try client.render(std.testing.allocator);
defer std.testing.allocator.free(cancel_frame);
try std.testing.expect(std.mem.indexOf(u8, cancel_frame, "search cancelled") != null);
try client.handleInput("/");
try client.handleInput("é");
try client.handleInput("l");
try client.handleInput("a");
try client.handleInput("n");
try client.handleInput("\n");
var snap = try client.session.snapshot();
try std.testing.expectEqualStrings("élan", snap.bytes[snap.selection.?.anchor..snap.selection.?.cursor]);
try client.handleInput("n");
snap = try client.session.snapshot();
try std.testing.expect(snap.cursor_byte > 20);
}