Shorten overflowing paths in file finder (#25303)

Closes #7711

This PR changes the file finder to shorten the path portion of each
match by replacing a segment with `...`, if it would otherwise overflow
horizontally. Details:

- The overflow calculation is based on a crude linear width estimate for
ASCII text at the current em width. No elision is done for non-ASCII
paths.
- A path component will not be elided if it contains a matching position
for the file finder's search, or if it's the first or last component.
- Elision is only applied when it is successful in shortening the path
enough to not overflow.

Release Notes:

- Improved the appearance of the file finder when long paths are shown
by eliding path segments
This commit is contained in:
Cole Miller 2025-02-21 17:04:44 -05:00 committed by GitHub
parent 7ff40091d8
commit aba89ba12a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 397 additions and 175 deletions

View file

@ -24,8 +24,10 @@ use picker::{Picker, PickerDelegate};
use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId}; use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
use settings::Settings; use settings::Settings;
use std::{ use std::{
borrow::Cow,
cmp, cmp,
path::{Path, PathBuf}, ops::Range,
path::{Component, Path, PathBuf},
sync::{ sync::{
atomic::{self, AtomicBool}, atomic::{self, AtomicBool},
Arc, Arc,
@ -36,7 +38,7 @@ use ui::{
prelude::*, ContextMenu, HighlightedLabel, ListItem, ListItemSpacing, PopoverMenu, prelude::*, ContextMenu, HighlightedLabel, ListItem, ListItemSpacing, PopoverMenu,
PopoverMenuHandle, PopoverMenuHandle,
}; };
use util::{paths::PathWithPosition, post_inc, ResultExt}; use util::{maybe, paths::PathWithPosition, post_inc, ResultExt};
use workspace::{ use workspace::{
item::PreviewTabsSettings, notifications::NotifyResultExt, pane, ModalView, SplitDirection, item::PreviewTabsSettings, notifications::NotifyResultExt, pane, ModalView, SplitDirection,
Workspace, Workspace,
@ -805,10 +807,12 @@ impl FileFinderDelegate {
fn labels_for_match( fn labels_for_match(
&self, &self,
path_match: &Match, path_match: &Match,
window: &mut Window,
cx: &App, cx: &App,
ix: usize, ix: usize,
) -> (String, Vec<usize>, String, Vec<usize>) { ) -> (HighlightedLabel, HighlightedLabel) {
let (file_name, file_name_positions, full_path, full_path_positions) = match &path_match { let (file_name, file_name_positions, mut full_path, mut full_path_positions) =
match &path_match {
Match::History { Match::History {
path: entry_path, path: entry_path,
panel_match, panel_match,
@ -821,9 +825,10 @@ impl FileFinderDelegate {
.worktree_for_id(worktree_id, cx) .worktree_for_id(worktree_id, cx)
.is_some(); .is_some();
if !has_worktree { if let Some(absolute_path) =
if let Some(absolute_path) = &entry_path.absolute { entry_path.absolute.as_ref().filter(|_| !has_worktree)
return ( {
(
absolute_path absolute_path
.file_name() .file_name()
.map_or_else( .map_or_else(
@ -834,10 +839,8 @@ impl FileFinderDelegate {
Vec::new(), Vec::new(),
absolute_path.to_string_lossy().to_string(), absolute_path.to_string_lossy().to_string(),
Vec::new(), Vec::new(),
); )
} } else {
}
let mut path = Arc::clone(project_relative_path); let mut path = Arc::clone(project_relative_path);
if project_relative_path.as_ref() == Path::new("") { if project_relative_path.as_ref() == Path::new("") {
if let Some(absolute_path) = &entry_path.absolute { if let Some(absolute_path) = &entry_path.absolute {
@ -862,6 +865,7 @@ impl FileFinderDelegate {
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),
}; };
@ -870,22 +874,67 @@ impl FileFinderDelegate {
let user_home_path = user_home_path.trim(); let user_home_path = user_home_path.trim();
if !user_home_path.is_empty() { if !user_home_path.is_empty() {
if (&full_path).starts_with(user_home_path) { if (&full_path).starts_with(user_home_path) {
return ( full_path.replace_range(0..user_home_path.len(), "~");
file_name, full_path_positions.retain_mut(|pos| {
file_name_positions, if *pos >= user_home_path.len() {
full_path.replace(user_home_path, "~"), *pos -= user_home_path.len();
full_path_positions, *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, HighlightedLabel::new(file_name, file_name_positions),
file_name_positions, HighlightedLabel::new(full_path, full_path_positions)
full_path, .size(LabelSize::Small)
full_path_positions, .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 { impl PickerDelegate for FileFinderDelegate {
type ListItem = ListItem; type ListItem = ListItem;
@ -1249,7 +1307,7 @@ impl PickerDelegate for FileFinderDelegate {
&self, &self,
ix: usize, ix: usize,
selected: bool, selected: bool,
_: &mut Window, window: &mut Window,
cx: &mut Context<Picker<Self>>, cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> { ) -> Option<Self::ListItem> {
let settings = FileFinderSettings::get_global(cx); let settings = FileFinderSettings::get_global(cx);
@ -1269,16 +1327,16 @@ impl PickerDelegate for FileFinderDelegate {
.size(IconSize::Small.rems()) .size(IconSize::Small.rems())
.into_any_element(), .into_any_element(),
}; };
let (file_name, file_name_positions, full_path, full_path_positions) = let (file_name_label, full_path_label) = self.labels_for_match(path_match, window, cx, ix);
self.labels_for_match(path_match, cx, ix);
let file_icon = if settings.file_icons { let file_icon = maybe!({
FileIcons::get_icon(Path::new(&file_name), cx) if !settings.file_icons {
.map(Icon::from_path) return None;
.map(|icon| icon.color(Color::Muted)) }
} else { let file_name = path_match.path().file_name()?;
None let icon = FileIcons::get_icon(file_name.as_ref(), cx)?;
}; Some(Icon::from_path(icon).color(Color::Muted))
});
Some( Some(
ListItem::new(ix) ListItem::new(ix)
@ -1291,12 +1349,8 @@ impl PickerDelegate for FileFinderDelegate {
h_flex() h_flex()
.gap_2() .gap_2()
.py_px() .py_px()
.child(HighlightedLabel::new(file_name, file_name_positions)) .child(file_name_label)
.child( .child(full_path_label),
HighlightedLabel::new(full_path, full_path_positions)
.size(LabelSize::Small)
.color(Color::Muted),
),
), ),
) )
} }
@ -1345,110 +1399,120 @@ impl PickerDelegate for FileFinderDelegate {
} }
} }
#[cfg(test)] #[derive(Clone, Debug, PartialEq, Eq)]
mod tests { struct PathComponentSlice<'a> {
use super::*; path: Cow<'a, Path>,
path_str: Cow<'a, str>,
component_ranges: Vec<(Component<'a>, Range<usize>)>,
}
#[test] impl<'a> PathComponentSlice<'a> {
fn test_custom_project_search_ordering_in_file_finder() { fn new(path: &'a str) -> Self {
let mut file_finder_sorted_output = vec![ let trimmed_path = Path::new(path).components().as_path().as_os_str();
ProjectPanelOrdMatch(PathMatch { let mut component_ranges = Vec::new();
score: 0.5, let mut components = Path::new(trimmed_path).components();
positions: Vec::new(), let len = trimmed_path.as_encoded_bytes().len();
worktree_id: 0, let mut pos = 0;
path: Arc::from(Path::new("b0.5")), while let Some(component) = components.next() {
path_prefix: Arc::default(), component_ranges.push((component, pos..0));
distance_to_relative_ancestor: 0, pos = len - components.as_path().as_os_str().as_encoded_bytes().len();
is_dir: false, }
}), for ((_, range), ancestor) in component_ranges
ProjectPanelOrdMatch(PathMatch { .iter_mut()
score: 1.0, .rev()
positions: Vec::new(), .zip(Path::new(trimmed_path).ancestors())
worktree_id: 0, {
path: Arc::from(Path::new("c1.0")), range.end = ancestor.as_os_str().as_encoded_bytes().len();
path_prefix: Arc::default(), }
distance_to_relative_ancestor: 0, Self {
is_dir: false, path: Cow::Borrowed(Path::new(path)),
}), path_str: Cow::Borrowed(path),
ProjectPanelOrdMatch(PathMatch { component_ranges,
score: 1.0, }
positions: Vec::new(), }
worktree_id: 0,
path: Arc::from(Path::new("a1.0")), fn elision_range(&self, budget: usize, matches: &[usize]) -> Option<Range<usize>> {
path_prefix: Arc::default(), let eligible_range = {
distance_to_relative_ancestor: 0, assert!(matches.windows(2).all(|w| w[0] <= w[1]));
is_dir: false, let mut matches = matches.iter().copied().peekable();
}), let mut longest: Option<Range<usize>> = None;
ProjectPanelOrdMatch(PathMatch { let mut cur = 0..0;
score: 0.5, let mut seen_normal = false;
positions: Vec::new(), for (i, (component, range)) in self.component_ranges.iter().enumerate() {
worktree_id: 0, let is_normal = matches!(component, Component::Normal(_));
path: Arc::from(Path::new("a0.5")), let is_first_normal = is_normal && !seen_normal;
path_prefix: Arc::default(), seen_normal |= is_normal;
distance_to_relative_ancestor: 0, let is_last = i == self.component_ranges.len() - 1;
is_dir: false, let contains_match = matches.peek().is_some_and(|mat| range.contains(mat));
}), if contains_match {
ProjectPanelOrdMatch(PathMatch { matches.next();
score: 1.0, }
positions: Vec::new(), if is_first_normal || is_last || !is_normal || contains_match {
worktree_id: 0, if !longest
path: Arc::from(Path::new("b1.0")), .as_ref()
path_prefix: Arc::default(), .is_some_and(|old| old.end - old.start > cur.end - cur.start)
distance_to_relative_ancestor: 0, {
is_dir: false, longest = Some(cur);
}), }
]; cur = i + 1..i + 1;
file_finder_sorted_output.sort_by(|a, b| b.cmp(a)); } else {
cur.end = i + 1;
assert_eq!( }
file_finder_sorted_output, }
vec![ if !longest
ProjectPanelOrdMatch(PathMatch { .as_ref()
score: 1.0, .is_some_and(|old| old.end - old.start > cur.end - cur.start)
positions: Vec::new(), {
worktree_id: 0, longest = Some(cur);
path: Arc::from(Path::new("a1.0")), }
path_prefix: Arc::default(), longest
distance_to_relative_ancestor: 0, };
is_dir: false,
}), let eligible_range = eligible_range?;
ProjectPanelOrdMatch(PathMatch { assert!(eligible_range.start <= eligible_range.end);
score: 1.0, if eligible_range.is_empty() {
positions: Vec::new(), return None;
worktree_id: 0, }
path: Arc::from(Path::new("b1.0")),
path_prefix: Arc::default(), let elided_range: Range<usize> = {
distance_to_relative_ancestor: 0, let byte_range = self.component_ranges[eligible_range.start].1.start
is_dir: false, ..self.component_ranges[eligible_range.end - 1].1.end;
}), let midpoint = self.path_str.len() / 2;
ProjectPanelOrdMatch(PathMatch { let distance_from_start = byte_range.start.abs_diff(midpoint);
score: 1.0, let distance_from_end = byte_range.end.abs_diff(midpoint);
positions: Vec::new(), let pick_from_end = distance_from_start > distance_from_end;
worktree_id: 0, let mut len_with_elision = self.path_str.len();
path: Arc::from(Path::new("c1.0")), let mut i = eligible_range.start;
path_prefix: Arc::default(), while i < eligible_range.end {
distance_to_relative_ancestor: 0, let x = if pick_from_end {
is_dir: false, eligible_range.end - i + eligible_range.start - 1
}), } else {
ProjectPanelOrdMatch(PathMatch { i
score: 0.5, };
positions: Vec::new(), len_with_elision -= self.component_ranges[x]
worktree_id: 0, .0
path: Arc::from(Path::new("a0.5")), .as_os_str()
path_prefix: Arc::default(), .as_encoded_bytes()
distance_to_relative_ancestor: 0, .len()
is_dir: false, + 1;
}), if len_with_elision <= budget {
ProjectPanelOrdMatch(PathMatch { break;
score: 0.5, }
positions: Vec::new(), i += 1;
worktree_id: 0, }
path: Arc::from(Path::new("b0.5")), if len_with_elision > budget {
path_prefix: Arc::default(), return None;
distance_to_relative_ancestor: 0, } else if pick_from_end {
is_dir: false, 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)
} }
} }

View file

@ -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] #[gpui::test]
async fn test_matching_paths(cx: &mut TestAppContext) { async fn test_matching_paths(cx: &mut TestAppContext) {
let app_state = init_test(cx); let app_state = init_test(cx);