Skip to main content

lb_rs/search/
path.rs

1use super::{SearchFilter, SearchResult, build_descendants};
2use crate::Lb;
3use crate::model::file::File;
4use nucleo::{
5    Matcher, Nucleo,
6    pattern::{CaseMatching, Normalization},
7};
8use std::cmp::Reverse;
9use std::collections::{HashMap, HashSet};
10use std::sync::Arc;
11use uuid::Uuid;
12
13/// Split a path into (parent_path, filename).
14pub(crate) fn split_path(path: &str) -> (&str, &str) {
15    // Strip trailing slash for folders
16    let path = path.trim_end_matches('/');
17    match path.rfind('/') {
18        Some(idx) if idx > 0 => (&path[..idx], &path[idx + 1..]),
19        Some(_) => ("/", &path[1..]), // root case: "/filename"
20        None => ("", path),
21    }
22}
23
24#[derive(Clone)]
25struct PathEntry {
26    file: File,
27    path: String,
28    filename: String,
29    parent_path: String,
30}
31
32fn path_result(entry: &PathEntry, path_indices: Vec<u32>) -> SearchResult {
33    SearchResult {
34        id: entry.file.id,
35        filename: entry.filename.clone(),
36        parent_path: entry.parent_path.clone(),
37        is_folder: entry.file.is_folder(),
38        path_indices,
39        path_matches: Vec::new(),
40        content_matches: Vec::new(),
41    }
42}
43
44pub struct PathSearcher {
45    nucleo: Nucleo<PathEntry>,
46    results: Vec<SearchResult>,
47    submitted_query: String,
48    descendants: HashMap<Uuid, Vec<Uuid>>,
49    path_to_id: HashMap<String, Uuid>,
50    filter_ids: Option<HashSet<Uuid>>,
51}
52
53impl PathSearcher {
54    pub async fn new(lb: &Lb) -> Self {
55        let files = lb.list_metadatas().await.unwrap_or_default();
56        let paths = lb.list_paths_with_ids(None).await.unwrap_or_default();
57        let by_id: HashMap<Uuid, File> = files.into_iter().map(|f| (f.id, f)).collect();
58        Self::from_files(
59            paths
60                .into_iter()
61                .filter_map(|(id, path)| by_id.get(&id).cloned().map(|file| (file, path))),
62        )
63    }
64
65    /// Build a path index from an in-memory file list (`File` + path string).
66    /// Same matcher as [`Self::new`]; used by editor link completions so they
67    /// share ranking (including empty-query recents) with filename search.
68    pub fn from_files(entries: impl IntoIterator<Item = (File, String)>) -> Self {
69        let notify = Arc::new(|| {});
70        let nucleo: Nucleo<PathEntry> = Nucleo::new(nucleo::Config::DEFAULT, notify, None, 1);
71        let injector = nucleo.injector();
72
73        let mut files = Vec::new();
74        let mut path_to_id = HashMap::new();
75
76        for (file, path) in entries {
77            if path == "/" {
78                files.push(file);
79                continue;
80            }
81            let (parent_path, filename) = split_path(&path);
82            injector.push(
83                PathEntry {
84                    file: file.clone(),
85                    path: path.clone(),
86                    filename: filename.to_string(),
87                    parent_path: parent_path.to_string(),
88                },
89                |entry, cols| {
90                    cols[0] = entry.path.as_str().into();
91                },
92            );
93            path_to_id.insert(path, file.id);
94            files.push(file);
95        }
96
97        let descendants = build_descendants(&files);
98
99        let mut searcher = Self {
100            nucleo,
101            results: Vec::new(),
102            submitted_query: String::new(),
103            descendants,
104            path_to_id,
105            filter_ids: None,
106        };
107        searcher.query("");
108        searcher
109    }
110
111    /// Update the active filter and refresh results for the current query.
112    pub fn update_filter(&mut self, filter: Option<SearchFilter>) {
113        self.filter_ids = super::resolve_filter(filter, &self.path_to_id, &self.descendants);
114        self.rebuild();
115    }
116
117    /// Update the search query. Results available via `results()`.
118    pub fn query(&mut self, input: &str) {
119        self.nucleo.pattern.reparse(
120            0,
121            input,
122            CaseMatching::Smart,
123            Normalization::Smart,
124            input.starts_with(&self.submitted_query),
125        );
126        self.submitted_query = input.to_string();
127
128        while self.nucleo.tick(10).running {}
129
130        self.rebuild();
131    }
132
133    /// Rebuild `results` from the current query and filter without re-running the
134    /// matcher. Shared by `query` (after a reparse) and `update_filter`.
135    fn rebuild(&mut self) {
136        self.results.clear();
137        let snapshot = self.nucleo.snapshot();
138
139        if self.submitted_query.is_empty() {
140            let mut entries: Vec<&PathEntry> = (0..snapshot.matched_item_count())
141                .filter_map(|i| snapshot.get_matched_item(i).map(|item| item.data))
142                .filter(|e| {
143                    self.filter_ids
144                        .as_ref()
145                        .is_none_or(|ids| ids.contains(&e.file.id))
146                })
147                .collect();
148            entries.sort_by_key(|e| Reverse(e.file.last_modified));
149            self.results.extend(
150                entries
151                    .into_iter()
152                    .take(100)
153                    .map(|e| path_result(e, Vec::new())),
154            );
155            return;
156        }
157
158        let mut matcher = Matcher::new(nucleo::Config::DEFAULT);
159
160        for i in 0..snapshot.matched_item_count() {
161            if self.results.len() >= 100 {
162                break;
163            }
164            if let Some(item) = snapshot.get_matched_item(i) {
165                if let Some(ids) = &self.filter_ids {
166                    if !ids.contains(&item.data.file.id) {
167                        continue;
168                    }
169                }
170
171                let mut indices = Vec::new();
172                self.nucleo.pattern.column_pattern(0).indices(
173                    item.matcher_columns[0].slice(..),
174                    &mut matcher,
175                    &mut indices,
176                );
177
178                self.results.push(path_result(item.data, indices));
179            }
180        }
181    }
182
183    /// Get search results.
184    pub fn results(&self) -> &[SearchResult] {
185        &self.results
186    }
187}