Disambiguate project names (#52848)

Disambiguate project names in the sidebar (and project picker) so that
we don't show e.g. `zed, zed` but rather `foo/zed, bar/zed` if the last
path component is the same but they are different absolute paths.

Release Notes:

- N/A
This commit is contained in:
Richard Feldman authored and GitHub committed 2026-04-09 14:11:55 -04:00
1 parent 2670c414b7
commit 57a0c73da5
7 files changed
+587 -57

No files matched your search

+26 -8
View File
@@ -6131,30 +6131,48 @@ impl ProjectGroupKey {
Self { paths, host }
}
pub fn display_name(&self) -> SharedString {
pub fn path_list(&self) -> &PathList {
&self.paths
}
pub fn display_name(
&self,
path_detail_map: &std::collections::HashMap<PathBuf, usize>,
) -> SharedString {
let mut names = Vec::with_capacity(self.paths.paths().len());
for abs_path in self.paths.paths() {
if let Some(name) = abs_path.file_name() {
names.push(name.to_string_lossy().to_string());
let detail = path_detail_map.get(abs_path).copied().unwrap_or(0);
let suffix = path_suffix(abs_path, detail);
if !suffix.is_empty() {
names.push(suffix);
}
}
if names.is_empty() {
// TODO: Can we do something better in this case?
"Empty Workspace".into()
} else {
names.join(", ").into()
}
}
pub fn path_list(&self) -> &PathList {
&self.paths
}
pub fn host(&self) -> Option<RemoteConnectionOptions> {
self.host.clone()
}
}
pub fn path_suffix(path: &Path, detail: usize) -> String {
let mut components: Vec<_> = path
.components()
.rev()
.filter_map(|component| match component {
std::path::Component::Normal(s) => Some(s.to_string_lossy()),
_ => None,
})
.take(detail + 1)
.collect();
components.reverse();
components.join("/")
}
pub struct PathMatchCandidateSet {
pub snapshot: Snapshot,
pub include_ignored: bool,
+42 -15
View File
@@ -99,27 +99,40 @@ pub async fn get_recent_projects(
.await
.unwrap_or_default();
let entries: Vec<RecentProjectEntry> = workspaces
let filtered: Vec<_> = workspaces
.into_iter()
.filter(|(id, _, _, _)| Some(*id) != current_workspace_id)
.filter(|(_, location, _, _)| matches!(location, SerializedWorkspaceLocation::Local))
.collect();
let mut all_paths: Vec<PathBuf> = filtered
.iter()
.flat_map(|(_, _, path_list, _)| path_list.paths().iter().cloned())
.collect();
all_paths.sort();
all_paths.dedup();
let path_details =
util::disambiguate::compute_disambiguation_details(&all_paths, |path, detail| {
project::path_suffix(path, detail)
});
let path_detail_map: std::collections::HashMap<PathBuf, usize> =
all_paths.into_iter().zip(path_details).collect();
let entries: Vec<RecentProjectEntry> = filtered
.into_iter()
.map(|(workspace_id, _, path_list, timestamp)| {
let paths: Vec<PathBuf> = path_list.paths().to_vec();
let ordered_paths: Vec<&PathBuf> = path_list.ordered_paths().collect();
let name = if ordered_paths.len() == 1 {
ordered_paths[0]
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| ordered_paths[0].to_string_lossy().to_string())
} else {
ordered_paths
.iter()
.filter_map(|p| p.file_name())
.map(|n| n.to_string_lossy().to_string())
.collect::<Vec<_>>()
.join(", ")
};
let name = ordered_paths
.iter()
.map(|p| {
let detail = path_detail_map.get(*p).copied().unwrap_or(0);
project::path_suffix(p, detail)
})
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(", ");
let full_path = ordered_paths
.iter()
@@ -172,6 +185,19 @@ fn get_open_folders(workspace: &Workspace, cx: &App) -> Vec<OpenFolderEntry> {
.map(|wt| wt.read(cx).id())
});
let mut all_paths: Vec<PathBuf> = visible_worktrees
.iter()
.map(|wt| wt.read(cx).abs_path().to_path_buf())
.collect();
all_paths.sort();
all_paths.dedup();
let path_details =
util::disambiguate::compute_disambiguation_details(&all_paths, |path, detail| {
project::path_suffix(path, detail)
});
let path_detail_map: std::collections::HashMap<PathBuf, usize> =
all_paths.into_iter().zip(path_details).collect();
let git_store = project.git_store().read(cx);
let repositories: Vec<_> = git_store.repositories().values().cloned().collect();
@@ -180,8 +206,9 @@ fn get_open_folders(workspace: &Workspace, cx: &App) -> Vec<OpenFolderEntry> {
.map(|worktree| {
let worktree_ref = worktree.read(cx);
let worktree_id = worktree_ref.id();
let name = SharedString::from(worktree_ref.root_name().as_unix_str().to_string());
let path = worktree_ref.abs_path().to_path_buf();
let detail = path_detail_map.get(&path).copied().unwrap_or(0);
let name = SharedString::from(project::path_suffix(&path, detail));
let branch = get_branch_for_worktree(worktree_ref, &repositories, cx);
let is_active = active_worktree_id == Some(worktree_id);
OpenFolderEntry {
+19 -4
View File
@@ -883,12 +883,27 @@ impl Sidebar {
(icon, icon_from_external_svg)
};
for (group_key, group_workspaces) in mw.project_groups(cx) {
let groups: Vec<_> = mw.project_groups(cx).collect();
let mut all_paths: Vec<PathBuf> = groups
.iter()
.flat_map(|(key, _)| key.path_list().paths().iter().cloned())
.collect();
all_paths.sort();
all_paths.dedup();
let path_details =
util::disambiguate::compute_disambiguation_details(&all_paths, |path, detail| {
project::path_suffix(path, detail)
});
let path_detail_map: HashMap<PathBuf, usize> =
all_paths.into_iter().zip(path_details).collect();
for (group_key, group_workspaces) in &groups {
if group_key.path_list().paths().is_empty() {
continue;
}
let label = group_key.display_name();
let label = group_key.display_name(&path_detail_map);
let is_collapsed = self.collapsed_groups.contains(&group_key);
let should_load_threads = !is_collapsed || !query.is_empty();
@@ -989,7 +1004,7 @@ impl Sidebar {
// Load any legacy threads for any single linked wortree of this project group.
let mut linked_worktree_paths = HashSet::new();
for workspace in &group_workspaces {
for workspace in group_workspaces {
if workspace.read(cx).visible_worktrees(cx).count() != 1 {
continue;
}
@@ -1192,7 +1207,7 @@ impl Sidebar {
None
};
let thread_store = ThreadMetadataStore::global(cx);
for ws in &group_workspaces {
for ws in group_workspaces {
if Some(ws.entity_id()) == draft_ws_id {
continue;
}
+202
View File
@@ -0,0 +1,202 @@
use std::collections::HashMap;
use std::hash::Hash;
/// Computes the minimum detail level needed for each item so that no two items
/// share the same description. Items whose descriptions are unique at level 0
/// stay at 0; items that collide get their detail level incremented until either
/// the collision is resolved or increasing the level no longer changes the
/// description (preventing infinite loops for truly identical items).
///
/// The `get_description` closure must return a sequence that eventually reaches
/// a "fixed point" where increasing `detail` no longer changes the output. If
/// an item reaches its fixed point, it is assumed it will no longer change and
/// will no longer be checked for collisions.
pub fn compute_disambiguation_details<T, D>(
items: &[T],
get_description: impl Fn(&T, usize) -> D,
) -> Vec<usize>
where
D: Eq + Hash + Clone,
{
let mut details = vec![0usize; items.len()];
let mut descriptions: HashMap<D, Vec<usize>> = HashMap::default();
let mut current_descriptions: Vec<D> =
items.iter().map(|item| get_description(item, 0)).collect();
loop {
let mut any_collisions = false;
for (index, (item, &detail)) in items.iter().zip(&details).enumerate() {
if detail > 0 {
let new_description = get_description(item, detail);
if new_description == current_descriptions[index] {
continue;
}
current_descriptions[index] = new_description;
}
descriptions
.entry(current_descriptions[index].clone())
.or_insert_with(Vec::new)
.push(index);
}
for (_, indices) in descriptions.drain() {
if indices.len() > 1 {
any_collisions = true;
for index in indices {
details[index] += 1;
}
}
}
if !any_collisions {
break;
}
}
details
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_no_conflicts() {
let items = vec!["alpha", "beta", "gamma"];
let details = compute_disambiguation_details(&items, |item, _detail| item.to_string());
assert_eq!(details, vec![0, 0, 0]);
}
#[test]
fn test_simple_two_way_conflict() {
// Two items with the same base name but different parents.
let items = vec![("src/foo.rs", "foo.rs"), ("lib/foo.rs", "foo.rs")];
let details = compute_disambiguation_details(&items, |item, detail| match detail {
0 => item.1.to_string(),
_ => item.0.to_string(),
});
assert_eq!(details, vec![1, 1]);
}
#[test]
fn test_three_way_conflict() {
let items = vec![
("foo.rs", "a/foo.rs"),
("foo.rs", "b/foo.rs"),
("foo.rs", "c/foo.rs"),
];
let details = compute_disambiguation_details(&items, |item, detail| match detail {
0 => item.0.to_string(),
_ => item.1.to_string(),
});
assert_eq!(details, vec![1, 1, 1]);
}
#[test]
fn test_deeper_conflict() {
// At detail 0, all three show "file.rs".
// At detail 1, items 0 and 1 both show "src/file.rs", item 2 shows "lib/file.rs".
// At detail 2, item 0 shows "a/src/file.rs", item 1 shows "b/src/file.rs".
let items = vec![
vec!["file.rs", "src/file.rs", "a/src/file.rs"],
vec!["file.rs", "src/file.rs", "b/src/file.rs"],
vec!["file.rs", "lib/file.rs", "x/lib/file.rs"],
];
let details = compute_disambiguation_details(&items, |item, detail| {
let clamped = detail.min(item.len() - 1);
item[clamped].to_string()
});
assert_eq!(details, vec![2, 2, 1]);
}
#[test]
fn test_mixed_conflicting_and_unique() {
let items = vec![
("src/foo.rs", "foo.rs"),
("lib/foo.rs", "foo.rs"),
("src/bar.rs", "bar.rs"),
];
let details = compute_disambiguation_details(&items, |item, detail| match detail {
0 => item.1.to_string(),
_ => item.0.to_string(),
});
assert_eq!(details, vec![1, 1, 0]);
}
#[test]
fn test_identical_items_terminates() {
// All items return the same description at every detail level.
// The algorithm must terminate rather than looping forever.
let items = vec!["same", "same", "same"];
let details = compute_disambiguation_details(&items, |item, _detail| item.to_string());
// After bumping to 1, the description doesn't change from level 0,
// so the items are skipped and the loop terminates.
assert_eq!(details, vec![1, 1, 1]);
}
#[test]
fn test_single_item() {
let items = vec!["only"];
let details = compute_disambiguation_details(&items, |item, _detail| item.to_string());
assert_eq!(details, vec![0]);
}
#[test]
fn test_empty_input() {
let items: Vec<&str> = vec![];
let details = compute_disambiguation_details(&items, |item, _detail| item.to_string());
let expected: Vec<usize> = vec![];
assert_eq!(details, expected);
}
#[test]
fn test_duplicate_paths_from_multiple_groups() {
use std::path::Path;
// Simulates the sidebar scenario: a path like /Users/rtfeldman/code/zed
// appears in two project groups (e.g. "zed" alone and "zed, roc").
// After deduplication, only unique paths should be disambiguated.
//
// Paths:
// /Users/rtfeldman/code/worktrees/zed/focal-arrow/zed (group 1)
// /Users/rtfeldman/code/zed (group 2)
// /Users/rtfeldman/code/zed (group 3, same path as group 2)
// /Users/rtfeldman/code/roc (group 3)
//
// A naive flat_map collects duplicates. The duplicate /code/zed entries
// collide with each other and drive the detail to the full path.
// The fix is to deduplicate before disambiguating.
fn path_suffix(path: &Path, detail: usize) -> String {
let mut components: Vec<_> = path
.components()
.rev()
.filter_map(|c| match c {
std::path::Component::Normal(s) => Some(s.to_string_lossy()),
_ => None,
})
.take(detail + 1)
.collect();
components.reverse();
components.join("/")
}
let all_paths: Vec<&Path> = vec![
Path::new("/Users/rtfeldman/code/worktrees/zed/focal-arrow/zed"),
Path::new("/Users/rtfeldman/code/zed"),
Path::new("/Users/rtfeldman/code/roc"),
];
let details =
compute_disambiguation_details(&all_paths, |path, detail| path_suffix(path, detail));
// focal-arrow/zed and code/zed both end in "zed", so they need detail 1.
// "roc" is unique at detail 0.
assert_eq!(details, vec![1, 1, 0]);
assert_eq!(path_suffix(all_paths[0], details[0]), "focal-arrow/zed");
assert_eq!(path_suffix(all_paths[1], details[1]), "code/zed");
assert_eq!(path_suffix(all_paths[2], details[2]), "roc");
}
}
+1
View File
@@ -1,5 +1,6 @@
pub mod archive;
pub mod command;
pub mod disambiguate;
pub mod fs;
pub mod markdown;
pub mod path_list;
+3 -30
View File
@@ -4897,36 +4897,9 @@ fn dirty_message_for(buffer_path: Option<ProjectPath>, path_style: PathStyle) ->
}
pub fn tab_details(items: &[Box<dyn ItemHandle>], _window: &Window, cx: &App) -> Vec<usize> {
let mut tab_details = items.iter().map(|_| 0).collect::<Vec<_>>();
let mut tab_descriptions = HashMap::default();
let mut done = false;
while !done {
done = true;
// Store item indices by their tab description.
for (ix, (item, detail)) in items.iter().zip(&tab_details).enumerate() {
let description = item.tab_content_text(*detail, cx);
if *detail == 0 || description != item.tab_content_text(detail - 1, cx) {
tab_descriptions
.entry(description)
.or_insert(Vec::new())
.push(ix);
}
}
// If two or more items have the same tab description, increase their level
// of detail and try again.
for (_, item_ixs) in tab_descriptions.drain() {
if item_ixs.len() > 1 {
done = false;
for ix in item_ixs {
tab_details[ix] += 1;
}
}
}
}
tab_details
util::disambiguate::compute_disambiguation_details(items, |item, detail| {
item.tab_content_text(detail, cx)
})
}
pub fn render_item_indicator(item: Box<dyn ItemHandle>, cx: &App) -> Option<Indicator> {
+294
View File
@@ -573,6 +573,27 @@ fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()>
}
}
// Run Test: Sidebar with duplicate project names
println!("\n--- Test: sidebar_duplicate_names ---");
match run_sidebar_duplicate_project_names_visual_tests(
app_state.clone(),
&mut cx,
update_baseline,
) {
Ok(TestResult::Passed) => {
println!("✓ sidebar_duplicate_names: PASSED");
passed += 1;
}
Ok(TestResult::BaselineUpdated(_)) => {
println!("✓ sidebar_duplicate_names: Baselines updated");
updated += 1;
}
Err(e) => {
eprintln!("✗ sidebar_duplicate_names: FAILED - {}", e);
failed += 1;
}
}
// Run Test 9: Tool Permissions Settings UI visual test
println!("\n--- Test 9: tool_permissions_settings ---");
match run_tool_permissions_visual_tests(app_state.clone(), &mut cx, update_baseline) {
@@ -3069,6 +3090,279 @@ fn run_git_command(args: &[&str], dir: &std::path::Path) -> Result<()> {
Ok(())
}
#[cfg(target_os = "macos")]
/// Helper to create a project, add a worktree at the given path, and return the project.
fn create_project_with_worktree(
worktree_dir: &Path,
app_state: &Arc<AppState>,
cx: &mut VisualTestAppContext,
) -> Result<Entity<Project>> {
let project = cx.update(|cx| {
project::Project::local(
app_state.client.clone(),
app_state.node_runtime.clone(),
app_state.user_store.clone(),
app_state.languages.clone(),
app_state.fs.clone(),
None,
project::LocalProjectFlags {
init_worktree_trust: false,
..Default::default()
},
cx,
)
});
let add_task = cx.update(|cx| {
project.update(cx, |project, cx| {
project.find_or_create_worktree(worktree_dir, true, cx)
})
});
cx.background_executor.allow_parking();
cx.foreground_executor
.block_test(add_task)
.context("Failed to add worktree")?;
cx.background_executor.forbid_parking();
cx.run_until_parked();
Ok(project)
}
#[cfg(target_os = "macos")]
fn open_sidebar_test_window(
projects: Vec<Entity<Project>>,
app_state: &Arc<AppState>,
cx: &mut VisualTestAppContext,
) -> Result<WindowHandle<MultiWorkspace>> {
anyhow::ensure!(!projects.is_empty(), "need at least one project");
let window_size = size(px(400.0), px(600.0));
let bounds = Bounds {
origin: point(px(0.0), px(0.0)),
size: window_size,
};
let mut projects_iter = projects.into_iter();
let first_project = projects_iter
.next()
.ok_or_else(|| anyhow::anyhow!("need at least one project"))?;
let remaining: Vec<_> = projects_iter.collect();
let multi_workspace_window: WindowHandle<MultiWorkspace> = cx
.update(|cx| {
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
focus: false,
show: false,
..Default::default()
},
|window, cx| {
let first_ws = cx.new(|cx| {
Workspace::new(None, first_project.clone(), app_state.clone(), window, cx)
});
cx.new(|cx| {
let mut mw = MultiWorkspace::new(first_ws, window, cx);
for project in remaining {
let ws = cx.new(|cx| {
Workspace::new(None, project, app_state.clone(), window, cx)
});
mw.activate(ws, window, cx);
}
mw
})
},
)
})
.context("Failed to open MultiWorkspace window")?;
cx.run_until_parked();
// Create the sidebar outside the MultiWorkspace update to avoid a
// re-entrant read panic (Sidebar::new reads the MultiWorkspace).
let sidebar = cx
.update_window(multi_workspace_window.into(), |root_view, window, cx| {
let mw_handle: Entity<MultiWorkspace> = root_view
.downcast()
.map_err(|_| anyhow::anyhow!("Failed to downcast root view to MultiWorkspace"))?;
Ok::<_, anyhow::Error>(cx.new(|cx| sidebar::Sidebar::new(mw_handle, window, cx)))
})
.context("Failed to create sidebar")??;
multi_workspace_window
.update(cx, |mw, _window, cx| {
mw.register_sidebar(sidebar.clone(), cx);
})
.context("Failed to register sidebar")?;
cx.run_until_parked();
// Open the sidebar
multi_workspace_window
.update(cx, |mw, window, cx| {
mw.toggle_sidebar(window, cx);
})
.context("Failed to toggle sidebar")?;
// Let rendering settle
for _ in 0..10 {
cx.advance_clock(Duration::from_millis(100));
cx.run_until_parked();
}
// Refresh the window
cx.update_window(multi_workspace_window.into(), |_, window, _cx| {
window.refresh();
})?;
cx.run_until_parked();
Ok(multi_workspace_window)
}
#[cfg(target_os = "macos")]
fn cleanup_sidebar_test_window(
window: WindowHandle<MultiWorkspace>,
cx: &mut VisualTestAppContext,
) -> Result<()> {
window.update(cx, |mw, _window, cx| {
for workspace in mw.workspaces() {
let project = workspace.read(cx).project().clone();
project.update(cx, |project, cx| {
let ids: Vec<_> = project.worktrees(cx).map(|wt| wt.read(cx).id()).collect();
for id in ids {
project.remove_worktree(id, cx);
}
});
}
})?;
cx.run_until_parked();
cx.update_window(window.into(), |_, window, _cx| {
window.remove_window();
})?;
cx.run_until_parked();
for _ in 0..15 {
cx.advance_clock(Duration::from_millis(100));
cx.run_until_parked();
}
Ok(())
}
#[cfg(target_os = "macos")]
fn run_sidebar_duplicate_project_names_visual_tests(
app_state: Arc<AppState>,
cx: &mut VisualTestAppContext,
update_baseline: bool,
) -> Result<TestResult> {
let temp_dir = tempfile::tempdir()?;
let temp_path = temp_dir.keep();
let canonical_temp = temp_path.canonicalize()?;
// Create directory structure where every leaf directory is named "zed" but
// lives at a distinct path. This lets us test that the sidebar correctly
// disambiguates projects whose names would otherwise collide.
//
// code/zed/ — project1 (single worktree)
// code/foo/zed/ — project2 (single worktree)
// code/bar/zed/ — project3, first worktree
// code/baz/zed/ — project3, second worktree
//
// No two projects share a worktree path, so ProjectGroupBuilder will
// place each in its own group.
let code_zed = canonical_temp.join("code").join("zed");
let foo_zed = canonical_temp.join("code").join("foo").join("zed");
let bar_zed = canonical_temp.join("code").join("bar").join("zed");
let baz_zed = canonical_temp.join("code").join("baz").join("zed");
std::fs::create_dir_all(&code_zed)?;
std::fs::create_dir_all(&foo_zed)?;
std::fs::create_dir_all(&bar_zed)?;
std::fs::create_dir_all(&baz_zed)?;
cx.update(|cx| {
cx.update_flags(true, vec!["agent-v2".to_string()]);
});
let mut has_baseline_update = None;
// Two single-worktree projects whose leaf name is "zed"
{
let project1 = create_project_with_worktree(&code_zed, &app_state, cx)?;
let project2 = create_project_with_worktree(&foo_zed, &app_state, cx)?;
let window = open_sidebar_test_window(vec![project1, project2], &app_state, cx)?;
let result = run_visual_test(
"sidebar_two_projects_same_leaf_name",
window.into(),
cx,
update_baseline,
);
cleanup_sidebar_test_window(window, cx)?;
match result? {
TestResult::Passed => {}
TestResult::BaselineUpdated(path) => {
has_baseline_update = Some(path);
}
}
}
// Three projects, third has two worktrees (all leaf names "zed")
//
// project1: code/zed
// project2: code/foo/zed
// project3: code/bar/zed + code/baz/zed
//
// Each project has a unique set of worktree paths, so they form
// separate groups. The sidebar must disambiguate all three.
{
let project1 = create_project_with_worktree(&code_zed, &app_state, cx)?;
let project2 = create_project_with_worktree(&foo_zed, &app_state, cx)?;
let project3 = create_project_with_worktree(&bar_zed, &app_state, cx)?;
let add_second_worktree = cx.update(|cx| {
project3.update(cx, |project, cx| {
project.find_or_create_worktree(&baz_zed, true, cx)
})
});
cx.background_executor.allow_parking();
cx.foreground_executor
.block_test(add_second_worktree)
.context("Failed to add second worktree to project 3")?;
cx.background_executor.forbid_parking();
cx.run_until_parked();
let window = open_sidebar_test_window(vec![project1, project2, project3], &app_state, cx)?;
let result = run_visual_test(
"sidebar_three_projects_with_multi_worktree",
window.into(),
cx,
update_baseline,
);
cleanup_sidebar_test_window(window, cx)?;
match result? {
TestResult::Passed => {}
TestResult::BaselineUpdated(path) => {
has_baseline_update = Some(path);
}
}
}
if let Some(path) = has_baseline_update {
Ok(TestResult::BaselineUpdated(path))
} else {
Ok(TestResult::Passed)
}
}
#[cfg(all(target_os = "macos", feature = "visual-tests"))]
fn run_start_thread_in_selector_visual_tests(
app_state: Arc<AppState>,