1use fuzzy::PathMatch;
2use gpui::{
3 actions, elements::*, AppContext, Entity, ModelHandle, MutableAppContext, RenderContext, Task,
4 View, ViewContext, ViewHandle,
5};
6use picker::{Picker, PickerDelegate};
7use project::{Project, ProjectPath, WorktreeId};
8use settings::Settings;
9use std::{
10 path::Path,
11 sync::{
12 atomic::{self, AtomicBool},
13 Arc,
14 },
15};
16use util::post_inc;
17use workspace::Workspace;
18
19pub struct FileFinder {
20 project: ModelHandle<Project>,
21 picker: ViewHandle<Picker<Self>>,
22 search_count: usize,
23 latest_search_id: usize,
24 latest_search_did_cancel: bool,
25 latest_search_query: String,
26 matches: Vec<PathMatch>,
27 selected: Option<(usize, Arc<Path>)>,
28 cancel_flag: Arc<AtomicBool>,
29}
30
31actions!(file_finder, [Toggle]);
32
33pub fn init(cx: &mut MutableAppContext) {
34 cx.add_action(FileFinder::toggle);
35 Picker::<FileFinder>::init(cx);
36}
37
38pub enum Event {
39 Selected(ProjectPath),
40 Dismissed,
41}
42
43impl Entity for FileFinder {
44 type Event = Event;
45}
46
47impl View for FileFinder {
48 fn ui_name() -> &'static str {
49 "FileFinder"
50 }
51
52 fn render(&mut self, _: &mut RenderContext<Self>) -> ElementBox {
53 ChildView::new(self.picker.clone()).boxed()
54 }
55
56 fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
57 cx.focus(&self.picker);
58 }
59}
60
61impl FileFinder {
62 fn labels_for_match(&self, path_match: &PathMatch) -> (String, Vec<usize>, String, Vec<usize>) {
63 let path_string = path_match.path.to_string_lossy();
64 let full_path = [path_match.path_prefix.as_ref(), path_string.as_ref()].join("");
65 let path_positions = path_match.positions.clone();
66
67 let file_name = path_match.path.file_name().map_or_else(
68 || path_match.path_prefix.to_string(),
69 |file_name| file_name.to_string_lossy().to_string(),
70 );
71 let file_name_start = path_match.path_prefix.chars().count() + path_string.chars().count()
72 - file_name.chars().count();
73 let file_name_positions = path_positions
74 .iter()
75 .filter_map(|pos| {
76 if pos >= &file_name_start {
77 Some(pos - file_name_start)
78 } else {
79 None
80 }
81 })
82 .collect();
83
84 (file_name, file_name_positions, full_path, path_positions)
85 }
86
87 fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
88 workspace.toggle_modal(cx, |cx, workspace| {
89 let project = workspace.project().clone();
90 let finder = cx.add_view(|cx| Self::new(project, cx));
91 cx.subscribe(&finder, Self::on_event).detach();
92 finder
93 });
94 }
95
96 fn on_event(
97 workspace: &mut Workspace,
98 _: ViewHandle<FileFinder>,
99 event: &Event,
100 cx: &mut ViewContext<Workspace>,
101 ) {
102 match event {
103 Event::Selected(project_path) => {
104 workspace
105 .open_path(project_path.clone(), true, cx)
106 .detach_and_log_err(cx);
107 workspace.dismiss_modal(cx);
108 }
109 Event::Dismissed => {
110 workspace.dismiss_modal(cx);
111 }
112 }
113 }
114
115 pub fn new(project: ModelHandle<Project>, cx: &mut ViewContext<Self>) -> Self {
116 let handle = cx.weak_handle();
117 cx.observe(&project, Self::project_updated).detach();
118 Self {
119 project,
120 picker: cx.add_view(|cx| Picker::new(handle, cx)),
121 search_count: 0,
122 latest_search_id: 0,
123 latest_search_did_cancel: false,
124 latest_search_query: String::new(),
125 matches: Vec::new(),
126 selected: None,
127 cancel_flag: Arc::new(AtomicBool::new(false)),
128 }
129 }
130
131 fn project_updated(&mut self, _: ModelHandle<Project>, cx: &mut ViewContext<Self>) {
132 self.spawn_search(self.picker.read(cx).query(cx), cx)
133 .detach();
134 }
135
136 fn spawn_search(&mut self, query: String, cx: &mut ViewContext<Self>) -> Task<()> {
137 let search_id = util::post_inc(&mut self.search_count);
138 self.cancel_flag.store(true, atomic::Ordering::Relaxed);
139 self.cancel_flag = Arc::new(AtomicBool::new(false));
140 let cancel_flag = self.cancel_flag.clone();
141 let project = self.project.clone();
142 cx.spawn(|this, mut cx| async move {
143 let matches = project
144 .read_with(&cx, |project, cx| {
145 project.match_paths(&query, false, false, 100, cancel_flag.as_ref(), cx)
146 })
147 .await;
148 let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed);
149 this.update(&mut cx, |this, cx| {
150 this.set_matches(search_id, did_cancel, query, matches, cx)
151 });
152 })
153 }
154
155 fn set_matches(
156 &mut self,
157 search_id: usize,
158 did_cancel: bool,
159 query: String,
160 matches: Vec<PathMatch>,
161 cx: &mut ViewContext<Self>,
162 ) {
163 if search_id >= self.latest_search_id {
164 self.latest_search_id = search_id;
165 if self.latest_search_did_cancel && query == self.latest_search_query {
166 util::extend_sorted(&mut self.matches, matches.into_iter(), 100, |a, b| b.cmp(a));
167 } else {
168 self.matches = matches;
169 }
170 self.latest_search_query = query;
171 self.latest_search_did_cancel = did_cancel;
172 cx.notify();
173 self.picker.update(cx, |_, cx| cx.notify());
174 }
175 }
176}
177
178impl PickerDelegate for FileFinder {
179 fn match_count(&self) -> usize {
180 self.matches.len()
181 }
182
183 fn selected_index(&self) -> usize {
184 if let Some(selected) = self.selected.as_ref() {
185 for (ix, path_match) in self.matches.iter().enumerate() {
186 if (path_match.worktree_id, path_match.path.as_ref())
187 == (selected.0, selected.1.as_ref())
188 {
189 return ix;
190 }
191 }
192 }
193 0
194 }
195
196 fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Self>) {
197 let mat = &self.matches[ix];
198 self.selected = Some((mat.worktree_id, mat.path.clone()));
199 cx.notify();
200 }
201
202 fn update_matches(&mut self, query: String, cx: &mut ViewContext<Self>) -> Task<()> {
203 if query.is_empty() {
204 self.latest_search_id = post_inc(&mut self.search_count);
205 self.matches.clear();
206 cx.notify();
207 Task::ready(())
208 } else {
209 self.spawn_search(query, cx)
210 }
211 }
212
213 fn confirm(&mut self, cx: &mut ViewContext<Self>) {
214 if let Some(m) = self.matches.get(self.selected_index()) {
215 cx.emit(Event::Selected(ProjectPath {
216 worktree_id: WorktreeId::from_usize(m.worktree_id),
217 path: m.path.clone(),
218 }));
219 }
220 }
221
222 fn dismiss(&mut self, cx: &mut ViewContext<Self>) {
223 cx.emit(Event::Dismissed);
224 }
225
226 fn render_match(
227 &self,
228 ix: usize,
229 mouse_state: &MouseState,
230 selected: bool,
231 cx: &AppContext,
232 ) -> ElementBox {
233 let path_match = &self.matches[ix];
234 let settings = cx.global::<Settings>();
235 let style = settings.theme.picker.item.style_for(mouse_state, selected);
236 let (file_name, file_name_positions, full_path, full_path_positions) =
237 self.labels_for_match(path_match);
238 Flex::column()
239 .with_child(
240 Label::new(file_name.to_string(), style.label.clone())
241 .with_highlights(file_name_positions)
242 .boxed(),
243 )
244 .with_child(
245 Label::new(full_path, style.label.clone())
246 .with_highlights(full_path_positions)
247 .boxed(),
248 )
249 .flex(1., false)
250 .contained()
251 .with_style(style.container)
252 .named("match")
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259 use editor::{Editor, Input};
260 use serde_json::json;
261 use std::path::PathBuf;
262 use workspace::menu::{Confirm, SelectNext};
263 use workspace::{Workspace, WorkspaceParams};
264
265 #[ctor::ctor]
266 fn init_logger() {
267 if std::env::var("RUST_LOG").is_ok() {
268 env_logger::init();
269 }
270 }
271
272 #[gpui::test]
273 async fn test_matching_paths(cx: &mut gpui::TestAppContext) {
274 cx.update(|cx| {
275 super::init(cx);
276 editor::init(cx);
277 });
278
279 let params = cx.update(WorkspaceParams::test);
280 params
281 .fs
282 .as_fake()
283 .insert_tree(
284 "/root",
285 json!({
286 "a": {
287 "banana": "",
288 "bandana": "",
289 }
290 }),
291 )
292 .await;
293
294 let (window_id, workspace) = cx.add_window(|cx| Workspace::new(¶ms, cx));
295 params
296 .project
297 .update(cx, |project, cx| {
298 project.find_or_create_local_worktree("/root", true, cx)
299 })
300 .await
301 .unwrap();
302 cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
303 .await;
304 cx.dispatch_action(window_id, Toggle);
305
306 let finder = cx.read(|cx| {
307 workspace
308 .read(cx)
309 .modal()
310 .cloned()
311 .unwrap()
312 .downcast::<FileFinder>()
313 .unwrap()
314 });
315 cx.dispatch_action(window_id, Input("b".into()));
316 cx.dispatch_action(window_id, Input("n".into()));
317 cx.dispatch_action(window_id, Input("a".into()));
318 finder
319 .condition(&cx, |finder, _| finder.matches.len() == 2)
320 .await;
321
322 let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone());
323 cx.dispatch_action(window_id, SelectNext);
324 cx.dispatch_action(window_id, Confirm);
325 active_pane
326 .condition(&cx, |pane, _| pane.active_item().is_some())
327 .await;
328 cx.read(|cx| {
329 let active_item = active_pane.read(cx).active_item().unwrap();
330 assert_eq!(
331 active_item
332 .to_any()
333 .downcast::<Editor>()
334 .unwrap()
335 .read(cx)
336 .title(cx),
337 "bandana"
338 );
339 });
340 }
341
342 #[gpui::test]
343 async fn test_matching_cancellation(cx: &mut gpui::TestAppContext) {
344 let params = cx.update(WorkspaceParams::test);
345 let fs = params.fs.as_fake();
346 fs.insert_tree(
347 "/dir",
348 json!({
349 "hello": "",
350 "goodbye": "",
351 "halogen-light": "",
352 "happiness": "",
353 "height": "",
354 "hi": "",
355 "hiccup": "",
356 }),
357 )
358 .await;
359
360 let (_, workspace) = cx.add_window(|cx| Workspace::new(¶ms, cx));
361 params
362 .project
363 .update(cx, |project, cx| {
364 project.find_or_create_local_worktree("/dir", true, cx)
365 })
366 .await
367 .unwrap();
368 cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
369 .await;
370 let (_, finder) =
371 cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), cx));
372
373 let query = "hi".to_string();
374 finder
375 .update(cx, |f, cx| f.spawn_search(query.clone(), cx))
376 .await;
377 finder.read_with(cx, |f, _| assert_eq!(f.matches.len(), 5));
378
379 finder.update(cx, |finder, cx| {
380 let matches = finder.matches.clone();
381
382 // Simulate a search being cancelled after the time limit,
383 // returning only a subset of the matches that would have been found.
384 drop(finder.spawn_search(query.clone(), cx));
385 finder.set_matches(
386 finder.latest_search_id,
387 true, // did-cancel
388 query.clone(),
389 vec![matches[1].clone(), matches[3].clone()],
390 cx,
391 );
392
393 // Simulate another cancellation.
394 drop(finder.spawn_search(query.clone(), cx));
395 finder.set_matches(
396 finder.latest_search_id,
397 true, // did-cancel
398 query.clone(),
399 vec![matches[0].clone(), matches[2].clone(), matches[3].clone()],
400 cx,
401 );
402
403 assert_eq!(finder.matches, matches[0..4])
404 });
405 }
406
407 #[gpui::test]
408 async fn test_single_file_worktrees(cx: &mut gpui::TestAppContext) {
409 let params = cx.update(WorkspaceParams::test);
410 params
411 .fs
412 .as_fake()
413 .insert_tree("/root", json!({ "the-parent-dir": { "the-file": "" } }))
414 .await;
415
416 let (_, workspace) = cx.add_window(|cx| Workspace::new(¶ms, cx));
417 params
418 .project
419 .update(cx, |project, cx| {
420 project.find_or_create_local_worktree("/root/the-parent-dir/the-file", true, cx)
421 })
422 .await
423 .unwrap();
424 cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
425 .await;
426 let (_, finder) =
427 cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), cx));
428
429 // Even though there is only one worktree, that worktree's filename
430 // is included in the matching, because the worktree is a single file.
431 finder
432 .update(cx, |f, cx| f.spawn_search("thf".into(), cx))
433 .await;
434 cx.read(|cx| {
435 let finder = finder.read(cx);
436 assert_eq!(finder.matches.len(), 1);
437
438 let (file_name, file_name_positions, full_path, full_path_positions) =
439 finder.labels_for_match(&finder.matches[0]);
440 assert_eq!(file_name, "the-file");
441 assert_eq!(file_name_positions, &[0, 1, 4]);
442 assert_eq!(full_path, "the-file");
443 assert_eq!(full_path_positions, &[0, 1, 4]);
444 });
445
446 // Since the worktree root is a file, searching for its name followed by a slash does
447 // not match anything.
448 finder
449 .update(cx, |f, cx| f.spawn_search("thf/".into(), cx))
450 .await;
451 finder.read_with(cx, |f, _| assert_eq!(f.matches.len(), 0));
452 }
453
454 #[gpui::test(retries = 5)]
455 async fn test_multiple_matches_with_same_relative_path(cx: &mut gpui::TestAppContext) {
456 let params = cx.update(WorkspaceParams::test);
457 params
458 .fs
459 .as_fake()
460 .insert_tree(
461 "/root",
462 json!({
463 "dir1": { "a.txt": "" },
464 "dir2": { "a.txt": "" }
465 }),
466 )
467 .await;
468
469 let (_, workspace) = cx.add_window(|cx| Workspace::new(¶ms, cx));
470
471 workspace
472 .update(cx, |workspace, cx| {
473 workspace.open_paths(
474 vec![PathBuf::from("/root/dir1"), PathBuf::from("/root/dir2")],
475 cx,
476 )
477 })
478 .await;
479 cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
480 .await;
481
482 let (_, finder) =
483 cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), cx));
484
485 // Run a search that matches two files with the same relative path.
486 finder
487 .update(cx, |f, cx| f.spawn_search("a.t".into(), cx))
488 .await;
489
490 // Can switch between different matches with the same relative path.
491 finder.update(cx, |f, cx| {
492 assert_eq!(f.matches.len(), 2);
493 assert_eq!(f.selected_index(), 0);
494 f.set_selected_index(1, cx);
495 assert_eq!(f.selected_index(), 1);
496 f.set_selected_index(0, cx);
497 assert_eq!(f.selected_index(), 0);
498 });
499 }
500}