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