@@ -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<usize>, String, Vec<usize>) {
- 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<Picker<Self>>,
) -> Option<Self::ListItem> {
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<usize>)>,
+}
+
+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<Range<usize>> {
+ let eligible_range = {
+ assert!(matches.windows(2).all(|w| w[0] <= w[1]));
+ let mut matches = matches.iter().copied().peekable();
+ let mut longest: Option<Range<usize>> = 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<usize> = {
+ 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)
}
}
@@ -16,6 +16,164 @@ fn init_logger() {
}
}
+#[test]
+fn test_path_elision() {
+ #[track_caller]
+ fn check(path: &str, budget: usize, matches: impl IntoIterator<Item = usize>, 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);