Skip to content

Commit 40e7ba2

Browse files
authored
feat(neotree): add recursive tree search (#290)
1 parent 8e81a06 commit 40e7ba2

13 files changed

Lines changed: 1413 additions & 51 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ are enough to begin editing.
112112
| `Space ?` | Discover commands and their effective keymaps |
113113
| `Space m` | Browse notifications and recent messages |
114114
| `Ctrl-p` | Find a file with fuzzy search and live preview |
115+
| `Ctrl-e`, then `/` | Open the file tree and search files or directories |
115116
| `Space G` | Open the Git status workspace |
116117
| `Space A` | Ask the agent with editor context |
117118
| `Space i` | Refactor the current line or visual selection inline |

docs/GETTING_STARTED.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,25 @@ The command palette includes descriptions, effective keymaps, and accepted
243243
`:Command` invocations. Pause after a configured prefix such as `Space`,
244244
`Ctrl-w`, or `g` to display available continuations.
245245

246+
### Searching the file tree
247+
248+
Press `Ctrl-e` to open the Neo-tree sidebar, then `/` to search files and
249+
directories recursively. Results appear directly in the tree, including entries
250+
inside collapsed folders. Queries match complete workspace-relative paths, so
251+
`ui pick` can find `src/ui/file_picker.rs`; matching filename characters are
252+
highlighted.
253+
254+
Use Up/Down or `Ctrl-p`/`Ctrl-n` to move between results and Enter to open a
255+
file or reveal a directory. `Ctrl-Enter` reveals the selected result without
256+
opening it, and Escape restores the tree as it appeared before the search.
257+
Press `D` instead of `/` to search directories only. Press `f` to apply a
258+
persistent filter, or `Shift-Enter` to keep an ordinary search visible; use
259+
`Ctrl-x` to clear either filter.
260+
261+
Search respects the tree's Git ignore settings, includes its visible dotfiles,
262+
and never traverses `.git` metadata. It runs in the background without requiring
263+
`fd`, `find`, or additional plugin process permissions.
264+
246265
## Windows and buffers
247266

248267
- `Ctrl-w s` splits horizontally (top/bottom); `Ctrl-w v` or `Ctrl-w d` splits

docs/PLUGIN_API.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,29 @@ restore its workflow without Red understanding the payload.
142142

143143
These calls were introduced in host API `0.6.0`.
144144

145+
## Row-panel search and workspace path discovery
146+
147+
Host API `0.15.0` supports an inline search prompt owned by an existing row
148+
panel. `OpenPanelSearch(id, initial, prefix)` focuses that prompt,
149+
`UpdatePanelSearch(id, status)` replaces its compact result indicator,
150+
`KeepPanelSearch(id)` preserves the query while returning focus to row
151+
navigation, and `ClosePanelSearch(id)` removes it.
152+
153+
Input emits ordinary `panel:event:<id>` events with actions `search_query`,
154+
`search_move`, `search_submit`, `search_reveal`, `search_keep`, and
155+
`search_cancel`. The current query is provided in `text`, while `row` identifies
156+
the current selection. Up/Down and `Ctrl-p`/`Ctrl-n` move the selection; Enter
157+
submits, `Ctrl-Enter` requests reveal, `Shift-Enter` keeps the prompt, and
158+
Escape cancels.
159+
160+
`SearchWorkspacePaths(callback, path, query, directories_only)` performs a
161+
bounded, asynchronous, ignore-aware recursive filesystem search. Its result
162+
contains the original `query`, `directories_only`, ranked `matches`, synthetic
163+
directory `children`, expanded ancestor paths, the total match count,
164+
`truncated`, and an optional `error`. Paths are normalized relative to the
165+
requested workspace root. `InvalidateWorkspacePaths(path)` drops its cached
166+
index after filesystem changes. Neither call grants subprocess permissions.
167+
145168
## Workspace file operations
146169

147170
`FileOperation(callback: fn(Json), operation: Json)` applies a structured filesystem

docs/PLUGIN_SYSTEM.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,10 @@ logic, and Git argument construction live in the native, multi-file
205205
`plugins/git_core` Husk package. Neo-tree follows the same split: filesystem
206206
actions, confirmations, and panel events stay in `plugins/neotree.hk`, while
207207
normalized path handling, typed Git-status presentation, and bounded tree-row
208-
construction live in `plugins/neotree_core`. Red embeds those pure sources and
208+
construction live in `plugins/neotree_core`. The tree's inline search delegates
209+
recursive, ignore-aware discovery and bounded fuzzy ranking to a shared Rust
210+
workspace-path index; the pure core renders matching paths and their ancestors
211+
without granting the plugin subprocess permissions. Red embeds those pure sources and
209212
exposes small internal bridges to the compatibility shells, so installed
210213
builds do not depend on checkout-relative source paths. The bridges are
211214
bundled-plugin implementation detail, not public plugin APIs.

plugins/neotree.hk

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,27 @@ struct TreePanelRow {
2828
struct NeoTreePanelEvent {
2929
action: String,
3030
row: Option<TreePanelRow>,
31+
text: Option<String>,
32+
}
33+
34+
struct TreeSearchMatch {
35+
path: String,
36+
ranges: [[i32]],
37+
}
38+
39+
struct TreeSearchResult {
40+
query: String,
41+
directories_only: bool,
42+
children: [Children],
43+
expanded: [String],
44+
matches: [TreeSearchMatch],
45+
total: i32,
46+
truncated: bool,
47+
error: Option<String>,
48+
}
49+
50+
struct TreeSearchTimer {
51+
timer_id: String,
3152
}
3253

3354
struct RestoredPane {
@@ -179,6 +200,14 @@ struct NeoTreeState {
179200
reveal_parts: [String],
180201
reveal_index: i32,
181202
reveal_parent: String,
203+
search_query: String,
204+
search_mode: String,
205+
search_timer: String,
206+
search_children: [Children],
207+
search_expanded: [String],
208+
search_matches: [TreeSearchMatch],
209+
search_saved_expanded: [String],
210+
search_persistent: bool,
182211
}
183212

184213
#[red::state]
@@ -212,6 +241,14 @@ fn initial_state() -> NeoTreeState {
212241
reveal_parts: [],
213242
reveal_index: 0,
214243
reveal_parent: ".",
244+
search_query: "",
245+
search_mode: "",
246+
search_timer: "",
247+
search_children: [],
248+
search_expanded: ["."],
249+
search_matches: [],
250+
search_saved_expanded: [],
251+
search_persistent: false,
215252
};
216253
}
217254

@@ -288,10 +325,15 @@ fn close() {
288325
return;
289326
}
290327

328+
cancel_tree_search_timer();
291329
for watch in state().watches {
292330
red::execute("UnwatchDirectory", watch.id);
293331
}
294332
red::state_patch(NeoTreeState { watches: [] });
333+
red::state_patch(NeoTreeState { search_mode: "" });
334+
red::state_patch(NeoTreeState { search_query: "" });
335+
red::state_patch(NeoTreeState { search_children: [] });
336+
red::state_patch(NeoTreeState { search_matches: [] });
295337
red::execute("ClosePanel", "neotree");
296338
red::execute("FocusEditor");
297339
red::state_patch(NeoTreeState { created: false });
@@ -318,7 +360,52 @@ fn panel_event(event: NeoTreePanelEvent) {
318360
}
319361

320362
if event.action == "refresh" {
363+
red::execute("InvalidateWorkspacePaths", red::string(state().cwd, "."));
321364
reload_visible_tree();
365+
if state().search_query != "" {
366+
run_tree_search();
367+
}
368+
return;
369+
}
370+
371+
if event.action == "/" || event.action == "D" || event.action == "f" {
372+
begin_tree_search(event.action);
373+
return;
374+
}
375+
376+
if event.action == "Ctrl-x" {
377+
clear_tree_search();
378+
return;
379+
}
380+
381+
if event.action == "search_query" {
382+
tree_search_query(optional_text(event.text, ""));
383+
return;
384+
}
385+
386+
if event.action == "search_cancel" {
387+
clear_tree_search();
388+
return;
389+
}
390+
391+
if event.action == "search_keep" {
392+
red::state_patch(NeoTreeState { search_persistent: true });
393+
red::execute("KeepPanelSearch", "neotree");
394+
return;
395+
}
396+
397+
if event.action == "search_submit" || event.action == "search_reveal" {
398+
if event.action == "search_submit" && state().search_persistent {
399+
red::execute("KeepPanelSearch", "neotree");
400+
return;
401+
}
402+
if let Some(row) = event.row {
403+
finish_tree_search(row, event.action == "search_reveal");
404+
}
405+
return;
406+
}
407+
408+
if event.action == "search_move" {
322409
return;
323410
}
324411

@@ -436,6 +523,131 @@ fn panel_event(event: NeoTreePanelEvent) {
436523
}
437524
}
438525

526+
fn begin_tree_search(mode: String) {
527+
if state().search_mode == "" {
528+
red::state_patch(NeoTreeState { search_saved_expanded: state().expanded });
529+
}
530+
red::state_patch(NeoTreeState { search_mode: mode });
531+
red::state_patch(NeoTreeState { search_persistent: mode == "f" });
532+
let prefix = "/";
533+
if mode == "D" { prefix = "D"; }
534+
if mode == "f" { prefix = "f"; }
535+
red::execute("OpenPanelSearch", "neotree", state().search_query, prefix);
536+
if state().search_query != "" {
537+
run_tree_search();
538+
}
539+
}
540+
541+
fn tree_search_query(query: String) {
542+
red::state_patch(NeoTreeState { search_query: query });
543+
cancel_tree_search_timer();
544+
if red::trim(query) == "" {
545+
red::state_patch(NeoTreeState { search_children: [] });
546+
red::state_patch(NeoTreeState { search_matches: [] });
547+
red::execute("UpdatePanelSearch", "neotree", "");
548+
render();
549+
return;
550+
}
551+
red::execute("UpdatePanelSearch", "neotree", "…");
552+
red::state_patch(NeoTreeState { search_timer: red::execute("SetTimeout", 60) });
553+
}
554+
555+
#[red::on("timeout:callback")]
556+
fn tree_search_timeout(event: TreeSearchTimer) {
557+
if event.timer_id != state().search_timer {
558+
return;
559+
}
560+
red::state_patch(NeoTreeState { search_timer: "" });
561+
run_tree_search();
562+
}
563+
564+
fn run_tree_search() {
565+
let query = red::trim(state().search_query);
566+
if query == "" {
567+
return;
568+
}
569+
red::request(
570+
"SearchWorkspacePaths",
571+
tree_search_loaded,
572+
red::string(state().cwd, "."),
573+
query,
574+
state().search_mode == "D"
575+
);
576+
}
577+
578+
fn tree_search_loaded(result: TreeSearchResult) {
579+
if state().search_mode == ""
580+
|| result.query != red::trim(state().search_query)
581+
|| result.directories_only != (state().search_mode == "D") {
582+
return;
583+
}
584+
if let Some(error) = result.error {
585+
red::execute("UpdatePanelSearch", "neotree", "error");
586+
red::execute("Print", "Neo-tree search failed: " + error);
587+
return;
588+
}
589+
red::state_patch(NeoTreeState { search_children: result.children });
590+
red::state_patch(NeoTreeState { search_expanded: result.expanded });
591+
red::state_patch(NeoTreeState { search_matches: result.matches });
592+
let count = "" + result.total;
593+
if result.truncated { count = count + "+"; }
594+
red::execute("UpdatePanelSearch", "neotree", count);
595+
render();
596+
if red::len(result.matches) > 0 {
597+
red::execute("SelectPanelRow", "neotree", result.matches[0].path);
598+
}
599+
}
600+
601+
fn finish_tree_search(row: TreePanelRow, reveal_only: bool) {
602+
if state().search_persistent && !reveal_only {
603+
red::execute("KeepPanelSearch", "neotree");
604+
if row.kind == "directory" {
605+
return;
606+
}
607+
}
608+
if row.kind == "file" && !reveal_only {
609+
red::execute("OpenLocation", Location { path: row.path, line: 0, column: 0 });
610+
close();
611+
return;
612+
}
613+
// Keep the synthetic ancestry available even when the selected child falls
614+
// beyond a normal 160-entry directory snapshot.
615+
for listing in state().search_children {
616+
set_children(listing.path, listing.entries, false);
617+
}
618+
red::state_patch(NeoTreeState { expanded: state().search_expanded });
619+
cancel_tree_search_timer();
620+
red::state_patch(NeoTreeState { search_mode: "" });
621+
red::state_patch(NeoTreeState { search_query: "" });
622+
red::state_patch(NeoTreeState { search_children: [] });
623+
red::state_patch(NeoTreeState { search_matches: [] });
624+
red::execute("ClosePanelSearch", "neotree");
625+
render();
626+
red::execute("SelectPanelRow", "neotree", row.path);
627+
}
628+
629+
fn clear_tree_search() {
630+
cancel_tree_search_timer();
631+
if state().search_mode != "" {
632+
red::state_patch(NeoTreeState { expanded: state().search_saved_expanded });
633+
}
634+
red::state_patch(NeoTreeState { search_mode: "" });
635+
red::state_patch(NeoTreeState { search_query: "" });
636+
red::state_patch(NeoTreeState { search_children: [] });
637+
red::state_patch(NeoTreeState { search_matches: [] });
638+
red::state_patch(NeoTreeState { search_saved_expanded: [] });
639+
red::state_patch(NeoTreeState { search_persistent: false });
640+
red::execute("ClosePanelSearch", "neotree");
641+
render();
642+
}
643+
644+
fn cancel_tree_search_timer() {
645+
if state().search_timer != "" {
646+
red::execute("CancelTimeout", state().search_timer);
647+
red::state_patch(NeoTreeState { search_timer: "" });
648+
}
649+
}
650+
439651
fn open_create_prompt(row: TreePanelRow, directory: bool) {
440652
let parent = row.path;
441653
if row.kind != "directory" {
@@ -730,6 +942,7 @@ fn stat_loaded(result: FileStatResult) {
730942

731943
fn show_help() {
732944
red::execute("OpenPicker", "Neo-tree keys", [
945+
PickerItem { id: "search", label: "/ / D / f / Ctrl-x search / directories / filter / clear", kind: "Search" },
733946
PickerItem { id: "select", label: "Tab toggle selection", kind: "Selection" },
734947
PickerItem { id: "create", label: "a / A create file / directory", kind: "Create" },
735948
PickerItem { id: "rename", label: "r / b rename / rename basename", kind: "Rename" },
@@ -820,8 +1033,12 @@ fn git_status_loaded(result: GitStatusResult) {
8201033
}
8211034

8221035
fn directory_changed(event: DirectoryChangedEvent) {
1036+
red::execute("InvalidateWorkspacePaths", red::string(state().cwd, "."));
8231037
request_directory(event.path);
8241038
request_git_status();
1039+
if state().search_query != "" {
1040+
run_tree_search();
1041+
}
8251042
}
8261043

8271044
fn watch_directory(path: String) {
@@ -1053,6 +1270,19 @@ fn loading_rows() -> [TreePanelRow] {
10531270
}
10541271

10551272
fn build_rows() -> [TreePanelRow] {
1273+
if state().search_query != "" {
1274+
return red::neotree_core(
1275+
"build_search_rows",
1276+
red::string(state().cwd, "."),
1277+
state().search_children,
1278+
state().search_expanded,
1279+
state().selected,
1280+
state().clipboard,
1281+
optional_text(state().status.root, ""),
1282+
state().status_entries,
1283+
state().search_matches
1284+
);
1285+
}
10561286
return red::neotree_core(
10571287
"build_rows",
10581288
red::string(state().cwd, "."),

0 commit comments

Comments
 (0)