1use fuzzy::PathMatch;
2use gpui::{
3 actions, elements::*, AppContext, Entity, ModelHandle, MouseState, MutableAppContext,
4 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, _: &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, |workspace, cx| {
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 worktrees = self
138 .project
139 .read(cx)
140 .visible_worktrees(cx)
141 .collect::<Vec<_>>();
142 let include_root_name = worktrees.len() > 1;
143 let candidate_sets = worktrees
144 .into_iter()
145 .map(|worktree| {
146 let worktree = worktree.read(cx);
147 PathMatchCandidateSet {
148 snapshot: worktree.snapshot(),
149 include_ignored: worktree
150 .root_entry()
151 .map_or(false, |entry| entry.is_ignored),
152 include_root_name,
153 }
154 })
155 .collect::<Vec<_>>();
156
157 let search_id = util::post_inc(&mut self.search_count);
158 self.cancel_flag.store(true, atomic::Ordering::Relaxed);
159 self.cancel_flag = Arc::new(AtomicBool::new(false));
160 let cancel_flag = self.cancel_flag.clone();
161 cx.spawn(|this, mut cx| async move {
162 let matches = fuzzy::match_paths(
163 candidate_sets.as_slice(),
164 &query,
165 false,
166 100,
167 &cancel_flag,
168 cx.background(),
169 )
170 .await;
171 let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed);
172 this.update(&mut cx, |this, cx| {
173 this.set_matches(search_id, did_cancel, query, matches, cx)
174 });
175 })
176 }
177
178 fn set_matches(
179 &mut self,
180 search_id: usize,
181 did_cancel: bool,
182 query: String,
183 matches: Vec<PathMatch>,
184 cx: &mut ViewContext<Self>,
185 ) {
186 if search_id >= self.latest_search_id {
187 self.latest_search_id = search_id;
188 if self.latest_search_did_cancel && query == self.latest_search_query {
189 util::extend_sorted(&mut self.matches, matches.into_iter(), 100, |a, b| b.cmp(a));
190 } else {
191 self.matches = matches;
192 }
193 self.latest_search_query = query;
194 self.latest_search_did_cancel = did_cancel;
195 cx.notify();
196 self.picker.update(cx, |_, cx| cx.notify());
197 }
198 }
199}
200
201impl PickerDelegate for FileFinder {
202 fn match_count(&self) -> usize {
203 self.matches.len()
204 }
205
206 fn selected_index(&self) -> usize {
207 if let Some(selected) = self.selected.as_ref() {
208 for (ix, path_match) in self.matches.iter().enumerate() {
209 if (path_match.worktree_id, path_match.path.as_ref())
210 == (selected.0, selected.1.as_ref())
211 {
212 return ix;
213 }
214 }
215 }
216 0
217 }
218
219 fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Self>) {
220 let mat = &self.matches[ix];
221 self.selected = Some((mat.worktree_id, mat.path.clone()));
222 cx.notify();
223 }
224
225 fn update_matches(&mut self, query: String, cx: &mut ViewContext<Self>) -> Task<()> {
226 if query.is_empty() {
227 self.latest_search_id = post_inc(&mut self.search_count);
228 self.matches.clear();
229 cx.notify();
230 Task::ready(())
231 } else {
232 self.spawn_search(query, cx)
233 }
234 }
235
236 fn confirm(&mut self, cx: &mut ViewContext<Self>) {
237 if let Some(m) = self.matches.get(self.selected_index()) {
238 cx.emit(Event::Selected(ProjectPath {
239 worktree_id: WorktreeId::from_usize(m.worktree_id),
240 path: m.path.clone(),
241 }));
242 }
243 }
244
245 fn dismiss(&mut self, cx: &mut ViewContext<Self>) {
246 cx.emit(Event::Dismissed);
247 }
248
249 fn render_match(
250 &self,
251 ix: usize,
252 mouse_state: MouseState,
253 selected: bool,
254 cx: &AppContext,
255 ) -> ElementBox {
256 let path_match = &self.matches[ix];
257 let settings = cx.global::<Settings>();
258 let style = settings.theme.picker.item.style_for(mouse_state, selected);
259 let (file_name, file_name_positions, full_path, full_path_positions) =
260 self.labels_for_match(path_match);
261 Flex::column()
262 .with_child(
263 Label::new(file_name.to_string(), style.label.clone())
264 .with_highlights(file_name_positions)
265 .boxed(),
266 )
267 .with_child(
268 Label::new(full_path, style.label.clone())
269 .with_highlights(full_path_positions)
270 .boxed(),
271 )
272 .flex(1., false)
273 .contained()
274 .with_style(style.container)
275 .named("match")
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282 use editor::{Editor, Input};
283 use menu::{Confirm, SelectNext};
284 use serde_json::json;
285 use workspace::{AppState, Workspace};
286
287 #[ctor::ctor]
288 fn init_logger() {
289 if std::env::var("RUST_LOG").is_ok() {
290 env_logger::init();
291 }
292 }
293
294 #[gpui::test]
295 async fn test_matching_paths(cx: &mut gpui::TestAppContext) {
296 let app_state = cx.update(|cx| {
297 super::init(cx);
298 editor::init(cx);
299 AppState::test(cx)
300 });
301
302 app_state
303 .fs
304 .as_fake()
305 .insert_tree(
306 "/root",
307 json!({
308 "a": {
309 "banana": "",
310 "bandana": "",
311 }
312 }),
313 )
314 .await;
315
316 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
317 let (window_id, workspace) = cx.add_window(|cx| Workspace::new(project, cx));
318 cx.dispatch_action(window_id, Toggle);
319
320 let finder = cx.read(|cx| {
321 workspace
322 .read(cx)
323 .modal()
324 .cloned()
325 .unwrap()
326 .downcast::<FileFinder>()
327 .unwrap()
328 });
329 cx.dispatch_action(window_id, Input("b".into()));
330 cx.dispatch_action(window_id, Input("n".into()));
331 cx.dispatch_action(window_id, Input("a".into()));
332 finder
333 .condition(&cx, |finder, _| finder.matches.len() == 2)
334 .await;
335
336 let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone());
337 cx.dispatch_action(window_id, SelectNext);
338 cx.dispatch_action(window_id, Confirm);
339 active_pane
340 .condition(&cx, |pane, _| pane.active_item().is_some())
341 .await;
342 cx.read(|cx| {
343 let active_item = active_pane.read(cx).active_item().unwrap();
344 assert_eq!(
345 active_item
346 .to_any()
347 .downcast::<Editor>()
348 .unwrap()
349 .read(cx)
350 .title(cx),
351 "bandana"
352 );
353 });
354 }
355
356 #[gpui::test]
357 async fn test_matching_cancellation(cx: &mut gpui::TestAppContext) {
358 let app_state = cx.update(AppState::test);
359 app_state
360 .fs
361 .as_fake()
362 .insert_tree(
363 "/dir",
364 json!({
365 "hello": "",
366 "goodbye": "",
367 "halogen-light": "",
368 "happiness": "",
369 "height": "",
370 "hi": "",
371 "hiccup": "",
372 }),
373 )
374 .await;
375
376 let project = Project::test(app_state.fs.clone(), ["/dir".as_ref()], cx).await;
377 let (_, workspace) = cx.add_window(|cx| Workspace::new(project, cx));
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| Workspace::new(project, cx));
452 let (_, finder) =
453 cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), cx));
454 finder
455 .update(cx, |f, cx| f.spawn_search("hi".into(), cx))
456 .await;
457 finder.read_with(cx, |f, _| assert_eq!(f.matches.len(), 7));
458 }
459
460 #[gpui::test]
461 async fn test_single_file_worktrees(cx: &mut gpui::TestAppContext) {
462 let app_state = cx.update(AppState::test);
463 app_state
464 .fs
465 .as_fake()
466 .insert_tree("/root", json!({ "the-parent-dir": { "the-file": "" } }))
467 .await;
468
469 let project = Project::test(
470 app_state.fs.clone(),
471 ["/root/the-parent-dir/the-file".as_ref()],
472 cx,
473 )
474 .await;
475 let (_, workspace) = cx.add_window(|cx| Workspace::new(project, cx));
476 let (_, finder) =
477 cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), cx));
478
479 // Even though there is only one worktree, that worktree's filename
480 // is included in the matching, because the worktree is a single file.
481 finder
482 .update(cx, |f, cx| f.spawn_search("thf".into(), cx))
483 .await;
484 cx.read(|cx| {
485 let finder = finder.read(cx);
486 assert_eq!(finder.matches.len(), 1);
487
488 let (file_name, file_name_positions, full_path, full_path_positions) =
489 finder.labels_for_match(&finder.matches[0]);
490 assert_eq!(file_name, "the-file");
491 assert_eq!(file_name_positions, &[0, 1, 4]);
492 assert_eq!(full_path, "the-file");
493 assert_eq!(full_path_positions, &[0, 1, 4]);
494 });
495
496 // Since the worktree root is a file, searching for its name followed by a slash does
497 // not match anything.
498 finder
499 .update(cx, |f, cx| f.spawn_search("thf/".into(), cx))
500 .await;
501 finder.read_with(cx, |f, _| assert_eq!(f.matches.len(), 0));
502 }
503
504 #[gpui::test]
505 async fn test_multiple_matches_with_same_relative_path(cx: &mut gpui::TestAppContext) {
506 cx.foreground().forbid_parking();
507
508 let app_state = cx.update(AppState::test);
509 app_state
510 .fs
511 .as_fake()
512 .insert_tree(
513 "/root",
514 json!({
515 "dir1": { "a.txt": "" },
516 "dir2": { "a.txt": "" }
517 }),
518 )
519 .await;
520
521 let project = Project::test(
522 app_state.fs.clone(),
523 ["/root/dir1".as_ref(), "/root/dir2".as_ref()],
524 cx,
525 )
526 .await;
527 let (_, workspace) = cx.add_window(|cx| Workspace::new(project, cx));
528 let (_, finder) =
529 cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), cx));
530
531 // Run a search that matches two files with the same relative path.
532 finder
533 .update(cx, |f, cx| f.spawn_search("a.t".into(), cx))
534 .await;
535
536 // Can switch between different matches with the same relative path.
537 finder.update(cx, |f, cx| {
538 assert_eq!(f.matches.len(), 2);
539 assert_eq!(f.selected_index(), 0);
540 f.set_selected_index(1, cx);
541 assert_eq!(f.selected_index(), 1);
542 f.set_selected_index(0, cx);
543 assert_eq!(f.selected_index(), 0);
544 });
545 }
546
547 #[gpui::test]
548 async fn test_search_worktree_without_files(cx: &mut gpui::TestAppContext) {
549 let app_state = cx.update(AppState::test);
550 app_state
551 .fs
552 .as_fake()
553 .insert_tree(
554 "/root",
555 json!({
556 "dir1": {},
557 "dir2": {
558 "dir3": {}
559 }
560 }),
561 )
562 .await;
563
564 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
565 let (_, workspace) = cx.add_window(|cx| Workspace::new(project, cx));
566 let (_, finder) =
567 cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), cx));
568 finder
569 .update(cx, |f, cx| f.spawn_search("dir".into(), cx))
570 .await;
571 cx.read(|cx| {
572 let finder = finder.read(cx);
573 assert_eq!(finder.matches.len(), 0);
574 });
575 }
576}