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