diff --git a/src/Project.zig b/src/Project.zig index 7d594cb5..d0f8dbb0 100644 --- a/src/Project.zig +++ b/src/Project.zig @@ -602,6 +602,82 @@ pub fn query_recent_files(self: *Self, from: tp.pid_ref, max: usize, query_: []c return @min(max, matches.items.len); } +pub fn query_tree_files(self: *Self, from: tp.pid_ref, max: usize, query_: []const u8) RequestError!usize { + const query = try self.strip_non_search_chars(query_); + defer self.allocator.free(query); + if (query.len < 3) + return self.simple_query_tree_files(from, max, query); + defer from.send(.{ "PRJ", "tree_search_done", self.longest_file_path, query, self.files.items.len }) catch {}; + + var searcher = try fuzzig.Ascii.init( + self.allocator, + 4096, + 4096, + .{ .case_sensitive = false }, + ); + defer searcher.deinit(); + + const Match = struct { + path: []const u8, + type: []const u8, + icon: []const u8, + color: u24, + score: i32, + matches: []const usize, + }; + var matches: std.ArrayList(Match) = .empty; + + for (self.files.items) |file| { + const match = searcher.scoreMatches(file.path, query); + if (match.score) |score| { + (try matches.addOne(self.allocator)).* = .{ + .path = file.path, + .type = file.type, + .icon = file.icon, + .color = file.color, + .score = score, + .matches = try self.allocator.dupe(usize, match.matches), + }; + } + } + if (matches.items.len == 0) return 0; + + const less_fn = struct { + fn less_fn(_: void, lhs: Match, rhs: Match) bool { + return lhs.score > rhs.score; + } + }.less_fn; + std.mem.sort(Match, matches.items, {}, less_fn); + + for (matches.items[0..@min(max, matches.items.len)]) |match| + from.send(.{ "PRJ", "tree_search", self.longest_file_path, match.path, match.type, match.icon, match.color, match.matches }) catch |e| { + std.log.err("send tree_search failed: {t}", .{e}); + return error.InvalidQueryRecentFilesRequest; + }; + return @min(max, matches.items.len); +} + +fn simple_query_tree_files(self: *Self, from: tp.pid_ref, max: usize, query: []const u8) RequestError!usize { + var i: usize = 0; + defer from.send(.{ "PRJ", "tree_search_done", self.longest_file_path, query, self.files.items.len }) catch {}; + for (self.files.items) |file| { + if (file.path.len < query.len) continue; + if (std.mem.indexOf(u8, file.path, query)) |idx| { + var matches = try self.allocator.alloc(usize, query.len); + defer self.allocator.free(matches); + var n: usize = 0; + while (n < query.len) : (n += 1) matches[n] = idx + n; + from.send(.{ "PRJ", "tree_search", self.longest_file_path, file.path, file.type, file.icon, file.color, matches }) catch |e| { + std.log.err("send tree_search failed: {t}", .{e}); + return error.InvalidRecentFilesRequest; + }; + i += 1; + if (i >= max) return i; + } + } + return i; +} + fn walk_tree_entry_callback(parent: tp.pid_ref, root_path: []const u8, file_path: []const u8, mtime_high: i64, mtime_low: i64) error{Exit}!void { const file_type: []const u8, const file_icon: []const u8, const file_color: u24 = guess_file_type(file_path); try parent.send(.{ "walk_tree_entry", root_path, file_path, mtime_high, mtime_low, file_type, file_icon, file_color }); diff --git a/src/project_manager.zig b/src/project_manager.zig index b04fa19b..7fda26cb 100644 --- a/src/project_manager.zig +++ b/src/project_manager.zig @@ -160,6 +160,13 @@ pub fn query_new_or_modified_files(max: usize, query: []const u8) (ProjectManage return send(.{ "query_new_or_modified_files", project, max, query }); } +pub fn query_tree_files(max: usize, query: []const u8) (ProjectManagerError || ProjectError)!void { + const project = tp.env.get().str("project"); + if (project.len == 0) + return error.NoProject; + return send(.{ "query_tree_files", project, max, query }); +} + pub fn request_path_files(max: usize, path: []const u8) (ProjectManagerError || ProjectError)!void { const project = tp.env.get().str("project"); if (project.len == 0) @@ -488,6 +495,8 @@ const Process = struct { self.query_recent_files(from, project_directory, max, query) catch |e| return from.forward_error(e, @errorReturnTrace()) catch error.ClientFailed; } else if (try cbor.match(m.buf, .{ "query_new_or_modified_files", tp.extract(&project_directory), tp.extract(&max), tp.extract(&query) })) { self.query_new_or_modified_files(from, project_directory, max, query) catch |e| return from.forward_error(e, @errorReturnTrace()) catch error.ClientFailed; + } else if (try cbor.match(m.buf, .{ "query_tree_files", tp.extract(&project_directory), tp.extract(&max), tp.extract(&query) })) { + self.query_tree_files(from, project_directory, max, query) catch |e| return from.forward_error(e, @errorReturnTrace()) catch error.ClientFailed; } else if (try cbor.match(m.buf, .{ "request_path_files", tp.extract(&project_directory), tp.extract(&max), tp.extract(&path) })) { self.request_path_files(from, project_directory, max, path) catch |e| return from.forward_error(e, @errorReturnTrace()) catch error.ClientFailed; } else if (try cbor.match(m.buf, .{ "request_tasks", tp.extract(&project_directory) })) { @@ -682,6 +691,15 @@ const Process = struct { self.logger.print("query \"{s}\" matched {d}/{d} in {d} ms", .{ query, matched, project.files.items.len, query_time }); } + fn query_tree_files(self: *Process, from: tp.pid_ref, project_directory: []const u8, max: usize, query: []const u8) (ProjectError || Project.RequestError)!void { + const project = self.projects.get(project_directory) orelse return error.NoProject; + const start_time = root.get_now().toMilliseconds(); + const matched = try project.query_tree_files(from, max, query); + const query_time = root.get_now().toMilliseconds() - start_time; + if (query_time > 250) + self.logger.print("tree query \"{s}\" matched {d}/{d} in {d} ms", .{ query, matched, project.files.items.len, query_time }); + } + fn request_path_files(self: *Process, from: tp.pid_ref, project_directory: []const u8, max: usize, path: []const u8) (ProjectError || SpawnError || std.Io.Dir.OpenError)!void { const project = self.projects.get(project_directory) orelse return error.NoProject; var buf: std.ArrayList(u8) = .empty; diff --git a/src/tui/mode/overlay/file_tree_palette.zig b/src/tui/mode/overlay/file_tree_palette.zig index 13657d55..e34e52fa 100644 --- a/src/tui/mode/overlay/file_tree_palette.zig +++ b/src/tui/mode/overlay/file_tree_palette.zig @@ -16,6 +16,7 @@ pub const description = "file tree"; pub const icon = " "; const max_path_entries = 1024; +const max_tree_search_results: usize = 100; pub const NodeType = enum { file, @@ -75,6 +76,11 @@ pub const ValueType = struct { root_node: ?*Node = null, pending_node: ?*Node = null, follow_path: ?[]const u8 = null, + is_searching: bool = false, + search_query_pending: bool = false, + search_need_reset: bool = false, + search_total: usize = 0, + search_project_total: usize = 0, }; pub const defaultValue: ValueType = .{}; @@ -126,19 +132,93 @@ fn receive_project_manager(palette: *Type, _: tp.pid_ref, m: tp.message) Message const pending = palette.value.pending_node; palette.value.pending_node = null; if (pending) |p| if (p.children) |*children| sort_children(children); - palette.entries.clearRetainingCapacity(); - if (palette.value.root_node) |root| try build_visible_list(palette, root, 0); - palette.longest_hint = max_entry_overhead(palette); - try follow_path(palette, pending); - palette.start_query(0) catch {}; - tui.need_render(@src()); + if (!palette.value.is_searching) { + palette.entries.clearRetainingCapacity(); + if (palette.value.root_node) |root| try build_visible_list(palette, root, 0); + palette.longest_hint = max_entry_overhead(palette); + try follow_path(palette, pending); + palette.start_query(0) catch {}; + tui.need_render(@src()); + } } else if (try cbor.match(m.buf, .{ "PRJ", "path_error", tp.any, tp.any, tp.any })) { palette.value.pending_node = null; + } else { + try receive_search_messages(palette, m); } return true; } +fn receive_search_messages(palette: *Type, m: tp.message) MessageFilter.Error!void { + var file_path: []const u8 = undefined; + var file_type: []const u8 = undefined; + var file_icon: []const u8 = undefined; + var file_color: u24 = undefined; + var longest: usize = undefined; + var matches: []const u8 = undefined; + var query: []const u8 = undefined; + + if (try cbor.match(m.buf, .{ // Accumulate results + "PRJ", + "tree_search", + tp.extract(&longest), + tp.extract(&file_path), + tp.extract(&file_type), + tp.extract(&file_icon), + tp.extract(&file_color), + tp.extract_cbor(&matches), + })) { + if (!palette.value.is_searching) return; + if (palette.value.search_need_reset) { + palette.menu.reset_items(); + palette.items = 0; + palette.total_items = 0; + palette.value.search_need_reset = false; + } + palette.longest = @max(palette.longest, longest); + try add_search_item(palette, file_path, file_icon, file_color, matches); + palette.value.search_total += 1; + palette.total_items += 1; + palette.resize(); + tui.need_render(@src()); + } else if (try cbor.match(m.buf, .{ // Render results + "PRJ", + "tree_search_done", + tp.extract(&longest), + tp.extract(&query), + tp.extract(&palette.value.search_project_total), + })) { + palette.longest = @max(palette.longest, longest); + palette.value.search_query_pending = false; + update_search_count_hint(palette); + if (palette.value.is_searching and !std.mem.eql(u8, palette.inputbox.text.items, query)) + try on_query_changed(palette, palette.inputbox.text.items); + palette.resize(); + tui.need_render(@src()); + } +} + +fn add_search_item(palette: *Type, file_path: []const u8, file_icon: []const u8, file_color: u24, matches_cbor: []const u8) !void { + var value: std.Io.Writer.Allocating = .init(palette.allocator); + defer value.deinit(); + const writer = &value.writer; + try cbor.writeValue(writer, file_path); + try cbor.writeValue(writer, file_icon); + try cbor.writeValue(writer, file_color); + try cbor.writeValue(writer, ""); + _ = try writer.write(matches_cbor); + try palette.menu.add_item_with_handler(value.written(), select_search_result); + palette.items += 1; +} + +fn update_search_count_hint(palette: *Type) void { + palette.inputbox.hint.clearRetainingCapacity(); + palette.inputbox.hint.print(palette.inputbox.allocator, "{d}/{d}", .{ + palette.value.search_total, + palette.value.search_project_total, + }) catch {}; +} + fn sort_children(children: *std.ArrayList(Node)) void { const less_fn = struct { fn less_fn(_: void, lhs: Node, rhs: Node) bool { @@ -251,7 +331,47 @@ pub fn load_entries(palette: *Type) !usize { return max_entry_overhead(palette); } +pub fn should_clear_on_query(palette: *Type) bool { + return !palette.value.is_searching; +} + +pub fn on_query_changed(palette: *Type, query: []const u8) !void { + if (query.len > 0) { + palette.value.is_searching = true; + palette.value.search_need_reset = true; + palette.value.search_total = 0; + palette.total_items = 0; + if (palette.value.search_query_pending) return; + palette.value.search_query_pending = true; + try project_manager.query_tree_files(max_tree_search_results, query); + } else { + if (palette.value.is_searching) { + palette.value.is_searching = false; + palette.value.search_query_pending = false; + palette.value.search_total = 0; + palette.inputbox.hint.clearRetainingCapacity(); + palette.entries.clearRetainingCapacity(); + if (palette.value.root_node) |root| try build_visible_list(palette, root, 0); + palette.longest_hint = max_entry_overhead(palette); + } + } +} + pub fn on_render_menu(_: *Type, button: *Type.ButtonType, theme: *const Widget.Theme, selected: bool) bool { + var iter = button.opts.label; + var first_str: []const u8 = undefined; + if (!(cbor.matchString(&iter, &first_str) catch false)) return false; + + var peek = iter; + var second_str: []const u8 = undefined; + if (cbor.matchString(&peek, &second_str) catch false) { // Search result: string - string - ... + return tui.render_file_item_cbor(&button.plane, button.opts.label, button.active, selected, button.hover, theme); + } + + return render_tree_item(button, theme, selected, first_str, iter); // Tree item: string - int - ... +} + +fn render_tree_item(button: *Type.ButtonType, theme: *const Widget.Theme, selected: bool, label_str: []const u8, iter_after_label: []const u8) bool { const style_base = theme.editor_widget; const style_label = if (button.active) theme.editor_cursor else if (button.hover or selected) theme.editor_selection else theme.editor_widget; const style_hint = if (tui.find_scope_style(theme, "entity.name")) |sty| sty.style else style_label; @@ -263,14 +383,12 @@ pub fn on_render_menu(_: *Type, button: *Type.ButtonType, theme: *const Widget.T button.plane.fill(" "); button.plane.home(); } - var label_str: []const u8 = undefined; var file_icon: []const u8 = undefined; var indent: usize = 0; var entry_index: usize = 0; var icon_color: u24 = 0; - var iter = button.opts.label; - if (!(cbor.matchString(&iter, &label_str) catch false)) return false; + var iter = iter_after_label; if (!(cbor.matchInt(usize, &iter, &entry_index) catch false)) return false; if (!(cbor.matchInt(usize, &iter, &indent) catch false)) return false; if (!(cbor.matchString(&iter, &file_icon) catch false)) return false; @@ -341,6 +459,15 @@ fn select(menu: **Type.MenuType, button: *Type.ButtonType, _: Type.Pos) void { } } +fn select_search_result(menu: **Type.MenuType, button: *Type.ButtonType, _: Type.Pos) void { + const palette = menu.*.opts.ctx; + var file_path: []const u8 = undefined; + var iter = button.opts.label; + if (!(cbor.matchString(&iter, &file_path) catch false)) return; + tp.self_pid().send(.{ "cmd", "exit_overlay_mode" }) catch |e| palette.logger.err(module_name, e); + tp.self_pid().send(.{ "cmd", "navigate", .{ .file = file_path } }) catch |e| palette.logger.err(module_name, e); +} + pub fn add_menu_entry(palette: *Type, entry: *Entry, matches: ?[]const usize) !void { var value: std.Io.Writer.Allocating = .init(palette.allocator); defer value.deinit(); diff --git a/src/tui/mode/overlay/palette.zig b/src/tui/mode/overlay/palette.zig index 4e31daf3..69290def 100644 --- a/src/tui/mode/overlay/palette.zig +++ b/src/tui/mode/overlay/palette.zig @@ -295,6 +295,11 @@ pub fn Create(options: type) type { self.after_resize(); } + pub fn resize(self: *Self) void { + const padding = tui.get_widget_style(widget_type).padding; + self.do_resize(padding); + } + fn get_view_rows(screen: Widget.Box) usize { var h = screen.h; if (h > 0) h = h / 5 * 4; @@ -352,9 +357,12 @@ pub fn Create(options: type) type { pub fn start_query(self: *Self, n: usize) !void { defer tui.reset_hover(@src()); defer self.update_count_hint(); - self.items = 0; - self.menu.reset_items(); - self.menu.selected = null; + const should_clear = !@hasDecl(options, "should_clear_on_query") or options.should_clear_on_query(self); + if (should_clear) { + self.items = 0; + self.menu.reset_items(); + self.menu.selected = null; + } self.longest = self.inputbox.text.items.len; for (self.entries.items) |entry| self.longest = @max(self.longest, entry.label.len); @@ -369,6 +377,8 @@ pub fn Create(options: type) type { if (self.items < self.view_rows) try options.add_menu_entry(self, entry, null); } + } else if (@hasDecl(options, "on_query_changed")) { + try options.on_query_changed(self, self.inputbox.text.items); } else { _ = try self.query_entries(self.inputbox.text.items); }