Adds two built-in agent tools, behind the staff-only `create-thread-tool` feature flag, that let the agent spawn independent sibling threads: - **`create_thread`** creates a new thread with a title and initial prompt. It runs on the user's behalf and shows up in the agent sidebar like a thread the user started themselves, but it runs in the background — the active thread is never interrupted — and it does **not** report results back to the caller. (Use `spawn_agent` when you want the results.) - **`list_agents_and_models`** enumerates the configured agents and, for the native agent, the available models, so the agent can pick a cheaper/faster model for bulk work. The motivating stress test was "spawn 100 threads that each write a haiku, using Haiku instead of Opus." By default the sibling shares the parent's project and worktree. If `use_new_worktree` is set, `create_thread` instead opens a new background workspace backed by fresh linked git worktrees of the project's repos — the same flow you get from the worktree picker's "Create worktree" — and starts the thread there. The new workspace opens as a background tab so it doesn't pull you away from what you're doing. A few notes on the design: - The `agent` crate can't depend on the UI, so there's a `SiblingThreadHost` trait that `agent_ui::AgentPanel` implements and installs on the `NativeAgent`. Tool calls in a native-agent thread route through it. - Sibling threads go through the same `create_agent_thread` → `ConversationView` path as the user-facing "new thread" action, so eviction, persistence, and sidebar rendering match user-created threads. - An explicit `agent`/`model` override doesn't clobber the panel's selected agent or the last-used-agent preference — those are snapshotted and restored around the call. - The worktree path reuses the existing "Create worktree" machinery rather than reimplementing it; the agent flow just opens the new workspace in the background instead of activating it. A couple of things I left out or want to call out: - There's no tool to operate on an already-created sibling by session id. The success payload deliberately omits it, since the session is populated asynchronously and nothing consumes it yet. - When a project has multiple worktrees that are linked worktrees of the *same* underlying repo, they collapse into a single new worktree (they resolve to the same target path). The tool returns a warning in that case rather than failing — and this also fixes a latent collision the manual "Create worktree" flow would hit in the same situation. Release Notes: - N/A
1596 lines
55 KiB
Rust
1596 lines
55 KiB
Rust
use std::error::Error;
|
|
use std::fmt;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
|
|
use anyhow::anyhow;
|
|
use askpass::AskPassDelegate;
|
|
use collections::HashSet;
|
|
use fs::Fs;
|
|
use gpui::{
|
|
AsyncWindowContext, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, SharedString,
|
|
Task, TaskExt, WeakEntity,
|
|
};
|
|
use project::Project;
|
|
use project::git_store::Repository;
|
|
use project::project_settings::ProjectSettings;
|
|
use project::trusted_worktrees::{PathTrust, TrustedWorktrees};
|
|
use remote::RemoteConnectionOptions;
|
|
use settings::Settings;
|
|
use ui::prelude::*;
|
|
use workspace::{
|
|
MultiWorkspace, OpenMode, PreviousWorkspaceState, ToastView, Workspace, dock::DockPosition,
|
|
};
|
|
use zed_actions::NewWorktreeBranchTarget;
|
|
|
|
use git::repository::{FetchOptions, Remote};
|
|
|
|
use util::ResultExt as _;
|
|
|
|
use crate::askpass_modal::AskPassModal;
|
|
use crate::git_panel::{open_output, show_error_toast};
|
|
use crate::worktree_names;
|
|
|
|
/// Whether a worktree operation is creating a new one or switching to an
|
|
/// existing one. Controls whether the source workspace's state (dock layout,
|
|
/// open files, agent panel draft) is inherited by the destination.
|
|
enum WorktreeOperation {
|
|
Create,
|
|
Switch,
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
enum RemoteBranchFetchMode {
|
|
Fetch,
|
|
UseLocal,
|
|
}
|
|
|
|
impl RemoteBranchFetchMode {
|
|
fn should_fetch(self) -> bool {
|
|
matches!(self, Self::Fetch)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct WorktreeFetchError {
|
|
remote_name: String,
|
|
branch_name: String,
|
|
source: anyhow::Error,
|
|
}
|
|
|
|
impl WorktreeFetchError {
|
|
fn remote_branch_name(&self) -> String {
|
|
format!("{}/{}", self.remote_name, self.branch_name)
|
|
}
|
|
|
|
fn output(&self) -> String {
|
|
format!("git fetch {} failed:\n{:#}", self.remote_name, self.source)
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for WorktreeFetchError {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
write!(
|
|
formatter,
|
|
"git fetch {} failed while creating worktree from {}: {}",
|
|
self.remote_name,
|
|
self.remote_branch_name(),
|
|
self.source
|
|
)
|
|
}
|
|
}
|
|
|
|
impl Error for WorktreeFetchError {
|
|
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
|
Some(self.source.as_ref())
|
|
}
|
|
}
|
|
|
|
struct WorktreeFetchFailedToast {
|
|
workspace: WeakEntity<Workspace>,
|
|
worktree_name: Option<String>,
|
|
branch_target: NewWorktreeBranchTarget,
|
|
focused_dock: Option<DockPosition>,
|
|
remote_branch_name: String,
|
|
operation: SharedString,
|
|
output: String,
|
|
focus_handle: FocusHandle,
|
|
}
|
|
|
|
impl WorktreeFetchFailedToast {
|
|
fn new(
|
|
workspace: WeakEntity<Workspace>,
|
|
worktree_name: Option<String>,
|
|
branch_target: NewWorktreeBranchTarget,
|
|
focused_dock: Option<DockPosition>,
|
|
fetch_error: &WorktreeFetchError,
|
|
cx: &mut gpui::Context<Self>,
|
|
) -> Self {
|
|
Self {
|
|
workspace,
|
|
worktree_name,
|
|
branch_target,
|
|
focused_dock,
|
|
remote_branch_name: fetch_error.remote_branch_name(),
|
|
operation: format!("fetch {}", fetch_error.remote_name).into(),
|
|
output: fetch_error.output(),
|
|
focus_handle: cx.focus_handle(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Focusable for WorktreeFetchFailedToast {
|
|
fn focus_handle(&self, _cx: &gpui::App) -> FocusHandle {
|
|
self.focus_handle.clone()
|
|
}
|
|
}
|
|
|
|
impl EventEmitter<DismissEvent> for WorktreeFetchFailedToast {}
|
|
|
|
impl ToastView for WorktreeFetchFailedToast {
|
|
fn action(&self) -> Option<workspace::ToastAction> {
|
|
None
|
|
}
|
|
|
|
fn auto_dismiss(&self) -> bool {
|
|
false
|
|
}
|
|
}
|
|
|
|
impl Render for WorktreeFetchFailedToast {
|
|
fn render(&mut self, _window: &mut Window, cx: &mut gpui::Context<Self>) -> impl IntoElement {
|
|
let workspace_for_retry = self.workspace.clone();
|
|
let worktree_name = self.worktree_name.clone();
|
|
let branch_target = self.branch_target.clone();
|
|
let focused_dock = self.focused_dock;
|
|
|
|
let workspace_for_log = self.workspace.clone();
|
|
let operation = self.operation.clone();
|
|
let output = self.output.clone();
|
|
|
|
h_flex()
|
|
.id("worktree-fetch-failed-toast")
|
|
.elevation_3(cx)
|
|
.gap_2()
|
|
.py_1p5()
|
|
.pl_2p5()
|
|
.pr_1p5()
|
|
.flex_none()
|
|
.bg(cx.theme().colors().surface_background)
|
|
.shadow_lg()
|
|
.child(
|
|
Icon::new(IconName::XCircle)
|
|
.size(IconSize::Small)
|
|
.color(Color::Error),
|
|
)
|
|
.child(Label::new(format!(
|
|
"git fetch failed for {}",
|
|
self.remote_branch_name
|
|
)))
|
|
.child(
|
|
Button::new(
|
|
"use-local-worktree-base",
|
|
format!("Use local {}", self.remote_branch_name),
|
|
)
|
|
.color(Color::Muted)
|
|
.on_click(cx.listener(move |_, _event, window, cx| {
|
|
cx.emit(DismissEvent);
|
|
if let Some(workspace) = workspace_for_retry.upgrade() {
|
|
workspace.update(cx, |workspace, cx| {
|
|
let task = create_worktree_workspace_inner(
|
|
workspace,
|
|
&zed_actions::CreateWorktree {
|
|
worktree_name: worktree_name.clone(),
|
|
branch_target: branch_target.clone(),
|
|
},
|
|
window,
|
|
focused_dock,
|
|
RemoteBranchFetchMode::UseLocal,
|
|
// User-initiated retry of a foreground create.
|
|
true,
|
|
cx,
|
|
);
|
|
task.detach_and_log_err(cx);
|
|
});
|
|
}
|
|
})),
|
|
)
|
|
.child(
|
|
Button::new("view-worktree-fetch-log", "Show Error Logs")
|
|
.color(Color::Muted)
|
|
.on_click(cx.listener(move |_, _event, window, cx| {
|
|
cx.emit(DismissEvent);
|
|
let output = output.clone();
|
|
let operation = operation.clone();
|
|
workspace_for_log
|
|
.update(cx, move |workspace, cx| {
|
|
open_output(operation, workspace, &output, window, cx)
|
|
})
|
|
.ok();
|
|
})),
|
|
)
|
|
.child(
|
|
IconButton::new("dismiss-worktree-fetch-failed-toast", IconName::Close)
|
|
.shape(ui::IconButtonShape::Square)
|
|
.icon_size(IconSize::Small)
|
|
.icon_color(Color::Muted)
|
|
.on_click(cx.listener(|_, _event, _window, cx| {
|
|
cx.emit(DismissEvent);
|
|
})),
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Classifies the project's visible worktrees into git-managed repositories
|
|
/// and non-git paths. Each unique repository is returned only once.
|
|
pub fn classify_worktrees(
|
|
project: &Project,
|
|
cx: &gpui::App,
|
|
) -> (Vec<Entity<Repository>>, Vec<PathBuf>) {
|
|
let repositories = project.repositories(cx).clone();
|
|
let mut git_repos: Vec<Entity<Repository>> = Vec::new();
|
|
let mut non_git_paths: Vec<PathBuf> = Vec::new();
|
|
let mut seen_repo_ids = HashSet::default();
|
|
|
|
for worktree in project.visible_worktrees(cx) {
|
|
let wt_path = worktree.read(cx).abs_path();
|
|
|
|
let matching_repo = repositories
|
|
.iter()
|
|
.filter_map(|(id, repo)| {
|
|
let work_dir = repo.read(cx).work_directory_abs_path.clone();
|
|
if wt_path.starts_with(work_dir.as_ref()) {
|
|
Some((*id, repo.clone(), work_dir.as_ref().components().count()))
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.max_by(
|
|
|(left_id, _left_repo, left_depth), (right_id, _right_repo, right_depth)| {
|
|
left_depth
|
|
.cmp(right_depth)
|
|
.then_with(|| left_id.cmp(right_id))
|
|
},
|
|
);
|
|
|
|
if let Some((id, repo, _)) = matching_repo {
|
|
if seen_repo_ids.insert(id) {
|
|
git_repos.push(repo);
|
|
}
|
|
} else {
|
|
non_git_paths.push(wt_path.to_path_buf());
|
|
}
|
|
}
|
|
|
|
(git_repos, non_git_paths)
|
|
}
|
|
|
|
/// Resolves a branch target into the ref the new worktree should be based on.
|
|
/// Returns `None` for `CurrentBranch`, meaning "use the current HEAD".
|
|
pub fn resolve_worktree_branch_target(branch_target: &NewWorktreeBranchTarget) -> Option<String> {
|
|
match branch_target {
|
|
NewWorktreeBranchTarget::CurrentBranch => None,
|
|
NewWorktreeBranchTarget::ExistingBranch { name } => Some(name.clone()),
|
|
NewWorktreeBranchTarget::RemoteBranch {
|
|
remote_name,
|
|
branch_name,
|
|
} => Some(format!("refs/remotes/{remote_name}/{branch_name}")),
|
|
}
|
|
}
|
|
|
|
fn remote_branch_to_fetch(branch_target: &NewWorktreeBranchTarget) -> Option<(&str, &str)> {
|
|
match branch_target {
|
|
NewWorktreeBranchTarget::RemoteBranch {
|
|
remote_name,
|
|
branch_name,
|
|
} => Some((remote_name, branch_name)),
|
|
NewWorktreeBranchTarget::CurrentBranch | NewWorktreeBranchTarget::ExistingBranch { .. } => {
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
fn create_worktree_askpass_delegate(
|
|
workspace: WeakEntity<Workspace>,
|
|
operation: impl Into<SharedString>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Workspace>,
|
|
) -> AskPassDelegate {
|
|
let operation = operation.into();
|
|
let window = window.window_handle();
|
|
AskPassDelegate::new(&mut cx.to_async(), move |prompt, tx, cx| {
|
|
window
|
|
.update(cx, |_, window, cx| {
|
|
workspace.update(cx, |workspace, cx| {
|
|
workspace.toggle_modal(window, cx, |window, cx| {
|
|
AskPassModal::new(operation.clone(), prompt.into(), tx, window, cx)
|
|
});
|
|
})
|
|
})
|
|
.ok();
|
|
})
|
|
}
|
|
|
|
async fn fetch_remote_for_worktree_base(
|
|
git_repos: &[Entity<Repository>],
|
|
remote_name: String,
|
|
askpass_delegates: Vec<AskPassDelegate>,
|
|
cx: &mut AsyncWindowContext,
|
|
) -> anyhow::Result<()> {
|
|
if askpass_delegates.len() != git_repos.len() {
|
|
return Err(anyhow!(
|
|
"Unable to fetch {remote_name}: missing credential prompt delegate"
|
|
));
|
|
}
|
|
|
|
let fetches = cx.update(|_, cx| {
|
|
git_repos
|
|
.iter()
|
|
.cloned()
|
|
.zip(askpass_delegates)
|
|
.map(|(repo, askpass)| {
|
|
repo.update(cx, |repo, cx| {
|
|
repo.fetch(
|
|
FetchOptions::Remote(Remote {
|
|
name: remote_name.clone().into(),
|
|
}),
|
|
askpass,
|
|
cx,
|
|
)
|
|
})
|
|
})
|
|
.collect::<Vec<_>>()
|
|
})?;
|
|
|
|
for fetch in futures::future::join_all(fetches).await {
|
|
fetch??;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Kicks off an async git-worktree creation for each repository. Returns:
|
|
///
|
|
/// - `creation_infos`: a vec of `(repo, new_path, receiver)` tuples.
|
|
/// - `path_remapping`: `(old_work_dir, new_worktree_path)` pairs for remapping editor tabs.
|
|
///
|
|
/// Multiple entries in `git_repos` can be linked worktrees of the *same*
|
|
/// underlying repository (e.g. a project that has both the main checkout and
|
|
/// one of its linked worktrees open as separate Zed worktrees). Those entries
|
|
/// resolve to the same target path via [`Repository::path_for_new_linked_worktree`],
|
|
/// so we create the new worktree only once and remap every contributing
|
|
/// work directory onto it. Without this dedup, the second `git worktree add`
|
|
/// fails with "already exists".
|
|
fn start_worktree_creations(
|
|
git_repos: &[Entity<Repository>],
|
|
worktree_name: Option<String>,
|
|
existing_worktree_names: &[String],
|
|
existing_worktree_paths: &HashSet<PathBuf>,
|
|
base_ref: Option<String>,
|
|
worktree_directory_setting: &str,
|
|
rng: &mut impl rand::Rng,
|
|
cx: &mut gpui::App,
|
|
) -> anyhow::Result<(
|
|
Vec<(
|
|
Entity<Repository>,
|
|
PathBuf,
|
|
futures::channel::oneshot::Receiver<anyhow::Result<()>>,
|
|
)>,
|
|
Vec<(PathBuf, PathBuf)>,
|
|
)> {
|
|
let mut creation_infos = Vec::new();
|
|
let mut path_remapping = Vec::new();
|
|
let mut scheduled_paths: HashSet<PathBuf> = HashSet::default();
|
|
|
|
let worktree_name = worktree_name.unwrap_or_else(|| {
|
|
let existing_refs: Vec<&str> = existing_worktree_names.iter().map(|s| s.as_str()).collect();
|
|
worktree_names::generate_worktree_name(&existing_refs, rng)
|
|
.unwrap_or_else(|| "worktree".to_string())
|
|
});
|
|
|
|
for repo in git_repos {
|
|
let (work_dir, new_path, receiver) = repo.update(cx, |repo, _cx| {
|
|
let new_path =
|
|
repo.path_for_new_linked_worktree(&worktree_name, worktree_directory_setting)?;
|
|
if existing_worktree_paths.contains(&new_path) {
|
|
anyhow::bail!("A worktree already exists at {}", new_path.display());
|
|
}
|
|
let work_dir = repo.work_directory_abs_path.clone();
|
|
// Only the first repo that resolves to a given target path
|
|
// actually creates the worktree; subsequent linked worktrees of
|
|
// the same repository just contribute a path remapping.
|
|
let receiver = if scheduled_paths.contains(&new_path) {
|
|
None
|
|
} else {
|
|
let target = git::repository::CreateWorktreeTarget::Detached {
|
|
base_sha: base_ref.clone(),
|
|
};
|
|
Some(repo.create_worktree(target, new_path.clone()))
|
|
};
|
|
anyhow::Ok((work_dir, new_path, receiver))
|
|
})?;
|
|
path_remapping.push((work_dir.to_path_buf(), new_path.clone()));
|
|
if let Some(receiver) = receiver {
|
|
scheduled_paths.insert(new_path.clone());
|
|
creation_infos.push((repo.clone(), new_path, receiver));
|
|
}
|
|
}
|
|
|
|
Ok((creation_infos, path_remapping))
|
|
}
|
|
|
|
/// Waits for every in-flight worktree creation to complete. If any
|
|
/// creation fails, all successfully-created worktrees are rolled back
|
|
/// (removed) so the project isn't left in a half-migrated state.
|
|
pub async fn await_and_rollback_on_failure(
|
|
creation_infos: Vec<(
|
|
Entity<Repository>,
|
|
PathBuf,
|
|
futures::channel::oneshot::Receiver<anyhow::Result<()>>,
|
|
)>,
|
|
fs: Arc<dyn Fs>,
|
|
cx: &mut AsyncWindowContext,
|
|
) -> anyhow::Result<Vec<PathBuf>> {
|
|
let mut created_paths: Vec<PathBuf> = Vec::new();
|
|
let mut repos_and_paths: Vec<(Entity<Repository>, PathBuf)> = Vec::new();
|
|
let mut first_error: Option<anyhow::Error> = None;
|
|
|
|
for (repo, new_path, receiver) in creation_infos {
|
|
repos_and_paths.push((repo.clone(), new_path.clone()));
|
|
match receiver.await {
|
|
Ok(Ok(())) => {
|
|
created_paths.push(new_path);
|
|
}
|
|
Ok(Err(err)) => {
|
|
if first_error.is_none() {
|
|
first_error = Some(err);
|
|
}
|
|
}
|
|
Err(_canceled) => {
|
|
if first_error.is_none() {
|
|
first_error = Some(anyhow!("Worktree creation was canceled"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let Some(err) = first_error else {
|
|
return Ok(created_paths);
|
|
};
|
|
|
|
// Rollback all attempted worktrees
|
|
let mut rollback_futures = Vec::new();
|
|
for (rollback_repo, rollback_path) in &repos_and_paths {
|
|
let receiver = cx
|
|
.update(|_, cx| {
|
|
rollback_repo.update(cx, |repo, _cx| {
|
|
repo.remove_worktree(rollback_path.clone(), true)
|
|
})
|
|
})
|
|
.ok();
|
|
|
|
rollback_futures.push((rollback_path.clone(), receiver));
|
|
}
|
|
|
|
let mut rollback_failures: Vec<String> = Vec::new();
|
|
for (path, receiver_opt) in rollback_futures {
|
|
let mut git_remove_failed = false;
|
|
|
|
if let Some(receiver) = receiver_opt {
|
|
match receiver.await {
|
|
Ok(Ok(())) => {}
|
|
Ok(Err(rollback_err)) => {
|
|
log::error!(
|
|
"git worktree remove failed for {}: {rollback_err}",
|
|
path.display()
|
|
);
|
|
git_remove_failed = true;
|
|
}
|
|
Err(canceled) => {
|
|
log::error!(
|
|
"git worktree remove failed for {}: {canceled}",
|
|
path.display()
|
|
);
|
|
git_remove_failed = true;
|
|
}
|
|
}
|
|
} else {
|
|
log::error!(
|
|
"failed to dispatch git worktree remove for {}",
|
|
path.display()
|
|
);
|
|
git_remove_failed = true;
|
|
}
|
|
|
|
if git_remove_failed {
|
|
if let Err(fs_err) = fs
|
|
.remove_dir(
|
|
&path,
|
|
fs::RemoveOptions {
|
|
recursive: true,
|
|
ignore_if_not_exists: true,
|
|
},
|
|
)
|
|
.await
|
|
{
|
|
let msg = format!("{}: failed to remove directory: {fs_err}", path.display());
|
|
log::error!("{}", msg);
|
|
rollback_failures.push(msg);
|
|
}
|
|
}
|
|
}
|
|
let mut error_message = format!("Failed to create worktree: {err}");
|
|
if !rollback_failures.is_empty() {
|
|
error_message.push_str("\n\nFailed to clean up: ");
|
|
error_message.push_str(&rollback_failures.join(", "));
|
|
}
|
|
Err(anyhow!(error_message))
|
|
}
|
|
|
|
/// Propagates worktree trust from the source workspace to the new workspace.
|
|
/// If the source project's worktrees are all trusted, the new worktree paths
|
|
/// will also be trusted automatically.
|
|
fn maybe_propagate_worktree_trust(
|
|
source_workspace: &WeakEntity<Workspace>,
|
|
new_workspace: &Entity<Workspace>,
|
|
paths: &[PathBuf],
|
|
cx: &mut AsyncWindowContext,
|
|
) {
|
|
cx.update(|_, cx| {
|
|
if ProjectSettings::get_global(cx).session.trust_all_worktrees {
|
|
return;
|
|
}
|
|
let source_is_trusted = source_workspace
|
|
.upgrade()
|
|
.map(|workspace| {
|
|
let source_worktree_store = workspace.read(cx).project().read(cx).worktree_store();
|
|
!TrustedWorktrees::has_restricted_worktrees(&source_worktree_store, cx)
|
|
})
|
|
.unwrap_or(false);
|
|
|
|
if !source_is_trusted {
|
|
return;
|
|
}
|
|
|
|
let worktree_store = new_workspace.read(cx).project().read(cx).worktree_store();
|
|
let paths_to_trust: HashSet<_> = paths
|
|
.iter()
|
|
.filter_map(|path| {
|
|
let (worktree, _) = worktree_store.read(cx).find_worktree(path, cx)?;
|
|
Some(PathTrust::Worktree(worktree.read(cx).id()))
|
|
})
|
|
.collect();
|
|
|
|
if !paths_to_trust.is_empty() {
|
|
if let Some(trusted_store) = TrustedWorktrees::try_get_global(cx) {
|
|
trusted_store.update(cx, |store, cx| {
|
|
store.trust(&worktree_store, paths_to_trust, cx);
|
|
});
|
|
}
|
|
}
|
|
})
|
|
.ok();
|
|
|
|
// After trust propagation, refresh the security modal on the new workspace
|
|
// so it dismisses itself if there are no more restricted worktrees.
|
|
cx.update(|window, cx| {
|
|
new_workspace.update(cx, |workspace, cx| {
|
|
workspace.show_worktree_trust_security_modal(false, window, cx);
|
|
});
|
|
})
|
|
.ok();
|
|
}
|
|
|
|
/// Handles the `CreateWorktree` action generically, without any agent panel involvement.
|
|
/// Creates a new git worktree, opens the workspace, restores layout and files.
|
|
/// Errors are surfaced to the user via toasts; the new workspace handle is
|
|
/// discarded. Use [`create_worktree_workspace`] when you need the resulting
|
|
/// workspace (e.g., the `create_thread` agent tool spawns a thread in it).
|
|
pub fn handle_create_worktree(
|
|
workspace: &mut Workspace,
|
|
action: &zed_actions::CreateWorktree,
|
|
window: &mut gpui::Window,
|
|
fallback_focused_dock: Option<DockPosition>,
|
|
cx: &mut gpui::Context<Workspace>,
|
|
) {
|
|
let task = create_worktree_workspace_inner(
|
|
workspace,
|
|
action,
|
|
window,
|
|
fallback_focused_dock,
|
|
RemoteBranchFetchMode::Fetch,
|
|
// The user explicitly asked to create a worktree, so foreground it.
|
|
true,
|
|
cx,
|
|
);
|
|
task.detach_and_log_err(cx);
|
|
}
|
|
|
|
/// Outcome of [`create_worktree_workspace`].
|
|
pub struct CreatedWorktreeWorkspace {
|
|
/// The newly opened workspace.
|
|
pub workspace: Entity<Workspace>,
|
|
/// True when the project contained more than one Zed worktree backed by
|
|
/// the same underlying git repository, so they were consolidated into a
|
|
/// single new worktree (they resolve to the same target path). Callers
|
|
/// that care — like the `create_thread` agent tool — can use this to warn
|
|
/// that the result may not reflect every source worktree's state.
|
|
pub consolidated_worktrees: bool,
|
|
}
|
|
|
|
/// Same as [`handle_create_worktree`], but returns a `Task` that resolves to
|
|
/// the new workspace once worktree creation and post-open setup are
|
|
/// complete. The caller receives errors as `Result`s and is expected to
|
|
/// handle them. Note that a small set of early failures (no git repositories,
|
|
/// disconnected remote, mid-creation `git fetch` failure) still surface a
|
|
/// toast on the source workspace so the user understands why the action
|
|
/// didn't take effect; the same error is also returned to the caller.
|
|
///
|
|
/// Used by the `create_thread` agent tool to spawn a sibling thread inside
|
|
/// the newly-opened workspace.
|
|
///
|
|
/// The new workspace is opened in the **background** (added as a retained
|
|
/// tab without switching to it or moving focus), and it's a clean checkout
|
|
/// rather than inheriting the source workspace's open files and dock layout.
|
|
/// This mirrors how the agent's non-worktree threads are created in the
|
|
/// background rather than yanking the user away from what they're doing.
|
|
pub fn create_worktree_workspace(
|
|
workspace: &mut Workspace,
|
|
action: &zed_actions::CreateWorktree,
|
|
window: &mut gpui::Window,
|
|
fallback_focused_dock: Option<DockPosition>,
|
|
cx: &mut gpui::Context<Workspace>,
|
|
) -> Task<anyhow::Result<CreatedWorktreeWorkspace>> {
|
|
create_worktree_workspace_inner(
|
|
workspace,
|
|
action,
|
|
window,
|
|
fallback_focused_dock,
|
|
RemoteBranchFetchMode::Fetch,
|
|
// Agent-created worktree workspaces open in the background.
|
|
false,
|
|
cx,
|
|
)
|
|
}
|
|
|
|
fn create_worktree_workspace_inner(
|
|
workspace: &mut Workspace,
|
|
action: &zed_actions::CreateWorktree,
|
|
window: &mut gpui::Window,
|
|
fallback_focused_dock: Option<DockPosition>,
|
|
remote_branch_fetch_mode: RemoteBranchFetchMode,
|
|
activate: bool,
|
|
cx: &mut gpui::Context<Workspace>,
|
|
) -> Task<anyhow::Result<CreatedWorktreeWorkspace>> {
|
|
let project = workspace.project().clone();
|
|
|
|
if project.read(cx).repositories(cx).is_empty() {
|
|
return Task::ready(Err(anyhow!(
|
|
"create_worktree: no git repository in the project"
|
|
)));
|
|
}
|
|
if project.read(cx).is_via_collab() {
|
|
return Task::ready(Err(anyhow!(
|
|
"create_worktree: not supported in collab projects"
|
|
)));
|
|
}
|
|
|
|
// Guard against concurrent creation. We treat a concurrent creation as
|
|
// a hard error here so the caller can surface it; the user-facing
|
|
// wrapper [`handle_create_worktree`] swallows the error via
|
|
// `detach_and_log_err`, matching the pre-existing silent return.
|
|
if workspace.active_worktree_creation().label.is_some() {
|
|
return Task::ready(Err(anyhow!("A worktree creation is already in progress")));
|
|
}
|
|
|
|
let previous_state =
|
|
workspace.capture_state_for_worktree_switch(window, fallback_focused_dock, cx);
|
|
let workspace_handle = workspace.weak_handle();
|
|
let window_handle = window.window_handle().downcast::<MultiWorkspace>();
|
|
let remote_connection_options = project.read(cx).remote_connection_options(cx);
|
|
|
|
let (git_repos, non_git_paths) = classify_worktrees(project.read(cx), cx);
|
|
|
|
if git_repos.is_empty() {
|
|
let toast_workspace = cx.entity();
|
|
show_error_toast(
|
|
toast_workspace,
|
|
"worktree create",
|
|
anyhow!("No git repositories found in the project"),
|
|
cx,
|
|
);
|
|
return Task::ready(Err(anyhow!("No git repositories found in the project")));
|
|
}
|
|
|
|
if remote_connection_options.is_some() {
|
|
let is_disconnected = project
|
|
.read(cx)
|
|
.remote_client()
|
|
.is_some_and(|client| client.read(cx).is_disconnected());
|
|
if is_disconnected {
|
|
let toast_workspace = cx.entity();
|
|
show_error_toast(
|
|
toast_workspace,
|
|
"worktree create",
|
|
anyhow!("Cannot create worktree: remote connection is not active"),
|
|
cx,
|
|
);
|
|
return Task::ready(Err(anyhow!(
|
|
"Cannot create worktree: remote connection is not active"
|
|
)));
|
|
}
|
|
}
|
|
|
|
let worktree_name = action.worktree_name.clone();
|
|
let branch_target = action.branch_target.clone();
|
|
let fetch_askpass_delegates = if remote_branch_fetch_mode.should_fetch() {
|
|
remote_branch_to_fetch(&branch_target)
|
|
.map(|(remote_name, _branch_name)| {
|
|
git_repos
|
|
.iter()
|
|
.map(|_| {
|
|
create_worktree_askpass_delegate(
|
|
workspace_handle.clone(),
|
|
format!("git fetch {remote_name}"),
|
|
window,
|
|
cx,
|
|
)
|
|
})
|
|
.collect()
|
|
})
|
|
.unwrap_or_default()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
let display_name: SharedString = worktree_name
|
|
.as_deref()
|
|
.unwrap_or("worktree")
|
|
.to_string()
|
|
.into();
|
|
|
|
workspace.set_active_worktree_creation(Some(display_name), false, cx);
|
|
|
|
cx.spawn_in(window, async move |_workspace_entity, mut cx| {
|
|
let result = do_create_worktree(
|
|
git_repos,
|
|
non_git_paths,
|
|
worktree_name.clone(),
|
|
branch_target.clone(),
|
|
fetch_askpass_delegates,
|
|
remote_branch_fetch_mode,
|
|
previous_state,
|
|
workspace_handle.clone(),
|
|
window_handle,
|
|
remote_connection_options,
|
|
activate,
|
|
&mut cx,
|
|
)
|
|
.await;
|
|
|
|
if let Err(err) = &result {
|
|
log::error!("Failed to create worktree: {err}");
|
|
workspace_handle
|
|
.update(cx, |workspace, cx| {
|
|
workspace.set_active_worktree_creation(None, false, cx);
|
|
if let Some(fetch_error) = err.downcast_ref::<WorktreeFetchError>() {
|
|
let toast = cx.new(|cx| {
|
|
WorktreeFetchFailedToast::new(
|
|
workspace.weak_handle(),
|
|
worktree_name,
|
|
branch_target,
|
|
fallback_focused_dock,
|
|
fetch_error,
|
|
cx,
|
|
)
|
|
});
|
|
workspace.toggle_status_toast(toast, cx);
|
|
} else {
|
|
show_error_toast(cx.entity(), "worktree create", anyhow!("{err:#}"), cx);
|
|
}
|
|
})
|
|
.ok();
|
|
}
|
|
|
|
result
|
|
})
|
|
}
|
|
|
|
pub fn handle_switch_worktree(
|
|
workspace: &mut Workspace,
|
|
action: &zed_actions::SwitchWorktree,
|
|
window: &mut gpui::Window,
|
|
fallback_focused_dock: Option<DockPosition>,
|
|
cx: &mut gpui::Context<Workspace>,
|
|
) {
|
|
let project = workspace.project().clone();
|
|
|
|
if project.read(cx).repositories(cx).is_empty() {
|
|
log::error!("switch_to_worktree: no git repository in the project");
|
|
return;
|
|
}
|
|
if project.read(cx).is_via_collab() {
|
|
log::error!("switch_to_worktree: not supported in collab projects");
|
|
return;
|
|
}
|
|
|
|
// Guard against concurrent creation
|
|
if workspace.active_worktree_creation().label.is_some() {
|
|
return;
|
|
}
|
|
|
|
let previous_state =
|
|
workspace.capture_state_for_worktree_switch(window, fallback_focused_dock, cx);
|
|
let workspace_handle = workspace.weak_handle();
|
|
let window_handle = window.window_handle().downcast::<MultiWorkspace>();
|
|
let remote_connection_options = project.read(cx).remote_connection_options(cx);
|
|
|
|
let (git_repos, non_git_paths) = classify_worktrees(project.read(cx), cx);
|
|
|
|
let git_repo_work_dirs: Vec<PathBuf> = git_repos
|
|
.iter()
|
|
.map(|repo| repo.read(cx).work_directory_abs_path.to_path_buf())
|
|
.collect();
|
|
|
|
let display_name: SharedString = action.display_name.clone().into();
|
|
|
|
workspace.set_active_worktree_creation(Some(display_name), true, cx);
|
|
|
|
let worktree_path = action.path.clone();
|
|
|
|
cx.spawn_in(window, async move |_workspace_entity, mut cx| {
|
|
let result = do_switch_worktree(
|
|
worktree_path,
|
|
git_repo_work_dirs,
|
|
non_git_paths,
|
|
previous_state,
|
|
workspace_handle.clone(),
|
|
window_handle,
|
|
remote_connection_options,
|
|
&mut cx,
|
|
)
|
|
.await;
|
|
|
|
if let Err(err) = &result {
|
|
log::error!("Failed to switch worktree: {err}");
|
|
workspace_handle
|
|
.update(cx, |workspace, cx| {
|
|
workspace.set_active_worktree_creation(None, false, cx);
|
|
show_error_toast(cx.entity(), "worktree switch", anyhow!("{err:#}"), cx);
|
|
})
|
|
.ok();
|
|
}
|
|
|
|
result
|
|
})
|
|
.detach_and_log_err(cx);
|
|
}
|
|
|
|
async fn do_create_worktree(
|
|
git_repos: Vec<Entity<Repository>>,
|
|
non_git_paths: Vec<PathBuf>,
|
|
worktree_name: Option<String>,
|
|
branch_target: NewWorktreeBranchTarget,
|
|
fetch_askpass_delegates: Vec<AskPassDelegate>,
|
|
remote_branch_fetch_mode: RemoteBranchFetchMode,
|
|
previous_state: PreviousWorkspaceState,
|
|
workspace: WeakEntity<Workspace>,
|
|
window_handle: Option<gpui::WindowHandle<MultiWorkspace>>,
|
|
remote_connection_options: Option<RemoteConnectionOptions>,
|
|
activate: bool,
|
|
cx: &mut AsyncWindowContext,
|
|
) -> anyhow::Result<CreatedWorktreeWorkspace> {
|
|
// List existing worktrees from all repos to detect name collisions
|
|
let worktree_receivers: Vec<_> = cx.update(|_, cx| {
|
|
git_repos
|
|
.iter()
|
|
.map(|repo| repo.update(cx, |repo, _cx| repo.worktrees()))
|
|
.collect()
|
|
})?;
|
|
let worktree_directory_setting = cx.update(|_, cx| {
|
|
ProjectSettings::get_global(cx)
|
|
.git
|
|
.worktree_directory
|
|
.clone()
|
|
})?;
|
|
|
|
let mut existing_worktree_names = Vec::new();
|
|
let mut existing_worktree_paths = HashSet::default();
|
|
for result in futures::future::join_all(worktree_receivers).await {
|
|
match result {
|
|
Ok(Ok(worktrees)) => {
|
|
for worktree in worktrees {
|
|
if let Some(name) = worktree
|
|
.path
|
|
.parent()
|
|
.and_then(|p| p.file_name())
|
|
.and_then(|n| n.to_str())
|
|
{
|
|
existing_worktree_names.push(name.to_string());
|
|
}
|
|
existing_worktree_paths.insert(worktree.path.clone());
|
|
}
|
|
}
|
|
Ok(Err(err)) => {
|
|
Err::<(), _>(err).log_err();
|
|
}
|
|
Err(_) => {}
|
|
}
|
|
}
|
|
|
|
if remote_branch_fetch_mode.should_fetch()
|
|
&& let Some((remote_name, branch_name)) = remote_branch_to_fetch(&branch_target)
|
|
{
|
|
let remote_name = remote_name.to_string();
|
|
let branch_name = branch_name.to_string();
|
|
if let Err(error) = fetch_remote_for_worktree_base(
|
|
&git_repos,
|
|
remote_name.clone(),
|
|
fetch_askpass_delegates,
|
|
cx,
|
|
)
|
|
.await
|
|
{
|
|
return Err(WorktreeFetchError {
|
|
remote_name,
|
|
branch_name,
|
|
source: error,
|
|
}
|
|
.into());
|
|
}
|
|
}
|
|
|
|
let mut rng = rand::rng();
|
|
|
|
let base_ref = resolve_worktree_branch_target(&branch_target);
|
|
|
|
let (creation_infos, path_remapping) = cx.update(|_, cx| {
|
|
start_worktree_creations(
|
|
&git_repos,
|
|
worktree_name,
|
|
&existing_worktree_names,
|
|
&existing_worktree_paths,
|
|
base_ref,
|
|
&worktree_directory_setting,
|
|
&mut rng,
|
|
cx,
|
|
)
|
|
})??;
|
|
|
|
let fs = cx.update(|_, cx| <dyn Fs>::global(cx))?;
|
|
|
|
let created_paths = await_and_rollback_on_failure(creation_infos, fs, cx).await?;
|
|
|
|
// `path_remapping` has one entry per source git repo, while `created_paths`
|
|
// has one per *unique* target worktree. When the former is larger, two or
|
|
// more source repos were linked worktrees of the same underlying
|
|
// repository and `start_worktree_creations` consolidated them.
|
|
let consolidated_worktrees = path_remapping.len() > created_paths.len();
|
|
|
|
let mut all_paths = created_paths;
|
|
let has_non_git = !non_git_paths.is_empty();
|
|
all_paths.extend(non_git_paths.iter().cloned());
|
|
|
|
let workspace = open_worktree_workspace(
|
|
all_paths,
|
|
path_remapping,
|
|
non_git_paths,
|
|
has_non_git,
|
|
previous_state,
|
|
workspace,
|
|
window_handle,
|
|
remote_connection_options,
|
|
WorktreeOperation::Create,
|
|
activate,
|
|
cx,
|
|
)
|
|
.await?;
|
|
|
|
Ok(CreatedWorktreeWorkspace {
|
|
workspace,
|
|
consolidated_worktrees,
|
|
})
|
|
}
|
|
|
|
async fn do_switch_worktree(
|
|
worktree_path: PathBuf,
|
|
git_repo_work_dirs: Vec<PathBuf>,
|
|
non_git_paths: Vec<PathBuf>,
|
|
previous_state: PreviousWorkspaceState,
|
|
workspace: WeakEntity<Workspace>,
|
|
window_handle: Option<gpui::WindowHandle<MultiWorkspace>>,
|
|
remote_connection_options: Option<RemoteConnectionOptions>,
|
|
cx: &mut AsyncWindowContext,
|
|
) -> anyhow::Result<Entity<Workspace>> {
|
|
let path_remapping: Vec<(PathBuf, PathBuf)> = git_repo_work_dirs
|
|
.iter()
|
|
.map(|work_dir| (work_dir.clone(), worktree_path.clone()))
|
|
.collect();
|
|
|
|
let mut all_paths = vec![worktree_path];
|
|
let has_non_git = !non_git_paths.is_empty();
|
|
all_paths.extend(non_git_paths.iter().cloned());
|
|
|
|
open_worktree_workspace(
|
|
all_paths,
|
|
path_remapping,
|
|
non_git_paths,
|
|
has_non_git,
|
|
previous_state,
|
|
workspace,
|
|
window_handle,
|
|
remote_connection_options,
|
|
WorktreeOperation::Switch,
|
|
// Switching is always an explicit, foreground user action.
|
|
true,
|
|
cx,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Core workspace opening logic shared by both create and switch flows.
|
|
/// Returns the newly opened workspace entity so callers can do post-open
|
|
/// work (e.g., the `create_thread` agent tool spawns a thread inside it).
|
|
async fn open_worktree_workspace(
|
|
all_paths: Vec<PathBuf>,
|
|
path_remapping: Vec<(PathBuf, PathBuf)>,
|
|
non_git_paths: Vec<PathBuf>,
|
|
has_non_git: bool,
|
|
previous_state: PreviousWorkspaceState,
|
|
workspace: WeakEntity<Workspace>,
|
|
window_handle: Option<gpui::WindowHandle<MultiWorkspace>>,
|
|
remote_connection_options: Option<RemoteConnectionOptions>,
|
|
operation: WorktreeOperation,
|
|
activate: bool,
|
|
cx: &mut AsyncWindowContext,
|
|
) -> anyhow::Result<Entity<Workspace>> {
|
|
let window_handle = window_handle
|
|
.ok_or_else(|| anyhow!("No window handle available for workspace creation"))?;
|
|
|
|
let focused_dock = previous_state.focused_dock;
|
|
|
|
let is_creating_new_worktree = matches!(operation, WorktreeOperation::Create);
|
|
|
|
// When `activate` is false the new workspace is opened in the background
|
|
// (e.g. the agent's `create_thread` tool), so it should be a clean
|
|
// checkout rather than inheriting the source workspace's open files and
|
|
// dock layout. The state transfer only applies when we're foregrounding
|
|
// a freshly-created worktree for the user.
|
|
let transfer_state = is_creating_new_worktree && activate;
|
|
|
|
let source_for_transfer = if transfer_state {
|
|
Some(workspace.clone())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let (workspace_task, modal_workspace) =
|
|
window_handle.update(cx, |multi_workspace, window, cx| {
|
|
let path_list = util::path_list::PathList::new(&all_paths);
|
|
let active_workspace = multi_workspace.workspace().clone();
|
|
let modal_workspace = active_workspace.clone();
|
|
|
|
let init: Option<
|
|
Box<
|
|
dyn FnOnce(&mut Workspace, &mut gpui::Window, &mut gpui::Context<Workspace>)
|
|
+ Send,
|
|
>,
|
|
> = if transfer_state {
|
|
let dock_structure = previous_state.dock_structure;
|
|
Some(Box::new(
|
|
move |workspace: &mut Workspace,
|
|
window: &mut gpui::Window,
|
|
cx: &mut gpui::Context<Workspace>| {
|
|
workspace.set_dock_structure(dock_structure, window, cx);
|
|
},
|
|
))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let task = multi_workspace.find_or_create_workspace_with_source_workspace(
|
|
path_list,
|
|
remote_connection_options,
|
|
None,
|
|
move |connection_options, window, cx| {
|
|
remote_connection::connect_with_modal(
|
|
&active_workspace,
|
|
connection_options,
|
|
window,
|
|
cx,
|
|
)
|
|
},
|
|
&[],
|
|
init,
|
|
OpenMode::Add,
|
|
source_for_transfer.clone(),
|
|
window,
|
|
cx,
|
|
);
|
|
(task, modal_workspace)
|
|
})?;
|
|
|
|
let result = workspace_task.await;
|
|
remote_connection::dismiss_connection_modal(&modal_workspace, cx);
|
|
let new_workspace = result?;
|
|
|
|
let panels_task = new_workspace.update(cx, |workspace, _cx| workspace.take_panels_task());
|
|
|
|
if let Some(task) = panels_task {
|
|
task.await.log_err();
|
|
}
|
|
|
|
new_workspace
|
|
.update(cx, |workspace, cx| {
|
|
workspace.project().read(cx).wait_for_initial_scan(cx)
|
|
})
|
|
.await;
|
|
|
|
new_workspace
|
|
.update(cx, |workspace, cx| {
|
|
let repos = workspace
|
|
.project()
|
|
.read(cx)
|
|
.repositories(cx)
|
|
.values()
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
|
|
let tasks = repos
|
|
.into_iter()
|
|
.map(|repo| repo.update(cx, |repo, _| repo.barrier()));
|
|
futures::future::join_all(tasks)
|
|
})
|
|
.await;
|
|
|
|
maybe_propagate_worktree_trust(&workspace, &new_workspace, &all_paths, cx);
|
|
|
|
if transfer_state {
|
|
window_handle.update(cx, |_multi_workspace, window, cx| {
|
|
new_workspace.update(cx, |workspace, cx| {
|
|
if has_non_git {
|
|
struct WorktreeCreationToast;
|
|
let toast_id =
|
|
workspace::notifications::NotificationId::unique::<WorktreeCreationToast>();
|
|
workspace.show_toast(
|
|
workspace::Toast::new(
|
|
toast_id,
|
|
"Some project folders are not git repositories. \
|
|
They were included as-is without creating a worktree.",
|
|
),
|
|
cx,
|
|
);
|
|
}
|
|
|
|
// Remap every previously-open file path into the new worktree.
|
|
let remap_path = |original_path: PathBuf| -> Option<PathBuf> {
|
|
let best_match = path_remapping
|
|
.iter()
|
|
.filter_map(|(old_root, new_root)| {
|
|
original_path.strip_prefix(old_root).ok().map(|relative| {
|
|
(old_root.components().count(), new_root.join(relative))
|
|
})
|
|
})
|
|
.max_by_key(|(depth, _)| *depth);
|
|
|
|
if let Some((_, remapped_path)) = best_match {
|
|
return Some(remapped_path);
|
|
}
|
|
|
|
for non_git in &non_git_paths {
|
|
if original_path.starts_with(non_git) {
|
|
return Some(original_path);
|
|
}
|
|
}
|
|
None
|
|
};
|
|
|
|
let remapped_active_path =
|
|
previous_state.active_file_path.and_then(|p| remap_path(p));
|
|
|
|
let mut paths_to_open: Vec<PathBuf> = Vec::new();
|
|
let mut seen = HashSet::default();
|
|
for path in previous_state.open_file_paths {
|
|
if let Some(remapped) = remap_path(path) {
|
|
if remapped_active_path.as_ref() != Some(&remapped)
|
|
&& seen.insert(remapped.clone())
|
|
{
|
|
paths_to_open.push(remapped);
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(active) = &remapped_active_path {
|
|
if seen.insert(active.clone()) {
|
|
paths_to_open.push(active.clone());
|
|
}
|
|
}
|
|
|
|
if !paths_to_open.is_empty() {
|
|
let should_focus_center = focused_dock.is_none();
|
|
let open_task = workspace.open_paths(
|
|
paths_to_open,
|
|
workspace::OpenOptions {
|
|
focus: Some(false),
|
|
..Default::default()
|
|
},
|
|
None,
|
|
window,
|
|
cx,
|
|
);
|
|
cx.spawn_in(window, async move |workspace, cx| {
|
|
for item in open_task.await.into_iter().flatten() {
|
|
item.log_err();
|
|
}
|
|
if should_focus_center {
|
|
workspace.update_in(cx, |workspace, window, cx| {
|
|
workspace.focus_center_pane(window, cx);
|
|
})?;
|
|
}
|
|
anyhow::Ok(())
|
|
})
|
|
.detach_and_log_err(cx);
|
|
}
|
|
});
|
|
})?;
|
|
}
|
|
|
|
// Clear the creation status on the SOURCE workspace so its title bar
|
|
// stops showing the loading indicator immediately.
|
|
workspace
|
|
.update(cx, |ws, cx| {
|
|
ws.set_active_worktree_creation(None, false, cx);
|
|
})
|
|
.ok();
|
|
|
|
window_handle.update(cx, |multi_workspace, window, cx| {
|
|
if activate {
|
|
multi_workspace.activate(new_workspace.clone(), source_for_transfer, window, cx);
|
|
} else {
|
|
// Background open: register the new workspace as a retained tab
|
|
// but leave the user where they are.
|
|
multi_workspace.add_background_workspace(new_workspace.clone(), window, cx);
|
|
}
|
|
|
|
if is_creating_new_worktree {
|
|
new_workspace.update(cx, |workspace, cx| {
|
|
// Run create-worktree setup hooks regardless of foreground vs
|
|
// background — the worktree was created either way.
|
|
workspace.run_create_worktree_tasks(window, cx);
|
|
|
|
if activate && let Some(dock_position) = focused_dock {
|
|
let dock = workspace.dock_at_position(dock_position);
|
|
if let Some(panel) = dock.read(cx).active_panel() {
|
|
panel.panel_focus_handle(cx).focus(window, cx);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
})?;
|
|
|
|
Ok(new_workspace)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use fs::Fs;
|
|
use gpui::{App, Task, TestAppContext};
|
|
use language::language_settings::AllLanguageSettings;
|
|
use project::project_settings::ProjectSettings;
|
|
use project::task_store::{TaskSettingsLocation, TaskStore};
|
|
use project::{FakeFs, WorktreeSettings};
|
|
use serde_json::json;
|
|
use settings::{SettingsLocation, SettingsStore};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::ExitStatus;
|
|
use std::sync::Mutex;
|
|
use task::SpawnInTerminal;
|
|
use theme::LoadThemes;
|
|
use util::path;
|
|
use util::rel_path::rel_path;
|
|
use workspace::{TerminalProvider, WorkspaceSettings};
|
|
|
|
struct CountingTerminalProvider {
|
|
spawned_task_labels: Arc<Mutex<Vec<String>>>,
|
|
}
|
|
|
|
impl TerminalProvider for CountingTerminalProvider {
|
|
fn spawn(
|
|
&self,
|
|
task: SpawnInTerminal,
|
|
_window: &mut ui::Window,
|
|
_cx: &mut App,
|
|
) -> Task<Option<anyhow::Result<ExitStatus>>> {
|
|
self.spawned_task_labels
|
|
.lock()
|
|
.expect("terminal spawn mutex should not be poisoned")
|
|
.push(task.label);
|
|
Task::ready(Some(Ok(ExitStatus::default())))
|
|
}
|
|
}
|
|
|
|
fn init_test(cx: &mut TestAppContext) {
|
|
zlog::init_test();
|
|
cx.update(|cx| {
|
|
let settings_store = SettingsStore::test(cx);
|
|
cx.set_global(settings_store);
|
|
theme_settings::init(LoadThemes::JustBase, cx);
|
|
AllLanguageSettings::register(cx);
|
|
editor::init(cx);
|
|
ProjectSettings::register(cx);
|
|
WorktreeSettings::register(cx);
|
|
WorkspaceSettings::register(cx);
|
|
TaskStore::init(None);
|
|
});
|
|
}
|
|
|
|
fn install_counting_provider_and_worktree_hook(
|
|
workspace: &Entity<Workspace>,
|
|
spawned_task_labels: &Arc<Mutex<Vec<String>>>,
|
|
main_project_root: &Path,
|
|
hook_tasks_json: &str,
|
|
cx: &mut App,
|
|
) {
|
|
workspace.update(cx, |workspace, cx| {
|
|
workspace.set_terminal_provider(CountingTerminalProvider {
|
|
spawned_task_labels: spawned_task_labels.clone(),
|
|
});
|
|
|
|
let project = workspace.project().clone();
|
|
let Some(worktree) = project.read(cx).worktrees(cx).next() else {
|
|
return;
|
|
};
|
|
let worktree = worktree.read(cx);
|
|
let worktree_id = worktree.id();
|
|
let worktree_root = worktree.abs_path().to_path_buf();
|
|
if worktree_root == main_project_root {
|
|
return;
|
|
}
|
|
|
|
let Some(task_inventory) = project
|
|
.read(cx)
|
|
.task_store()
|
|
.read(cx)
|
|
.task_inventory()
|
|
.cloned()
|
|
else {
|
|
return;
|
|
};
|
|
task_inventory.update(cx, |inventory, _| {
|
|
inventory
|
|
.update_file_based_tasks(
|
|
TaskSettingsLocation::Worktree(SettingsLocation {
|
|
worktree_id,
|
|
path: rel_path(".zed"),
|
|
}),
|
|
Some(hook_tasks_json),
|
|
)
|
|
.expect("should inject create_worktree hook tasks for linked worktree");
|
|
});
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_create_worktree_hook_does_not_run_when_switching_back_to_main_worktree(
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
init_test(cx);
|
|
|
|
let hook_tasks_json = r#"[{"label":"setup worktree","command":"echo","hide":"never","hooks":["create_worktree"]}]"#;
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
cx.update(|cx| <dyn Fs>::set_global(fs.clone(), cx));
|
|
fs.insert_tree(
|
|
"/root",
|
|
json!({
|
|
"project": {
|
|
".git": {},
|
|
".zed": {
|
|
"tasks.json": hook_tasks_json,
|
|
},
|
|
"src": {
|
|
"main.rs": "fn main() {}",
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let main_project_root = PathBuf::from(path!("/root/project"));
|
|
let project = Project::test(fs.clone(), [main_project_root.as_path()], cx).await;
|
|
project
|
|
.update(cx, |project, cx| project.git_scans_complete(cx))
|
|
.await;
|
|
|
|
let (multi_workspace, cx) =
|
|
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
|
|
|
|
let spawned_task_labels = Arc::new(Mutex::new(Vec::new()));
|
|
multi_workspace.update(cx, |multi_workspace, cx| {
|
|
multi_workspace.retain_active_workspace(cx);
|
|
let active_workspace = multi_workspace.workspace().clone();
|
|
install_counting_provider_and_worktree_hook(
|
|
&active_workspace,
|
|
&spawned_task_labels,
|
|
&main_project_root,
|
|
hook_tasks_json,
|
|
cx,
|
|
);
|
|
});
|
|
|
|
let main_workspace =
|
|
multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone());
|
|
main_workspace.update_in(cx, |workspace, window, cx| {
|
|
handle_create_worktree(
|
|
workspace,
|
|
&zed_actions::CreateWorktree {
|
|
worktree_name: Some("feature".to_string()),
|
|
branch_target: NewWorktreeBranchTarget::CurrentBranch,
|
|
},
|
|
window,
|
|
None,
|
|
cx,
|
|
);
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
let active_workspace =
|
|
multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone());
|
|
cx.update(|_, cx| {
|
|
install_counting_provider_and_worktree_hook(
|
|
&active_workspace,
|
|
&spawned_task_labels,
|
|
&main_project_root,
|
|
hook_tasks_json,
|
|
cx,
|
|
);
|
|
});
|
|
active_workspace.update_in(cx, |workspace, window, cx| {
|
|
workspace.run_create_worktree_tasks(window, cx);
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
assert_eq!(
|
|
spawned_task_labels
|
|
.lock()
|
|
.expect("terminal spawn mutex should not be poisoned")
|
|
.as_slice(),
|
|
["setup worktree"],
|
|
"create_worktree hook should run once for the created linked worktree"
|
|
);
|
|
|
|
active_workspace.update_in(cx, |workspace, window, cx| {
|
|
handle_switch_worktree(
|
|
workspace,
|
|
&zed_actions::SwitchWorktree {
|
|
path: main_project_root.clone(),
|
|
display_name: "project".to_string(),
|
|
},
|
|
window,
|
|
None,
|
|
cx,
|
|
);
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
assert_eq!(
|
|
spawned_task_labels
|
|
.lock()
|
|
.expect("terminal spawn mutex should not be poisoned")
|
|
.as_slice(),
|
|
["setup worktree"],
|
|
"switching back to the main worktree should not rerun create_worktree hooks"
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_linked_worktree_inherits_trust_from_main_worktree(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
cx.update(|cx| {
|
|
project::trusted_worktrees::init(collections::HashMap::default(), cx);
|
|
});
|
|
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
cx.update(|cx| <dyn Fs>::set_global(fs.clone(), cx));
|
|
fs.insert_tree(
|
|
"/root",
|
|
json!({
|
|
"project": {
|
|
".git": {},
|
|
"src": {
|
|
"main.rs": "fn main() {}",
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let main_project_root = PathBuf::from(path!("/root/project"));
|
|
let project =
|
|
Project::test_with_worktree_trust(fs.clone(), [main_project_root.as_path()], cx).await;
|
|
project
|
|
.update(cx, |project, cx| project.git_scans_complete(cx))
|
|
.await;
|
|
|
|
// The main worktree starts restricted; trust it explicitly
|
|
let worktree_store = project.read_with(cx, |project, _| project.worktree_store());
|
|
let main_worktree_id = worktree_store.read_with(cx, |store, cx| {
|
|
store
|
|
.worktrees()
|
|
.next()
|
|
.map(|wt| wt.read(cx).id())
|
|
.expect("should have a worktree")
|
|
});
|
|
let trusted_store = cx
|
|
.read(|cx| project::trusted_worktrees::TrustedWorktrees::try_get_global(cx))
|
|
.expect("trust store should exist");
|
|
trusted_store.update(cx, |store, cx| {
|
|
store.trust(
|
|
&worktree_store,
|
|
collections::HashSet::from_iter([project::trusted_worktrees::PathTrust::Worktree(
|
|
main_worktree_id,
|
|
)]),
|
|
cx,
|
|
);
|
|
});
|
|
|
|
// Verify main worktree is now trusted
|
|
let has_restricted = cx.read(|cx| {
|
|
project::trusted_worktrees::TrustedWorktrees::has_restricted_worktrees(
|
|
&worktree_store,
|
|
cx,
|
|
)
|
|
});
|
|
assert!(
|
|
!has_restricted,
|
|
"main worktree should be trusted after explicit trust"
|
|
);
|
|
|
|
let (multi_workspace, cx) =
|
|
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
|
|
multi_workspace.update(cx, |multi_workspace, cx| {
|
|
multi_workspace.retain_active_workspace(cx);
|
|
});
|
|
|
|
// Create a linked worktree from the trusted main worktree
|
|
let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
|
|
main_workspace.update_in(cx, |workspace, window, cx| {
|
|
handle_create_worktree(
|
|
workspace,
|
|
&zed_actions::CreateWorktree {
|
|
worktree_name: Some("feature".to_string()),
|
|
branch_target: NewWorktreeBranchTarget::CurrentBranch,
|
|
},
|
|
window,
|
|
None,
|
|
cx,
|
|
);
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
// The new workspace (linked worktree) should inherit trust
|
|
let new_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
|
|
let new_worktree_store =
|
|
new_workspace.read_with(cx, |ws, cx| ws.project().read(cx).worktree_store());
|
|
let new_has_restricted = cx.read(|cx| {
|
|
project::trusted_worktrees::TrustedWorktrees::has_restricted_worktrees(
|
|
&new_worktree_store,
|
|
cx,
|
|
)
|
|
});
|
|
assert!(
|
|
!new_has_restricted,
|
|
"linked worktree should inherit trust from the main worktree"
|
|
);
|
|
|
|
// The security modal should not be showing
|
|
let has_modal = new_workspace.read_with(cx, |ws, cx| {
|
|
ws.active_modal::<workspace::security_modal::SecurityModal>(cx)
|
|
.is_some()
|
|
});
|
|
assert!(
|
|
!has_modal,
|
|
"security modal should not show for a linked worktree created from a trusted main worktree"
|
|
);
|
|
}
|
|
}
|