Cargo.lock π
@@ -7116,6 +7116,8 @@ dependencies = [
"picker",
"pretty_assertions",
"project",
+ "recent_projects",
+ "remote",
"schemars 1.0.4",
"serde",
"serde_json",
Alvaro Parker and Cole Miller created
Related discussions #26084
Worktree creations are implemented similar to how branch creations are
handled on the branch picker (the user types a new name that's not on
the list and a new entry option appears to create a new branch with that
name).
https://github.com/user-attachments/assets/39e58983-740c-4a91-be88-57ef95aed85b
With this picker you have a few workflows:
- Open the picker and type the name of a branch that's checked out on an
existing worktree:
- Press enter to open the worktree on a new window
- Press ctrl-enter to open the worktree and replace the current window
- Open the picker and type the name of a new branch or an existing one
that's not checked out in another worktree:
- Press enter to create the worktree and open in a new window. If the
branch doesn't exists, we will create a new one based on the branch you
have currently checked out. If the branch does exists then we create a
worktree with that branch checked out.
- Press ctrl-enter to do everything on the previous point but instead,
replace the current window with the new worktre.
- Open the picker and type the name of a new branch or an existing one
that's not checked out in another worktree:
- If a default branch is detected on the repo, you can create a new
worktree based on that branch by pressing ctrl-enter or
ctrl-shift-enter. The first one will open a new window and the last one
will replace the current one.
Note: If you preffer to not use the system prompt for choosing a
directory, you can set `"use_system_path_prompts": false` in zed
settings.
Release Notes:
- Added git worktree picker to open a git worktree on a new window or
replace the current one
- Added git worktree creation action
---------
Co-authored-by: Cole Miller <cole@zed.dev>
Cargo.lock | 2
assets/keymaps/default-linux.json | 8
assets/keymaps/default-macos.json | 8
assets/keymaps/default-windows.json | 8
crates/fs/src/fake_git_repo.rs | 15
crates/git/src/repository.rs | 113 ++
crates/git_ui/Cargo.toml | 2
crates/git_ui/src/git_ui.rs | 2
crates/git_ui/src/worktree_picker.rs | 743 ++++++++++++++++++
crates/project/src/git_store.rs | 119 ++
crates/proto/proto/git.proto | 23
crates/proto/proto/zed.proto | 6
crates/proto/src/proto.rs | 7
crates/recent_projects/src/recent_projects.rs | 2
crates/recent_projects/src/remote_connections.rs | 4
crates/zed_actions/src/lib.rs | 4
16 files changed, 1,059 insertions(+), 7 deletions(-)
@@ -7116,6 +7116,8 @@ dependencies = [
"picker",
"pretty_assertions",
"project",
+ "recent_projects",
+ "remote",
"schemars 1.0.4",
"serde",
"serde_json",
@@ -1255,6 +1255,14 @@
"ctrl-shift-enter": "workspace::OpenWithSystem"
}
},
+ {
+ "context": "GitWorktreeSelector || (GitWorktreeSelector > Picker > Editor)",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow",
+ "ctrl-space": "git::WorktreeFromDefault"
+ }
+ },
{
"context": "SettingsWindow",
"use_key_equivalents": true,
@@ -1360,6 +1360,14 @@
"ctrl-shift-enter": "workspace::OpenWithSystem"
}
},
+ {
+ "context": "GitWorktreeSelector || (GitWorktreeSelector > Picker > Editor)",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow",
+ "ctrl-space": "git::WorktreeFromDefault"
+ }
+ },
{
"context": "SettingsWindow",
"use_key_equivalents": true,
@@ -1282,6 +1282,14 @@
"shift-alt-a": "onboarding::OpenAccount"
}
},
+ {
+ "context": "GitWorktreeSelector || (GitWorktreeSelector > Picker > Editor)",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow",
+ "ctrl-space": "git::WorktreeFromDefault"
+ }
+ },
{
"context": "SettingsWindow",
"use_key_equivalents": true,
@@ -7,7 +7,7 @@ use git::{
blame::Blame,
repository::{
AskPassDelegate, Branch, CommitDetails, CommitOptions, FetchOptions, GitRepository,
- GitRepositoryCheckpoint, PushOptions, Remote, RepoPath, ResetMode,
+ GitRepositoryCheckpoint, PushOptions, Remote, RepoPath, ResetMode, Worktree,
},
status::{
DiffTreeType, FileStatus, GitStatus, StatusCode, TrackedStatus, TreeDiff, TreeDiffStatus,
@@ -387,6 +387,19 @@ impl GitRepository for FakeGitRepository {
})
}
+ fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>> {
+ unimplemented!()
+ }
+
+ fn create_worktree(
+ &self,
+ _: String,
+ _: PathBuf,
+ _: Option<String>,
+ ) -> BoxFuture<'_, Result<()>> {
+ unimplemented!()
+ }
+
fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
self.with_state_async(true, |state| {
state.current_branch_name = Some(name);
@@ -72,6 +72,50 @@ impl Branch {
}
}
+#[derive(Clone, Debug, Hash, PartialEq, Eq)]
+pub struct Worktree {
+ pub path: PathBuf,
+ pub ref_name: SharedString,
+ pub sha: SharedString,
+}
+
+impl Worktree {
+ pub fn branch(&self) -> &str {
+ self.ref_name
+ .as_ref()
+ .strip_prefix("refs/heads/")
+ .or_else(|| self.ref_name.as_ref().strip_prefix("refs/remotes/"))
+ .unwrap_or(self.ref_name.as_ref())
+ }
+}
+
+pub fn parse_worktrees_from_str<T: AsRef<str>>(raw_worktrees: T) -> Vec<Worktree> {
+ let mut worktrees = Vec::new();
+ let entries = raw_worktrees.as_ref().split("\n\n");
+ for entry in entries {
+ let mut parts = entry.splitn(3, '\n');
+ let path = parts
+ .next()
+ .and_then(|p| p.split_once(' ').map(|(_, path)| path.to_string()));
+ let sha = parts
+ .next()
+ .and_then(|p| p.split_once(' ').map(|(_, sha)| sha.to_string()));
+ let ref_name = parts
+ .next()
+ .and_then(|p| p.split_once(' ').map(|(_, ref_name)| ref_name.to_string()));
+
+ if let (Some(path), Some(sha), Some(ref_name)) = (path, sha, ref_name) {
+ worktrees.push(Worktree {
+ path: PathBuf::from(path),
+ ref_name: ref_name.into(),
+ sha: sha.into(),
+ })
+ }
+ }
+
+ worktrees
+}
+
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct Upstream {
pub ref_name: SharedString,
@@ -390,6 +434,15 @@ pub trait GitRepository: Send + Sync {
fn create_branch(&self, name: String) -> BoxFuture<'_, Result<()>>;
fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>>;
+ fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>>;
+
+ fn create_worktree(
+ &self,
+ name: String,
+ directory: PathBuf,
+ from_commit: Option<String>,
+ ) -> BoxFuture<'_, Result<()>>;
+
fn reset(
&self,
commit: String,
@@ -1206,6 +1259,66 @@ impl GitRepository for RealGitRepository {
.boxed()
}
+ fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>> {
+ let git_binary_path = self.any_git_binary_path.clone();
+ let working_directory = self.working_directory();
+ self.executor
+ .spawn(async move {
+ let output = new_smol_command(&git_binary_path)
+ .current_dir(working_directory?)
+ .args(&["--no-optional-locks", "worktree", "list", "--porcelain"])
+ .output()
+ .await?;
+ if output.status.success() {
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ Ok(parse_worktrees_from_str(&stdout))
+ } else {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ anyhow::bail!("git worktree list failed: {stderr}");
+ }
+ })
+ .boxed()
+ }
+
+ fn create_worktree(
+ &self,
+ name: String,
+ directory: PathBuf,
+ from_commit: Option<String>,
+ ) -> BoxFuture<'_, Result<()>> {
+ let git_binary_path = self.any_git_binary_path.clone();
+ let working_directory = self.working_directory();
+ let final_path = directory.join(&name);
+ let mut args = vec![
+ OsString::from("--no-optional-locks"),
+ OsString::from("worktree"),
+ OsString::from("add"),
+ OsString::from(final_path.as_os_str()),
+ ];
+ if let Some(from_commit) = from_commit {
+ args.extend([
+ OsString::from("-b"),
+ OsString::from(name.as_str()),
+ OsString::from(from_commit),
+ ]);
+ }
+ self.executor
+ .spawn(async move {
+ let output = new_smol_command(&git_binary_path)
+ .current_dir(working_directory?)
+ .args(args)
+ .output()
+ .await?;
+ if output.status.success() {
+ Ok(())
+ } else {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ anyhow::bail!("git worktree list failed: {stderr}");
+ }
+ })
+ .boxed()
+ }
+
fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> {
let repo = self.repository.clone();
let working_directory = self.working_directory();
@@ -45,6 +45,8 @@ notifications.workspace = true
panel.workspace = true
picker.workspace = true
project.workspace = true
+recent_projects.workspace = true
+remote.workspace = true
schemars.workspace = true
serde.workspace = true
serde_json.workspace = true
@@ -46,6 +46,7 @@ pub(crate) mod remote_output;
pub mod repository_selector;
pub mod stash_picker;
pub mod text_diff_view;
+pub mod worktree_picker;
actions!(
git,
@@ -72,6 +73,7 @@ pub fn init(cx: &mut App) {
git_panel::register(workspace);
repository_selector::register(workspace);
branch_picker::register(workspace);
+ worktree_picker::register(workspace);
stash_picker::register(workspace);
let project = workspace.project().read(cx);
@@ -0,0 +1,743 @@
+use anyhow::Context as _;
+use fuzzy::StringMatchCandidate;
+
+use git::repository::Worktree as GitWorktree;
+use gpui::{
+ Action, App, AsyncApp, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
+ InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement,
+ PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window,
+ actions, rems,
+};
+use picker::{Picker, PickerDelegate, PickerEditorPosition};
+use project::{DirectoryLister, git_store::Repository};
+use recent_projects::{RemoteConnectionModal, connect};
+use remote::{RemoteConnectionOptions, remote_client::ConnectionIdentifier};
+use std::{path::PathBuf, sync::Arc};
+use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*};
+use util::ResultExt;
+use workspace::{ModalView, Workspace, notifications::DetachAndPromptErr};
+
+actions!(git, [WorktreeFromDefault, WorktreeFromDefaultOnWindow]);
+
+pub fn register(workspace: &mut Workspace) {
+ workspace.register_action(open);
+}
+
+pub fn open(
+ workspace: &mut Workspace,
+ _: &zed_actions::git::Worktree,
+ window: &mut Window,
+ cx: &mut Context<Workspace>,
+) {
+ let repository = workspace.project().read(cx).active_repository(cx);
+ let workspace_handle = workspace.weak_handle();
+ workspace.toggle_modal(window, cx, |window, cx| {
+ WorktreeList::new(repository, workspace_handle, rems(34.), window, cx)
+ })
+}
+
+pub struct WorktreeList {
+ width: Rems,
+ pub picker: Entity<Picker<WorktreeListDelegate>>,
+ picker_focus_handle: FocusHandle,
+ _subscription: Subscription,
+}
+
+impl WorktreeList {
+ fn new(
+ repository: Option<Entity<Repository>>,
+ workspace: WeakEntity<Workspace>,
+ width: Rems,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Self {
+ let all_worktrees_request = repository
+ .clone()
+ .map(|repository| repository.update(cx, |repository, _| repository.worktrees()));
+
+ let default_branch_request = repository
+ .clone()
+ .map(|repository| repository.update(cx, |repository, _| repository.default_branch()));
+
+ cx.spawn_in(window, async move |this, cx| {
+ let all_worktrees = all_worktrees_request
+ .context("No active repository")?
+ .await??;
+
+ let default_branch = default_branch_request
+ .context("No active repository")?
+ .await
+ .map(Result::ok)
+ .ok()
+ .flatten()
+ .flatten();
+
+ this.update_in(cx, |this, window, cx| {
+ this.picker.update(cx, |picker, cx| {
+ picker.delegate.all_worktrees = Some(all_worktrees);
+ picker.delegate.default_branch = default_branch;
+ picker.refresh(window, cx);
+ })
+ })?;
+
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx);
+
+ let delegate = WorktreeListDelegate::new(workspace, repository, window, cx);
+ let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
+ let picker_focus_handle = picker.focus_handle(cx);
+ picker.update(cx, |picker, _| {
+ picker.delegate.focus_handle = picker_focus_handle.clone();
+ });
+
+ let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
+ cx.emit(DismissEvent);
+ });
+
+ Self {
+ picker,
+ picker_focus_handle,
+ width,
+ _subscription,
+ }
+ }
+
+ fn handle_modifiers_changed(
+ &mut self,
+ ev: &ModifiersChangedEvent,
+ _: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ self.picker
+ .update(cx, |picker, _| picker.delegate.modifiers = ev.modifiers)
+ }
+
+ fn handle_new_worktree(
+ &mut self,
+ replace_current_window: bool,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ self.picker.update(cx, |picker, cx| {
+ let ix = picker.delegate.selected_index();
+ let Some(entry) = picker.delegate.matches.get(ix) else {
+ return;
+ };
+ let Some(default_branch) = picker.delegate.default_branch.clone() else {
+ return;
+ };
+ if !entry.is_new {
+ return;
+ }
+ picker.delegate.create_worktree(
+ entry.worktree.branch(),
+ replace_current_window,
+ Some(default_branch.into()),
+ window,
+ cx,
+ );
+ })
+ }
+}
+impl ModalView for WorktreeList {}
+impl EventEmitter<DismissEvent> for WorktreeList {}
+
+impl Focusable for WorktreeList {
+ fn focus_handle(&self, _: &App) -> FocusHandle {
+ self.picker_focus_handle.clone()
+ }
+}
+
+impl Render for WorktreeList {
+ fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ v_flex()
+ .key_context("GitWorktreeSelector")
+ .w(self.width)
+ .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
+ .on_action(cx.listener(|this, _: &WorktreeFromDefault, w, cx| {
+ this.handle_new_worktree(false, w, cx)
+ }))
+ .on_action(cx.listener(|this, _: &WorktreeFromDefaultOnWindow, w, cx| {
+ this.handle_new_worktree(true, w, cx)
+ }))
+ .child(self.picker.clone())
+ .on_mouse_down_out({
+ cx.listener(move |this, _, window, cx| {
+ this.picker.update(cx, |this, cx| {
+ this.cancel(&Default::default(), window, cx);
+ })
+ })
+ })
+ }
+}
+
+#[derive(Debug, Clone)]
+struct WorktreeEntry {
+ worktree: GitWorktree,
+ positions: Vec<usize>,
+ is_new: bool,
+}
+
+pub struct WorktreeListDelegate {
+ matches: Vec<WorktreeEntry>,
+ all_worktrees: Option<Vec<GitWorktree>>,
+ workspace: WeakEntity<Workspace>,
+ repo: Option<Entity<Repository>>,
+ selected_index: usize,
+ last_query: String,
+ modifiers: Modifiers,
+ focus_handle: FocusHandle,
+ default_branch: Option<SharedString>,
+}
+
+impl WorktreeListDelegate {
+ fn new(
+ workspace: WeakEntity<Workspace>,
+ repo: Option<Entity<Repository>>,
+ _window: &mut Window,
+ cx: &mut Context<WorktreeList>,
+ ) -> Self {
+ Self {
+ matches: vec![],
+ all_worktrees: None,
+ workspace,
+ selected_index: 0,
+ repo,
+ last_query: Default::default(),
+ modifiers: Default::default(),
+ focus_handle: cx.focus_handle(),
+ default_branch: None,
+ }
+ }
+
+ fn create_worktree(
+ &self,
+ worktree_branch: &str,
+ replace_current_window: bool,
+ commit: Option<String>,
+ window: &mut Window,
+ cx: &mut Context<Picker<Self>>,
+ ) {
+ let workspace = self.workspace.clone();
+ let Some(repo) = self.repo.clone() else {
+ return;
+ };
+
+ let worktree_path = self
+ .workspace
+ .clone()
+ .update(cx, |this, cx| {
+ this.prompt_for_open_path(
+ PathPromptOptions {
+ files: false,
+ directories: true,
+ multiple: false,
+ prompt: Some("Select directory for new worktree".into()),
+ },
+ DirectoryLister::Project(this.project().clone()),
+ window,
+ cx,
+ )
+ })
+ .log_err();
+ let Some(worktree_path) = worktree_path else {
+ return;
+ };
+
+ let branch = worktree_branch.to_string();
+ let window_handle = window.window_handle();
+ cx.spawn_in(window, async move |_, cx| {
+ let Some(paths) = worktree_path.await? else {
+ return anyhow::Ok(());
+ };
+ let path = paths.get(0).cloned().context("No path selected")?;
+
+ repo.update(cx, |repo, _| {
+ repo.create_worktree(branch.clone(), path.clone(), commit)
+ })?
+ .await??;
+
+ let final_path = path.join(branch);
+
+ let (connection_options, app_state, is_local) =
+ workspace.update(cx, |workspace, cx| {
+ let project = workspace.project().clone();
+ let connection_options = project.read(cx).remote_connection_options(cx);
+ let app_state = workspace.app_state().clone();
+ let is_local = project.read(cx).is_local();
+ (connection_options, app_state, is_local)
+ })?;
+
+ if is_local {
+ workspace
+ .update_in(cx, |workspace, window, cx| {
+ workspace.open_workspace_for_paths(
+ replace_current_window,
+ vec![final_path],
+ window,
+ cx,
+ )
+ })?
+ .await?;
+ } else if let Some(connection_options) = connection_options {
+ open_remote_worktree(
+ connection_options,
+ vec![final_path],
+ app_state,
+ window_handle,
+ replace_current_window,
+ cx,
+ )
+ .await?;
+ }
+
+ anyhow::Ok(())
+ })
+ .detach_and_prompt_err("Failed to create worktree", window, cx, |e, _, _| {
+ Some(e.to_string())
+ });
+ }
+
+ fn open_worktree(
+ &self,
+ worktree_path: &PathBuf,
+ replace_current_window: bool,
+ window: &mut Window,
+ cx: &mut Context<Picker<Self>>,
+ ) {
+ let workspace = self.workspace.clone();
+ let path = worktree_path.clone();
+
+ let Some((connection_options, app_state, is_local)) = workspace
+ .update(cx, |workspace, cx| {
+ let project = workspace.project().clone();
+ let connection_options = project.read(cx).remote_connection_options(cx);
+ let app_state = workspace.app_state().clone();
+ let is_local = project.read(cx).is_local();
+ (connection_options, app_state, is_local)
+ })
+ .log_err()
+ else {
+ return;
+ };
+
+ if is_local {
+ let open_task = workspace.update(cx, |workspace, cx| {
+ workspace.open_workspace_for_paths(replace_current_window, vec![path], window, cx)
+ });
+ cx.spawn(async move |_, _| {
+ open_task?.await?;
+ anyhow::Ok(())
+ })
+ .detach_and_prompt_err(
+ "Failed to open worktree",
+ window,
+ cx,
+ |e, _, _| Some(e.to_string()),
+ );
+ } else if let Some(connection_options) = connection_options {
+ let window_handle = window.window_handle();
+ cx.spawn_in(window, async move |_, cx| {
+ open_remote_worktree(
+ connection_options,
+ vec![path],
+ app_state,
+ window_handle,
+ replace_current_window,
+ cx,
+ )
+ .await
+ })
+ .detach_and_prompt_err(
+ "Failed to open worktree",
+ window,
+ cx,
+ |e, _, _| Some(e.to_string()),
+ );
+ }
+
+ cx.emit(DismissEvent);
+ }
+
+ fn base_branch<'a>(&'a self, cx: &'a mut Context<Picker<Self>>) -> Option<&'a str> {
+ self.repo
+ .as_ref()
+ .and_then(|repo| repo.read(cx).branch.as_ref().map(|b| b.name()))
+ }
+}
+
+async fn open_remote_worktree(
+ connection_options: RemoteConnectionOptions,
+ paths: Vec<PathBuf>,
+ app_state: Arc<workspace::AppState>,
+ window: gpui::AnyWindowHandle,
+ replace_current_window: bool,
+ cx: &mut AsyncApp,
+) -> anyhow::Result<()> {
+ let workspace_window = window
+ .downcast::<Workspace>()
+ .ok_or_else(|| anyhow::anyhow!("Window is not a Workspace window"))?;
+
+ let connect_task = workspace_window.update(cx, |workspace, window, cx| {
+ workspace.toggle_modal(window, cx, |window, cx| {
+ RemoteConnectionModal::new(&connection_options, Vec::new(), window, cx)
+ });
+
+ let prompt = workspace
+ .active_modal::<RemoteConnectionModal>(cx)
+ .expect("Modal just created")
+ .read(cx)
+ .prompt
+ .clone();
+
+ connect(
+ ConnectionIdentifier::setup(),
+ connection_options.clone(),
+ prompt,
+ window,
+ cx,
+ )
+ .prompt_err("Failed to connect", window, cx, |_, _, _| None)
+ })?;
+
+ let session = connect_task.await;
+
+ workspace_window.update(cx, |workspace, _window, cx| {
+ if let Some(prompt) = workspace.active_modal::<RemoteConnectionModal>(cx) {
+ prompt.update(cx, |prompt, cx| prompt.finished(cx))
+ }
+ })?;
+
+ let Some(Some(session)) = session else {
+ return Ok(());
+ };
+
+ let new_project = cx.update(|cx| {
+ project::Project::remote(
+ session,
+ app_state.client.clone(),
+ app_state.node_runtime.clone(),
+ app_state.user_store.clone(),
+ app_state.languages.clone(),
+ app_state.fs.clone(),
+ cx,
+ )
+ })?;
+
+ let window_to_use = if replace_current_window {
+ workspace_window
+ } else {
+ let workspace_position = cx
+ .update(|cx| {
+ workspace::remote_workspace_position_from_db(connection_options.clone(), &paths, cx)
+ })?
+ .await
+ .context("fetching workspace position from db")?;
+
+ let mut options =
+ cx.update(|cx| (app_state.build_window_options)(workspace_position.display, cx))?;
+ options.window_bounds = workspace_position.window_bounds;
+
+ cx.open_window(options, |window, cx| {
+ cx.new(|cx| {
+ let mut workspace =
+ Workspace::new(None, new_project.clone(), app_state.clone(), window, cx);
+ workspace.centered_layout = workspace_position.centered_layout;
+ workspace
+ })
+ })?
+ };
+
+ workspace::open_remote_project_with_existing_connection(
+ connection_options,
+ new_project,
+ paths,
+ app_state,
+ window_to_use,
+ cx,
+ )
+ .await?;
+
+ Ok(())
+}
+
+impl PickerDelegate for WorktreeListDelegate {
+ type ListItem = ListItem;
+
+ fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
+ "Select worktreeβ¦".into()
+ }
+
+ fn editor_position(&self) -> PickerEditorPosition {
+ PickerEditorPosition::Start
+ }
+
+ fn match_count(&self) -> usize {
+ self.matches.len()
+ }
+
+ fn selected_index(&self) -> usize {
+ self.selected_index
+ }
+
+ fn set_selected_index(
+ &mut self,
+ ix: usize,
+ _window: &mut Window,
+ _: &mut Context<Picker<Self>>,
+ ) {
+ self.selected_index = ix;
+ }
+
+ fn update_matches(
+ &mut self,
+ query: String,
+ window: &mut Window,
+ cx: &mut Context<Picker<Self>>,
+ ) -> Task<()> {
+ let Some(all_worktrees) = self.all_worktrees.clone() else {
+ return Task::ready(());
+ };
+
+ cx.spawn_in(window, async move |picker, cx| {
+ let mut matches: Vec<WorktreeEntry> = if query.is_empty() {
+ all_worktrees
+ .into_iter()
+ .map(|worktree| WorktreeEntry {
+ worktree,
+ positions: Vec::new(),
+ is_new: false,
+ })
+ .collect()
+ } else {
+ let candidates = all_worktrees
+ .iter()
+ .enumerate()
+ .map(|(ix, worktree)| StringMatchCandidate::new(ix, worktree.branch()))
+ .collect::<Vec<StringMatchCandidate>>();
+ fuzzy::match_strings(
+ &candidates,
+ &query,
+ true,
+ true,
+ 10000,
+ &Default::default(),
+ cx.background_executor().clone(),
+ )
+ .await
+ .into_iter()
+ .map(|candidate| WorktreeEntry {
+ worktree: all_worktrees[candidate.candidate_id].clone(),
+ positions: candidate.positions,
+ is_new: false,
+ })
+ .collect()
+ };
+ picker
+ .update(cx, |picker, _| {
+ if !query.is_empty()
+ && !matches
+ .first()
+ .is_some_and(|entry| entry.worktree.branch() == query)
+ {
+ let query = query.replace(' ', "-");
+ matches.push(WorktreeEntry {
+ worktree: GitWorktree {
+ path: Default::default(),
+ ref_name: format!("refs/heads/{query}").into(),
+ sha: Default::default(),
+ },
+ positions: Vec::new(),
+ is_new: true,
+ })
+ }
+ let delegate = &mut picker.delegate;
+ delegate.matches = matches;
+ if delegate.matches.is_empty() {
+ delegate.selected_index = 0;
+ } else {
+ delegate.selected_index =
+ core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
+ }
+ delegate.last_query = query;
+ })
+ .log_err();
+ })
+ }
+
+ fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
+ let Some(entry) = self.matches.get(self.selected_index()) else {
+ return;
+ };
+ if entry.is_new {
+ self.create_worktree(&entry.worktree.branch(), secondary, None, window, cx);
+ } else {
+ self.open_worktree(&entry.worktree.path, secondary, window, cx);
+ }
+
+ cx.emit(DismissEvent);
+ }
+
+ fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
+ cx.emit(DismissEvent);
+ }
+
+ fn render_match(
+ &self,
+ ix: usize,
+ selected: bool,
+ _window: &mut Window,
+ cx: &mut Context<Picker<Self>>,
+ ) -> Option<Self::ListItem> {
+ let entry = &self.matches.get(ix)?;
+ let path = entry.worktree.path.to_string_lossy().to_string();
+ let sha = entry
+ .worktree
+ .sha
+ .clone()
+ .chars()
+ .take(7)
+ .collect::<String>();
+
+ let focus_handle = self.focus_handle.clone();
+ let icon = if let Some(default_branch) = self.default_branch.clone()
+ && entry.is_new
+ {
+ Some(
+ IconButton::new("worktree-from-default", IconName::GitBranchAlt)
+ .on_click(|_, window, cx| {
+ window.dispatch_action(WorktreeFromDefault.boxed_clone(), cx)
+ })
+ .on_right_click(|_, window, cx| {
+ window.dispatch_action(WorktreeFromDefaultOnWindow.boxed_clone(), cx)
+ })
+ .tooltip(move |_, cx| {
+ Tooltip::for_action_in(
+ format!("From default branch {default_branch}"),
+ &WorktreeFromDefault,
+ &focus_handle,
+ cx,
+ )
+ }),
+ )
+ } else {
+ None
+ };
+
+ let branch_name = if entry.is_new {
+ h_flex()
+ .gap_1()
+ .child(
+ Icon::new(IconName::Plus)
+ .size(IconSize::Small)
+ .color(Color::Muted),
+ )
+ .child(
+ Label::new(format!("Create worktree \"{}\"β¦", entry.worktree.branch()))
+ .single_line()
+ .truncate(),
+ )
+ .into_any_element()
+ } else {
+ h_flex()
+ .gap_1()
+ .child(
+ Icon::new(IconName::GitBranch)
+ .size(IconSize::Small)
+ .color(Color::Muted),
+ )
+ .child(HighlightedLabel::new(
+ entry.worktree.branch().to_owned(),
+ entry.positions.clone(),
+ ))
+ .truncate()
+ .into_any_element()
+ };
+
+ let sublabel = if entry.is_new {
+ format!(
+ "based off {}",
+ self.base_branch(cx).unwrap_or("the current branch")
+ )
+ } else {
+ format!("at {}", path)
+ };
+
+ Some(
+ ListItem::new(SharedString::from(format!("worktree-menu-{ix}")))
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .toggle_state(selected)
+ .child(
+ v_flex()
+ .w_full()
+ .overflow_hidden()
+ .child(
+ h_flex()
+ .gap_6()
+ .justify_between()
+ .overflow_x_hidden()
+ .child(branch_name)
+ .when(!entry.is_new, |el| {
+ el.child(
+ Label::new(sha)
+ .size(LabelSize::Small)
+ .color(Color::Muted)
+ .into_element(),
+ )
+ }),
+ )
+ .child(
+ div().max_w_96().child(
+ Label::new(sublabel)
+ .size(LabelSize::Small)
+ .color(Color::Muted)
+ .truncate()
+ .into_any_element(),
+ ),
+ ),
+ )
+ .end_slot::<IconButton>(icon),
+ )
+ }
+
+ fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
+ Some("No worktrees found".into())
+ }
+
+ fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
+ let focus_handle = self.focus_handle.clone();
+
+ Some(
+ h_flex()
+ .w_full()
+ .p_1p5()
+ .gap_0p5()
+ .justify_end()
+ .border_t_1()
+ .border_color(cx.theme().colors().border_variant)
+ .child(
+ Button::new("open-in-new-window", "Open in new window")
+ .key_binding(
+ KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
+ .map(|kb| kb.size(rems_from_px(12.))),
+ )
+ .on_click(|_, window, cx| {
+ window.dispatch_action(menu::Confirm.boxed_clone(), cx)
+ }),
+ )
+ .child(
+ Button::new("open-in-window", "Open")
+ .key_binding(
+ KeyBinding::for_action_in(&menu::SecondaryConfirm, &focus_handle, cx)
+ .map(|kb| kb.size(rems_from_px(12.))),
+ )
+ .on_click(|_, window, cx| {
+ window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
+ }),
+ )
+ .into_any(),
+ )
+ }
+}
@@ -27,7 +27,7 @@ use git::{
repository::{
Branch, CommitDetails, CommitDiff, CommitFile, CommitOptions, DiffType, FetchOptions,
GitRepository, GitRepositoryCheckpoint, PushOptions, Remote, RemoteCommandOutput, RepoPath,
- ResetMode, UpstreamTrackingStatus,
+ ResetMode, UpstreamTrackingStatus, Worktree as GitWorktree,
},
stash::{GitStash, StashEntry},
status::{
@@ -445,6 +445,8 @@ impl GitStore {
client.add_entity_message_handler(Self::handle_update_repository);
client.add_entity_message_handler(Self::handle_remove_repository);
client.add_entity_request_handler(Self::handle_git_clone);
+ client.add_entity_request_handler(Self::handle_get_worktrees);
+ client.add_entity_request_handler(Self::handle_create_worktree);
}
pub fn is_local(&self) -> bool {
@@ -1932,6 +1934,48 @@ impl GitStore {
})
}
+ async fn handle_get_worktrees(
+ this: Entity<Self>,
+ envelope: TypedEnvelope<proto::GitGetWorktrees>,
+ mut cx: AsyncApp,
+ ) -> Result<proto::GitWorktreesResponse> {
+ let repository_id = RepositoryId::from_proto(envelope.payload.repository_id);
+ let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
+
+ let worktrees = repository_handle
+ .update(&mut cx, |repository_handle, _| {
+ repository_handle.worktrees()
+ })?
+ .await??;
+
+ Ok(proto::GitWorktreesResponse {
+ worktrees: worktrees
+ .into_iter()
+ .map(|worktree| worktree_to_proto(&worktree))
+ .collect::<Vec<_>>(),
+ })
+ }
+
+ async fn handle_create_worktree(
+ this: Entity<Self>,
+ envelope: TypedEnvelope<proto::GitCreateWorktree>,
+ mut cx: AsyncApp,
+ ) -> Result<proto::Ack> {
+ let repository_id = RepositoryId::from_proto(envelope.payload.repository_id);
+ let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
+ let directory = PathBuf::from(envelope.payload.directory);
+ let name = envelope.payload.name;
+ let commit = envelope.payload.commit;
+
+ repository_handle
+ .update(&mut cx, |repository_handle, _| {
+ repository_handle.create_worktree(name, directory, commit)
+ })?
+ .await??;
+
+ Ok(proto::Ack {})
+ }
+
async fn handle_get_branches(
this: Entity<Self>,
envelope: TypedEnvelope<proto::GitGetBranches>,
@@ -4422,6 +4466,63 @@ impl Repository {
})
}
+ pub fn worktrees(&mut self) -> oneshot::Receiver<Result<Vec<GitWorktree>>> {
+ let id = self.id;
+ self.send_job(None, move |repo, _| async move {
+ match repo {
+ RepositoryState::Local { backend, .. } => backend.worktrees().await,
+ RepositoryState::Remote { project_id, client } => {
+ let response = client
+ .request(proto::GitGetWorktrees {
+ project_id: project_id.0,
+ repository_id: id.to_proto(),
+ })
+ .await?;
+
+ let worktrees = response
+ .worktrees
+ .into_iter()
+ .map(|worktree| proto_to_worktree(&worktree))
+ .collect();
+
+ Ok(worktrees)
+ }
+ }
+ })
+ }
+
+ pub fn create_worktree(
+ &mut self,
+ name: String,
+ path: PathBuf,
+ commit: Option<String>,
+ ) -> oneshot::Receiver<Result<()>> {
+ let id = self.id;
+ self.send_job(
+ Some("git worktree add".into()),
+ move |repo, _cx| async move {
+ match repo {
+ RepositoryState::Local { backend, .. } => {
+ backend.create_worktree(name, path, commit).await
+ }
+ RepositoryState::Remote { project_id, client } => {
+ client
+ .request(proto::GitCreateWorktree {
+ project_id: project_id.0,
+ repository_id: id.to_proto(),
+ name,
+ directory: path.to_string_lossy().to_string(),
+ commit,
+ })
+ .await?;
+
+ Ok(())
+ }
+ }
+ },
+ )
+ }
+
pub fn default_branch(&mut self) -> oneshot::Receiver<Result<Option<SharedString>>> {
let id = self.id;
self.send_job(None, move |repo, _| async move {
@@ -5254,6 +5355,22 @@ fn branch_to_proto(branch: &git::repository::Branch) -> proto::Branch {
}
}
+fn worktree_to_proto(worktree: &git::repository::Worktree) -> proto::Worktree {
+ proto::Worktree {
+ path: worktree.path.to_string_lossy().to_string(),
+ ref_name: worktree.ref_name.to_string(),
+ sha: worktree.sha.to_string(),
+ }
+}
+
+fn proto_to_worktree(proto: &proto::Worktree) -> git::repository::Worktree {
+ git::repository::Worktree {
+ path: PathBuf::from(proto.path.clone()),
+ ref_name: proto.ref_name.clone().into(),
+ sha: proto.sha.clone().into(),
+ }
+}
+
fn proto_to_branch(proto: &proto::Branch) -> git::repository::Branch {
git::repository::Branch {
is_head: proto.is_head,
@@ -506,3 +506,26 @@ message GetBlobContent {
message GetBlobContentResponse {
string content = 1;
}
+
+message GitGetWorktrees {
+ uint64 project_id = 1;
+ uint64 repository_id = 2;
+}
+
+message GitWorktreesResponse {
+ repeated Worktree worktrees = 1;
+}
+
+message Worktree {
+ string path = 1;
+ string ref_name = 2;
+ string sha = 3;
+}
+
+message GitCreateWorktree {
+ uint64 project_id = 1;
+ uint64 repository_id = 2;
+ string name = 3;
+ string directory = 4;
+ optional string commit = 5;
+}
@@ -427,7 +427,11 @@ message Envelope {
GetTreeDiffResponse get_tree_diff_response = 385;
GetBlobContent get_blob_content = 386;
- GetBlobContentResponse get_blob_content_response = 387; // current max
+ GetBlobContentResponse get_blob_content_response = 387;
+
+ GitWorktreesResponse git_worktrees_response = 388;
+ GitGetWorktrees git_get_worktrees = 389;
+ GitCreateWorktree git_create_worktree = 390; // current max
}
reserved 87 to 88;
@@ -331,6 +331,9 @@ messages!(
(ExternalAgentLoadingStatusUpdated, Background),
(NewExternalAgentVersionAvailable, Background),
(RemoteStarted, Background),
+ (GitGetWorktrees, Background),
+ (GitWorktreesResponse, Background),
+ (GitCreateWorktree, Background)
);
request_messages!(
@@ -509,6 +512,8 @@ request_messages!(
(GetProcesses, GetProcessesResponse),
(GetAgentServerCommand, AgentServerCommand),
(RemoteStarted, Ack),
+ (GitGetWorktrees, GitWorktreesResponse),
+ (GitCreateWorktree, Ack)
);
lsp_messages!(
@@ -672,6 +677,8 @@ entity_messages!(
ExternalAgentsUpdated,
ExternalAgentLoadingStatusUpdated,
NewExternalAgentVersionAvailable,
+ GitGetWorktrees,
+ GitCreateWorktree
);
entity_messages!(
@@ -7,7 +7,7 @@ mod ssh_config;
mod wsl_picker;
use remote::RemoteConnectionOptions;
-pub use remote_connections::open_remote_project;
+pub use remote_connections::{RemoteConnectionModal, connect, open_remote_project};
use disconnected_overlay::DisconnectedOverlay;
use fuzzy::{StringMatch, StringMatchCandidate};
@@ -137,7 +137,7 @@ impl Drop for RemoteConnectionPrompt {
}
pub struct RemoteConnectionModal {
- pub(crate) prompt: Entity<RemoteConnectionPrompt>,
+ pub prompt: Entity<RemoteConnectionPrompt>,
paths: Vec<PathBuf>,
finished: bool,
}
@@ -280,7 +280,7 @@ impl Render for RemoteConnectionPrompt {
}
impl RemoteConnectionModal {
- pub(crate) fn new(
+ pub fn new(
connection_options: &RemoteConnectionOptions,
paths: Vec<PathBuf>,
window: &mut Window,
@@ -214,7 +214,9 @@ pub mod git {
#[action(deprecated_aliases = ["branches::OpenRecent"])]
Branch,
/// Opens the git stash selector.
- ViewStash
+ ViewStash,
+ /// Opens the git worktree selector.
+ Worktree
]
);
}