Add branch to git panel (#24485)

Mikayla Maki , ConradIrwin , and Conrad created

This PR adds the branch selector to the git panel and fixes a few bugs
in the repository selector.

Release Notes:

- N/A

---------

Co-authored-by: ConradIrwin <conrad.irwin@gmail.com>
Co-authored-by: Conrad <conrad@zed.dev>

Change summary

Cargo.lock                               |  19 -
Cargo.toml                               |   3 
crates/git_ui/Cargo.toml                 |   2 
crates/git_ui/src/branch_picker.rs       | 123 +++++-------
crates/git_ui/src/git_panel.rs           |  78 +++----
crates/git_ui/src/git_ui.rs              |   2 
crates/git_ui/src/repository_selector.rs |   1 
crates/project/src/git.rs                |  28 +-
crates/title_bar/src/title_bar.rs        |   4 
crates/ui/src/components/tooltip.rs      |  16 +
crates/vcs_menu/Cargo.toml               |  21 --
crates/vcs_menu/LICENSE-GPL              |   1 
crates/worktree/Cargo.toml               |   6 
crates/worktree/src/worktree.rs          | 258 ++++++++++++++-----------
crates/worktree/src/worktree_tests.rs    |  51 +++-
crates/zed/Cargo.toml                    |   1 
crates/zed/src/main.rs                   |   1 
crates/zed_actions/src/lib.rs            |   6 
18 files changed, 309 insertions(+), 312 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -5330,6 +5330,7 @@ dependencies = [
  "editor",
  "feature_flags",
  "futures 0.3.31",
+ "fuzzy",
  "git",
  "gpui",
  "language",
@@ -5349,6 +5350,7 @@ dependencies = [
  "util",
  "windows 0.58.0",
  "workspace",
+ "zed_actions",
 ]
 
 [[package]]
@@ -14616,22 +14618,6 @@ version = "0.2.15"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
 
-[[package]]
-name = "vcs_menu"
-version = "0.1.0"
-dependencies = [
- "anyhow",
- "fuzzy",
- "git",
- "gpui",
- "picker",
- "project",
- "ui",
- "util",
- "workspace",
- "zed_actions",
-]
-
 [[package]]
 name = "version-compare"
 version = "0.2.0"
@@ -16657,7 +16643,6 @@ dependencies = [
  "urlencoding",
  "util",
  "uuid",
- "vcs_menu",
  "vim",
  "vim_mode_setting",
  "welcome",

Cargo.toml 🔗

@@ -147,7 +147,6 @@ members = [
     "crates/ui_macros",
     "crates/util",
     "crates/util_macros",
-    "crates/vcs_menu",
     "crates/vim",
     "crates/vim_mode_setting",
     "crates/welcome",
@@ -346,7 +345,6 @@ ui_input = { path = "crates/ui_input" }
 ui_macros = { path = "crates/ui_macros" }
 util = { path = "crates/util" }
 util_macros = { path = "crates/util_macros" }
-vcs_menu = { path = "crates/vcs_menu" }
 vim = { path = "crates/vim" }
 vim_mode_setting = { path = "crates/vim_mode_setting" }
 welcome = { path = "crates/welcome" }
@@ -676,7 +674,6 @@ telemetry_events = { codegen-units = 1 }
 theme_selector = { codegen-units = 1 }
 time_format = { codegen-units = 1 }
 ui_input = { codegen-units = 1 }
-vcs_menu = { codegen-units = 1 }
 zed_actions = { codegen-units = 1 }
 
 [profile.release]

crates/git_ui/Cargo.toml 🔗

@@ -20,6 +20,7 @@ diff.workspace = true
 editor.workspace = true
 feature_flags.workspace = true
 futures.workspace = true
+fuzzy.workspace = true
 git.workspace = true
 gpui.workspace = true
 language.workspace = true
@@ -38,6 +39,7 @@ theme.workspace = true
 ui.workspace = true
 util.workspace = true
 workspace.workspace = true
+zed_actions.workspace = true
 
 [target.'cfg(windows)'.dependencies]
 windows.workspace = true

crates/vcs_menu/src/lib.rs → crates/git_ui/src/branch_picker.rs 🔗

@@ -1,27 +1,49 @@
 use anyhow::{anyhow, Context as _, Result};
 use fuzzy::{StringMatch, StringMatchCandidate};
+
 use git::repository::Branch;
 use gpui::{
-    rems, AnyElement, App, AsyncApp, Context, DismissEvent, Entity, EventEmitter, FocusHandle,
-    Focusable, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled,
-    Subscription, Task, WeakEntity, Window,
+    rems, App, AsyncApp, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
+    InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
+    Task, WeakEntity, Window,
 };
 use picker::{Picker, PickerDelegate};
 use project::ProjectPath;
-use std::{ops::Not, sync::Arc};
+use std::sync::Arc;
 use ui::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing};
 use util::ResultExt;
 use workspace::notifications::DetachAndPromptErr;
 use workspace::{ModalView, Workspace};
-use zed_actions::branches::OpenRecent;
 
 pub fn init(cx: &mut App) {
     cx.observe_new(|workspace: &mut Workspace, _, _| {
-        workspace.register_action(BranchList::open);
+        workspace.register_action(open);
     })
     .detach();
 }
 
+pub fn open(
+    _: &mut Workspace,
+    _: &zed_actions::git::Branch,
+    window: &mut Window,
+    cx: &mut Context<Workspace>,
+) {
+    let this = cx.entity().clone();
+    cx.spawn_in(window, |_, mut cx| async move {
+        // Modal branch picker has a longer trailoff than a popover one.
+        let delegate = BranchListDelegate::new(this.clone(), 70, &cx).await?;
+
+        this.update_in(&mut cx, |workspace, window, cx| {
+            workspace.toggle_modal(window, cx, |window, cx| {
+                BranchList::new(delegate, 34., window, cx)
+            })
+        })?;
+
+        Ok(())
+    })
+    .detach_and_prompt_err("Failed to read branches", window, cx, |_, _, _| None)
+}
+
 pub struct BranchList {
     pub picker: Entity<Picker<BranchListDelegate>>,
     rem_width: f32,
@@ -29,29 +51,7 @@ pub struct BranchList {
 }
 
 impl BranchList {
-    pub fn open(
-        _: &mut Workspace,
-        _: &OpenRecent,
-        window: &mut Window,
-        cx: &mut Context<Workspace>,
-    ) {
-        let this = cx.entity().clone();
-        cx.spawn_in(window, |_, mut cx| async move {
-            // Modal branch picker has a longer trailoff than a popover one.
-            let delegate = BranchListDelegate::new(this.clone(), 70, &cx).await?;
-
-            this.update_in(&mut cx, |workspace, window, cx| {
-                workspace.toggle_modal(window, cx, |window, cx| {
-                    BranchList::new(delegate, 34., window, cx)
-                })
-            })?;
-
-            Ok(())
-        })
-        .detach_and_prompt_err("Failed to read branches", window, cx, |_, _, _| None)
-    }
-
-    fn new(
+    pub fn new(
         delegate: BranchListDelegate,
         rem_width: f32,
         window: &mut Window,
@@ -91,6 +91,7 @@ impl Render for BranchList {
 #[derive(Debug, Clone)]
 enum BranchEntry {
     Branch(StringMatch),
+    History(String),
     NewBranch { name: String },
 }
 
@@ -98,6 +99,7 @@ impl BranchEntry {
     fn name(&self) -> &str {
         match self {
             Self::Branch(branch) => &branch.string,
+            Self::History(branch) => &branch,
             Self::NewBranch { name } => &name,
         }
     }
@@ -114,7 +116,7 @@ pub struct BranchListDelegate {
 }
 
 impl BranchListDelegate {
-    async fn new(
+    pub async fn new(
         workspace: Entity<Workspace>,
         branch_name_trailoff_after: usize,
         cx: &AsyncApp,
@@ -141,7 +143,7 @@ impl BranchListDelegate {
         })
     }
 
-    fn branch_count(&self) -> usize {
+    pub fn branch_count(&self) -> usize {
         self.matches
             .iter()
             .filter(|item| matches!(item, BranchEntry::Branch(_)))
@@ -207,16 +209,10 @@ impl PickerDelegate for BranchListDelegate {
             let Some(candidates) = candidates.log_err() else {
                 return;
             };
-            let matches = if query.is_empty() {
+            let matches: Vec<BranchEntry> = if query.is_empty() {
                 candidates
                     .into_iter()
-                    .enumerate()
-                    .map(|(index, candidate)| StringMatch {
-                        candidate_id: index,
-                        string: candidate.string,
-                        positions: Vec::new(),
-                        score: 0.0,
-                    })
+                    .map(|candidate| BranchEntry::History(candidate.string))
                     .collect()
             } else {
                 fuzzy::match_strings(
@@ -228,11 +224,15 @@ impl PickerDelegate for BranchListDelegate {
                     cx.background_executor().clone(),
                 )
                 .await
+                .iter()
+                .cloned()
+                .map(BranchEntry::Branch)
+                .collect()
             };
             picker
                 .update(&mut cx, |picker, _| {
                     let delegate = &mut picker.delegate;
-                    delegate.matches = matches.into_iter().map(BranchEntry::Branch).collect();
+                    delegate.matches = matches;
                     if delegate.matches.is_empty() {
                         if !query.is_empty() {
                             delegate.matches.push(BranchEntry::NewBranch {
@@ -268,6 +268,7 @@ impl PickerDelegate for BranchListDelegate {
                     let project = workspace.read(cx).project().read(cx);
                     let branch_to_checkout = match branch {
                         BranchEntry::Branch(branch) => branch.string,
+                        BranchEntry::History(string) => string,
                         BranchEntry::NewBranch { name: branch_name } => branch_name,
                     };
                     let worktree = project
@@ -311,7 +312,14 @@ impl PickerDelegate for BranchListDelegate {
                 .inset(true)
                 .spacing(ListItemSpacing::Sparse)
                 .toggle_state(selected)
-                .map(|parent| match hit {
+                .when(matches!(hit, BranchEntry::History(_)), |el| {
+                    el.end_slot(
+                        Icon::new(IconName::HistoryRerun)
+                            .color(Color::Muted)
+                            .size(IconSize::Small),
+                    )
+                })
+                .map(|el| match hit {
                     BranchEntry::Branch(branch) => {
                         let highlights: Vec<_> = branch
                             .positions
@@ -320,40 +328,13 @@ impl PickerDelegate for BranchListDelegate {
                             .copied()
                             .collect();
 
-                        parent.child(HighlightedLabel::new(shortened_branch_name, highlights))
+                        el.child(HighlightedLabel::new(shortened_branch_name, highlights))
                     }
+                    BranchEntry::History(_) => el.child(Label::new(shortened_branch_name)),
                     BranchEntry::NewBranch { name } => {
-                        parent.child(Label::new(format!("Create branch '{name}'")))
+                        el.child(Label::new(format!("Create branch '{name}'")))
                     }
                 }),
         )
     }
-
-    fn render_header(
-        &self,
-        _window: &mut Window,
-        _: &mut Context<Picker<Self>>,
-    ) -> Option<AnyElement> {
-        let label = if self.last_query.is_empty() {
-            Label::new("Recent Branches")
-                .size(LabelSize::Small)
-                .mt_1()
-                .ml_3()
-                .into_any_element()
-        } else {
-            let match_label = self.matches.is_empty().not().then(|| {
-                let suffix = if self.branch_count() == 1 { "" } else { "es" };
-                Label::new(format!("{} match{}", self.branch_count(), suffix))
-                    .color(Color::Muted)
-                    .size(LabelSize::Small)
-            });
-            h_flex()
-                .px_3()
-                .justify_between()
-                .child(Label::new("Branches").size(LabelSize::Small))
-                .children(match_label)
-                .into_any_element()
-        };
-        Some(v_flex().mt_1().child(label).into_any_element())
-    }
 }

crates/git_ui/src/git_panel.rs 🔗

@@ -1110,33 +1110,43 @@ impl GitPanel {
             .git_state()
             .read(cx)
             .all_repositories();
-        let entry_count = self
+
+        let branch = self
             .active_repository
             .as_ref()
-            .map_or(0, |repo| repo.read(cx).entry_count());
+            .and_then(|repository| repository.read(cx).branch())
+            .unwrap_or_else(|| "(no current branch)".into());
+
+        let has_repo_above = all_repositories.iter().any(|repo| {
+            repo.read(cx)
+                .repository_entry
+                .work_directory
+                .is_above_project()
+        });
 
-        let changes_string = match entry_count {
-            0 => "No changes".to_string(),
-            1 => "1 change".to_string(),
-            n => format!("{} changes", n),
-        };
+        let icon_button = Button::new("branch-selector", branch)
+            .color(Color::Muted)
+            .style(ButtonStyle::Subtle)
+            .icon(IconName::GitBranch)
+            .icon_size(IconSize::Small)
+            .icon_color(Color::Muted)
+            .size(ButtonSize::Compact)
+            .icon_position(IconPosition::Start)
+            .tooltip(Tooltip::for_action_title(
+                "Switch Branch",
+                &zed_actions::git::Branch,
+            ))
+            .on_click(cx.listener(|_, _, window, cx| {
+                window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
+            }))
+            .style(ButtonStyle::Transparent);
 
         self.panel_header_container(window, cx)
-            .child(h_flex().gap_2().child(if all_repositories.len() <= 1 {
-                div()
-                    .id("changes-label")
-                    .text_buffer(cx)
-                    .text_ui_sm(cx)
-                    .child(
-                        Label::new(changes_string)
-                            .single_line()
-                            .size(LabelSize::Small),
-                    )
-                    .into_any_element()
-            } else {
-                self.render_repository_selector(cx).into_any_element()
-            }))
+            .child(h_flex().pl_1().child(icon_button))
             .child(div().flex_grow())
+            .when(all_repositories.len() > 1 || has_repo_above, |el| {
+                el.child(self.render_repository_selector(cx))
+            })
     }
 
     pub fn render_repository_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
@@ -1146,35 +1156,11 @@ impl GitPanel {
             .map(|repo| repo.read(cx).display_name(self.project.read(cx), cx))
             .unwrap_or_default();
 
-        let entry_count = self.entries.len();
-
         RepositorySelectorPopoverMenu::new(
             self.repository_selector.clone(),
             ButtonLike::new("active-repository")
                 .style(ButtonStyle::Subtle)
-                .child(
-                    h_flex().w_full().gap_0p5().child(
-                        div()
-                            .overflow_x_hidden()
-                            .flex_grow()
-                            .whitespace_nowrap()
-                            .child(
-                                h_flex()
-                                    .gap_1()
-                                    .child(
-                                        Label::new(repository_display_name).size(LabelSize::Small),
-                                    )
-                                    .when(entry_count > 0, |flex| {
-                                        flex.child(
-                                            Label::new(format!("({})", entry_count))
-                                                .size(LabelSize::Small)
-                                                .color(Color::Muted),
-                                        )
-                                    })
-                                    .into_any_element(),
-                            ),
-                    ),
-                ),
+                .child(Label::new(repository_display_name).size(LabelSize::Small)),
         )
     }
 

crates/git_ui/src/git_ui.rs 🔗

@@ -5,6 +5,7 @@ use gpui::App;
 use project_diff::ProjectDiff;
 use ui::{ActiveTheme, Color, Icon, IconName, IntoElement};
 
+pub mod branch_picker;
 pub mod git_panel;
 mod git_panel_settings;
 pub mod project_diff;
@@ -12,6 +13,7 @@ pub mod repository_selector;
 
 pub fn init(cx: &mut App) {
     GitPanelSettings::register(cx);
+    branch_picker::init(cx);
     cx.observe_new(ProjectDiff::register).detach();
 }
 

crates/git_ui/src/repository_selector.rs 🔗

@@ -34,6 +34,7 @@ impl RepositorySelector {
         let picker = cx.new(|cx| {
             Picker::nonsearchable_uniform_list(delegate, window, cx)
                 .max_height(Some(rems(20.).into()))
+                .width(rems(15.))
         });
 
         let _subscriptions =

crates/project/src/git.rs 🔗

@@ -15,7 +15,7 @@ use gpui::{
 use language::{Buffer, LanguageRegistry};
 use rpc::{proto, AnyProtoClient};
 use settings::WorktreeId;
-use std::path::Path;
+use std::path::{Path, PathBuf};
 use std::sync::Arc;
 use text::BufferId;
 use util::{maybe, ResultExt};
@@ -299,19 +299,25 @@ impl Repository {
         (self.worktree_id, self.repository_entry.work_directory_id())
     }
 
+    pub fn branch(&self) -> Option<Arc<str>> {
+        self.repository_entry.branch()
+    }
+
     pub fn display_name(&self, project: &Project, cx: &App) -> SharedString {
         maybe!({
-            let path = self.repo_path_to_project_path(&"".into())?;
-            Some(
-                project
-                    .absolute_path(&path, cx)?
-                    .file_name()?
-                    .to_string_lossy()
-                    .to_string()
-                    .into(),
-            )
+            let project_path = self.repo_path_to_project_path(&"".into())?;
+            let worktree_name = project
+                .worktree_for_id(project_path.worktree_id, cx)?
+                .read(cx)
+                .root_name();
+
+            let mut path = PathBuf::new();
+            path = path.join(worktree_name);
+            path = path.join(project_path.path);
+            Some(path.to_string_lossy().to_string())
         })
-        .unwrap_or("".into())
+        .unwrap_or_else(|| self.repository_entry.work_directory.display_name())
+        .into()
     }
 
     pub fn activate(&self, cx: &mut Context<Self>) {

crates/title_bar/src/title_bar.rs 🔗

@@ -530,7 +530,7 @@ impl TitleBar {
                 .tooltip(move |window, cx| {
                     Tooltip::with_meta(
                         "Recent Branches",
-                        Some(&zed_actions::branches::OpenRecent),
+                        Some(&zed_actions::git::Branch),
                         "Local branches only",
                         window,
                         cx,
@@ -538,7 +538,7 @@ impl TitleBar {
                 })
                 .on_click(move |_, window, cx| {
                     let _ = workspace.update(cx, |_this, cx| {
-                        window.dispatch_action(zed_actions::branches::OpenRecent.boxed_clone(), cx);
+                        window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
                     });
                 }),
         )

crates/ui/src/components/tooltip.rs 🔗

@@ -35,6 +35,22 @@ impl Tooltip {
         }
     }
 
+    pub fn for_action_title(
+        title: impl Into<SharedString>,
+        action: &dyn Action,
+    ) -> impl Fn(&mut Window, &mut App) -> AnyView {
+        let title = title.into();
+        let action = action.boxed_clone();
+        move |window, cx| {
+            cx.new(|_| Self {
+                title: title.clone(),
+                meta: None,
+                key_binding: KeyBinding::for_action(action.as_ref(), window),
+            })
+            .into()
+        }
+    }
+
     pub fn for_action(
         title: impl Into<SharedString>,
         action: &dyn Action,

crates/vcs_menu/Cargo.toml 🔗

@@ -1,21 +0,0 @@
-[package]
-name = "vcs_menu"
-version = "0.1.0"
-edition.workspace = true
-publish.workspace = true
-license = "GPL-3.0-or-later"
-
-[lints]
-workspace = true
-
-[dependencies]
-anyhow.workspace = true
-fuzzy.workspace = true
-git.workspace = true
-gpui.workspace = true
-picker.workspace = true
-project.workspace = true
-ui.workspace = true
-util.workspace = true
-workspace.workspace = true
-zed_actions.workspace = true

crates/worktree/Cargo.toml 🔗

@@ -14,11 +14,12 @@ workspace = true
 
 [features]
 test-support = [
+    "gpui/test-support",
+    "http_client/test-support",
     "language/test-support",
     "settings/test-support",
     "text/test-support",
-    "gpui/test-support",
-    "http_client/test-support",
+    "util/test-support",
 ]
 
 [dependencies]
@@ -59,3 +60,4 @@ pretty_assertions.workspace = true
 rand.workspace = true
 rpc = { workspace = true, features = ["test-support"] }
 settings = { workspace = true, features = ["test-support"] }
+util = { workspace = true, features = ["test-support"] }

crates/worktree/src/worktree.rs 🔗

@@ -213,12 +213,6 @@ impl Deref for RepositoryEntry {
     }
 }
 
-impl AsRef<Path> for RepositoryEntry {
-    fn as_ref(&self) -> &Path {
-        &self.path
-    }
-}
-
 impl RepositoryEntry {
     pub fn branch(&self) -> Option<Arc<str>> {
         self.branch.clone()
@@ -326,33 +320,53 @@ impl RepositoryEntry {
 /// But if a sub-folder of a git repository is opened, this corresponds to the
 /// project root and the .git folder is located in a parent directory.
 #[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Hash)]
-pub struct WorkDirectory {
-    path: Arc<Path>,
-
-    /// If location_in_repo is set, it means the .git folder is external
-    /// and in a parent folder of the project root.
-    /// In that case, the work_directory field will point to the
-    /// project-root and location_in_repo contains the location of the
-    /// project-root in the repository.
-    ///
-    /// Example:
-    ///
-    ///     my_root_folder/          <-- repository root
-    ///       .git
-    ///       my_sub_folder_1/
-    ///         project_root/        <-- Project root, Zed opened here
-    ///           ...
-    ///
-    /// For this setup, the attributes will have the following values:
-    ///
-    ///     work_directory: pointing to "" entry
-    ///     location_in_repo: Some("my_sub_folder_1/project_root")
-    pub(crate) location_in_repo: Option<Arc<Path>>,
+pub enum WorkDirectory {
+    InProject {
+        relative_path: Arc<Path>,
+    },
+    AboveProject {
+        absolute_path: Arc<Path>,
+        location_in_repo: Arc<Path>,
+    },
 }
 
 impl WorkDirectory {
-    pub fn path_key(&self) -> PathKey {
-        PathKey(self.path.clone())
+    #[cfg(test)]
+    fn in_project(path: &str) -> Self {
+        let path = Path::new(path);
+        Self::InProject {
+            relative_path: path.into(),
+        }
+    }
+
+    #[cfg(test)]
+    fn canonicalize(&self) -> Self {
+        match self {
+            WorkDirectory::InProject { relative_path } => WorkDirectory::InProject {
+                relative_path: relative_path.clone(),
+            },
+            WorkDirectory::AboveProject {
+                absolute_path,
+                location_in_repo,
+            } => WorkDirectory::AboveProject {
+                absolute_path: absolute_path.canonicalize().unwrap().into(),
+                location_in_repo: location_in_repo.clone(),
+            },
+        }
+    }
+
+    pub fn is_above_project(&self) -> bool {
+        match self {
+            WorkDirectory::InProject { .. } => false,
+            WorkDirectory::AboveProject { .. } => true,
+        }
+    }
+
+    fn path_key(&self) -> PathKey {
+        match self {
+            WorkDirectory::InProject { relative_path } => PathKey(relative_path.clone()),
+            WorkDirectory::AboveProject { .. } => PathKey(Path::new("").into()),
+        }
     }
 
     /// Returns true if the given path is a child of the work directory.
@@ -360,9 +374,14 @@ impl WorkDirectory {
     /// Note that the path may not be a member of this repository, if there
     /// is a repository in a directory between these two paths
     /// external .git folder in a parent folder of the project root.
+    #[track_caller]
     pub fn directory_contains(&self, path: impl AsRef<Path>) -> bool {
         let path = path.as_ref();
-        path.starts_with(&self.path)
+        debug_assert!(path.is_relative());
+        match self {
+            WorkDirectory::InProject { relative_path } => path.starts_with(relative_path),
+            WorkDirectory::AboveProject { .. } => true,
+        }
     }
 
     /// relativize returns the given project path relative to the root folder of the
@@ -371,53 +390,71 @@ impl WorkDirectory {
     /// of the project root folder, then the returned RepoPath is relative to the root
     /// of the repository and not a valid path inside the project.
     pub fn relativize(&self, path: &Path) -> Result<RepoPath> {
-        let repo_path = if let Some(location_in_repo) = &self.location_in_repo {
-            // Avoid joining a `/` to location_in_repo in the case of a single-file worktree.
-            if path == Path::new("") {
-                RepoPath(location_in_repo.clone())
-            } else {
-                location_in_repo.join(path).into()
+        // path is assumed to be relative to worktree root.
+        debug_assert!(path.is_relative());
+        match self {
+            WorkDirectory::InProject { relative_path } => Ok(path
+                .strip_prefix(relative_path)
+                .map_err(|_| {
+                    anyhow!(
+                        "could not relativize {:?} against {:?}",
+                        path,
+                        relative_path
+                    )
+                })?
+                .into()),
+            WorkDirectory::AboveProject {
+                location_in_repo, ..
+            } => {
+                // Avoid joining a `/` to location_in_repo in the case of a single-file worktree.
+                if path == Path::new("") {
+                    Ok(RepoPath(location_in_repo.clone()))
+                } else {
+                    Ok(location_in_repo.join(path).into())
+                }
             }
-        } else {
-            path.strip_prefix(&self.path)
-                .map_err(|_| anyhow!("could not relativize {:?} against {:?}", path, self.path))?
-                .into()
-        };
-        Ok(repo_path)
+        }
     }
 
     /// This is the opposite operation to `relativize` above
     pub fn unrelativize(&self, path: &RepoPath) -> Option<Arc<Path>> {
-        if let Some(location) = &self.location_in_repo {
-            // If we fail to strip the prefix, that means this status entry is
-            // external to this worktree, and we definitely won't have an entry_id
-            path.strip_prefix(location).ok().map(Into::into)
-        } else {
-            Some(self.path.join(path).into())
+        match self {
+            WorkDirectory::InProject { relative_path } => Some(relative_path.join(path).into()),
+            WorkDirectory::AboveProject {
+                location_in_repo, ..
+            } => {
+                // If we fail to strip the prefix, that means this status entry is
+                // external to this worktree, and we definitely won't have an entry_id
+                path.strip_prefix(location_in_repo).ok().map(Into::into)
+            }
         }
     }
-}
 
-impl Default for WorkDirectory {
-    fn default() -> Self {
-        Self {
-            path: Arc::from(Path::new("")),
-            location_in_repo: None,
+    pub fn display_name(&self) -> String {
+        match self {
+            WorkDirectory::InProject { relative_path } => relative_path.display().to_string(),
+            WorkDirectory::AboveProject {
+                absolute_path,
+                location_in_repo,
+            } => {
+                let num_of_dots = location_in_repo.components().count();
+
+                "../".repeat(num_of_dots)
+                    + &absolute_path
+                        .file_name()
+                        .map(|s| s.to_string_lossy())
+                        .unwrap_or_default()
+                    + "/"
+            }
         }
     }
 }
 
-impl Deref for WorkDirectory {
-    type Target = Path;
-
-    fn deref(&self) -> &Self::Target {
-        self.as_ref()
-    }
-}
-
-impl AsRef<Path> for WorkDirectory {
-    fn as_ref(&self) -> &Path {
-        self.path.as_ref()
+impl Default for WorkDirectory {
+    fn default() -> Self {
+        Self::InProject {
+            relative_path: Arc::from(Path::new("")),
+        }
     }
 }
 
@@ -487,7 +524,7 @@ impl sum_tree::Item for LocalRepositoryEntry {
 
     fn summary(&self, _: &<Self::Summary as Summary>::Context) -> Self::Summary {
         PathSummary {
-            max_path: self.work_directory.path.clone(),
+            max_path: self.work_directory.path_key().0,
             item_summary: Unit,
         }
     }
@@ -497,7 +534,7 @@ impl KeyedItem for LocalRepositoryEntry {
     type Key = PathKey;
 
     fn key(&self) -> Self::Key {
-        PathKey(self.work_directory.path.clone())
+        self.work_directory.path_key()
     }
 }
 
@@ -2574,12 +2611,11 @@ impl Snapshot {
                     self.repositories.insert_or_replace(
                         RepositoryEntry {
                             work_directory_id,
-                            work_directory: WorkDirectory {
-                                path: work_dir_entry.path.clone(),
-                                // When syncing repository entries from a peer, we don't need
-                                // the location_in_repo field, since git operations don't happen locally
-                                // anyway.
-                                location_in_repo: None,
+                            // When syncing repository entries from a peer, we don't need
+                            // the location_in_repo field, since git operations don't happen locally
+                            // anyway.
+                            work_directory: WorkDirectory::InProject {
+                                relative_path: work_dir_entry.path.clone(),
                             },
                             branch: repository.branch.map(Into::into),
                             statuses_by_path: statuses,
@@ -2690,23 +2726,13 @@ impl Snapshot {
         &self.repositories
     }
 
-    pub fn repositories_with_abs_paths(
-        &self,
-    ) -> impl '_ + Iterator<Item = (&RepositoryEntry, PathBuf)> {
-        let base = self.abs_path();
-        self.repositories.iter().map(|repo| {
-            let path = repo.work_directory.location_in_repo.as_deref();
-            let path = path.unwrap_or(repo.work_directory.as_ref());
-            (repo, base.join(path))
-        })
-    }
-
     /// Get the repository whose work directory corresponds to the given path.
     pub(crate) fn repository(&self, work_directory: PathKey) -> Option<RepositoryEntry> {
         self.repositories.get(&work_directory, &()).cloned()
     }
 
     /// Get the repository whose work directory contains the given path.
+    #[track_caller]
     pub fn repository_for_path(&self, path: &Path) -> Option<&RepositoryEntry> {
         self.repositories
             .iter()
@@ -2716,6 +2742,7 @@ impl Snapshot {
 
     /// Given an ordered iterator of entries, returns an iterator of those entries,
     /// along with their containing git repository.
+    #[track_caller]
     pub fn entries_with_repositories<'a>(
         &'a self,
         entries: impl 'a + Iterator<Item = &'a Entry>,
@@ -3081,7 +3108,7 @@ impl LocalSnapshot {
         let work_dir_paths = self
             .repositories
             .iter()
-            .map(|repo| repo.work_directory.path.clone())
+            .map(|repo| repo.work_directory.path_key())
             .collect::<HashSet<_>>();
         assert_eq!(dotgit_paths.len(), work_dir_paths.len());
         assert_eq!(self.repositories.iter().count(), work_dir_paths.len());
@@ -3289,7 +3316,7 @@ impl BackgroundScannerState {
             .git_repositories
             .retain(|id, _| removed_ids.binary_search(id).is_err());
         self.snapshot.repositories.retain(&(), |repository| {
-            !repository.work_directory.starts_with(path)
+            !repository.work_directory.path_key().0.starts_with(path)
         });
 
         #[cfg(test)]
@@ -3327,20 +3354,26 @@ impl BackgroundScannerState {
             }
         };
 
-        self.insert_git_repository_for_path(work_dir_path, dot_git_path, None, fs, watcher)
+        self.insert_git_repository_for_path(
+            WorkDirectory::InProject {
+                relative_path: work_dir_path,
+            },
+            dot_git_path,
+            fs,
+            watcher,
+        )
     }
 
     fn insert_git_repository_for_path(
         &mut self,
-        work_dir_path: Arc<Path>,
+        work_directory: WorkDirectory,
         dot_git_path: Arc<Path>,
-        location_in_repo: Option<Arc<Path>>,
         fs: &dyn Fs,
         watcher: &dyn Watcher,
     ) -> Option<LocalRepositoryEntry> {
         let work_dir_id = self
             .snapshot
-            .entry_for_path(work_dir_path.clone())
+            .entry_for_path(work_directory.path_key().0)
             .map(|entry| entry.id)?;
 
         if self.snapshot.git_repositories.get(&work_dir_id).is_some() {
@@ -3374,10 +3407,6 @@ impl BackgroundScannerState {
         };
 
         log::trace!("constructed libgit2 repo in {:?}", t0.elapsed());
-        let work_directory = WorkDirectory {
-            path: work_dir_path.clone(),
-            location_in_repo,
-        };
 
         if let Some(git_hosting_provider_registry) = self.git_hosting_provider_registry.clone() {
             git_hosting_providers::register_additional_providers(
@@ -3840,7 +3869,7 @@ impl sum_tree::Item for RepositoryEntry {
 
     fn summary(&self, _: &<Self::Summary as Summary>::Context) -> Self::Summary {
         PathSummary {
-            max_path: self.work_directory.path.clone(),
+            max_path: self.work_directory.path_key().0,
             item_summary: Unit,
         }
     }
@@ -3850,7 +3879,7 @@ impl sum_tree::KeyedItem for RepositoryEntry {
     type Key = PathKey;
 
     fn key(&self) -> Self::Key {
-        PathKey(self.work_directory.path.clone())
+        self.work_directory.path_key()
     }
 }
 
@@ -4089,7 +4118,7 @@ impl<'a> sum_tree::Dimension<'a, PathEntrySummary> for ProjectEntryId {
     }
 }
 
-#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
+#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
 pub struct PathKey(Arc<Path>);
 
 impl Default for PathKey {
@@ -4168,15 +4197,15 @@ impl BackgroundScanner {
                         // We associate the external git repo with our root folder and
                         // also mark where in the git repo the root folder is located.
                         self.state.lock().insert_git_repository_for_path(
-                            Path::new("").into(),
-                            ancestor_dot_git.into(),
-                            Some(
-                                root_abs_path
+                            WorkDirectory::AboveProject {
+                                absolute_path: ancestor.into(),
+                                location_in_repo: root_abs_path
                                     .as_path()
                                     .strip_prefix(ancestor)
                                     .unwrap()
                                     .into(),
-                            ),
+                            },
+                            ancestor_dot_git.into(),
                             self.fs.as_ref(),
                             self.watcher.as_ref(),
                         );
@@ -4385,13 +4414,6 @@ impl BackgroundScanner {
                         dot_git_abs_paths.push(dot_git_abs_path);
                     }
                 }
-                if abs_path.0.file_name() == Some(*GITIGNORE) {
-                    for (_, repo) in snapshot.git_repositories.iter().filter(|(_, repo)| repo.directory_contains(&abs_path.0)) {
-                        if !dot_git_abs_paths.iter().any(|dot_git_abs_path| dot_git_abs_path == repo.dot_git_dir_abs_path.as_ref()) {
-                            dot_git_abs_paths.push(repo.dot_git_dir_abs_path.to_path_buf());
-                        }
-                    }
-                }
 
                 let relative_path: Arc<Path> =
                     if let Ok(path) = abs_path.strip_prefix(&root_canonical_path) {
@@ -4409,6 +4431,14 @@ impl BackgroundScanner {
                         return false;
                     };
 
+                if abs_path.0.file_name() == Some(*GITIGNORE) {
+                    for (_, repo) in snapshot.git_repositories.iter().filter(|(_, repo)| repo.directory_contains(&relative_path)) {
+                        if !dot_git_abs_paths.iter().any(|dot_git_abs_path| dot_git_abs_path == repo.dot_git_dir_abs_path.as_ref()) {
+                            dot_git_abs_paths.push(repo.dot_git_dir_abs_path.to_path_buf());
+                        }
+                    }
+                }
+
                 let parent_dir_is_loaded = relative_path.parent().map_or(true, |parent| {
                     snapshot
                         .entry_for_path(parent)
@@ -4992,7 +5022,7 @@ impl BackgroundScanner {
                 snapshot
                     .snapshot
                     .repositories
-                    .remove(&PathKey(repository.work_directory.path.clone()), &());
+                    .remove(&repository.work_directory.path_key(), &());
                 return Some(());
             }
         }
@@ -5286,7 +5316,7 @@ impl BackgroundScanner {
     fn update_git_statuses(&self, job: UpdateGitStatusesJob) {
         log::trace!(
             "updating git statuses for repo {:?}",
-            job.local_repository.work_directory.path
+            job.local_repository.work_directory.display_name()
         );
         let t0 = Instant::now();
 
@@ -5300,7 +5330,7 @@ impl BackgroundScanner {
         };
         log::trace!(
             "computed git statuses for repo {:?} in {:?}",
-            job.local_repository.work_directory.path,
+            job.local_repository.work_directory.display_name(),
             t0.elapsed()
         );
 
@@ -5364,7 +5394,7 @@ impl BackgroundScanner {
 
         log::trace!(
             "applied git status updates for repo {:?} in {:?}",
-            job.local_repository.work_directory.path,
+            job.local_repository.work_directory.display_name(),
             t0.elapsed(),
         );
     }

crates/worktree/src/worktree_tests.rs 🔗

@@ -1,6 +1,6 @@
 use crate::{
-    worktree_settings::WorktreeSettings, Entry, EntryKind, Event, PathChange, Snapshot, Worktree,
-    WorktreeModelHandle,
+    worktree_settings::WorktreeSettings, Entry, EntryKind, Event, PathChange, Snapshot,
+    WorkDirectory, Worktree, WorktreeModelHandle,
 };
 use anyhow::Result;
 use fs::{FakeFs, Fs, RealFs, RemoveOptions};
@@ -2200,7 +2200,10 @@ async fn test_rename_work_directory(cx: &mut TestAppContext) {
     cx.read(|cx| {
         let tree = tree.read(cx);
         let repo = tree.repositories().iter().next().unwrap();
-        assert_eq!(repo.path.as_ref(), Path::new("projects/project1"));
+        assert_eq!(
+            repo.work_directory,
+            WorkDirectory::in_project("projects/project1")
+        );
         assert_eq!(
             tree.status_for_file(Path::new("projects/project1/a")),
             Some(StatusCode::Modified.worktree()),
@@ -2221,7 +2224,10 @@ async fn test_rename_work_directory(cx: &mut TestAppContext) {
     cx.read(|cx| {
         let tree = tree.read(cx);
         let repo = tree.repositories().iter().next().unwrap();
-        assert_eq!(repo.path.as_ref(), Path::new("projects/project2"));
+        assert_eq!(
+            repo.work_directory,
+            WorkDirectory::in_project("projects/project2")
+        );
         assert_eq!(
             tree.status_for_file(Path::new("projects/project2/a")),
             Some(StatusCode::Modified.worktree()),
@@ -2275,12 +2281,15 @@ async fn test_git_repository_for_path(cx: &mut TestAppContext) {
         assert!(tree.repository_for_path("c.txt".as_ref()).is_none());
 
         let repo = tree.repository_for_path("dir1/src/b.txt".as_ref()).unwrap();
-        assert_eq!(repo.path.as_ref(), Path::new("dir1"));
+        assert_eq!(repo.work_directory, WorkDirectory::in_project("dir1"));
 
         let repo = tree
             .repository_for_path("dir1/deps/dep1/src/a.txt".as_ref())
             .unwrap();
-        assert_eq!(repo.path.as_ref(), Path::new("dir1/deps/dep1"));
+        assert_eq!(
+            repo.work_directory,
+            WorkDirectory::in_project("dir1/deps/dep1")
+        );
 
         let entries = tree.files(false, 0);
 
@@ -2289,7 +2298,7 @@ async fn test_git_repository_for_path(cx: &mut TestAppContext) {
             .map(|(entry, repo)| {
                 (
                     entry.path.as_ref(),
-                    repo.map(|repo| repo.path.to_path_buf()),
+                    repo.map(|repo| repo.work_directory.clone()),
                 )
             })
             .collect::<Vec<_>>();
@@ -2300,9 +2309,12 @@ async fn test_git_repository_for_path(cx: &mut TestAppContext) {
                 (Path::new("c.txt"), None),
                 (
                     Path::new("dir1/deps/dep1/src/a.txt"),
-                    Some(Path::new("dir1/deps/dep1").into())
+                    Some(WorkDirectory::in_project("dir1/deps/dep1"))
+                ),
+                (
+                    Path::new("dir1/src/b.txt"),
+                    Some(WorkDirectory::in_project("dir1"))
                 ),
-                (Path::new("dir1/src/b.txt"), Some(Path::new("dir1").into())),
             ]
         );
     });
@@ -2408,8 +2420,10 @@ async fn test_file_status(cx: &mut TestAppContext) {
         let snapshot = tree.snapshot();
         assert_eq!(snapshot.repositories().iter().count(), 1);
         let repo_entry = snapshot.repositories().iter().next().unwrap();
-        assert_eq!(repo_entry.path.as_ref(), Path::new("project"));
-        assert!(repo_entry.location_in_repo.is_none());
+        assert_eq!(
+            repo_entry.work_directory,
+            WorkDirectory::in_project("project")
+        );
 
         assert_eq!(
             snapshot.status_for_file(project_path.join(B_TXT)),
@@ -2760,15 +2774,14 @@ async fn test_repository_subfolder_git_status(cx: &mut TestAppContext) {
         let snapshot = tree.snapshot();
         assert_eq!(snapshot.repositories().iter().count(), 1);
         let repo = snapshot.repositories().iter().next().unwrap();
-        // Path is blank because the working directory of
-        // the git repository is located at the root of the project
-        assert_eq!(repo.path.as_ref(), Path::new(""));
-
-        // This is the missing path between the root of the project (sub-folder-2) and its
-        // location relative to the root of the repository.
         assert_eq!(
-            repo.location_in_repo,
-            Some(Arc::from(Path::new("sub-folder-1/sub-folder-2")))
+            repo.work_directory.canonicalize(),
+            WorkDirectory::AboveProject {
+                absolute_path: Arc::from(root.path().join("my-repo").canonicalize().unwrap()),
+                location_in_repo: Arc::from(Path::new(util::separator!(
+                    "sub-folder-1/sub-folder-2"
+                )))
+            }
         );
 
         assert_eq!(snapshot.status_for_file("c.txt"), None);

crates/zed/Cargo.toml 🔗

@@ -126,7 +126,6 @@ url.workspace = true
 urlencoding = "2.1.2"
 util.workspace = true
 uuid.workspace = true
-vcs_menu.workspace = true
 vim.workspace = true
 vim_mode_setting.workspace = true
 welcome.workspace = true

crates/zed/src/main.rs 🔗

@@ -505,7 +505,6 @@ fn main() {
         notifications::init(app_state.client.clone(), app_state.user_store.clone(), cx);
         collab_ui::init(&app_state, cx);
         git_ui::init(cx);
-        vcs_menu::init(cx);
         feedback::init(cx);
         markdown_preview::init(cx);
         welcome::init(cx);

crates/zed_actions/src/lib.rs 🔗

@@ -47,10 +47,10 @@ actions!(
     ]
 );
 
-pub mod branches {
-    use gpui::actions;
+pub mod git {
+    use gpui::action_with_deprecated_aliases;
 
-    actions!(branches, [OpenRecent]);
+    action_with_deprecated_aliases!(git, Branch, ["branches::OpenRecent"]);
 }
 
 pub mod command_palette {