diff --git a/Cargo.lock b/Cargo.lock index 7b64221a6282de2d93562690a0f32cd7fea81077..c59889d635c34bdbfadf39286f21dfa655ab56d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7116,6 +7116,8 @@ dependencies = [ "picker", "pretty_assertions", "project", + "recent_projects", + "remote", "schemars 1.0.4", "serde", "serde_json", diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 90e39530e1b6bb7b651ccbdb40c267580a657ff5..a0c190fe7456f00351b609a97361c40b6b2d9b9a 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.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, diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 4fda5f91d4b7ac8f8ecada076c7600de036f2878..83a998be35dc38f925f8f764c1801c80e5fb56bc 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -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, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 3979d016bbad8ce240a825d9a7d5b2f531f77d02..ae61bb887ce2e9f956c6d11d7bfc1f8036a00284 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -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, diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 8e9f8501dbcd4858f709dd5bd08f7f4d65aab986..81dbd28cd9de8e191747bce2b777270e0e8e2a97 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -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>> { + unimplemented!() + } + + fn create_worktree( + &self, + _: String, + _: PathBuf, + _: Option, + ) -> BoxFuture<'_, Result<()>> { + unimplemented!() + } + fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>> { self.with_state_async(true, |state| { state.current_branch_name = Some(name); diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index eaefd4ba22c34ac2e3c30e822e6dbcd31468f9b8..8db32dec693d2816c07e06b06bb2db594c8c725b 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -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>(raw_worktrees: T) -> Vec { + 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>>; + + fn create_worktree( + &self, + name: String, + directory: PathBuf, + from_commit: Option, + ) -> BoxFuture<'_, Result<()>>; + fn reset( &self, commit: String, @@ -1206,6 +1259,66 @@ impl GitRepository for RealGitRepository { .boxed() } + fn worktrees(&self) -> BoxFuture<'_, Result>> { + 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, + ) -> 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(); diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index 486e43fea94f53e2ad9fd67d88cfe2279afb353c..29b52e7b79e1b659d43f70d431ae5e2b1d631a1b 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -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 diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 919cdf154d438e8ee5b38422032aa150edc5dd34..ed99f2c2afc61cac680bedd6dc6278daa0365d83 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -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); diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs new file mode 100644 index 0000000000000000000000000000000000000000..d1231b51e3a37db2b3ee2316e866fcbdbe70d459 --- /dev/null +++ b/crates/git_ui/src/worktree_picker.rs @@ -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, +) { + 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_focus_handle: FocusHandle, + _subscription: Subscription, +} + +impl WorktreeList { + fn new( + repository: Option>, + workspace: WeakEntity, + width: Rems, + window: &mut Window, + cx: &mut Context, + ) -> 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.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.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 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) -> 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, + is_new: bool, +} + +pub struct WorktreeListDelegate { + matches: Vec, + all_worktrees: Option>, + workspace: WeakEntity, + repo: Option>, + selected_index: usize, + last_query: String, + modifiers: Modifiers, + focus_handle: FocusHandle, + default_branch: Option, +} + +impl WorktreeListDelegate { + fn new( + workspace: WeakEntity, + repo: Option>, + _window: &mut Window, + cx: &mut Context, + ) -> 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, + window: &mut Window, + cx: &mut Context>, + ) { + 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>, + ) { + 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>) -> 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, + app_state: Arc, + window: gpui::AnyWindowHandle, + replace_current_window: bool, + cx: &mut AsyncApp, +) -> anyhow::Result<()> { + let workspace_window = window + .downcast::() + .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::(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::(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 { + "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>, + ) { + self.selected_index = ix; + } + + fn update_matches( + &mut self, + query: String, + window: &mut Window, + cx: &mut Context>, + ) -> 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 = 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::>(); + 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>) { + 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>) { + cx.emit(DismissEvent); + } + + fn render_match( + &self, + ix: usize, + selected: bool, + _window: &mut Window, + cx: &mut Context>, + ) -> Option { + 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::(); + + 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::(icon), + ) + } + + fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { + Some("No worktrees found".into()) + } + + fn render_footer(&self, _: &mut Window, cx: &mut Context>) -> Option { + 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(), + ) + } +} diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 2ada4a94eff69e73cd4e9d5fc360443d583ced91..6c6603941b6e523b9a3f8d250207942d320697f7 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -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, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + 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::>(), + }) + } + + async fn handle_create_worktree( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + 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, envelope: TypedEnvelope, @@ -4422,6 +4466,63 @@ impl Repository { }) } + pub fn worktrees(&mut self) -> oneshot::Receiver>> { + 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, + ) -> oneshot::Receiver> { + 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>> { 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, diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index 34b57d610be5703f581d363a238eb28e3533606f..c56005ef29a79c0abda7789229bd0aca99b07079 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -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; +} diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 44450f83d1e6554e80fac951f5cb5e28dd830de1..c1551dca48a6d49eada9a0db65fbf770500d33b7 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -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; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index af9bf99a721b4450b746043fbb7411dfa182d1fa..cd125aa70fa440001f514a4d651d85687ce2456b 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -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!( diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 80c20ca21f1add0962995c1e948acdbeff14c374..0f4257985407ba0745738847978e1da6b8fae3d2 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -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}; diff --git a/crates/recent_projects/src/remote_connections.rs b/crates/recent_projects/src/remote_connections.rs index 7c8557f9dac2131a84c54cc60657e105d2839658..54d87350ff368935d56f979cd260a925e7614a6d 100644 --- a/crates/recent_projects/src/remote_connections.rs +++ b/crates/recent_projects/src/remote_connections.rs @@ -137,7 +137,7 @@ impl Drop for RemoteConnectionPrompt { } pub struct RemoteConnectionModal { - pub(crate) prompt: Entity, + pub prompt: Entity, paths: Vec, finished: bool, } @@ -280,7 +280,7 @@ impl Render for RemoteConnectionPrompt { } impl RemoteConnectionModal { - pub(crate) fn new( + pub fn new( connection_options: &RemoteConnectionOptions, paths: Vec, window: &mut Window, diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 5cb2903fa653fc765bfb3471aa51b232e4bfadec..c0739c74c7ac6c103e34c7a2cd730096503ef565 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -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 ] ); }