diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 74ff2e542f64ac..984cf953f66cde 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -24,8 +24,10 @@ use picker::{Picker, PickerDelegate}; use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId}; use settings::Settings; use std::{ + borrow::Cow, cmp, - path::{Path, PathBuf}, + ops::Range, + path::{Component, Path, PathBuf}, sync::{ atomic::{self, AtomicBool}, Arc, @@ -36,7 +38,7 @@ use ui::{ prelude::*, ContextMenu, HighlightedLabel, ListItem, ListItemSpacing, PopoverMenu, PopoverMenuHandle, }; -use util::{paths::PathWithPosition, post_inc, ResultExt}; +use util::{maybe, paths::PathWithPosition, post_inc, ResultExt}; use workspace::{ item::PreviewTabsSettings, notifications::NotifyResultExt, pane, ModalView, SplitDirection, Workspace, @@ -805,25 +807,28 @@ impl FileFinderDelegate { fn labels_for_match( &self, path_match: &Match, + window: &mut Window, cx: &App, ix: usize, - ) -> (String, Vec, String, Vec) { - let (file_name, file_name_positions, full_path, full_path_positions) = match &path_match { - Match::History { - path: entry_path, - panel_match, - } => { - let worktree_id = entry_path.project.worktree_id; - let project_relative_path = &entry_path.project.path; - let has_worktree = self - .project - .read(cx) - .worktree_for_id(worktree_id, cx) - .is_some(); - - if !has_worktree { - if let Some(absolute_path) = &entry_path.absolute { - return ( + ) -> (HighlightedLabel, HighlightedLabel) { + let (file_name, file_name_positions, mut full_path, mut full_path_positions) = + match &path_match { + Match::History { + path: entry_path, + panel_match, + } => { + let worktree_id = entry_path.project.worktree_id; + let project_relative_path = &entry_path.project.path; + let has_worktree = self + .project + .read(cx) + .worktree_for_id(worktree_id, cx) + .is_some(); + + if let Some(absolute_path) = + entry_path.absolute.as_ref().filter(|_| !has_worktree) + { + ( absolute_path .file_name() .map_or_else( @@ -834,58 +839,102 @@ impl FileFinderDelegate { Vec::new(), absolute_path.to_string_lossy().to_string(), Vec::new(), - ); - } - } + ) + } else { + let mut path = Arc::clone(project_relative_path); + if project_relative_path.as_ref() == Path::new("") { + if let Some(absolute_path) = &entry_path.absolute { + path = Arc::from(absolute_path.as_path()); + } + } - let mut path = Arc::clone(project_relative_path); - if project_relative_path.as_ref() == Path::new("") { - if let Some(absolute_path) = &entry_path.absolute { - path = Arc::from(absolute_path.as_path()); - } - } + let mut path_match = PathMatch { + score: ix as f64, + positions: Vec::new(), + worktree_id: worktree_id.to_usize(), + path, + is_dir: false, // File finder doesn't support directories + path_prefix: "".into(), + distance_to_relative_ancestor: usize::MAX, + }; + if let Some(found_path_match) = &panel_match { + path_match + .positions + .extend(found_path_match.0.positions.iter()) + } - let mut path_match = PathMatch { - score: ix as f64, - positions: Vec::new(), - worktree_id: worktree_id.to_usize(), - path, - is_dir: false, // File finder doesn't support directories - path_prefix: "".into(), - distance_to_relative_ancestor: usize::MAX, - }; - if let Some(found_path_match) = &panel_match { - path_match - .positions - .extend(found_path_match.0.positions.iter()) + self.labels_for_path_match(&path_match) + } } - - self.labels_for_path_match(&path_match) - } - Match::Search(path_match) => self.labels_for_path_match(&path_match.0), - }; + Match::Search(path_match) => self.labels_for_path_match(&path_match.0), + }; if file_name_positions.is_empty() { if let Some(user_home_path) = std::env::var("HOME").ok() { let user_home_path = user_home_path.trim(); if !user_home_path.is_empty() { if (&full_path).starts_with(user_home_path) { - return ( - file_name, - file_name_positions, - full_path.replace(user_home_path, "~"), - full_path_positions, - ); + full_path.replace_range(0..user_home_path.len(), "~"); + full_path_positions.retain_mut(|pos| { + if *pos >= user_home_path.len() { + *pos -= user_home_path.len(); + *pos += 1; + true + } else { + false + } + }) } } } } + if full_path.is_ascii() { + let file_finder_settings = FileFinderSettings::get_global(cx); + let max_width = + FileFinder::modal_max_width(file_finder_settings.modal_max_width, window); + let (normal_em, small_em) = { + let style = window.text_style(); + let font_id = window.text_system().resolve_font(&style.font()); + let font_size = TextSize::Default.rems(cx).to_pixels(window.rem_size()); + let normal = cx + .text_system() + .em_width(font_id, font_size) + .unwrap_or(px(16.)); + let font_size = TextSize::Small.rems(cx).to_pixels(window.rem_size()); + let small = cx + .text_system() + .em_width(font_id, font_size) + .unwrap_or(px(10.)); + (normal, small) + }; + let budget = full_path_budget(&file_name, normal_em, small_em, max_width); + if full_path.len() > budget { + let components = PathComponentSlice::new(&full_path); + if let Some(elided_range) = + components.elision_range(budget - 1, &full_path_positions) + { + let elided_len = elided_range.end - elided_range.start; + let placeholder = "…"; + full_path_positions.retain_mut(|mat| { + if *mat >= elided_range.end { + *mat -= elided_len; + *mat += placeholder.len(); + } else if *mat >= elided_range.start { + return false; + } + true + }); + full_path.replace_range(elided_range, placeholder); + } + } + } + ( - file_name, - file_name_positions, - full_path, - full_path_positions, + HighlightedLabel::new(file_name, file_name_positions), + HighlightedLabel::new(full_path, full_path_positions) + .size(LabelSize::Small) + .color(Color::Muted), ) } @@ -1004,6 +1053,15 @@ impl FileFinderDelegate { } } +fn full_path_budget( + file_name: &str, + normal_em: Pixels, + small_em: Pixels, + max_width: Pixels, +) -> usize { + ((px(max_width / px(0.8)) - px(file_name.len() as f32) * normal_em) / small_em) as usize +} + impl PickerDelegate for FileFinderDelegate { type ListItem = ListItem; @@ -1249,7 +1307,7 @@ impl PickerDelegate for FileFinderDelegate { &self, ix: usize, selected: bool, - _: &mut Window, + window: &mut Window, cx: &mut Context>, ) -> Option { let settings = FileFinderSettings::get_global(cx); @@ -1269,16 +1327,16 @@ impl PickerDelegate for FileFinderDelegate { .size(IconSize::Small.rems()) .into_any_element(), }; - let (file_name, file_name_positions, full_path, full_path_positions) = - self.labels_for_match(path_match, cx, ix); + let (file_name_label, full_path_label) = self.labels_for_match(path_match, window, cx, ix); - let file_icon = if settings.file_icons { - FileIcons::get_icon(Path::new(&file_name), cx) - .map(Icon::from_path) - .map(|icon| icon.color(Color::Muted)) - } else { - None - }; + let file_icon = maybe!({ + if !settings.file_icons { + return None; + } + let file_name = path_match.path().file_name()?; + let icon = FileIcons::get_icon(file_name.as_ref(), cx)?; + Some(Icon::from_path(icon).color(Color::Muted)) + }); Some( ListItem::new(ix) @@ -1291,12 +1349,8 @@ impl PickerDelegate for FileFinderDelegate { h_flex() .gap_2() .py_px() - .child(HighlightedLabel::new(file_name, file_name_positions)) - .child( - HighlightedLabel::new(full_path, full_path_positions) - .size(LabelSize::Small) - .color(Color::Muted), - ), + .child(file_name_label) + .child(full_path_label), ), ) } @@ -1345,110 +1399,120 @@ impl PickerDelegate for FileFinderDelegate { } } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_custom_project_search_ordering_in_file_finder() { - let mut file_finder_sorted_output = vec![ - ProjectPanelOrdMatch(PathMatch { - score: 0.5, - positions: Vec::new(), - worktree_id: 0, - path: Arc::from(Path::new("b0.5")), - path_prefix: Arc::default(), - distance_to_relative_ancestor: 0, - is_dir: false, - }), - ProjectPanelOrdMatch(PathMatch { - score: 1.0, - positions: Vec::new(), - worktree_id: 0, - path: Arc::from(Path::new("c1.0")), - path_prefix: Arc::default(), - distance_to_relative_ancestor: 0, - is_dir: false, - }), - ProjectPanelOrdMatch(PathMatch { - score: 1.0, - positions: Vec::new(), - worktree_id: 0, - path: Arc::from(Path::new("a1.0")), - path_prefix: Arc::default(), - distance_to_relative_ancestor: 0, - is_dir: false, - }), - ProjectPanelOrdMatch(PathMatch { - score: 0.5, - positions: Vec::new(), - worktree_id: 0, - path: Arc::from(Path::new("a0.5")), - path_prefix: Arc::default(), - distance_to_relative_ancestor: 0, - is_dir: false, - }), - ProjectPanelOrdMatch(PathMatch { - score: 1.0, - positions: Vec::new(), - worktree_id: 0, - path: Arc::from(Path::new("b1.0")), - path_prefix: Arc::default(), - distance_to_relative_ancestor: 0, - is_dir: false, - }), - ]; - file_finder_sorted_output.sort_by(|a, b| b.cmp(a)); - - assert_eq!( - file_finder_sorted_output, - vec![ - ProjectPanelOrdMatch(PathMatch { - score: 1.0, - positions: Vec::new(), - worktree_id: 0, - path: Arc::from(Path::new("a1.0")), - path_prefix: Arc::default(), - distance_to_relative_ancestor: 0, - is_dir: false, - }), - ProjectPanelOrdMatch(PathMatch { - score: 1.0, - positions: Vec::new(), - worktree_id: 0, - path: Arc::from(Path::new("b1.0")), - path_prefix: Arc::default(), - distance_to_relative_ancestor: 0, - is_dir: false, - }), - ProjectPanelOrdMatch(PathMatch { - score: 1.0, - positions: Vec::new(), - worktree_id: 0, - path: Arc::from(Path::new("c1.0")), - path_prefix: Arc::default(), - distance_to_relative_ancestor: 0, - is_dir: false, - }), - ProjectPanelOrdMatch(PathMatch { - score: 0.5, - positions: Vec::new(), - worktree_id: 0, - path: Arc::from(Path::new("a0.5")), - path_prefix: Arc::default(), - distance_to_relative_ancestor: 0, - is_dir: false, - }), - ProjectPanelOrdMatch(PathMatch { - score: 0.5, - positions: Vec::new(), - worktree_id: 0, - path: Arc::from(Path::new("b0.5")), - path_prefix: Arc::default(), - distance_to_relative_ancestor: 0, - is_dir: false, - }), - ] - ); +#[derive(Clone, Debug, PartialEq, Eq)] +struct PathComponentSlice<'a> { + path: Cow<'a, Path>, + path_str: Cow<'a, str>, + component_ranges: Vec<(Component<'a>, Range)>, +} + +impl<'a> PathComponentSlice<'a> { + fn new(path: &'a str) -> Self { + let trimmed_path = Path::new(path).components().as_path().as_os_str(); + let mut component_ranges = Vec::new(); + let mut components = Path::new(trimmed_path).components(); + let len = trimmed_path.as_encoded_bytes().len(); + let mut pos = 0; + while let Some(component) = components.next() { + component_ranges.push((component, pos..0)); + pos = len - components.as_path().as_os_str().as_encoded_bytes().len(); + } + for ((_, range), ancestor) in component_ranges + .iter_mut() + .rev() + .zip(Path::new(trimmed_path).ancestors()) + { + range.end = ancestor.as_os_str().as_encoded_bytes().len(); + } + Self { + path: Cow::Borrowed(Path::new(path)), + path_str: Cow::Borrowed(path), + component_ranges, + } + } + + fn elision_range(&self, budget: usize, matches: &[usize]) -> Option> { + let eligible_range = { + assert!(matches.windows(2).all(|w| w[0] <= w[1])); + let mut matches = matches.iter().copied().peekable(); + let mut longest: Option> = None; + let mut cur = 0..0; + let mut seen_normal = false; + for (i, (component, range)) in self.component_ranges.iter().enumerate() { + let is_normal = matches!(component, Component::Normal(_)); + let is_first_normal = is_normal && !seen_normal; + seen_normal |= is_normal; + let is_last = i == self.component_ranges.len() - 1; + let contains_match = matches.peek().is_some_and(|mat| range.contains(mat)); + if contains_match { + matches.next(); + } + if is_first_normal || is_last || !is_normal || contains_match { + if !longest + .as_ref() + .is_some_and(|old| old.end - old.start > cur.end - cur.start) + { + longest = Some(cur); + } + cur = i + 1..i + 1; + } else { + cur.end = i + 1; + } + } + if !longest + .as_ref() + .is_some_and(|old| old.end - old.start > cur.end - cur.start) + { + longest = Some(cur); + } + longest + }; + + let eligible_range = eligible_range?; + assert!(eligible_range.start <= eligible_range.end); + if eligible_range.is_empty() { + return None; + } + + let elided_range: Range = { + let byte_range = self.component_ranges[eligible_range.start].1.start + ..self.component_ranges[eligible_range.end - 1].1.end; + let midpoint = self.path_str.len() / 2; + let distance_from_start = byte_range.start.abs_diff(midpoint); + let distance_from_end = byte_range.end.abs_diff(midpoint); + let pick_from_end = distance_from_start > distance_from_end; + let mut len_with_elision = self.path_str.len(); + let mut i = eligible_range.start; + while i < eligible_range.end { + let x = if pick_from_end { + eligible_range.end - i + eligible_range.start - 1 + } else { + i + }; + len_with_elision -= self.component_ranges[x] + .0 + .as_os_str() + .as_encoded_bytes() + .len() + + 1; + if len_with_elision <= budget { + break; + } + i += 1; + } + if len_with_elision > budget { + return None; + } else if pick_from_end { + let x = eligible_range.end - i + eligible_range.start - 1; + x..eligible_range.end + } else { + let x = i; + eligible_range.start..x + 1 + } + }; + + let byte_range = self.component_ranges[elided_range.start].1.start + ..self.component_ranges[elided_range.end - 1].1.end; + Some(byte_range) } } diff --git a/crates/file_finder/src/file_finder_tests.rs b/crates/file_finder/src/file_finder_tests.rs index f14106d62af035..1dd7815956d8aa 100644 --- a/crates/file_finder/src/file_finder_tests.rs +++ b/crates/file_finder/src/file_finder_tests.rs @@ -16,6 +16,164 @@ fn init_logger() { } } +#[test] +fn test_path_elision() { + #[track_caller] + fn check(path: &str, budget: usize, matches: impl IntoIterator, expected: &str) { + let mut path = path.to_owned(); + let slice = PathComponentSlice::new(&path); + let matches = Vec::from_iter(matches); + if let Some(range) = slice.elision_range(budget - 1, &matches) { + path.replace_range(range, "…"); + } + assert_eq!(path, expected); + } + + // Simple cases, mostly to check that different path shapes are handled gracefully. + check("p/a/b/c/d/", 6, [], "p/…/d/"); + check("p/a/b/c/d/", 1, [2, 4, 6], "p/a/b/c/d/"); + check("p/a/b/c/d/", 10, [2, 6], "p/a/…/c/d/"); + check("p/a/b/c/d/", 8, [6], "p/…/c/d/"); + + check("p/a/b/c/d", 5, [], "p/…/d"); + check("p/a/b/c/d", 9, [2, 4, 6], "p/a/b/c/d"); + check("p/a/b/c/d", 9, [2, 6], "p/a/…/c/d"); + check("p/a/b/c/d", 7, [6], "p/…/c/d"); + + check("/p/a/b/c/d/", 7, [], "/p/…/d/"); + check("/p/a/b/c/d/", 11, [3, 5, 7], "/p/a/b/c/d/"); + check("/p/a/b/c/d/", 11, [3, 7], "/p/a/…/c/d/"); + check("/p/a/b/c/d/", 9, [7], "/p/…/c/d/"); + + // If the budget can't be met, no elision is done. + check( + "project/dir/child/grandchild", + 5, + [], + "project/dir/child/grandchild", + ); + + // The longest unmatched segment is picked for elision. + check( + "project/one/two/X/three/sub", + 21, + [16], + "project/…/X/three/sub", + ); + + // Elision stops when the budget is met, even though there are more components in the chosen segment. + // It proceeds from the end of the unmatched segment that is closer to the midpoint of the path. + check( + "project/one/two/three/X/sub", + 21, + [22], + "project/…/three/X/sub", + ) +} + +#[test] +fn test_custom_project_search_ordering_in_file_finder() { + let mut file_finder_sorted_output = vec![ + ProjectPanelOrdMatch(PathMatch { + score: 0.5, + positions: Vec::new(), + worktree_id: 0, + path: Arc::from(Path::new("b0.5")), + path_prefix: Arc::default(), + distance_to_relative_ancestor: 0, + is_dir: false, + }), + ProjectPanelOrdMatch(PathMatch { + score: 1.0, + positions: Vec::new(), + worktree_id: 0, + path: Arc::from(Path::new("c1.0")), + path_prefix: Arc::default(), + distance_to_relative_ancestor: 0, + is_dir: false, + }), + ProjectPanelOrdMatch(PathMatch { + score: 1.0, + positions: Vec::new(), + worktree_id: 0, + path: Arc::from(Path::new("a1.0")), + path_prefix: Arc::default(), + distance_to_relative_ancestor: 0, + is_dir: false, + }), + ProjectPanelOrdMatch(PathMatch { + score: 0.5, + positions: Vec::new(), + worktree_id: 0, + path: Arc::from(Path::new("a0.5")), + path_prefix: Arc::default(), + distance_to_relative_ancestor: 0, + is_dir: false, + }), + ProjectPanelOrdMatch(PathMatch { + score: 1.0, + positions: Vec::new(), + worktree_id: 0, + path: Arc::from(Path::new("b1.0")), + path_prefix: Arc::default(), + distance_to_relative_ancestor: 0, + is_dir: false, + }), + ]; + file_finder_sorted_output.sort_by(|a, b| b.cmp(a)); + + assert_eq!( + file_finder_sorted_output, + vec![ + ProjectPanelOrdMatch(PathMatch { + score: 1.0, + positions: Vec::new(), + worktree_id: 0, + path: Arc::from(Path::new("a1.0")), + path_prefix: Arc::default(), + distance_to_relative_ancestor: 0, + is_dir: false, + }), + ProjectPanelOrdMatch(PathMatch { + score: 1.0, + positions: Vec::new(), + worktree_id: 0, + path: Arc::from(Path::new("b1.0")), + path_prefix: Arc::default(), + distance_to_relative_ancestor: 0, + is_dir: false, + }), + ProjectPanelOrdMatch(PathMatch { + score: 1.0, + positions: Vec::new(), + worktree_id: 0, + path: Arc::from(Path::new("c1.0")), + path_prefix: Arc::default(), + distance_to_relative_ancestor: 0, + is_dir: false, + }), + ProjectPanelOrdMatch(PathMatch { + score: 0.5, + positions: Vec::new(), + worktree_id: 0, + path: Arc::from(Path::new("a0.5")), + path_prefix: Arc::default(), + distance_to_relative_ancestor: 0, + is_dir: false, + }), + ProjectPanelOrdMatch(PathMatch { + score: 0.5, + positions: Vec::new(), + worktree_id: 0, + path: Arc::from(Path::new("b0.5")), + path_prefix: Arc::default(), + distance_to_relative_ancestor: 0, + is_dir: false, + }), + ] + ); +} + #[gpui::test] async fn test_matching_paths(cx: &mut TestAppContext) { let app_state = init_test(cx);