Disambiguate project names (#52848)

Richard Feldman created

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

Change summary

crates/project/src/project.rs                 |  34 +
crates/recent_projects/src/recent_projects.rs |  57 +++-
crates/sidebar/src/sidebar.rs                 |  23 +
crates/util/src/disambiguate.rs               | 202 ++++++++++++++
crates/util/src/util.rs                       |   1 
crates/workspace/src/pane.rs                  |  33 --
crates/zed/src/visual_test_runner.rs          | 294 +++++++++++++++++++++
7 files changed, 587 insertions(+), 57 deletions(-)

Detailed changes

crates/project/src/project.rs 🔗

@@ -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,

crates/recent_projects/src/recent_projects.rs 🔗

@@ -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 {

crates/sidebar/src/sidebar.rs 🔗

@@ -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;
                         }

crates/util/src/disambiguate.rs 🔗

@@ -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");
+    }
+}

crates/util/src/util.rs 🔗

@@ -1,5 +1,6 @@
 pub mod archive;
 pub mod command;
+pub mod disambiguate;
 pub mod fs;
 pub mod markdown;
 pub mod path_list;

crates/workspace/src/pane.rs 🔗

@@ -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> {

crates/zed/src/visual_test_runner.rs 🔗

@@ -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>,