Files
zed/crates/git_ui/src/worktree_picker.rs
T
Danilo Leal 2230c1a5bf worktree_picker: Improve discoverability of tasks as a creation setup mechanism (#61148)
Sometimes when creating Git worktrees, it's necessary to set things up
so the new worktree is ready. For example, copying env.vars, installing
dependencies, etc. That was already possible in Zed through the tasks
system, but you'd only maybe know about that if you read the
documentation or is super familiar with it already; nothing in the
product in the context of worktrees told you about that. This is what
this PR does.

Now, in the worktree picker, there's a "" button that opens the
`tasks.json` file exposing the `create_worktree` hook, which allows you
to plugin all sorts of things to be run by the time of a worktree
creation. If you don't already have a `tasks.json` file, we will create
a new one for you with a worktree-creation template. Otherwise, we
either append the `create_worktree` hook content to it or just open the
file.

Here's a quick video:


https://github.com/user-attachments/assets/21908be4-306b-4cf3-bed0-50d52cad9d79

Release Notes:

- Git Worktrees: Improved discoverability of the `create_worktree` hook
for setting up things that need to happen by the time of worktree
creation.
2026-07-20 11:17:21 +00:00

2302 lines
86 KiB
Rust

use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::Context as _;
use collections::HashSet;
use fuzzy::StringMatchCandidate;
use git::repository::Worktree as GitWorktree;
use gpui::{
Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, PromptLevel,
Render, SharedString, Styled, Subscription, Task, TaskExt, WeakEntity, Window, actions,
};
use picker::{Picker, PickerDelegate, PickerEditorPosition};
use project::Project;
use project::git_store::RepositoryEvent;
use ui::{
Button, CommonAnimationExt as _, Divider, HighlightedLabel, IconButton, KeyBinding, ListItem,
ListItemSpacing, ListSubHeader, Tooltip, prelude::*,
};
use util::ResultExt as _;
use util::paths::PathExt;
use workspace::{
ModalView, MultiWorkspace, Workspace, dock::DockPosition, notifications::DetachAndPromptErr,
};
use crate::git_panel::show_error_toast;
use crate::worktree_service::{RemoteBranchName, WorktreeCreateTarget, worktree_create_targets};
use zed_actions::{
CreateWorktree, NewWorktreeBranchTarget, OpenWorktreeInNewWindow, OpenWorktreeSetupTasks,
SwitchWorktree,
};
actions!(
worktree_picker,
[
/// Deletes the selected git worktree.
DeleteWorktree,
/// Force deletes the selected git worktree.
ForceDeleteWorktree
]
);
pub struct WorktreePicker {
picker: Entity<Picker<WorktreePickerDelegate>>,
focus_handle: FocusHandle,
_subscriptions: Vec<Subscription>,
}
impl WorktreePicker {
pub fn new(
project: Entity<Project>,
workspace: WeakEntity<Workspace>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let focused_dock = workspace
.upgrade()
.and_then(|workspace| workspace.read(cx).focused_dock_position(window, cx));
Self::new_inner(project, workspace, focused_dock, false, window, cx)
}
pub fn new_modal(
project: Entity<Project>,
workspace: WeakEntity<Workspace>,
focused_dock: Option<DockPosition>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self::new_inner(project, workspace, focused_dock, true, window, cx)
}
fn new_inner(
project: Entity<Project>,
workspace: WeakEntity<Workspace>,
focused_dock: Option<DockPosition>,
show_footer: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let project_ref = project.read(cx);
let active_worktree_paths: HashSet<PathBuf> = project_ref
.visible_worktrees(cx)
.map(|wt| wt.read(cx).abs_path().to_path_buf())
.collect();
let project_worktree_paths = active_worktree_paths.clone();
let has_multiple_repositories = project_ref.repositories(cx).len() > 1;
let repository = project_ref.active_repository(cx);
let current_branch_name = repository.as_ref().and_then(|repo| {
repo.read(cx)
.branch
.as_ref()
.map(|branch| branch.name().to_string())
});
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(true))
});
let initial_matches = vec![WorktreeEntry::CreateFromCurrentBranch];
let delegate = WorktreePickerDelegate {
matches: initial_matches,
all_worktrees: Vec::new(),
project_worktree_paths,
selected_index: 0,
project,
workspace,
focused_dock,
current_branch_name,
default_branch: None,
has_multiple_repositories,
focus_handle: cx.focus_handle(),
show_footer,
modifiers: Modifiers::default(),
active_worktree_paths,
hovered_delete_index: None,
deleting_worktree_paths: HashSet::default(),
};
let picker = cx.new(|cx| {
Picker::list(delegate, window, cx)
.list_measure_all()
.show_scrollbar(true)
.embedded()
});
let picker_focus_handle = picker.focus_handle(cx);
picker.update(cx, |picker, _| {
picker.delegate.focus_handle = picker_focus_handle;
});
let mut subscriptions = Vec::new();
{
let picker_handle = picker.downgrade();
cx.spawn_in(window, async move |_this, cx| {
let all_worktrees: Vec<_> = match all_worktrees_request {
Some(req) => match req.await {
Ok(Ok(worktrees)) => {
worktrees.into_iter().filter(|wt| !wt.is_bare).collect()
}
Ok(Err(err)) => {
log::warn!("WorktreePicker: git worktree list failed: {err}");
return anyhow::Ok(());
}
Err(_) => {
log::warn!("WorktreePicker: worktree request was cancelled");
return anyhow::Ok(());
}
},
None => Vec::new(),
};
let default_branch = match default_branch_request {
Some(req) => req.await.ok().and_then(Result::ok).flatten(),
None => None,
};
picker_handle.update_in(cx, |picker, window, cx| {
picker.delegate.all_worktrees = all_worktrees;
picker.delegate.default_branch =
default_branch.and_then(|branch| RemoteBranchName::parse(&branch));
picker.delegate.refresh_project_worktree_paths(window, cx);
picker.refresh(window, cx);
})?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
if let Some(repo) = &repository {
let picker_entity = picker.downgrade();
subscriptions.push(cx.subscribe_in(
repo,
window,
move |_this, repo, event: &RepositoryEvent, window, cx| {
if matches!(event, RepositoryEvent::GitWorktreeListChanged) {
let worktrees_request = repo.update(cx, |repo, _| repo.worktrees());
let picker = picker_entity.clone();
cx.spawn_in(window, async move |_, cx| {
let all_worktrees: Vec<_> = worktrees_request
.await??
.into_iter()
.filter(|wt| !wt.is_bare)
.collect();
picker.update_in(cx, |picker, window, cx| {
picker.delegate.all_worktrees = all_worktrees;
picker.refresh(window, cx);
})?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
},
));
}
subscriptions.push(cx.subscribe(&picker, |_, _, _, cx| {
cx.emit(DismissEvent);
}));
Self {
focus_handle: picker.focus_handle(cx),
picker,
_subscriptions: subscriptions,
}
}
fn handle_modifiers_changed(
&mut self,
ev: &ModifiersChangedEvent,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.picker.update(cx, |picker, cx| {
picker.delegate.modifiers = ev.modifiers;
cx.notify();
});
}
}
impl Focusable for WorktreePicker {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl ModalView for WorktreePicker {}
impl EventEmitter<DismissEvent> for WorktreePicker {}
impl Render for WorktreePicker {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.key_context("WorktreePicker")
.elevation_3(cx)
.child(self.picker.clone())
.on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
.on_mouse_down_out(cx.listener(|_, _, _, cx| {
cx.emit(DismissEvent);
}))
.on_action(cx.listener(|_, _: &OpenWorktreeSetupTasks, _, cx| {
cx.emit(DismissEvent);
cx.propagate();
}))
.on_action(cx.listener(|this, _: &DeleteWorktree, window, cx| {
this.picker.update(cx, |picker, cx| {
let ix = picker.delegate.selected_index;
picker.delegate.delete_worktree(ix, false, window, cx);
});
}))
.on_action(cx.listener(|this, _: &ForceDeleteWorktree, window, cx| {
this.picker.update(cx, |picker, cx| {
let ix = picker.delegate.selected_index;
picker.delegate.delete_worktree(ix, true, window, cx);
});
}))
}
}
#[derive(Clone)]
enum WorktreeEntry {
CreateFromCurrentBranch,
CreateFromDefaultBranch {
default_branch: RemoteBranchName,
},
Separator,
SectionHeader(SharedString),
Worktree {
worktree: GitWorktree,
positions: Vec<usize>,
},
CreateNamed {
name: String,
from_branch: Option<RemoteBranchName>,
disabled_reason: Option<String>,
},
}
struct WorktreePickerDelegate {
matches: Vec<WorktreeEntry>,
all_worktrees: Vec<GitWorktree>,
project_worktree_paths: HashSet<PathBuf>,
active_worktree_paths: HashSet<PathBuf>,
selected_index: usize,
project: Entity<Project>,
workspace: WeakEntity<Workspace>,
focused_dock: Option<DockPosition>,
current_branch_name: Option<String>,
default_branch: Option<RemoteBranchName>,
has_multiple_repositories: bool,
focus_handle: FocusHandle,
show_footer: bool,
modifiers: Modifiers,
hovered_delete_index: Option<usize>,
deleting_worktree_paths: HashSet<PathBuf>,
}
fn remove_worktree_command(path: &Path, force: bool) -> String {
if force {
format!("worktree remove --force {}", path.display())
} else {
format!("worktree remove {}", path.display())
}
}
struct WorktreeRemoveForceDeletePrompt {
required_error_substrings: &'static [&'static str],
message: fn(&str) -> String,
}
impl WorktreeRemoveForceDeletePrompt {
fn matches(&self, normalized_error_message: &str) -> bool {
self.required_error_substrings
.iter()
.all(|substring| normalized_error_message.contains(substring))
}
}
const WORKTREE_REMOVE_FORCE_DELETE_PROMPTS: &[WorktreeRemoveForceDeletePrompt] =
&[WorktreeRemoveForceDeletePrompt {
required_error_substrings: &[
"contains modified or untracked files",
"use --force to delete it",
],
message: dirty_worktree_force_delete_prompt,
}];
fn dirty_worktree_force_delete_prompt(display_name: &str) -> String {
format!("Worktree \"{display_name}\" contains modified or untracked files. Force delete it?")
}
fn force_delete_prompt_for_worktree_remove_error(
error: &anyhow::Error,
display_name: &str,
) -> Option<String> {
let normalized_error_message = error.to_string().to_lowercase();
WORKTREE_REMOVE_FORCE_DELETE_PROMPTS
.iter()
.find(|prompt| prompt.matches(&normalized_error_message))
.map(|prompt| (prompt.message)(display_name))
}
struct DeleteWorktreeTooltip {
picker: WeakEntity<Picker<WorktreePickerDelegate>>,
focus_handle: FocusHandle,
delete_index: usize,
_subscription: Subscription,
}
impl DeleteWorktreeTooltip {
fn new(
picker: Entity<Picker<WorktreePickerDelegate>>,
focus_handle: FocusHandle,
delete_index: usize,
cx: &mut Context<Self>,
) -> Self {
let subscription = cx.observe(&picker, |_, _, cx| cx.notify());
Self {
picker: picker.downgrade(),
focus_handle,
delete_index,
_subscription: subscription,
}
}
}
impl Render for DeleteWorktreeTooltip {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let force_delete = self
.picker
.read_with(cx, |picker, _| {
picker
.delegate
.is_force_delete_hovering_index(self.delete_index)
})
.unwrap_or(false);
if force_delete {
Tooltip::for_action_in(
"Force Delete Worktree",
&ForceDeleteWorktree,
&self.focus_handle,
cx,
)
.into_any_element()
} else {
Tooltip::with_meta_in(
"Delete Worktree",
Some(&DeleteWorktree),
"Hold alt to force delete",
&self.focus_handle,
cx,
)
.into_any_element()
}
}
}
impl WorktreePickerDelegate {
fn build_fixed_entries(&self) -> Vec<WorktreeEntry> {
worktree_create_targets(
self.has_multiple_repositories,
self.default_branch.clone(),
self.current_branch_name.as_deref(),
)
.into_iter()
.map(|target| match target {
WorktreeCreateTarget::CurrentBranch => WorktreeEntry::CreateFromCurrentBranch,
WorktreeCreateTarget::DefaultBranch(default_branch) => {
WorktreeEntry::CreateFromDefaultBranch { default_branch }
}
})
.collect()
}
fn all_repo_worktrees(&self) -> &[GitWorktree] {
&self.all_worktrees
}
fn creation_blocked_reason(&self, cx: &App) -> Option<SharedString> {
let project = self.project.read(cx);
if project.is_via_collab() {
Some("Worktree creation is not supported in collaborative projects".into())
} else if project.repositories(cx).is_empty() {
Some("Requires a Git repository in the project".into())
} else {
None
}
}
fn can_delete_worktree(&self, worktree: &GitWorktree) -> bool {
!worktree.is_main && !self.project_worktree_paths.contains(&worktree.path)
}
fn refresh_project_worktree_paths(&mut self, window: &mut Window, cx: &mut App) {
let mut paths = self.active_worktree_paths.clone();
if let Some(multi_workspace) = window.root::<MultiWorkspace>().flatten()
&& let Some(workspace) = self.workspace.upgrade()
{
let group_key = workspace.read(cx).project_group_key(cx);
if let Some(group_workspaces) = multi_workspace
.read(cx)
.workspaces_for_project_group(&group_key, cx)
{
for group_workspace in group_workspaces {
for worktree in group_workspace
.read(cx)
.project()
.read(cx)
.visible_worktrees(cx)
{
paths.insert(worktree.read(cx).abs_path().to_path_buf());
}
}
}
}
self.project_worktree_paths = paths;
}
fn is_force_delete_hovering_index(&self, index: usize) -> bool {
self.modifiers.alt && self.hovered_delete_index == Some(index)
}
fn delete_worktree(
&mut self,
ix: usize,
force: bool,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) {
let Some(entry) = self.matches.get(ix) else {
return;
};
let WorktreeEntry::Worktree { worktree, .. } = entry else {
return;
};
if !self.can_delete_worktree(worktree)
|| self.deleting_worktree_paths.contains(&worktree.path)
{
return;
}
let repo = self.project.read(cx).active_repository(cx);
let Some(repo) = repo else {
return;
};
let path = worktree.path.clone();
let display_name = worktree.directory_name(
self.all_worktrees
.iter()
.find(|worktree| worktree.is_main)
.map(|worktree| worktree.path.as_path()),
);
let workspace = self.workspace.clone();
self.deleting_worktree_paths.insert(path.clone());
if self.hovered_delete_index == Some(ix) {
self.hovered_delete_index = None;
}
cx.notify();
cx.spawn_in(window, async move |picker, cx| {
let initial_result = match repo
.update(cx, |repo, _| repo.remove_worktree(path.clone(), force))
.await
{
Ok(result) => result,
Err(error) => {
picker.update_in(cx, |picker, _window, cx| {
if picker.delegate.deleting_worktree_paths.remove(&path) {
cx.notify();
}
})?;
return Err(error.into());
}
};
let (result, attempted_force) = match initial_result {
Ok(()) => (Ok(()), force),
Err(error) => {
log::error!("Failed to remove worktree: {}", error);
let force_delete_prompt = (!force)
.then(|| {
force_delete_prompt_for_worktree_remove_error(&error, &display_name)
})
.flatten();
if let Some(prompt_message) = force_delete_prompt {
picker.update_in(cx, |picker, _window, cx| {
if picker.delegate.deleting_worktree_paths.remove(&path) {
cx.notify();
}
})?;
let answer = cx.update(|window, cx| {
window.prompt(
PromptLevel::Warning,
&prompt_message,
None,
&["Force Delete", "Cancel"],
cx,
)
})?;
if answer.await != Ok(0) {
return Ok(());
}
let should_retry = picker.update_in(cx, |picker, _window, cx| {
let worktree_still_exists = picker
.delegate
.all_worktrees
.iter()
.any(|worktree| worktree.path == path);
if !worktree_still_exists
|| !picker.delegate.deleting_worktree_paths.insert(path.clone())
{
return false;
}
cx.notify();
true
})?;
if !should_retry {
return Ok(());
}
let retry = match repo
.update(cx, |repo, _| repo.remove_worktree(path.clone(), true))
.await
{
Ok(result) => result,
Err(error) => {
picker.update_in(cx, |picker, _window, cx| {
if picker.delegate.deleting_worktree_paths.remove(&path) {
cx.notify();
}
})?;
return Err(error.into());
}
};
if let Err(error) = &retry {
log::error!("Failed to force remove worktree: {error}");
}
(retry, true)
} else {
(Err(error), force)
}
}
};
if let Err(error) = result {
picker.update_in(cx, |picker, _window, cx| {
if picker.delegate.deleting_worktree_paths.remove(&path) {
cx.notify();
}
})?;
if let Some(workspace) = workspace.upgrade() {
cx.update(|_window, cx| {
show_error_toast(
workspace,
remove_worktree_command(&path, attempted_force),
error,
cx,
)
})?;
}
return Ok(());
}
picker.update_in(cx, |picker, _window, cx| {
picker.delegate.deleting_worktree_paths.remove(&path);
picker.delegate.matches.retain(|e| {
!matches!(e, WorktreeEntry::Worktree { worktree, .. } if worktree.path == path)
});
picker.delegate.all_worktrees.retain(|w| w.path != path);
if picker.delegate.matches.is_empty() {
picker.delegate.selected_index = 0;
} else if picker.delegate.selected_index >= picker.delegate.matches.len() {
picker.delegate.selected_index = picker.delegate.matches.len() - 1;
}
picker.delegate.hovered_delete_index = None;
cx.notify();
})?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
/// Finds the workspace in this window (other than the picker's own
/// workspace) that has `worktree_path` open as a visible worktree.
fn workspace_for_open_worktree(
&self,
worktree_path: &Path,
window: &Window,
cx: &App,
) -> Option<Entity<Workspace>> {
if self.active_worktree_paths.contains(worktree_path) {
return None;
}
let multi_workspace = window.root::<MultiWorkspace>().flatten()?;
let workspace = self.workspace.upgrade()?;
let group_key = workspace.read(cx).project_group_key(cx);
multi_workspace
.read(cx)
.workspaces_for_project_group(&group_key, cx)?
.into_iter()
.find(|group_workspace| {
*group_workspace != workspace
&& group_workspace
.read(cx)
.project()
.read(cx)
.visible_worktrees(cx)
.any(|worktree| worktree.read(cx).abs_path().as_ref() == worktree_path)
})
}
fn remove_worktree_from_window(
&mut self,
worktree_path: &Path,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) {
if self.deleting_worktree_paths.contains(worktree_path) {
return;
}
let Some(workspace_to_remove) = self.workspace_for_open_worktree(worktree_path, window, cx)
else {
return;
};
let Some(window_handle) = window.window_handle().downcast::<MultiWorkspace>() else {
return;
};
cx.spawn_in(window, async move |picker, cx| {
let removed = window_handle
.update(cx, |multi_workspace, window, cx| {
multi_workspace.close_workspace(&workspace_to_remove, window, cx)
})?
.await?;
if removed {
picker.update_in(cx, |picker, window, cx| {
picker.delegate.refresh_project_worktree_paths(window, cx);
picker.refresh(window, cx);
})?;
}
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
fn sync_selected_index(&mut self, has_query: bool) {
if !has_query {
return;
}
if let Some(index) = self
.matches
.iter()
.position(|entry| matches!(entry, WorktreeEntry::Worktree { .. }))
{
self.selected_index = index;
} else if let Some(index) = self
.matches
.iter()
.position(|entry| matches!(entry, WorktreeEntry::CreateNamed { .. }))
{
self.selected_index = index;
} else {
self.selected_index = 0;
}
}
}
impl PickerDelegate for WorktreePickerDelegate {
type ListItem = AnyElement;
fn name() -> &'static str {
"worktree picker"
}
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
"Select or type to create a 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,
_cx: &mut Context<Picker<Self>>,
) {
self.selected_index = ix;
}
fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> bool {
!matches!(
self.matches.get(ix),
Some(WorktreeEntry::Separator | WorktreeEntry::SectionHeader(_))
)
}
fn update_matches(
&mut self,
query: String,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Task<()> {
let repo_worktrees = self.all_repo_worktrees().to_vec();
let normalized_query = query.replace(' ', "-");
let main_worktree_path = self
.all_worktrees
.iter()
.find(|wt| wt.is_main)
.map(|wt| wt.path.clone());
let has_named_worktree = self.all_worktrees.iter().any(|worktree| {
worktree.directory_name(main_worktree_path.as_deref()) == normalized_query
});
let create_named_disabled_reason: Option<String> = if self.has_multiple_repositories {
Some("Cannot create a named worktree in a project with multiple repositories".into())
} else if has_named_worktree {
Some("A worktree with this name already exists".into())
} else {
None
};
let show_default_branch_create =
!self.has_multiple_repositories && self.default_branch.is_some();
let default_branch = self.default_branch.clone();
if query.is_empty() {
let mut matches = self.build_fixed_entries();
if !repo_worktrees.is_empty() {
let main_worktree_path = repo_worktrees
.iter()
.find(|wt| wt.is_main)
.map(|wt| wt.path.clone());
let project_paths = &self.project_worktree_paths;
let sort_by_name = |a: &GitWorktree, b: &GitWorktree| {
a.directory_name(main_worktree_path.as_deref())
.cmp(&b.directory_name(main_worktree_path.as_deref()))
};
let (mut open_here, mut others): (Vec<_>, Vec<_>) = repo_worktrees
.into_iter()
.partition(|worktree| project_paths.contains(&worktree.path));
open_here.sort_by(sort_by_name);
others.sort_by(sort_by_name);
matches.push(WorktreeEntry::Separator);
if open_here.len() > 1 {
matches.push(WorktreeEntry::SectionHeader("This Window".into()));
for worktree in open_here {
matches.push(WorktreeEntry::Worktree {
worktree,
positions: Vec::new(),
});
}
if !others.is_empty() {
matches.push(WorktreeEntry::Separator);
}
for worktree in others {
matches.push(WorktreeEntry::Worktree {
worktree,
positions: Vec::new(),
});
}
} else {
for worktree in open_here.into_iter().chain(others) {
matches.push(WorktreeEntry::Worktree {
worktree,
positions: Vec::new(),
});
}
}
}
self.matches = matches;
self.sync_selected_index(false);
return Task::ready(());
}
let main_worktree_path = repo_worktrees
.iter()
.find(|wt| wt.is_main)
.map(|wt| wt.path.clone());
let candidates: Vec<_> = repo_worktrees
.iter()
.enumerate()
.map(|(ix, worktree)| {
StringMatchCandidate::new(
ix,
&worktree.directory_name(main_worktree_path.as_deref()),
)
})
.collect();
let executor = cx.background_executor().clone();
let task = cx.background_executor().spawn(async move {
fuzzy::match_strings(
&candidates,
&query,
true,
true,
10000,
&Default::default(),
executor,
)
.await
});
let repo_worktrees_clone = repo_worktrees;
cx.spawn_in(window, async move |picker, cx| {
let fuzzy_matches = task.await;
picker
.update_in(cx, |picker, _window, cx| {
let mut new_matches: Vec<WorktreeEntry> = Vec::new();
for candidate in &fuzzy_matches {
new_matches.push(WorktreeEntry::Worktree {
worktree: repo_worktrees_clone[candidate.candidate_id].clone(),
positions: candidate.positions.clone(),
});
}
if !new_matches.is_empty() {
new_matches.push(WorktreeEntry::Separator);
}
if show_default_branch_create {
if let Some(ref default_branch) = default_branch {
new_matches.push(WorktreeEntry::CreateNamed {
name: normalized_query.clone(),
from_branch: Some(default_branch.clone()),
disabled_reason: create_named_disabled_reason.clone(),
});
}
} else {
new_matches.push(WorktreeEntry::CreateNamed {
name: normalized_query.clone(),
from_branch: None,
disabled_reason: create_named_disabled_reason.clone(),
});
}
picker.delegate.matches = new_matches;
picker.delegate.sync_selected_index(true);
cx.notify();
})
.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;
};
match entry {
WorktreeEntry::Separator | WorktreeEntry::SectionHeader(_) => return,
WorktreeEntry::CreateFromCurrentBranch => {
if self.creation_blocked_reason(cx).is_some() {
return;
}
if let Some(workspace) = self.workspace.upgrade() {
workspace.update(cx, |workspace, cx| {
crate::worktree_service::handle_create_worktree(
workspace,
&CreateWorktree {
worktree_name: None,
branch_target: NewWorktreeBranchTarget::CurrentBranch,
},
window,
self.focused_dock,
cx,
);
});
}
}
WorktreeEntry::CreateFromDefaultBranch { default_branch } => {
if self.creation_blocked_reason(cx).is_some() {
return;
}
if let Some(workspace) = self.workspace.upgrade() {
workspace.update(cx, |workspace, cx| {
crate::worktree_service::handle_create_worktree(
workspace,
&CreateWorktree {
worktree_name: None,
branch_target: NewWorktreeBranchTarget::RemoteBranch {
remote_name: default_branch.remote_name.clone(),
branch_name: default_branch.branch_name.clone(),
},
},
window,
self.focused_dock,
cx,
);
});
}
}
WorktreeEntry::Worktree { worktree, .. } => {
if self.deleting_worktree_paths.contains(&worktree.path) {
return;
}
let is_current = self.active_worktree_paths.contains(&worktree.path);
if !is_current {
if secondary {
window.dispatch_action(
Box::new(OpenWorktreeInNewWindow {
path: worktree.path.clone(),
}),
cx,
);
} else {
let main_worktree_path = self
.all_worktrees
.iter()
.find(|wt| wt.is_main)
.map(|wt| wt.path.as_path());
if let Some(workspace) = self.workspace.upgrade() {
workspace.update(cx, |workspace, cx| {
crate::worktree_service::handle_switch_worktree(
workspace,
&SwitchWorktree {
path: worktree.path.clone(),
display_name: worktree.directory_name(main_worktree_path),
},
window,
self.focused_dock,
cx,
);
});
}
}
}
}
WorktreeEntry::CreateNamed {
name,
from_branch,
disabled_reason: None,
} => {
let branch_target = match from_branch {
Some(branch) => NewWorktreeBranchTarget::RemoteBranch {
remote_name: branch.remote_name.clone(),
branch_name: branch.branch_name.clone(),
},
None => NewWorktreeBranchTarget::CurrentBranch,
};
if let Some(workspace) = self.workspace.upgrade() {
workspace.update(cx, |workspace, cx| {
crate::worktree_service::handle_create_worktree(
workspace,
&CreateWorktree {
worktree_name: Some(name.clone()),
branch_target,
},
window,
self.focused_dock,
cx,
);
});
}
}
WorktreeEntry::CreateNamed {
disabled_reason: Some(_),
..
} => {
return;
}
}
cx.emit(DismissEvent);
}
fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
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)?;
match entry {
WorktreeEntry::Separator => Some(
div()
.py(DynamicSpacing::Base04.rems(cx))
.child(Divider::horizontal())
.into_any_element(),
),
WorktreeEntry::SectionHeader(label) => Some(
ListSubHeader::new(label.clone())
.inset(true)
.into_any_element(),
),
WorktreeEntry::CreateFromCurrentBranch => {
let branch_label = WorktreeCreateTarget::CurrentBranch.branch_label(
self.has_multiple_repositories,
self.current_branch_name.as_deref(),
);
let label = format!("Create new worktree based on {branch_label}");
let item = create_new_list_item(
"create-from-current".to_string().into(),
label.into(),
self.creation_blocked_reason(cx),
selected,
);
Some(item.into_any_element())
}
WorktreeEntry::CreateFromDefaultBranch { default_branch } => {
let branch_label = WorktreeCreateTarget::DefaultBranch(default_branch.clone())
.branch_label(
self.has_multiple_repositories,
self.current_branch_name.as_deref(),
);
let label = format!("Create new worktree based on {branch_label}");
let item = create_new_list_item(
"create-from-main".to_string().into(),
label.into(),
self.creation_blocked_reason(cx),
selected,
);
Some(item.into_any_element())
}
WorktreeEntry::Worktree {
worktree,
positions,
} => {
let main_worktree_path = self
.all_worktrees
.iter()
.find(|wt| wt.is_main)
.map(|wt| wt.path.as_path());
let display_name = worktree.directory_name(main_worktree_path);
let first_line = display_name.lines().next().unwrap_or(&display_name);
let positions: Vec<_> = positions
.iter()
.copied()
.filter(|&pos| pos < first_line.len())
.collect();
let path = worktree.path.compact().to_string_lossy().to_string();
let sha = worktree.sha.chars().take(7).collect::<String>();
let is_current = self.active_worktree_paths.contains(&worktree.path);
let is_deleting = self.deleting_worktree_paths.contains(&worktree.path);
let can_delete = self.can_delete_worktree(worktree);
let can_remove_from_window =
!is_current && self.project_worktree_paths.contains(&worktree.path);
let entry_icon = if is_current {
IconName::Check
} else {
IconName::GitWorktree
};
let picker = cx.entity();
Some(
ListItem::new(SharedString::from(format!("worktree-{ix}")))
.inset(true)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
.child(
h_flex()
.w_full()
.gap_2p5()
.child(
Icon::new(entry_icon)
.color(if is_current {
Color::Accent
} else {
Color::Muted
})
.size(IconSize::Small),
)
.child(
v_flex()
.w_full()
.min_w_0()
.child(
HighlightedLabel::new(first_line.to_owned(), positions)
.truncate(),
)
.child(
h_flex()
.w_full()
.min_w_0()
.gap_1p5()
.when_some(
worktree.branch_name().map(|b| b.to_string()),
|this, branch| {
this.child(
Label::new(branch)
.size(LabelSize::Small)
.color(Color::Muted),
)
.child(
Label::new("\u{2022}")
.alpha(0.5)
.color(Color::Muted)
.size(LabelSize::Small),
)
},
)
.when(!sha.is_empty(), |this| {
this.child(
Label::new(sha)
.size(LabelSize::Small)
.color(Color::Muted),
)
.child(
Label::new("\u{2022}")
.alpha(0.5)
.color(Color::Muted)
.size(LabelSize::Small),
)
})
.child(
Label::new(path)
.truncate_start()
.color(Color::Muted)
.size(LabelSize::Small)
.flex_1(),
),
),
),
)
.when(is_deleting, |this| {
this.end_slot(
h_flex()
.gap_1()
.child(
Icon::new(IconName::LoadCircle)
.size(IconSize::Small)
.color(Color::Muted)
.with_rotate_animation(2),
)
.child(
Label::new("Deleting…")
.size(LabelSize::Small)
.color(Color::Muted),
),
)
})
.when(!is_deleting && !is_current, |this| {
let open_in_new_window_button =
IconButton::new(("open-new-window", ix), IconName::ArrowUpRight)
.icon_size(IconSize::Small)
.tooltip(Tooltip::text("Open in New Window"))
.on_click(cx.listener(move |picker, _, window, cx| {
let Some(entry) = picker.delegate.matches.get(ix) else {
return;
};
if let WorktreeEntry::Worktree { worktree, .. } = entry {
if picker
.delegate
.deleting_worktree_paths
.contains(&worktree.path)
{
return;
}
window.dispatch_action(
Box::new(OpenWorktreeInNewWindow {
path: worktree.path.clone(),
}),
cx,
);
cx.emit(DismissEvent);
}
}));
let focus_handle_delete = self.focus_handle.clone();
let force_delete = self.is_force_delete_hovering_index(ix);
let delete_button = div()
.id(("delete-worktree-hover", ix))
.on_hover(cx.listener(move |picker, hovered: &bool, _, cx| {
if *hovered {
picker.delegate.hovered_delete_index = Some(ix);
} else if picker.delegate.hovered_delete_index == Some(ix) {
picker.delegate.hovered_delete_index = None;
}
cx.notify();
}))
.child(
IconButton::new(("delete-worktree", ix), IconName::Trash)
.icon_size(IconSize::Small)
.when(force_delete, |this| this.icon_color(Color::Error))
.tooltip(move |_, cx| {
cx.new(|cx| {
DeleteWorktreeTooltip::new(
picker.clone(),
focus_handle_delete.clone(),
ix,
cx,
)
})
.into()
})
.on_click(cx.listener(move |picker, _, window, cx| {
let force = picker.delegate.modifiers.alt;
picker.delegate.delete_worktree(ix, force, window, cx);
})),
);
this.end_slot(
h_flex()
.gap_0p5()
.child(open_in_new_window_button)
.when(can_remove_from_window, |this| {
let worktree_path = worktree.path.clone();
this.child(
IconButton::new(
("remove-worktree-from-window", ix),
IconName::Close,
)
.icon_size(IconSize::Small)
.tooltip(Tooltip::text("Remove Worktree from Window"))
.on_click(
cx.listener(move |picker, _, window, cx| {
picker.delegate.remove_worktree_from_window(
&worktree_path,
window,
cx,
);
}),
),
)
})
.when(can_delete, |this| this.child(delete_button)),
)
.show_end_slot_on_hover()
})
.into_any_element(),
)
}
WorktreeEntry::CreateNamed {
name,
from_branch,
disabled_reason,
} => {
let branch_label = from_branch
.as_ref()
.map(RemoteBranchName::display_name)
.unwrap_or_else(|| {
self.current_branch_name
.clone()
.unwrap_or_else(|| "HEAD".to_string())
});
let label = format!("Create \"{name}\" based on {branch_label}");
let element_id = match from_branch {
Some(branch) => format!("create-named-from-{}", branch.display_name()),
None => "create-named-from-current".to_string(),
};
let item = create_new_list_item(
element_id.into(),
label.into(),
disabled_reason.clone().map(SharedString::from),
selected,
);
Some(item.into_any_element())
}
}
}
fn searchbar_trailer(
&self,
_window: &mut Window,
_cx: &mut Context<Picker<Self>>,
) -> Option<AnyElement> {
if self.show_footer {
return None;
}
let focus_handle = self.focus_handle.clone();
Some(
IconButton::new("configure-worktree-tasks", IconName::Settings)
.icon_size(IconSize::Small)
.tooltip(move |_window, cx| {
Tooltip::for_action_in(
"Automate Worktree Setup",
&OpenWorktreeSetupTasks,
&focus_handle,
cx,
)
})
.on_click(|_, window, cx| {
window.dispatch_action(OpenWorktreeSetupTasks.boxed_clone(), cx)
})
.into_any_element(),
)
}
fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
if !self.show_footer {
return None;
}
let focus_handle = self.focus_handle.clone();
let selected_entry = self.matches.get(self.selected_index);
let is_creating = selected_entry.is_some_and(|e| {
matches!(
e,
WorktreeEntry::CreateFromCurrentBranch
| WorktreeEntry::CreateFromDefaultBranch { .. }
| WorktreeEntry::CreateNamed { .. }
)
});
let is_existing_worktree =
selected_entry.is_some_and(|e| matches!(e, WorktreeEntry::Worktree { .. }));
let can_delete = selected_entry.is_some_and(|e| {
matches!(e, WorktreeEntry::Worktree { worktree, .. } if self.can_delete_worktree(worktree))
});
let is_current = selected_entry.is_some_and(|e| {
matches!(e, WorktreeEntry::Worktree { worktree, .. } if self.project_worktree_paths.contains(&worktree.path))
});
let is_deleting = selected_entry.is_some_and(|e| {
matches!(e, WorktreeEntry::Worktree { worktree, .. } if self.deleting_worktree_paths.contains(&worktree.path))
});
let footer = h_flex()
.w_full()
.p_1p5()
.gap_0p5()
.justify_between()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.child(
Button::new("configure-worktree-tasks", "Automate Setup")
.key_binding(
KeyBinding::for_action_in(&OpenWorktreeSetupTasks, &focus_handle, cx)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(|_, window, cx| {
window.dispatch_action(OpenWorktreeSetupTasks.boxed_clone(), cx)
}),
);
if is_creating {
Some(
footer
.child(
Button::new("create-worktree", "Create")
.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)
}),
)
.into_any(),
)
} else if is_existing_worktree {
Some(
footer
.child(
h_flex()
.gap_0p5()
.when(is_deleting, |this| {
this.child(
Button::new("delete-worktree", "Deleting…")
.loading(true)
.disabled(true),
)
})
.when(!is_deleting && can_delete, |this| {
let focus_handle = focus_handle.clone();
this.child(
Button::new("delete-worktree", "Delete")
.key_binding(
KeyBinding::for_action_in(
&DeleteWorktree,
&focus_handle,
cx,
)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(|_, window, cx| {
window.dispatch_action(DeleteWorktree.boxed_clone(), cx)
}),
)
})
.when(!is_deleting && !is_current, |this| {
let focus_handle = focus_handle.clone();
this.child(
Button::new("open-in-new-window", "Open in New Window")
.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,
)
}),
)
})
.when(!is_deleting, |this| {
this.child(
Button::new("open-worktree", "Open")
.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)
}),
)
}),
)
.into_any(),
)
} else {
None
}
}
}
fn create_new_list_item(
id: SharedString,
label: SharedString,
disabled_tooltip: Option<SharedString>,
selected: bool,
) -> AnyElement {
let is_disabled = disabled_tooltip.is_some();
ListItem::new(id)
.inset(true)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
.child(
h_flex()
.w_full()
.gap_2p5()
.child(
Icon::new(IconName::Plus)
.map(|this| {
if is_disabled {
this.color(Color::Disabled)
} else {
this.color(Color::Muted)
}
})
.size(IconSize::Small),
)
.child(Label::new(label).when(is_disabled, |this| this.color(Color::Disabled))),
)
.when_some(disabled_tooltip, |this, reason| {
this.tooltip(Tooltip::text(reason))
})
.into_any_element()
}
pub async fn open_remote_worktree(
connection_options: remote::RemoteConnectionOptions,
paths: Vec<PathBuf>,
app_state: Arc<workspace::AppState>,
workspace: gpui::WeakEntity<Workspace>,
cx: &mut gpui::AsyncWindowContext,
) -> anyhow::Result<()> {
let connect_task = workspace.update_in(cx, |workspace, window, cx| {
workspace.toggle_modal(window, cx, |window, cx| {
remote_connection::RemoteConnectionModal::new(
&connection_options,
Vec::new(),
window,
cx,
)
});
let prompt = workspace
.active_modal::<remote_connection::RemoteConnectionModal>(cx)
.expect("Modal just created")
.read(cx)
.prompt
.clone();
remote_connection::connect(
remote::remote_client::ConnectionIdentifier::setup(),
connection_options.clone(),
prompt,
window,
cx,
)
.prompt_err("Failed to connect", window, cx, |_, _, _| None)
})?;
let session = connect_task.await;
workspace
.update_in(cx, |workspace, _window, cx| {
if let Some(prompt) =
workspace.active_modal::<remote_connection::RemoteConnectionModal>(cx)
{
prompt.update(cx, |prompt, cx| prompt.finished(cx))
}
})
.ok();
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(),
true,
cx,
)
})?;
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;
let new_window = cx.open_window(options, |window, cx| {
let workspace = 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
});
cx.new(|cx| MultiWorkspace::new(workspace, window, cx))
})?;
workspace::open_remote_project_with_existing_connection(
connection_options,
new_project,
paths,
app_state,
new_window,
None,
None,
cx,
)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use fs::FakeFs;
use gpui::{AppContext, TestAppContext, VisualTestContext};
use project::project_settings::ProjectSettings;
use project::{Project, WorktreeSettings};
use serde_json::json;
use settings::Settings as _;
use settings::SettingsStore;
use util::path;
use workspace::MultiWorkspace;
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
theme_settings::init(theme::LoadThemes::JustBase, cx);
editor::init(cx);
ProjectSettings::register(cx);
WorktreeSettings::register(cx);
});
}
async fn init_worktree_picker_test(
cx: &mut TestAppContext,
) -> (
Arc<FakeFs>,
Entity<WorktreePicker>,
Entity<project::git_store::Repository>,
PathBuf,
VisualTestContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
json!({
"project": {
".git": {},
"file.txt": "buffer_text",
},
"worktrees": {},
}),
)
.await;
fs.set_head_for_repo(
path!("/root/project/.git").as_ref(),
&[("file.txt", "buffer_text".to_string())],
"deadbeef",
);
let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
cx.executor().run_until_parked();
let repository = project.read_with(cx, |project, cx| {
project.repositories(cx).values().next().unwrap().clone()
});
let worktree_path = PathBuf::from(path!("/root/worktrees/dirty-wt"));
cx.update(|cx| {
repository.update(cx, |repository, _| {
repository.create_worktree(
git::repository::CreateWorktreeTarget::NewBranch {
branch_name: "dirty-wt".to_string(),
base_sha: Some("deadbeef".to_string()),
},
worktree_path.clone(),
)
})
})
.await
.unwrap()
.unwrap();
let window_handle =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = window_handle
.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
.unwrap();
let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
let worktree_picker = cx.update(|window, cx| {
cx.new(|cx| WorktreePicker::new(project, workspace.downgrade(), window, cx))
});
cx.run_until_parked();
(fs, worktree_picker, repository, worktree_path, cx)
}
fn worktree_index(
worktree_picker: &Entity<WorktreePicker>,
worktree_path: &Path,
cx: &mut VisualTestContext,
) -> usize {
worktree_picker.update(cx, |worktree_picker, cx| {
worktree_picker.picker.update(cx, |picker, _| {
picker
.delegate
.matches
.iter()
.position(|entry| {
matches!(entry, WorktreeEntry::Worktree { worktree, .. } if worktree.path == *worktree_path)
})
.expect("worktree should appear in picker")
})
})
}
fn picker_contains_worktree(
worktree_picker: &Entity<WorktreePicker>,
worktree_path: &Path,
cx: &mut VisualTestContext,
) -> bool {
worktree_picker.update(cx, |worktree_picker, cx| {
worktree_picker.picker.update(cx, |picker, _| {
picker.delegate.all_worktrees.iter().any(|worktree| {
worktree.path == *worktree_path
}) && picker.delegate.matches.iter().any(|entry| {
matches!(entry, WorktreeEntry::Worktree { worktree, .. } if worktree.path == *worktree_path)
})
})
})
}
fn deleting_worktree_paths(
worktree_picker: &Entity<WorktreePicker>,
cx: &mut VisualTestContext,
) -> HashSet<PathBuf> {
worktree_picker.update(cx, |worktree_picker, cx| {
worktree_picker.picker.update(cx, |picker, _| {
picker.delegate.deleting_worktree_paths.clone()
})
})
}
async fn repo_contains_worktree(
repository: &Entity<project::git_store::Repository>,
worktree_path: &Path,
cx: &mut VisualTestContext,
) -> bool {
let worktrees = repository
.update(cx, |repository, _| repository.worktrees())
.await
.unwrap()
.unwrap();
worktrees
.iter()
.any(|worktree| worktree.path == *worktree_path)
}
#[gpui::test]
async fn test_delete_worktree_marks_row_pending_immediately(cx: &mut TestAppContext) {
let (_, worktree_picker, _repository, worktree_path, mut cx) =
init_worktree_picker_test(cx).await;
let index = worktree_index(&worktree_picker, &worktree_path, &mut cx);
worktree_picker.update_in(&mut cx, |worktree_picker, window, cx| {
worktree_picker.picker.update(cx, |picker, cx| {
picker.delegate.delete_worktree(index, false, window, cx);
})
});
let pending_paths = deleting_worktree_paths(&worktree_picker, &mut cx);
assert_eq!(pending_paths.len(), 1);
assert!(pending_paths.contains(&worktree_path));
cx.run_until_parked();
}
#[gpui::test]
async fn test_delete_worktree_clears_pending_and_removes_row_on_success(
cx: &mut TestAppContext,
) {
let (_, worktree_picker, repository, worktree_path, mut cx) =
init_worktree_picker_test(cx).await;
let index = worktree_index(&worktree_picker, &worktree_path, &mut cx);
worktree_picker.update_in(&mut cx, |worktree_picker, window, cx| {
worktree_picker.picker.update(cx, |picker, cx| {
picker.delegate.delete_worktree(index, false, window, cx);
})
});
assert!(deleting_worktree_paths(&worktree_picker, &mut cx).contains(&worktree_path));
cx.run_until_parked();
assert!(deleting_worktree_paths(&worktree_picker, &mut cx).is_empty());
assert!(!picker_contains_worktree(
&worktree_picker,
&worktree_path,
&mut cx
));
assert!(
!repo_contains_worktree(&repository, &worktree_path, &mut cx).await,
"worktree should be removed after successful delete"
);
}
#[gpui::test]
async fn test_remote_default_branch_is_preferred_create_target(cx: &mut TestAppContext) {
let (_fs, worktree_picker, _repository, _worktree_path, mut cx) =
init_worktree_picker_test(cx).await;
worktree_picker.update(&mut cx, |worktree_picker, cx| {
worktree_picker.picker.update(cx, |picker, _| {
assert_eq!(picker.delegate.selected_index, 0);
match picker.delegate.matches.first() {
Some(WorktreeEntry::CreateFromDefaultBranch { default_branch }) => {
assert_eq!(default_branch.display_name(), "origin/main");
}
_ => panic!("remote default branch should be the first create target"),
}
})
});
let update_matches = worktree_picker.update_in(&mut cx, |worktree_picker, window, cx| {
worktree_picker.picker.update(cx, |picker, cx| {
picker
.delegate
.update_matches("feature".to_string(), window, cx)
})
});
update_matches.await;
cx.run_until_parked();
worktree_picker.update(&mut cx, |worktree_picker, cx| {
worktree_picker
.picker
.update(cx, |picker, _| match picker.delegate.matches.first() {
Some(WorktreeEntry::CreateNamed {
from_branch: Some(default_branch),
..
}) => {
assert_eq!(default_branch.display_name(), "origin/main");
}
_ => panic!("named worktree creation should prefer the remote default branch"),
})
});
}
#[gpui::test]
async fn test_current_branch_create_target_is_shown_without_default_branch(
cx: &mut TestAppContext,
) {
let (_fs, worktree_picker, _repository, _worktree_path, mut cx) =
init_worktree_picker_test(cx).await;
worktree_picker.update_in(&mut cx, |worktree_picker, window, cx| {
worktree_picker.picker.update(cx, |picker, cx| {
picker.delegate.default_branch = None;
picker.refresh(window, cx);
});
});
cx.run_until_parked();
worktree_picker.update(&mut cx, |worktree_picker, cx| {
worktree_picker.picker.update(cx, |picker, _| {
assert!(matches!(
picker.delegate.matches.first(),
Some(WorktreeEntry::CreateFromCurrentBranch)
));
assert!(
!picker.delegate.matches.iter().any(|entry| matches!(
entry,
WorktreeEntry::CreateFromDefaultBranch { .. }
))
);
});
});
}
#[gpui::test]
async fn test_delete_dirty_worktree_prompts_for_force_delete(cx: &mut TestAppContext) {
let (fs, worktree_picker, repository, worktree_path, mut cx) =
init_worktree_picker_test(cx).await;
fs.with_git_state(path!("/root/project/.git").as_ref(), true, |state| {
state
.worktrees_requiring_force_delete
.insert(worktree_path.clone());
})
.expect("failed to mark test worktree as requiring force delete");
let index = worktree_index(&worktree_picker, &worktree_path, &mut cx);
worktree_picker.update_in(&mut cx, |worktree_picker, window, cx| {
worktree_picker.picker.update(cx, |picker, cx| {
picker.delegate.delete_worktree(index, false, window, cx);
})
});
assert!(deleting_worktree_paths(&worktree_picker, &mut cx).contains(&worktree_path));
cx.run_until_parked();
assert!(cx.has_pending_prompt());
assert!(
!deleting_worktree_paths(&worktree_picker, &mut cx).contains(&worktree_path),
"pending delete state should clear while waiting for force-delete confirmation"
);
cx.simulate_prompt_answer("Force Delete");
cx.run_until_parked();
assert!(!cx.has_pending_prompt());
assert!(deleting_worktree_paths(&worktree_picker, &mut cx).is_empty());
assert!(!picker_contains_worktree(
&worktree_picker,
&worktree_path,
&mut cx
));
assert!(
!repo_contains_worktree(&repository, &worktree_path, &mut cx).await,
"worktree should be removed after confirming force delete"
);
}
#[gpui::test]
async fn test_duplicate_delete_worktree_is_ignored_while_pending(cx: &mut TestAppContext) {
let (fs, worktree_picker, _repository, worktree_path, mut cx) =
init_worktree_picker_test(cx).await;
fs.with_git_state(path!("/root/project/.git").as_ref(), true, |state| {
state
.worktrees_requiring_force_delete
.insert(worktree_path.clone());
})
.expect("failed to mark test worktree as requiring force delete");
let index = worktree_index(&worktree_picker, &worktree_path, &mut cx);
worktree_picker.update_in(&mut cx, |worktree_picker, window, cx| {
worktree_picker.picker.update(cx, |picker, cx| {
picker.delegate.delete_worktree(index, false, window, cx);
picker.delegate.delete_worktree(index, false, window, cx);
})
});
let pending_paths = deleting_worktree_paths(&worktree_picker, &mut cx);
assert_eq!(pending_paths.len(), 1);
assert!(pending_paths.contains(&worktree_path));
cx.run_until_parked();
assert!(cx.has_pending_prompt());
assert!(deleting_worktree_paths(&worktree_picker, &mut cx).is_empty());
cx.simulate_prompt_answer("Cancel");
cx.run_until_parked();
assert!(!cx.has_pending_prompt());
assert!(picker_contains_worktree(
&worktree_picker,
&worktree_path,
&mut cx
));
}
#[gpui::test]
async fn test_selected_deleting_worktree_cannot_be_opened(cx: &mut TestAppContext) {
let (_, worktree_picker, _repository, worktree_path, mut cx) =
init_worktree_picker_test(cx).await;
let subscription = cx.update(|_, cx| {
cx.subscribe(&worktree_picker, |_, _: &DismissEvent, _| {
panic!("DismissEvent should not be emitted for a deleting worktree");
})
});
let index = worktree_index(&worktree_picker, &worktree_path, &mut cx);
worktree_picker.update_in(&mut cx, |worktree_picker, window, cx| {
worktree_picker.picker.update(cx, |picker, cx| {
picker.delegate.selected_index = index;
picker.delegate.delete_worktree(index, false, window, cx);
picker.delegate.confirm(false, window, cx);
})
});
assert!(deleting_worktree_paths(&worktree_picker, &mut cx).contains(&worktree_path));
drop(subscription);
cx.run_until_parked();
}
#[gpui::test]
async fn test_force_delete_worktree_deletes_without_prompt(cx: &mut TestAppContext) {
let (fs, worktree_picker, repository, worktree_path, mut cx) =
init_worktree_picker_test(cx).await;
fs.with_git_state(path!("/root/project/.git").as_ref(), true, |state| {
state
.worktrees_requiring_force_delete
.insert(worktree_path.clone());
})
.expect("failed to mark test worktree as requiring force delete");
let index = worktree_index(&worktree_picker, &worktree_path, &mut cx);
worktree_picker.update_in(&mut cx, |worktree_picker, window, cx| {
worktree_picker.picker.update(cx, |picker, cx| {
picker.delegate.modifiers = Modifiers::alt();
picker.delegate.delete_worktree(index, true, window, cx);
})
});
assert!(deleting_worktree_paths(&worktree_picker, &mut cx).contains(&worktree_path));
cx.run_until_parked();
assert!(!cx.has_pending_prompt());
assert!(deleting_worktree_paths(&worktree_picker, &mut cx).is_empty());
assert!(!picker_contains_worktree(
&worktree_picker,
&worktree_path,
&mut cx
));
assert!(
!repo_contains_worktree(&repository, &worktree_path, &mut cx).await,
"worktree should be removed by explicit force delete"
);
}
#[gpui::test]
async fn test_open_worktrees_are_grouped_under_section_header(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
json!({
"project": {
".git": {},
"file.txt": "buffer_text",
},
"worktrees": {},
}),
)
.await;
fs.set_head_for_repo(
path!("/root/project/.git").as_ref(),
&[("file.txt", "buffer_text".to_string())],
"deadbeef",
);
let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
cx.executor().run_until_parked();
let repository = project.read_with(cx, |project, cx| {
project.repositories(cx).values().next().unwrap().clone()
});
let second_worktree_path = PathBuf::from(path!("/root/worktrees/second-wt"));
cx.update(|cx| {
repository.update(cx, |repository, _| {
repository.create_worktree(
git::repository::CreateWorktreeTarget::NewBranch {
branch_name: "second-wt".to_string(),
base_sha: Some("deadbeef".to_string()),
},
second_worktree_path.clone(),
)
})
})
.await
.unwrap()
.unwrap();
// Open the second worktree as a visible worktree of the active project so
// that two worktrees of the same repo are open in this window.
project
.update(cx, |project, cx| {
project.create_worktree(&second_worktree_path, true, cx)
})
.await
.unwrap();
cx.executor().run_until_parked();
let window_handle =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = window_handle
.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
.unwrap();
let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
let worktree_picker = cx.update(|window, cx| {
cx.new(|cx| WorktreePicker::new(project, workspace.downgrade(), window, cx))
});
cx.run_until_parked();
let project_path = PathBuf::from(path!("/root/project"));
worktree_picker.update(&mut cx, |worktree_picker, cx| {
worktree_picker.picker.update(cx, |picker, _| {
let matches = &picker.delegate.matches;
let header_index = matches
.iter()
.position(|entry| {
matches!(entry, WorktreeEntry::SectionHeader(label) if label.as_ref() == "This Window")
})
.expect("section header should be present when multiple worktrees are open");
let grouped_paths: Vec<&Path> = matches[header_index + 1..]
.iter()
.map_while(|entry| match entry {
WorktreeEntry::Worktree { worktree, .. } => Some(worktree.path.as_path()),
_ => None,
})
.collect();
assert!(
grouped_paths.contains(&project_path.as_path()),
"main worktree should be grouped under the header"
);
assert!(
grouped_paths.contains(&second_worktree_path.as_path()),
"second open worktree should be grouped under the header"
);
})
});
}
#[gpui::test]
async fn test_remove_open_worktree_workspace_from_window(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
json!({
"project": {
".git": {},
"file.txt": "buffer_text",
},
"worktrees": {},
}),
)
.await;
fs.set_head_for_repo(
path!("/root/project/.git").as_ref(),
&[("file.txt", "buffer_text".to_string())],
"deadbeef",
);
let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
cx.executor().run_until_parked();
let repository = project.read_with(cx, |project, cx| {
project.repositories(cx).values().next().unwrap().clone()
});
let worktree_path = PathBuf::from(path!("/root/worktrees/open-wt"));
cx.update(|cx| {
repository.update(cx, |repository, _| {
repository.create_worktree(
git::repository::CreateWorktreeTarget::NewBranch {
branch_name: "open-wt".to_string(),
base_sha: Some("deadbeef".to_string()),
},
worktree_path.clone(),
)
})
})
.await
.unwrap()
.unwrap();
let worktree_project = Project::test(fs.clone(), [worktree_path.as_path()], cx).await;
cx.executor().run_until_parked();
let main_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
let worktree_group_key =
worktree_project.read_with(cx, |project, cx| project.project_group_key(cx));
assert_eq!(
main_group_key, worktree_group_key,
"the worktree workspace should belong to the same project group as the main repo"
);
let window_handle =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = window_handle
.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
.unwrap();
let worktree_workspace = window_handle
.update(cx, |multi_workspace, window, cx| {
let worktree_workspace =
cx.new(|cx| Workspace::test_new(worktree_project.clone(), window, cx));
multi_workspace.add(worktree_workspace.clone(), window, cx);
worktree_workspace
})
.unwrap();
let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
let worktree_picker = cx.update(|window, cx| {
cx.new(|cx| WorktreePicker::new(project, workspace.downgrade(), window, cx))
});
cx.run_until_parked();
worktree_picker.update(&mut cx, |worktree_picker, cx| {
worktree_picker.picker.update(cx, |picker, _| {
assert!(
picker
.delegate
.project_worktree_paths
.contains(&worktree_path),
"the worktree should be considered open in this window"
);
})
});
worktree_picker.update_in(&mut cx, |worktree_picker, window, cx| {
worktree_picker.picker.update(cx, |picker, cx| {
picker
.delegate
.remove_worktree_from_window(&worktree_path, window, cx);
})
});
cx.run_until_parked();
window_handle
.read_with(&cx, |multi_workspace, _| {
assert!(
multi_workspace
.workspaces()
.all(|workspace| *workspace != worktree_workspace),
"the worktree workspace should be removed from the window"
);
})
.unwrap();
worktree_picker.update(&mut cx, |worktree_picker, cx| {
worktree_picker.picker.update(cx, |picker, _| {
assert!(
!picker
.delegate
.project_worktree_paths
.contains(&worktree_path),
"the worktree should no longer be considered open in this window"
);
})
});
assert!(
repo_contains_worktree(&repository, &worktree_path, &mut cx).await,
"removing the worktree from the window should not delete the git worktree"
);
}
}