1#[cfg(test)]
2mod file_finder_tests;
3#[cfg(test)]
4mod open_path_prompt_tests;
5
6pub mod file_finder_settings;
7mod new_path_prompt;
8mod open_path_prompt;
9
10use futures::future::join_all;
11pub use open_path_prompt::OpenPathDelegate;
12
13use collections::HashMap;
14use editor::Editor;
15use file_finder_settings::{FileFinderSettings, FileFinderWidth};
16use file_icons::FileIcons;
17use fuzzy::{CharBag, PathMatch, PathMatchCandidate};
18use gpui::{
19 Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
20 KeyContext, Modifiers, ModifiersChangedEvent, ParentElement, Render, Styled, Task, WeakEntity,
21 Window, actions,
22};
23use new_path_prompt::NewPathPrompt;
24use open_path_prompt::OpenPathPrompt;
25use picker::{Picker, PickerDelegate};
26use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
27use search::ToggleIncludeIgnored;
28use settings::Settings;
29use std::{
30 borrow::Cow,
31 cmp,
32 ops::Range,
33 path::{Component, Path, PathBuf},
34 sync::{
35 Arc,
36 atomic::{self, AtomicBool},
37 },
38};
39use text::Point;
40use ui::{
41 ContextMenu, HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, PopoverMenu,
42 PopoverMenuHandle, Tooltip, prelude::*,
43};
44use util::{ResultExt, maybe, paths::PathWithPosition, post_inc};
45use workspace::{
46 ModalView, OpenOptions, OpenVisible, SplitDirection, Workspace, item::PreviewTabsSettings,
47 notifications::NotifyResultExt, pane,
48};
49
50actions!(file_finder, [SelectPrevious, ToggleMenu]);
51
52impl ModalView for FileFinder {
53 fn on_before_dismiss(
54 &mut self,
55 window: &mut Window,
56 cx: &mut Context<Self>,
57 ) -> workspace::DismissDecision {
58 let submenu_focused = self.picker.update(cx, |picker, cx| {
59 picker.delegate.popover_menu_handle.is_focused(window, cx)
60 });
61 workspace::DismissDecision::Dismiss(!submenu_focused)
62 }
63}
64
65pub struct FileFinder {
66 picker: Entity<Picker<FileFinderDelegate>>,
67 picker_focus_handle: FocusHandle,
68 init_modifiers: Option<Modifiers>,
69}
70
71pub fn init_settings(cx: &mut App) {
72 FileFinderSettings::register(cx);
73}
74
75pub fn init(cx: &mut App) {
76 init_settings(cx);
77 cx.observe_new(FileFinder::register).detach();
78 cx.observe_new(NewPathPrompt::register).detach();
79 cx.observe_new(OpenPathPrompt::register).detach();
80}
81
82impl FileFinder {
83 fn register(
84 workspace: &mut Workspace,
85 _window: Option<&mut Window>,
86 _: &mut Context<Workspace>,
87 ) {
88 workspace.register_action(
89 |workspace, action: &workspace::ToggleFileFinder, window, cx| {
90 let Some(file_finder) = workspace.active_modal::<Self>(cx) else {
91 Self::open(workspace, action.separate_history, window, cx).detach();
92 return;
93 };
94
95 file_finder.update(cx, |file_finder, cx| {
96 file_finder.init_modifiers = Some(window.modifiers());
97 file_finder.picker.update(cx, |picker, cx| {
98 picker.cycle_selection(window, cx);
99 });
100 });
101 },
102 );
103 }
104
105 fn open(
106 workspace: &mut Workspace,
107 separate_history: bool,
108 window: &mut Window,
109 cx: &mut Context<Workspace>,
110 ) -> Task<()> {
111 let project = workspace.project().read(cx);
112 let fs = project.fs();
113
114 let currently_opened_path = workspace
115 .active_item(cx)
116 .and_then(|item| item.project_path(cx))
117 .map(|project_path| {
118 let abs_path = project
119 .worktree_for_id(project_path.worktree_id, cx)
120 .map(|worktree| worktree.read(cx).abs_path().join(&project_path.path));
121 FoundPath::new(project_path, abs_path)
122 });
123
124 let history_items = workspace
125 .recent_navigation_history(Some(MAX_RECENT_SELECTIONS), cx)
126 .into_iter()
127 .filter_map(|(project_path, abs_path)| {
128 if project.entry_for_path(&project_path, cx).is_some() {
129 return Some(Task::ready(Some(FoundPath::new(project_path, abs_path))));
130 }
131 let abs_path = abs_path?;
132 if project.is_local() {
133 let fs = fs.clone();
134 Some(cx.background_spawn(async move {
135 if fs.is_file(&abs_path).await {
136 Some(FoundPath::new(project_path, Some(abs_path)))
137 } else {
138 None
139 }
140 }))
141 } else {
142 Some(Task::ready(Some(FoundPath::new(
143 project_path,
144 Some(abs_path),
145 ))))
146 }
147 })
148 .collect::<Vec<_>>();
149 cx.spawn_in(window, async move |workspace, cx| {
150 let history_items = join_all(history_items).await.into_iter().flatten();
151
152 workspace
153 .update_in(cx, |workspace, window, cx| {
154 let project = workspace.project().clone();
155 let weak_workspace = cx.entity().downgrade();
156 workspace.toggle_modal(window, cx, |window, cx| {
157 let delegate = FileFinderDelegate::new(
158 cx.entity().downgrade(),
159 weak_workspace,
160 project,
161 currently_opened_path,
162 history_items.collect(),
163 separate_history,
164 window,
165 cx,
166 );
167
168 FileFinder::new(delegate, window, cx)
169 });
170 })
171 .ok();
172 })
173 }
174
175 fn new(delegate: FileFinderDelegate, window: &mut Window, cx: &mut Context<Self>) -> Self {
176 let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
177 let picker_focus_handle = picker.focus_handle(cx);
178 picker.update(cx, |picker, _| {
179 picker.delegate.focus_handle = picker_focus_handle.clone();
180 });
181 Self {
182 picker,
183 picker_focus_handle,
184 init_modifiers: window.modifiers().modified().then_some(window.modifiers()),
185 }
186 }
187
188 fn handle_modifiers_changed(
189 &mut self,
190 event: &ModifiersChangedEvent,
191 window: &mut Window,
192 cx: &mut Context<Self>,
193 ) {
194 let Some(init_modifiers) = self.init_modifiers.take() else {
195 return;
196 };
197 if self.picker.read(cx).delegate.has_changed_selected_index {
198 if !event.modified() || !init_modifiers.is_subset_of(&event) {
199 self.init_modifiers = None;
200 window.dispatch_action(menu::Confirm.boxed_clone(), cx);
201 }
202 }
203 }
204
205 fn handle_select_prev(
206 &mut self,
207 _: &SelectPrevious,
208 window: &mut Window,
209 cx: &mut Context<Self>,
210 ) {
211 self.init_modifiers = Some(window.modifiers());
212 window.dispatch_action(Box::new(menu::SelectPrevious), cx);
213 }
214
215 fn handle_toggle_menu(&mut self, _: &ToggleMenu, window: &mut Window, cx: &mut Context<Self>) {
216 self.picker.update(cx, |picker, cx| {
217 let menu_handle = &picker.delegate.popover_menu_handle;
218 if menu_handle.is_deployed() {
219 menu_handle.hide(cx);
220 } else {
221 menu_handle.show(window, cx);
222 }
223 });
224 }
225
226 fn handle_toggle_ignored(
227 &mut self,
228 _: &ToggleIncludeIgnored,
229 window: &mut Window,
230 cx: &mut Context<Self>,
231 ) {
232 self.picker.update(cx, |picker, cx| {
233 picker.delegate.include_ignored = match picker.delegate.include_ignored {
234 Some(true) => match FileFinderSettings::get_global(cx).include_ignored {
235 Some(_) => Some(false),
236 None => None,
237 },
238 Some(false) => Some(true),
239 None => Some(true),
240 };
241 picker.delegate.include_ignored_refresh =
242 picker.delegate.update_matches(picker.query(cx), window, cx);
243 });
244 }
245
246 fn go_to_file_split_left(
247 &mut self,
248 _: &pane::SplitLeft,
249 window: &mut Window,
250 cx: &mut Context<Self>,
251 ) {
252 self.go_to_file_split_inner(SplitDirection::Left, window, cx)
253 }
254
255 fn go_to_file_split_right(
256 &mut self,
257 _: &pane::SplitRight,
258 window: &mut Window,
259 cx: &mut Context<Self>,
260 ) {
261 self.go_to_file_split_inner(SplitDirection::Right, window, cx)
262 }
263
264 fn go_to_file_split_up(
265 &mut self,
266 _: &pane::SplitUp,
267 window: &mut Window,
268 cx: &mut Context<Self>,
269 ) {
270 self.go_to_file_split_inner(SplitDirection::Up, window, cx)
271 }
272
273 fn go_to_file_split_down(
274 &mut self,
275 _: &pane::SplitDown,
276 window: &mut Window,
277 cx: &mut Context<Self>,
278 ) {
279 self.go_to_file_split_inner(SplitDirection::Down, window, cx)
280 }
281
282 fn go_to_file_split_inner(
283 &mut self,
284 split_direction: SplitDirection,
285 window: &mut Window,
286 cx: &mut Context<Self>,
287 ) {
288 self.picker.update(cx, |picker, cx| {
289 let delegate = &mut picker.delegate;
290 if let Some(workspace) = delegate.workspace.upgrade() {
291 if let Some(m) = delegate.matches.get(delegate.selected_index()) {
292 let path = match &m {
293 Match::History { path, .. } => {
294 let worktree_id = path.project.worktree_id;
295 ProjectPath {
296 worktree_id,
297 path: Arc::clone(&path.project.path),
298 }
299 }
300 Match::Search(m) => ProjectPath {
301 worktree_id: WorktreeId::from_usize(m.0.worktree_id),
302 path: m.0.path.clone(),
303 },
304 };
305 let open_task = workspace.update(cx, move |workspace, cx| {
306 workspace.split_path_preview(path, false, Some(split_direction), window, cx)
307 });
308 open_task.detach_and_log_err(cx);
309 }
310 }
311 })
312 }
313
314 pub fn modal_max_width(width_setting: Option<FileFinderWidth>, window: &mut Window) -> Pixels {
315 let window_width = window.viewport_size().width;
316 let small_width = Pixels(545.);
317
318 match width_setting {
319 None | Some(FileFinderWidth::Small) => small_width,
320 Some(FileFinderWidth::Full) => window_width,
321 Some(FileFinderWidth::XLarge) => (window_width - Pixels(512.)).max(small_width),
322 Some(FileFinderWidth::Large) => (window_width - Pixels(768.)).max(small_width),
323 Some(FileFinderWidth::Medium) => (window_width - Pixels(1024.)).max(small_width),
324 }
325 }
326}
327
328impl EventEmitter<DismissEvent> for FileFinder {}
329
330impl Focusable for FileFinder {
331 fn focus_handle(&self, _: &App) -> FocusHandle {
332 self.picker_focus_handle.clone()
333 }
334}
335
336impl Render for FileFinder {
337 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
338 let key_context = self.picker.read(cx).delegate.key_context(window, cx);
339
340 let file_finder_settings = FileFinderSettings::get_global(cx);
341 let modal_max_width = Self::modal_max_width(file_finder_settings.modal_max_width, window);
342
343 v_flex()
344 .key_context(key_context)
345 .w(modal_max_width)
346 .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
347 .on_action(cx.listener(Self::handle_select_prev))
348 .on_action(cx.listener(Self::handle_toggle_menu))
349 .on_action(cx.listener(Self::handle_toggle_ignored))
350 .on_action(cx.listener(Self::go_to_file_split_left))
351 .on_action(cx.listener(Self::go_to_file_split_right))
352 .on_action(cx.listener(Self::go_to_file_split_up))
353 .on_action(cx.listener(Self::go_to_file_split_down))
354 .child(self.picker.clone())
355 }
356}
357
358pub struct FileFinderDelegate {
359 file_finder: WeakEntity<FileFinder>,
360 workspace: WeakEntity<Workspace>,
361 project: Entity<Project>,
362 search_count: usize,
363 latest_search_id: usize,
364 latest_search_did_cancel: bool,
365 latest_search_query: Option<FileSearchQuery>,
366 currently_opened_path: Option<FoundPath>,
367 matches: Matches,
368 selected_index: usize,
369 has_changed_selected_index: bool,
370 cancel_flag: Arc<AtomicBool>,
371 history_items: Vec<FoundPath>,
372 separate_history: bool,
373 first_update: bool,
374 popover_menu_handle: PopoverMenuHandle<ContextMenu>,
375 focus_handle: FocusHandle,
376 include_ignored: Option<bool>,
377 include_ignored_refresh: Task<()>,
378}
379
380/// Use a custom ordering for file finder: the regular one
381/// defines max element with the highest score and the latest alphanumerical path (in case of a tie on other params), e.g:
382/// `[{score: 0.5, path = "c/d" }, { score: 0.5, path = "/a/b" }]`
383///
384/// In the file finder, we would prefer to have the max element with the highest score and the earliest alphanumerical path, e.g:
385/// `[{ score: 0.5, path = "/a/b" }, {score: 0.5, path = "c/d" }]`
386/// as the files are shown in the project panel lists.
387#[derive(Debug, Clone, PartialEq, Eq)]
388struct ProjectPanelOrdMatch(PathMatch);
389
390impl Ord for ProjectPanelOrdMatch {
391 fn cmp(&self, other: &Self) -> cmp::Ordering {
392 self.0
393 .score
394 .partial_cmp(&other.0.score)
395 .unwrap_or(cmp::Ordering::Equal)
396 .then_with(|| self.0.worktree_id.cmp(&other.0.worktree_id))
397 .then_with(|| {
398 other
399 .0
400 .distance_to_relative_ancestor
401 .cmp(&self.0.distance_to_relative_ancestor)
402 })
403 .then_with(|| self.0.path.cmp(&other.0.path).reverse())
404 }
405}
406
407impl PartialOrd for ProjectPanelOrdMatch {
408 fn partial_cmp(&self, other: &Self) -> Option<cmp::Ordering> {
409 Some(self.cmp(other))
410 }
411}
412
413#[derive(Debug, Default)]
414struct Matches {
415 separate_history: bool,
416 matches: Vec<Match>,
417}
418
419#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
420enum Match {
421 History {
422 path: FoundPath,
423 panel_match: Option<ProjectPanelOrdMatch>,
424 },
425 Search(ProjectPanelOrdMatch),
426}
427
428impl Match {
429 fn path(&self) -> &Arc<Path> {
430 match self {
431 Match::History { path, .. } => &path.project.path,
432 Match::Search(panel_match) => &panel_match.0.path,
433 }
434 }
435
436 fn panel_match(&self) -> Option<&ProjectPanelOrdMatch> {
437 match self {
438 Match::History { panel_match, .. } => panel_match.as_ref(),
439 Match::Search(panel_match) => Some(&panel_match),
440 }
441 }
442}
443
444impl Matches {
445 fn len(&self) -> usize {
446 self.matches.len()
447 }
448
449 fn get(&self, index: usize) -> Option<&Match> {
450 self.matches.get(index)
451 }
452
453 fn position(
454 &self,
455 entry: &Match,
456 currently_opened: Option<&FoundPath>,
457 ) -> Result<usize, usize> {
458 if let Match::History {
459 path,
460 panel_match: None,
461 } = entry
462 {
463 // Slow case: linear search by path. Should not happen actually,
464 // since we call `position` only if matches set changed, but the query has not changed.
465 // And History entries do not have panel_match if query is empty, so there's no
466 // reason for the matches set to change.
467 self.matches
468 .iter()
469 .position(|m| path.project.path == *m.path())
470 .ok_or(0)
471 } else {
472 self.matches.binary_search_by(|m| {
473 // `reverse()` since if cmp_matches(a, b) == Ordering::Greater, then a is better than b.
474 // And we want the better entries go first.
475 Self::cmp_matches(self.separate_history, currently_opened, &m, &entry).reverse()
476 })
477 }
478 }
479
480 fn push_new_matches<'a>(
481 &'a mut self,
482 history_items: impl IntoIterator<Item = &'a FoundPath> + Clone,
483 currently_opened: Option<&'a FoundPath>,
484 query: Option<&FileSearchQuery>,
485 new_search_matches: impl Iterator<Item = ProjectPanelOrdMatch>,
486 extend_old_matches: bool,
487 ) {
488 let Some(query) = query else {
489 // assuming that if there's no query, then there's no search matches.
490 self.matches.clear();
491 let path_to_entry = |found_path: &FoundPath| Match::History {
492 path: found_path.clone(),
493 panel_match: None,
494 };
495
496 self.matches
497 .extend(history_items.into_iter().map(path_to_entry));
498 return;
499 };
500
501 let new_history_matches = matching_history_items(history_items, currently_opened, query);
502 let new_search_matches: Vec<Match> = new_search_matches
503 .filter(|path_match| !new_history_matches.contains_key(&path_match.0.path))
504 .map(Match::Search)
505 .collect();
506
507 if extend_old_matches {
508 // since we take history matches instead of new search matches
509 // and history matches has not changed(since the query has not changed and we do not extend old matches otherwise),
510 // old matches can't contain paths present in history_matches as well.
511 self.matches.retain(|m| matches!(m, Match::Search(_)));
512 } else {
513 self.matches.clear();
514 }
515
516 // At this point we have an unsorted set of new history matches, an unsorted set of new search matches
517 // and a sorted set of old search matches.
518 // It is possible that the new search matches' paths contain some of the old search matches' paths.
519 // History matches' paths are unique, since store in a HashMap by path.
520 // We build a sorted Vec<Match>, eliminating duplicate search matches.
521 // Search matches with the same paths should have equal `ProjectPanelOrdMatch`, so we should
522 // not have any duplicates after building the final list.
523 for new_match in new_history_matches
524 .into_values()
525 .chain(new_search_matches.into_iter())
526 {
527 match self.position(&new_match, currently_opened) {
528 Ok(_duplicate) => continue,
529 Err(i) => {
530 self.matches.insert(i, new_match);
531 if self.matches.len() == 100 {
532 break;
533 }
534 }
535 }
536 }
537 }
538
539 /// If a < b, then a is a worse match, aligning with the `ProjectPanelOrdMatch` ordering.
540 fn cmp_matches(
541 separate_history: bool,
542 currently_opened: Option<&FoundPath>,
543 a: &Match,
544 b: &Match,
545 ) -> cmp::Ordering {
546 debug_assert!(a.panel_match().is_some() && b.panel_match().is_some());
547
548 match (&a, &b) {
549 // bubble currently opened files to the top
550 (Match::History { path, .. }, _) if Some(path) == currently_opened => {
551 return cmp::Ordering::Greater;
552 }
553 (_, Match::History { path, .. }) if Some(path) == currently_opened => {
554 return cmp::Ordering::Less;
555 }
556
557 _ => {}
558 }
559
560 if separate_history {
561 match (a, b) {
562 (Match::History { .. }, Match::Search(_)) => return cmp::Ordering::Greater,
563 (Match::Search(_), Match::History { .. }) => return cmp::Ordering::Less,
564
565 _ => {}
566 }
567 }
568
569 let a_panel_match = match a.panel_match() {
570 Some(pm) => pm,
571 None => {
572 return if b.panel_match().is_some() {
573 cmp::Ordering::Less
574 } else {
575 cmp::Ordering::Equal
576 };
577 }
578 };
579
580 let b_panel_match = match b.panel_match() {
581 Some(pm) => pm,
582 None => return cmp::Ordering::Greater,
583 };
584
585 let a_in_filename = Self::is_filename_match(a_panel_match);
586 let b_in_filename = Self::is_filename_match(b_panel_match);
587
588 match (a_in_filename, b_in_filename) {
589 (true, false) => return cmp::Ordering::Greater,
590 (false, true) => return cmp::Ordering::Less,
591 _ => {} // Both are filename matches or both are path matches
592 }
593
594 a_panel_match.cmp(b_panel_match)
595 }
596
597 /// Determines if the match occurred within the filename rather than in the path
598 fn is_filename_match(panel_match: &ProjectPanelOrdMatch) -> bool {
599 if panel_match.0.positions.is_empty() {
600 return false;
601 }
602
603 if let Some(filename) = panel_match.0.path.file_name() {
604 let path_str = panel_match.0.path.to_string_lossy();
605 let filename_str = filename.to_string_lossy();
606
607 if let Some(filename_pos) = path_str.rfind(&*filename_str) {
608 if panel_match.0.positions[0] >= filename_pos {
609 let mut prev_position = panel_match.0.positions[0];
610 for p in &panel_match.0.positions[1..] {
611 if *p != prev_position + 1 {
612 return false;
613 }
614 prev_position = *p;
615 }
616 return true;
617 }
618 }
619 }
620
621 false
622 }
623}
624
625fn matching_history_items<'a>(
626 history_items: impl IntoIterator<Item = &'a FoundPath>,
627 currently_opened: Option<&'a FoundPath>,
628 query: &FileSearchQuery,
629) -> HashMap<Arc<Path>, Match> {
630 let mut candidates_paths = HashMap::default();
631
632 let history_items_by_worktrees = history_items
633 .into_iter()
634 .chain(currently_opened)
635 .filter_map(|found_path| {
636 let candidate = PathMatchCandidate {
637 is_dir: false, // You can't open directories as project items
638 path: &found_path.project.path,
639 // Only match history items names, otherwise their paths may match too many queries, producing false positives.
640 // E.g. `foo` would match both `something/foo/bar.rs` and `something/foo/foo.rs` and if the former is a history item,
641 // it would be shown first always, despite the latter being a better match.
642 char_bag: CharBag::from_iter(
643 found_path
644 .project
645 .path
646 .file_name()?
647 .to_string_lossy()
648 .to_lowercase()
649 .chars(),
650 ),
651 };
652 candidates_paths.insert(&found_path.project, found_path);
653 Some((found_path.project.worktree_id, candidate))
654 })
655 .fold(
656 HashMap::default(),
657 |mut candidates, (worktree_id, new_candidate)| {
658 candidates
659 .entry(worktree_id)
660 .or_insert_with(Vec::new)
661 .push(new_candidate);
662 candidates
663 },
664 );
665 let mut matching_history_paths = HashMap::default();
666 for (worktree, candidates) in history_items_by_worktrees {
667 let max_results = candidates.len() + 1;
668 matching_history_paths.extend(
669 fuzzy::match_fixed_path_set(
670 candidates,
671 worktree.to_usize(),
672 query.path_query(),
673 false,
674 max_results,
675 )
676 .into_iter()
677 .filter_map(|path_match| {
678 candidates_paths
679 .remove_entry(&ProjectPath {
680 worktree_id: WorktreeId::from_usize(path_match.worktree_id),
681 path: Arc::clone(&path_match.path),
682 })
683 .map(|(_, found_path)| {
684 (
685 Arc::clone(&path_match.path),
686 Match::History {
687 path: found_path.clone(),
688 panel_match: Some(ProjectPanelOrdMatch(path_match)),
689 },
690 )
691 })
692 }),
693 );
694 }
695 matching_history_paths
696}
697
698#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
699struct FoundPath {
700 project: ProjectPath,
701 absolute: Option<PathBuf>,
702}
703
704impl FoundPath {
705 fn new(project: ProjectPath, absolute: Option<PathBuf>) -> Self {
706 Self { project, absolute }
707 }
708}
709
710const MAX_RECENT_SELECTIONS: usize = 20;
711
712pub enum Event {
713 Selected(ProjectPath),
714 Dismissed,
715}
716
717#[derive(Debug, Clone)]
718struct FileSearchQuery {
719 raw_query: String,
720 file_query_end: Option<usize>,
721 path_position: PathWithPosition,
722}
723
724impl FileSearchQuery {
725 fn path_query(&self) -> &str {
726 match self.file_query_end {
727 Some(file_path_end) => &self.raw_query[..file_path_end],
728 None => &self.raw_query,
729 }
730 }
731}
732
733impl FileFinderDelegate {
734 fn new(
735 file_finder: WeakEntity<FileFinder>,
736 workspace: WeakEntity<Workspace>,
737 project: Entity<Project>,
738 currently_opened_path: Option<FoundPath>,
739 history_items: Vec<FoundPath>,
740 separate_history: bool,
741 window: &mut Window,
742 cx: &mut Context<FileFinder>,
743 ) -> Self {
744 Self::subscribe_to_updates(&project, window, cx);
745 Self {
746 file_finder,
747 workspace,
748 project,
749 search_count: 0,
750 latest_search_id: 0,
751 latest_search_did_cancel: false,
752 latest_search_query: None,
753 currently_opened_path,
754 matches: Matches::default(),
755 has_changed_selected_index: false,
756 selected_index: 0,
757 cancel_flag: Arc::new(AtomicBool::new(false)),
758 history_items,
759 separate_history,
760 first_update: true,
761 popover_menu_handle: PopoverMenuHandle::default(),
762 focus_handle: cx.focus_handle(),
763 include_ignored: FileFinderSettings::get_global(cx).include_ignored,
764 include_ignored_refresh: Task::ready(()),
765 }
766 }
767
768 fn subscribe_to_updates(
769 project: &Entity<Project>,
770 window: &mut Window,
771 cx: &mut Context<FileFinder>,
772 ) {
773 cx.subscribe_in(project, window, |file_finder, _, event, window, cx| {
774 match event {
775 project::Event::WorktreeUpdatedEntries(_, _)
776 | project::Event::WorktreeAdded(_)
777 | project::Event::WorktreeRemoved(_) => file_finder
778 .picker
779 .update(cx, |picker, cx| picker.refresh(window, cx)),
780 _ => {}
781 };
782 })
783 .detach();
784 }
785
786 fn spawn_search(
787 &mut self,
788 query: FileSearchQuery,
789 window: &mut Window,
790 cx: &mut Context<Picker<Self>>,
791 ) -> Task<()> {
792 let relative_to = self
793 .currently_opened_path
794 .as_ref()
795 .map(|found_path| Arc::clone(&found_path.project.path));
796 let worktrees = self
797 .project
798 .read(cx)
799 .visible_worktrees(cx)
800 .collect::<Vec<_>>();
801 let include_root_name = worktrees.len() > 1;
802 let candidate_sets = worktrees
803 .into_iter()
804 .map(|worktree| {
805 let worktree = worktree.read(cx);
806 PathMatchCandidateSet {
807 snapshot: worktree.snapshot(),
808 include_ignored: self.include_ignored.unwrap_or_else(|| {
809 worktree
810 .root_entry()
811 .map_or(false, |entry| entry.is_ignored)
812 }),
813 include_root_name,
814 candidates: project::Candidates::Files,
815 }
816 })
817 .collect::<Vec<_>>();
818
819 let search_id = util::post_inc(&mut self.search_count);
820 self.cancel_flag.store(true, atomic::Ordering::Relaxed);
821 self.cancel_flag = Arc::new(AtomicBool::new(false));
822 let cancel_flag = self.cancel_flag.clone();
823 cx.spawn_in(window, async move |picker, cx| {
824 let matches = fuzzy::match_path_sets(
825 candidate_sets.as_slice(),
826 query.path_query(),
827 relative_to,
828 false,
829 100,
830 &cancel_flag,
831 cx.background_executor().clone(),
832 )
833 .await
834 .into_iter()
835 .map(ProjectPanelOrdMatch);
836 let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed);
837 picker
838 .update(cx, |picker, cx| {
839 picker
840 .delegate
841 .set_search_matches(search_id, did_cancel, query, matches, cx)
842 })
843 .log_err();
844 })
845 }
846
847 fn set_search_matches(
848 &mut self,
849 search_id: usize,
850 did_cancel: bool,
851 query: FileSearchQuery,
852 matches: impl IntoIterator<Item = ProjectPanelOrdMatch>,
853 cx: &mut Context<Picker<Self>>,
854 ) {
855 if search_id >= self.latest_search_id {
856 self.latest_search_id = search_id;
857 let query_changed = Some(query.path_query())
858 != self
859 .latest_search_query
860 .as_ref()
861 .map(|query| query.path_query());
862 let extend_old_matches = self.latest_search_did_cancel && !query_changed;
863
864 let selected_match = if query_changed {
865 None
866 } else {
867 self.matches.get(self.selected_index).cloned()
868 };
869
870 self.matches.push_new_matches(
871 &self.history_items,
872 self.currently_opened_path.as_ref(),
873 Some(&query),
874 matches.into_iter(),
875 extend_old_matches,
876 );
877
878 self.selected_index = selected_match.map_or_else(
879 || self.calculate_selected_index(cx),
880 |m| {
881 self.matches
882 .position(&m, self.currently_opened_path.as_ref())
883 .unwrap_or(0)
884 },
885 );
886
887 self.latest_search_query = Some(query);
888 self.latest_search_did_cancel = did_cancel;
889
890 cx.notify();
891 }
892 }
893
894 fn labels_for_match(
895 &self,
896 path_match: &Match,
897 window: &mut Window,
898 cx: &App,
899 ix: usize,
900 ) -> (HighlightedLabel, HighlightedLabel) {
901 let (file_name, file_name_positions, mut full_path, mut full_path_positions) =
902 match &path_match {
903 Match::History {
904 path: entry_path,
905 panel_match,
906 } => {
907 let worktree_id = entry_path.project.worktree_id;
908 let project_relative_path = &entry_path.project.path;
909 let has_worktree = self
910 .project
911 .read(cx)
912 .worktree_for_id(worktree_id, cx)
913 .is_some();
914
915 if let Some(absolute_path) =
916 entry_path.absolute.as_ref().filter(|_| !has_worktree)
917 {
918 (
919 absolute_path
920 .file_name()
921 .map_or_else(
922 || project_relative_path.to_string_lossy(),
923 |file_name| file_name.to_string_lossy(),
924 )
925 .to_string(),
926 Vec::new(),
927 absolute_path.to_string_lossy().to_string(),
928 Vec::new(),
929 )
930 } else {
931 let mut path = Arc::clone(project_relative_path);
932 if project_relative_path.as_ref() == Path::new("") {
933 if let Some(absolute_path) = &entry_path.absolute {
934 path = Arc::from(absolute_path.as_path());
935 }
936 }
937
938 let mut path_match = PathMatch {
939 score: ix as f64,
940 positions: Vec::new(),
941 worktree_id: worktree_id.to_usize(),
942 path,
943 is_dir: false, // File finder doesn't support directories
944 path_prefix: "".into(),
945 distance_to_relative_ancestor: usize::MAX,
946 };
947 if let Some(found_path_match) = &panel_match {
948 path_match
949 .positions
950 .extend(found_path_match.0.positions.iter())
951 }
952
953 self.labels_for_path_match(&path_match)
954 }
955 }
956 Match::Search(path_match) => self.labels_for_path_match(&path_match.0),
957 };
958
959 if file_name_positions.is_empty() {
960 if let Some(user_home_path) = std::env::var("HOME").ok() {
961 let user_home_path = user_home_path.trim();
962 if !user_home_path.is_empty() {
963 if (&full_path).starts_with(user_home_path) {
964 full_path.replace_range(0..user_home_path.len(), "~");
965 full_path_positions.retain_mut(|pos| {
966 if *pos >= user_home_path.len() {
967 *pos -= user_home_path.len();
968 *pos += 1;
969 true
970 } else {
971 false
972 }
973 })
974 }
975 }
976 }
977 }
978
979 if full_path.is_ascii() {
980 let file_finder_settings = FileFinderSettings::get_global(cx);
981 let max_width =
982 FileFinder::modal_max_width(file_finder_settings.modal_max_width, window);
983 let (normal_em, small_em) = {
984 let style = window.text_style();
985 let font_id = window.text_system().resolve_font(&style.font());
986 let font_size = TextSize::Default.rems(cx).to_pixels(window.rem_size());
987 let normal = cx
988 .text_system()
989 .em_width(font_id, font_size)
990 .unwrap_or(px(16.));
991 let font_size = TextSize::Small.rems(cx).to_pixels(window.rem_size());
992 let small = cx
993 .text_system()
994 .em_width(font_id, font_size)
995 .unwrap_or(px(10.));
996 (normal, small)
997 };
998 let budget = full_path_budget(&file_name, normal_em, small_em, max_width);
999 // If the computed budget is zero, we certainly won't be able to achieve it,
1000 // so no point trying to elide the path.
1001 if budget > 0 && full_path.len() > budget {
1002 let components = PathComponentSlice::new(&full_path);
1003 if let Some(elided_range) =
1004 components.elision_range(budget - 1, &full_path_positions)
1005 {
1006 let elided_len = elided_range.end - elided_range.start;
1007 let placeholder = "…";
1008 full_path_positions.retain_mut(|mat| {
1009 if *mat >= elided_range.end {
1010 *mat -= elided_len;
1011 *mat += placeholder.len();
1012 } else if *mat >= elided_range.start {
1013 return false;
1014 }
1015 true
1016 });
1017 full_path.replace_range(elided_range, placeholder);
1018 }
1019 }
1020 }
1021
1022 (
1023 HighlightedLabel::new(file_name, file_name_positions),
1024 HighlightedLabel::new(full_path, full_path_positions)
1025 .size(LabelSize::Small)
1026 .color(Color::Muted),
1027 )
1028 }
1029
1030 fn labels_for_path_match(
1031 &self,
1032 path_match: &PathMatch,
1033 ) -> (String, Vec<usize>, String, Vec<usize>) {
1034 let path = &path_match.path;
1035 let path_string = path.to_string_lossy();
1036 let full_path = [path_match.path_prefix.as_ref(), path_string.as_ref()].join("");
1037 let mut path_positions = path_match.positions.clone();
1038
1039 let file_name = path.file_name().map_or_else(
1040 || path_match.path_prefix.to_string(),
1041 |file_name| file_name.to_string_lossy().to_string(),
1042 );
1043 let file_name_start = path_match.path_prefix.len() + path_string.len() - file_name.len();
1044 let file_name_positions = path_positions
1045 .iter()
1046 .filter_map(|pos| {
1047 if pos >= &file_name_start {
1048 Some(pos - file_name_start)
1049 } else {
1050 None
1051 }
1052 })
1053 .collect();
1054
1055 let full_path = full_path.trim_end_matches(&file_name).to_string();
1056 path_positions.retain(|idx| *idx < full_path.len());
1057
1058 (file_name, file_name_positions, full_path, path_positions)
1059 }
1060
1061 fn lookup_absolute_path(
1062 &self,
1063 query: FileSearchQuery,
1064 window: &mut Window,
1065 cx: &mut Context<Picker<Self>>,
1066 ) -> Task<()> {
1067 cx.spawn_in(window, async move |picker, cx| {
1068 let Some(project) = picker
1069 .read_with(cx, |picker, _| picker.delegate.project.clone())
1070 .log_err()
1071 else {
1072 return;
1073 };
1074
1075 let query_path = Path::new(query.path_query());
1076 let mut path_matches = Vec::new();
1077
1078 let abs_file_exists = if let Ok(task) = project.update(cx, |this, cx| {
1079 this.resolve_abs_file_path(query.path_query(), cx)
1080 }) {
1081 task.await.is_some()
1082 } else {
1083 false
1084 };
1085
1086 if abs_file_exists {
1087 let update_result = project
1088 .update(cx, |project, cx| {
1089 if let Some((worktree, relative_path)) =
1090 project.find_worktree(query_path, cx)
1091 {
1092 path_matches.push(ProjectPanelOrdMatch(PathMatch {
1093 score: 1.0,
1094 positions: Vec::new(),
1095 worktree_id: worktree.read(cx).id().to_usize(),
1096 path: Arc::from(relative_path),
1097 path_prefix: "".into(),
1098 is_dir: false, // File finder doesn't support directories
1099 distance_to_relative_ancestor: usize::MAX,
1100 }));
1101 }
1102 })
1103 .log_err();
1104 if update_result.is_none() {
1105 return;
1106 }
1107 }
1108
1109 picker
1110 .update_in(cx, |picker, _, cx| {
1111 let picker_delegate = &mut picker.delegate;
1112 let search_id = util::post_inc(&mut picker_delegate.search_count);
1113 picker_delegate.set_search_matches(search_id, false, query, path_matches, cx);
1114
1115 anyhow::Ok(())
1116 })
1117 .log_err();
1118 })
1119 }
1120
1121 /// Skips first history match (that is displayed topmost) if it's currently opened.
1122 fn calculate_selected_index(&self, cx: &mut Context<Picker<Self>>) -> usize {
1123 if FileFinderSettings::get_global(cx).skip_focus_for_active_in_search {
1124 if let Some(Match::History { path, .. }) = self.matches.get(0) {
1125 if Some(path) == self.currently_opened_path.as_ref() {
1126 let elements_after_first = self.matches.len() - 1;
1127 if elements_after_first > 0 {
1128 return 1;
1129 }
1130 }
1131 }
1132 }
1133
1134 0
1135 }
1136
1137 fn key_context(&self, window: &Window, cx: &App) -> KeyContext {
1138 let mut key_context = KeyContext::new_with_defaults();
1139 key_context.add("FileFinder");
1140 if self.popover_menu_handle.is_focused(window, cx) {
1141 key_context.add("menu_open");
1142 }
1143 key_context
1144 }
1145}
1146
1147fn full_path_budget(
1148 file_name: &str,
1149 normal_em: Pixels,
1150 small_em: Pixels,
1151 max_width: Pixels,
1152) -> usize {
1153 (((max_width / 0.8) - file_name.len() * normal_em) / small_em) as usize
1154}
1155
1156impl PickerDelegate for FileFinderDelegate {
1157 type ListItem = ListItem;
1158
1159 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
1160 "Search project files...".into()
1161 }
1162
1163 fn match_count(&self) -> usize {
1164 self.matches.len()
1165 }
1166
1167 fn selected_index(&self) -> usize {
1168 self.selected_index
1169 }
1170
1171 fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
1172 self.has_changed_selected_index = true;
1173 self.selected_index = ix;
1174 cx.notify();
1175 }
1176
1177 fn separators_after_indices(&self) -> Vec<usize> {
1178 if self.separate_history {
1179 let first_non_history_index = self
1180 .matches
1181 .matches
1182 .iter()
1183 .enumerate()
1184 .find(|(_, m)| !matches!(m, Match::History { .. }))
1185 .map(|(i, _)| i);
1186 if let Some(first_non_history_index) = first_non_history_index {
1187 if first_non_history_index > 0 {
1188 return vec![first_non_history_index - 1];
1189 }
1190 }
1191 }
1192 Vec::new()
1193 }
1194
1195 fn update_matches(
1196 &mut self,
1197 raw_query: String,
1198 window: &mut Window,
1199 cx: &mut Context<Picker<Self>>,
1200 ) -> Task<()> {
1201 let raw_query = raw_query.replace(' ', "");
1202 let raw_query = raw_query.trim();
1203
1204 let raw_query = match &raw_query.get(0..2) {
1205 Some(".\\") | Some("./") => &raw_query[2..],
1206 Some("a\\") | Some("a/") => {
1207 if self
1208 .workspace
1209 .upgrade()
1210 .into_iter()
1211 .flat_map(|workspace| workspace.read(cx).worktrees(cx))
1212 .all(|worktree| {
1213 worktree
1214 .read(cx)
1215 .entry_for_path(Path::new("a"))
1216 .is_none_or(|entry| !entry.is_dir())
1217 })
1218 {
1219 &raw_query[2..]
1220 } else {
1221 raw_query
1222 }
1223 }
1224 Some("b\\") | Some("b/") => {
1225 if self
1226 .workspace
1227 .upgrade()
1228 .into_iter()
1229 .flat_map(|workspace| workspace.read(cx).worktrees(cx))
1230 .all(|worktree| {
1231 worktree
1232 .read(cx)
1233 .entry_for_path(Path::new("b"))
1234 .is_none_or(|entry| !entry.is_dir())
1235 })
1236 {
1237 &raw_query[2..]
1238 } else {
1239 raw_query
1240 }
1241 }
1242 _ => raw_query,
1243 };
1244
1245 if raw_query.is_empty() {
1246 // if there was no query before, and we already have some (history) matches
1247 // there's no need to update anything, since nothing has changed.
1248 // We also want to populate matches set from history entries on the first update.
1249 if self.latest_search_query.is_some() || self.first_update {
1250 let project = self.project.read(cx);
1251
1252 self.latest_search_id = post_inc(&mut self.search_count);
1253 self.latest_search_query = None;
1254 self.matches = Matches {
1255 separate_history: self.separate_history,
1256 ..Matches::default()
1257 };
1258 self.matches.push_new_matches(
1259 self.history_items.iter().filter(|history_item| {
1260 project
1261 .worktree_for_id(history_item.project.worktree_id, cx)
1262 .is_some()
1263 || ((project.is_local() || project.is_via_ssh())
1264 && history_item.absolute.is_some())
1265 }),
1266 self.currently_opened_path.as_ref(),
1267 None,
1268 None.into_iter(),
1269 false,
1270 );
1271
1272 self.first_update = false;
1273 self.selected_index = 0;
1274 }
1275 cx.notify();
1276 Task::ready(())
1277 } else {
1278 let path_position = PathWithPosition::parse_str(&raw_query);
1279
1280 let query = FileSearchQuery {
1281 raw_query: raw_query.trim().to_owned(),
1282 file_query_end: if path_position.path.to_str().unwrap_or(raw_query) == raw_query {
1283 None
1284 } else {
1285 // Safe to unwrap as we won't get here when the unwrap in if fails
1286 Some(path_position.path.to_str().unwrap().len())
1287 },
1288 path_position,
1289 };
1290
1291 if Path::new(query.path_query()).is_absolute() {
1292 self.lookup_absolute_path(query, window, cx)
1293 } else {
1294 self.spawn_search(query, window, cx)
1295 }
1296 }
1297 }
1298
1299 fn confirm(
1300 &mut self,
1301 secondary: bool,
1302 window: &mut Window,
1303 cx: &mut Context<Picker<FileFinderDelegate>>,
1304 ) {
1305 if let Some(m) = self.matches.get(self.selected_index()) {
1306 if let Some(workspace) = self.workspace.upgrade() {
1307 let open_task = workspace.update(cx, |workspace, cx| {
1308 let split_or_open =
1309 |workspace: &mut Workspace,
1310 project_path,
1311 window: &mut Window,
1312 cx: &mut Context<Workspace>| {
1313 let allow_preview =
1314 PreviewTabsSettings::get_global(cx).enable_preview_from_file_finder;
1315 if secondary {
1316 workspace.split_path_preview(
1317 project_path,
1318 allow_preview,
1319 None,
1320 window,
1321 cx,
1322 )
1323 } else {
1324 workspace.open_path_preview(
1325 project_path,
1326 None,
1327 true,
1328 allow_preview,
1329 true,
1330 window,
1331 cx,
1332 )
1333 }
1334 };
1335 match &m {
1336 Match::History { path, .. } => {
1337 let worktree_id = path.project.worktree_id;
1338 if workspace
1339 .project()
1340 .read(cx)
1341 .worktree_for_id(worktree_id, cx)
1342 .is_some()
1343 {
1344 split_or_open(
1345 workspace,
1346 ProjectPath {
1347 worktree_id,
1348 path: Arc::clone(&path.project.path),
1349 },
1350 window,
1351 cx,
1352 )
1353 } else {
1354 match path.absolute.as_ref() {
1355 Some(abs_path) => {
1356 if secondary {
1357 workspace.split_abs_path(
1358 abs_path.to_path_buf(),
1359 false,
1360 window,
1361 cx,
1362 )
1363 } else {
1364 workspace.open_abs_path(
1365 abs_path.to_path_buf(),
1366 OpenOptions {
1367 visible: Some(OpenVisible::None),
1368 ..Default::default()
1369 },
1370 window,
1371 cx,
1372 )
1373 }
1374 }
1375 None => split_or_open(
1376 workspace,
1377 ProjectPath {
1378 worktree_id,
1379 path: Arc::clone(&path.project.path),
1380 },
1381 window,
1382 cx,
1383 ),
1384 }
1385 }
1386 }
1387 Match::Search(m) => split_or_open(
1388 workspace,
1389 ProjectPath {
1390 worktree_id: WorktreeId::from_usize(m.0.worktree_id),
1391 path: m.0.path.clone(),
1392 },
1393 window,
1394 cx,
1395 ),
1396 }
1397 });
1398
1399 let row = self
1400 .latest_search_query
1401 .as_ref()
1402 .and_then(|query| query.path_position.row)
1403 .map(|row| row.saturating_sub(1));
1404 let col = self
1405 .latest_search_query
1406 .as_ref()
1407 .and_then(|query| query.path_position.column)
1408 .unwrap_or(0)
1409 .saturating_sub(1);
1410 let finder = self.file_finder.clone();
1411
1412 cx.spawn_in(window, async move |_, cx| {
1413 let item = open_task.await.notify_async_err(cx)?;
1414 if let Some(row) = row {
1415 if let Some(active_editor) = item.downcast::<Editor>() {
1416 active_editor
1417 .downgrade()
1418 .update_in(cx, |editor, window, cx| {
1419 editor.go_to_singleton_buffer_point(
1420 Point::new(row, col),
1421 window,
1422 cx,
1423 );
1424 })
1425 .log_err();
1426 }
1427 }
1428 finder.update(cx, |_, cx| cx.emit(DismissEvent)).ok()?;
1429
1430 Some(())
1431 })
1432 .detach();
1433 }
1434 }
1435 }
1436
1437 fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<FileFinderDelegate>>) {
1438 self.file_finder
1439 .update(cx, |_, cx| cx.emit(DismissEvent))
1440 .log_err();
1441 }
1442
1443 fn render_match(
1444 &self,
1445 ix: usize,
1446 selected: bool,
1447 window: &mut Window,
1448 cx: &mut Context<Picker<Self>>,
1449 ) -> Option<Self::ListItem> {
1450 let settings = FileFinderSettings::get_global(cx);
1451
1452 let path_match = self
1453 .matches
1454 .get(ix)
1455 .expect("Invalid matches state: no element for index {ix}");
1456
1457 let history_icon = match &path_match {
1458 Match::History { .. } => Icon::new(IconName::HistoryRerun)
1459 .color(Color::Muted)
1460 .size(IconSize::Small)
1461 .into_any_element(),
1462 Match::Search(_) => v_flex()
1463 .flex_none()
1464 .size(IconSize::Small.rems())
1465 .into_any_element(),
1466 };
1467 let (file_name_label, full_path_label) = self.labels_for_match(path_match, window, cx, ix);
1468
1469 let file_icon = maybe!({
1470 if !settings.file_icons {
1471 return None;
1472 }
1473 let file_name = path_match.path().file_name()?;
1474 let icon = FileIcons::get_icon(file_name.as_ref(), cx)?;
1475 Some(Icon::from_path(icon).color(Color::Muted))
1476 });
1477
1478 Some(
1479 ListItem::new(ix)
1480 .spacing(ListItemSpacing::Sparse)
1481 .start_slot::<Icon>(file_icon)
1482 .end_slot::<AnyElement>(history_icon)
1483 .inset(true)
1484 .toggle_state(selected)
1485 .child(
1486 h_flex()
1487 .gap_2()
1488 .py_px()
1489 .child(file_name_label)
1490 .child(full_path_label),
1491 ),
1492 )
1493 }
1494
1495 fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
1496 let context = self.focus_handle.clone();
1497 Some(
1498 h_flex()
1499 .w_full()
1500 .p_2()
1501 .justify_between()
1502 .border_t_1()
1503 .border_color(cx.theme().colors().border_variant)
1504 .child(
1505 IconButton::new("toggle-ignored", IconName::Sliders)
1506 .on_click({
1507 let focus_handle = self.focus_handle.clone();
1508 move |_, window, cx| {
1509 focus_handle.dispatch_action(&ToggleIncludeIgnored, window, cx);
1510 }
1511 })
1512 .style(ButtonStyle::Subtle)
1513 .shape(IconButtonShape::Square)
1514 .toggle_state(self.include_ignored.unwrap_or(false))
1515 .tooltip({
1516 let focus_handle = self.focus_handle.clone();
1517 move |window, cx| {
1518 Tooltip::for_action_in(
1519 "Use ignored files",
1520 &ToggleIncludeIgnored,
1521 &focus_handle,
1522 window,
1523 cx,
1524 )
1525 }
1526 }),
1527 )
1528 .child(
1529 h_flex()
1530 .gap_2()
1531 .child(
1532 Button::new("open-selection", "Open").on_click(|_, window, cx| {
1533 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1534 }),
1535 )
1536 .child(
1537 PopoverMenu::new("menu-popover")
1538 .with_handle(self.popover_menu_handle.clone())
1539 .attach(gpui::Corner::TopRight)
1540 .anchor(gpui::Corner::BottomRight)
1541 .trigger(
1542 Button::new("actions-trigger", "Split…")
1543 .selected_label_color(Color::Accent),
1544 )
1545 .menu({
1546 move |window, cx| {
1547 Some(ContextMenu::build(window, cx, {
1548 let context = context.clone();
1549 move |menu, _, _| {
1550 menu.context(context)
1551 .action(
1552 "Split Left",
1553 pane::SplitLeft.boxed_clone(),
1554 )
1555 .action(
1556 "Split Right",
1557 pane::SplitRight.boxed_clone(),
1558 )
1559 .action("Split Up", pane::SplitUp.boxed_clone())
1560 .action(
1561 "Split Down",
1562 pane::SplitDown.boxed_clone(),
1563 )
1564 }
1565 }))
1566 }
1567 }),
1568 ),
1569 )
1570 .into_any(),
1571 )
1572 }
1573}
1574
1575#[derive(Clone, Debug, PartialEq, Eq)]
1576struct PathComponentSlice<'a> {
1577 path: Cow<'a, Path>,
1578 path_str: Cow<'a, str>,
1579 component_ranges: Vec<(Component<'a>, Range<usize>)>,
1580}
1581
1582impl<'a> PathComponentSlice<'a> {
1583 fn new(path: &'a str) -> Self {
1584 let trimmed_path = Path::new(path).components().as_path().as_os_str();
1585 let mut component_ranges = Vec::new();
1586 let mut components = Path::new(trimmed_path).components();
1587 let len = trimmed_path.as_encoded_bytes().len();
1588 let mut pos = 0;
1589 while let Some(component) = components.next() {
1590 component_ranges.push((component, pos..0));
1591 pos = len - components.as_path().as_os_str().as_encoded_bytes().len();
1592 }
1593 for ((_, range), ancestor) in component_ranges
1594 .iter_mut()
1595 .rev()
1596 .zip(Path::new(trimmed_path).ancestors())
1597 {
1598 range.end = ancestor.as_os_str().as_encoded_bytes().len();
1599 }
1600 Self {
1601 path: Cow::Borrowed(Path::new(path)),
1602 path_str: Cow::Borrowed(path),
1603 component_ranges,
1604 }
1605 }
1606
1607 fn elision_range(&self, budget: usize, matches: &[usize]) -> Option<Range<usize>> {
1608 let eligible_range = {
1609 assert!(matches.windows(2).all(|w| w[0] <= w[1]));
1610 let mut matches = matches.iter().copied().peekable();
1611 let mut longest: Option<Range<usize>> = None;
1612 let mut cur = 0..0;
1613 let mut seen_normal = false;
1614 for (i, (component, range)) in self.component_ranges.iter().enumerate() {
1615 let is_normal = matches!(component, Component::Normal(_));
1616 let is_first_normal = is_normal && !seen_normal;
1617 seen_normal |= is_normal;
1618 let is_last = i == self.component_ranges.len() - 1;
1619 let contains_match = matches.peek().is_some_and(|mat| range.contains(mat));
1620 if contains_match {
1621 matches.next();
1622 }
1623 if is_first_normal || is_last || !is_normal || contains_match {
1624 if longest
1625 .as_ref()
1626 .is_none_or(|old| old.end - old.start <= cur.end - cur.start)
1627 {
1628 longest = Some(cur);
1629 }
1630 cur = i + 1..i + 1;
1631 } else {
1632 cur.end = i + 1;
1633 }
1634 }
1635 if longest
1636 .as_ref()
1637 .is_none_or(|old| old.end - old.start <= cur.end - cur.start)
1638 {
1639 longest = Some(cur);
1640 }
1641 longest
1642 };
1643
1644 let eligible_range = eligible_range?;
1645 assert!(eligible_range.start <= eligible_range.end);
1646 if eligible_range.is_empty() {
1647 return None;
1648 }
1649
1650 let elided_range: Range<usize> = {
1651 let byte_range = self.component_ranges[eligible_range.start].1.start
1652 ..self.component_ranges[eligible_range.end - 1].1.end;
1653 let midpoint = self.path_str.len() / 2;
1654 let distance_from_start = byte_range.start.abs_diff(midpoint);
1655 let distance_from_end = byte_range.end.abs_diff(midpoint);
1656 let pick_from_end = distance_from_start > distance_from_end;
1657 let mut len_with_elision = self.path_str.len();
1658 let mut i = eligible_range.start;
1659 while i < eligible_range.end {
1660 let x = if pick_from_end {
1661 eligible_range.end - i + eligible_range.start - 1
1662 } else {
1663 i
1664 };
1665 len_with_elision -= self.component_ranges[x]
1666 .0
1667 .as_os_str()
1668 .as_encoded_bytes()
1669 .len()
1670 + 1;
1671 if len_with_elision <= budget {
1672 break;
1673 }
1674 i += 1;
1675 }
1676 if len_with_elision > budget {
1677 return None;
1678 } else if pick_from_end {
1679 let x = eligible_range.end - i + eligible_range.start - 1;
1680 x..eligible_range.end
1681 } else {
1682 let x = i;
1683 eligible_range.start..x + 1
1684 }
1685 };
1686
1687 let byte_range = self.component_ranges[elided_range.start].1.start
1688 ..self.component_ranges[elided_range.end - 1].1.end;
1689 Some(byte_range)
1690 }
1691}