Add current file search
This commit is contained in:
+191
-8
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user