From 70ef989c2a966be14c48fd7891fa8284aab4f570 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Sun, 21 Jun 2026 12:53:17 +0200 Subject: [PATCH] Add current file search --- src/leader.zig | 64 +++++++++--- src/main.zig | 6 +- src/mobile_acceptance.zig | 8 +- src/tui.zig | 199 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 249 insertions(+), 28 deletions(-) diff --git a/src/leader.zig b/src/leader.zig index f11a26f..65bfdbe 100644 --- a/src/leader.zig +++ b/src/leader.zig @@ -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" { diff --git a/src/main.zig b/src/main.zig index 211158e..27ad35d 100644 --- a/src/main.zig +++ b/src/main.zig @@ -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"); diff --git a/src/mobile_acceptance.zig b/src/mobile_acceptance.zig index 2184156..3503679 100644 --- a/src/mobile_acceptance.zig +++ b/src/mobile_acceptance.zig @@ -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()); } diff --git a/src/tui.zig b/src/tui.zig index 7331382..12cb9a8 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -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); +}