Add branch to git panel (#24485)

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>
This commit is contained in:
Mikayla Maki 2025-02-07 19:27:58 -08:00 committed by GitHub
parent d9183c7669
commit ca4e8043d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 309 additions and 312 deletions

19
Cargo.lock generated
View file

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

View file

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

View file

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

View file

@ -1,27 +1,49 @@
use anyhow::{anyhow, Context as _, Result}; use anyhow::{anyhow, Context as _, Result};
use fuzzy::{StringMatch, StringMatchCandidate}; use fuzzy::{StringMatch, StringMatchCandidate};
use git::repository::Branch; use git::repository::Branch;
use gpui::{ use gpui::{
rems, AnyElement, App, AsyncApp, Context, DismissEvent, Entity, EventEmitter, FocusHandle, rems, App, AsyncApp, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
Focusable, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
Subscription, Task, WeakEntity, Window, Task, WeakEntity, Window,
}; };
use picker::{Picker, PickerDelegate}; use picker::{Picker, PickerDelegate};
use project::ProjectPath; use project::ProjectPath;
use std::{ops::Not, sync::Arc}; use std::sync::Arc;
use ui::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing}; use ui::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing};
use util::ResultExt; use util::ResultExt;
use workspace::notifications::DetachAndPromptErr; use workspace::notifications::DetachAndPromptErr;
use workspace::{ModalView, Workspace}; use workspace::{ModalView, Workspace};
use zed_actions::branches::OpenRecent;
pub fn init(cx: &mut App) { pub fn init(cx: &mut App) {
cx.observe_new(|workspace: &mut Workspace, _, _| { cx.observe_new(|workspace: &mut Workspace, _, _| {
workspace.register_action(BranchList::open); workspace.register_action(open);
}) })
.detach(); .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 struct BranchList {
pub picker: Entity<Picker<BranchListDelegate>>, pub picker: Entity<Picker<BranchListDelegate>>,
rem_width: f32, rem_width: f32,
@ -29,29 +51,7 @@ pub struct BranchList {
} }
impl BranchList { impl BranchList {
pub fn open( pub fn new(
_: &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(
delegate: BranchListDelegate, delegate: BranchListDelegate,
rem_width: f32, rem_width: f32,
window: &mut Window, window: &mut Window,
@ -91,6 +91,7 @@ impl Render for BranchList {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
enum BranchEntry { enum BranchEntry {
Branch(StringMatch), Branch(StringMatch),
History(String),
NewBranch { name: String }, NewBranch { name: String },
} }
@ -98,6 +99,7 @@ impl BranchEntry {
fn name(&self) -> &str { fn name(&self) -> &str {
match self { match self {
Self::Branch(branch) => &branch.string, Self::Branch(branch) => &branch.string,
Self::History(branch) => &branch,
Self::NewBranch { name } => &name, Self::NewBranch { name } => &name,
} }
} }
@ -114,7 +116,7 @@ pub struct BranchListDelegate {
} }
impl BranchListDelegate { impl BranchListDelegate {
async fn new( pub async fn new(
workspace: Entity<Workspace>, workspace: Entity<Workspace>,
branch_name_trailoff_after: usize, branch_name_trailoff_after: usize,
cx: &AsyncApp, cx: &AsyncApp,
@ -141,7 +143,7 @@ impl BranchListDelegate {
}) })
} }
fn branch_count(&self) -> usize { pub fn branch_count(&self) -> usize {
self.matches self.matches
.iter() .iter()
.filter(|item| matches!(item, BranchEntry::Branch(_))) .filter(|item| matches!(item, BranchEntry::Branch(_)))
@ -207,16 +209,10 @@ impl PickerDelegate for BranchListDelegate {
let Some(candidates) = candidates.log_err() else { let Some(candidates) = candidates.log_err() else {
return; return;
}; };
let matches = if query.is_empty() { let matches: Vec<BranchEntry> = if query.is_empty() {
candidates candidates
.into_iter() .into_iter()
.enumerate() .map(|candidate| BranchEntry::History(candidate.string))
.map(|(index, candidate)| StringMatch {
candidate_id: index,
string: candidate.string,
positions: Vec::new(),
score: 0.0,
})
.collect() .collect()
} else { } else {
fuzzy::match_strings( fuzzy::match_strings(
@ -228,11 +224,15 @@ impl PickerDelegate for BranchListDelegate {
cx.background_executor().clone(), cx.background_executor().clone(),
) )
.await .await
.iter()
.cloned()
.map(BranchEntry::Branch)
.collect()
}; };
picker picker
.update(&mut cx, |picker, _| { .update(&mut cx, |picker, _| {
let delegate = &mut picker.delegate; let delegate = &mut picker.delegate;
delegate.matches = matches.into_iter().map(BranchEntry::Branch).collect(); delegate.matches = matches;
if delegate.matches.is_empty() { if delegate.matches.is_empty() {
if !query.is_empty() { if !query.is_empty() {
delegate.matches.push(BranchEntry::NewBranch { delegate.matches.push(BranchEntry::NewBranch {
@ -268,6 +268,7 @@ impl PickerDelegate for BranchListDelegate {
let project = workspace.read(cx).project().read(cx); let project = workspace.read(cx).project().read(cx);
let branch_to_checkout = match branch { let branch_to_checkout = match branch {
BranchEntry::Branch(branch) => branch.string, BranchEntry::Branch(branch) => branch.string,
BranchEntry::History(string) => string,
BranchEntry::NewBranch { name: branch_name } => branch_name, BranchEntry::NewBranch { name: branch_name } => branch_name,
}; };
let worktree = project let worktree = project
@ -311,7 +312,14 @@ impl PickerDelegate for BranchListDelegate {
.inset(true) .inset(true)
.spacing(ListItemSpacing::Sparse) .spacing(ListItemSpacing::Sparse)
.toggle_state(selected) .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) => { BranchEntry::Branch(branch) => {
let highlights: Vec<_> = branch let highlights: Vec<_> = branch
.positions .positions
@ -320,40 +328,13 @@ impl PickerDelegate for BranchListDelegate {
.copied() .copied()
.collect(); .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 } => { 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())
}
} }

View file

@ -1110,33 +1110,43 @@ impl GitPanel {
.git_state() .git_state()
.read(cx) .read(cx)
.all_repositories(); .all_repositories();
let entry_count = self
let branch = self
.active_repository .active_repository
.as_ref() .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 changes_string = match entry_count { let has_repo_above = all_repositories.iter().any(|repo| {
0 => "No changes".to_string(), repo.read(cx)
1 => "1 change".to_string(), .repository_entry
n => format!("{} changes", n), .work_directory
}; .is_above_project()
});
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) self.panel_header_container(window, cx)
.child(h_flex().gap_2().child(if all_repositories.len() <= 1 { .child(h_flex().pl_1().child(icon_button))
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(div().flex_grow()) .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 { 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)) .map(|repo| repo.read(cx).display_name(self.project.read(cx), cx))
.unwrap_or_default(); .unwrap_or_default();
let entry_count = self.entries.len();
RepositorySelectorPopoverMenu::new( RepositorySelectorPopoverMenu::new(
self.repository_selector.clone(), self.repository_selector.clone(),
ButtonLike::new("active-repository") ButtonLike::new("active-repository")
.style(ButtonStyle::Subtle) .style(ButtonStyle::Subtle)
.child( .child(Label::new(repository_display_name).size(LabelSize::Small)),
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(),
),
),
),
) )
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -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( pub fn for_action(
title: impl Into<SharedString>, title: impl Into<SharedString>,
action: &dyn Action, action: &dyn Action,

View file

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

View file

@ -1 +0,0 @@
../../LICENSE-GPL

View file

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

View file

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

View file

@ -1,6 +1,6 @@
use crate::{ use crate::{
worktree_settings::WorktreeSettings, Entry, EntryKind, Event, PathChange, Snapshot, Worktree, worktree_settings::WorktreeSettings, Entry, EntryKind, Event, PathChange, Snapshot,
WorktreeModelHandle, WorkDirectory, Worktree, WorktreeModelHandle,
}; };
use anyhow::Result; use anyhow::Result;
use fs::{FakeFs, Fs, RealFs, RemoveOptions}; use fs::{FakeFs, Fs, RealFs, RemoveOptions};
@ -2200,7 +2200,10 @@ async fn test_rename_work_directory(cx: &mut TestAppContext) {
cx.read(|cx| { cx.read(|cx| {
let tree = tree.read(cx); let tree = tree.read(cx);
let repo = tree.repositories().iter().next().unwrap(); 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!( assert_eq!(
tree.status_for_file(Path::new("projects/project1/a")), tree.status_for_file(Path::new("projects/project1/a")),
Some(StatusCode::Modified.worktree()), Some(StatusCode::Modified.worktree()),
@ -2221,7 +2224,10 @@ async fn test_rename_work_directory(cx: &mut TestAppContext) {
cx.read(|cx| { cx.read(|cx| {
let tree = tree.read(cx); let tree = tree.read(cx);
let repo = tree.repositories().iter().next().unwrap(); 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!( assert_eq!(
tree.status_for_file(Path::new("projects/project2/a")), tree.status_for_file(Path::new("projects/project2/a")),
Some(StatusCode::Modified.worktree()), 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()); assert!(tree.repository_for_path("c.txt".as_ref()).is_none());
let repo = tree.repository_for_path("dir1/src/b.txt".as_ref()).unwrap(); 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 let repo = tree
.repository_for_path("dir1/deps/dep1/src/a.txt".as_ref()) .repository_for_path("dir1/deps/dep1/src/a.txt".as_ref())
.unwrap(); .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); let entries = tree.files(false, 0);
@ -2289,7 +2298,7 @@ async fn test_git_repository_for_path(cx: &mut TestAppContext) {
.map(|(entry, repo)| { .map(|(entry, repo)| {
( (
entry.path.as_ref(), entry.path.as_ref(),
repo.map(|repo| repo.path.to_path_buf()), repo.map(|repo| repo.work_directory.clone()),
) )
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@ -2300,9 +2309,12 @@ async fn test_git_repository_for_path(cx: &mut TestAppContext) {
(Path::new("c.txt"), None), (Path::new("c.txt"), None),
( (
Path::new("dir1/deps/dep1/src/a.txt"), 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(); let snapshot = tree.snapshot();
assert_eq!(snapshot.repositories().iter().count(), 1); assert_eq!(snapshot.repositories().iter().count(), 1);
let repo_entry = snapshot.repositories().iter().next().unwrap(); let repo_entry = snapshot.repositories().iter().next().unwrap();
assert_eq!(repo_entry.path.as_ref(), Path::new("project")); assert_eq!(
assert!(repo_entry.location_in_repo.is_none()); repo_entry.work_directory,
WorkDirectory::in_project("project")
);
assert_eq!( assert_eq!(
snapshot.status_for_file(project_path.join(B_TXT)), 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(); let snapshot = tree.snapshot();
assert_eq!(snapshot.repositories().iter().count(), 1); assert_eq!(snapshot.repositories().iter().count(), 1);
let repo = snapshot.repositories().iter().next().unwrap(); 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!( assert_eq!(
repo.location_in_repo, repo.work_directory.canonicalize(),
Some(Arc::from(Path::new("sub-folder-1/sub-folder-2"))) 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); assert_eq!(snapshot.status_for_file("c.txt"), None);

View file

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

View file

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

View file

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