Files
zed/crates/git_ui/src/worktree_picker.rs
T
Danilo Leal 40afe267f3 git_picker: Make the unified version of it available through the title bar (#47151)
The recently-introduced unified Git picker was previously only available
if you reached for the branch, worktree or stash pickers through the
keybinding. Now, if you click on the title bar's branch button, you'll
also be able to quickly view the worktree and stash pickers.

Release Notes:

- N/A
2026-01-19 12:37:40 -03:00

821 lines
27 KiB
Rust

use anyhow::Context as _;
use collections::HashSet;
use fuzzy::StringMatchCandidate;
use git::repository::Worktree as GitWorktree;
use gpui::{
Action, App, AsyncApp, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement,
PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window,
actions, rems,
};
use picker::{Picker, PickerDelegate, PickerEditorPosition};
use project::{
DirectoryLister,
git_store::Repository,
trusted_worktrees::{PathTrust, TrustedWorktrees},
};
use recent_projects::{RemoteConnectionModal, connect};
use remote::{RemoteConnectionOptions, remote_client::ConnectionIdentifier};
use std::{path::PathBuf, sync::Arc};
use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, prelude::*};
use util::ResultExt;
use workspace::{ModalView, Workspace, notifications::DetachAndPromptErr};
actions!(git, [WorktreeFromDefault, WorktreeFromDefaultOnWindow]);
pub fn open(
workspace: &mut Workspace,
_: &zed_actions::git::Worktree,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
let repository = workspace.project().read(cx).active_repository(cx);
let workspace_handle = workspace.weak_handle();
workspace.toggle_modal(window, cx, |window, cx| {
WorktreeList::new(repository, workspace_handle, rems(34.), window, cx)
})
}
pub fn create_embedded(
repository: Option<Entity<Repository>>,
workspace: WeakEntity<Workspace>,
width: Rems,
window: &mut Window,
cx: &mut Context<WorktreeList>,
) -> WorktreeList {
WorktreeList::new_embedded(repository, workspace, width, window, cx)
}
pub struct WorktreeList {
width: Rems,
pub picker: Entity<Picker<WorktreeListDelegate>>,
picker_focus_handle: FocusHandle,
_subscription: Option<Subscription>,
embedded: bool,
}
impl WorktreeList {
fn new(
repository: Option<Entity<Repository>>,
workspace: WeakEntity<Workspace>,
width: Rems,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let mut this = Self::new_inner(repository, workspace, width, false, window, cx);
this._subscription = Some(cx.subscribe(&this.picker, |_, _, _, cx| {
cx.emit(DismissEvent);
}));
this
}
fn new_inner(
repository: Option<Entity<Repository>>,
workspace: WeakEntity<Workspace>,
width: Rems,
embedded: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let all_worktrees_request = repository
.clone()
.map(|repository| repository.update(cx, |repository, _| repository.worktrees()));
let default_branch_request = repository.clone().map(|repository| {
repository.update(cx, |repository, _| repository.default_branch(false))
});
cx.spawn_in(window, async move |this, cx| {
let all_worktrees = all_worktrees_request
.context("No active repository")?
.await??;
let default_branch = default_branch_request
.context("No active repository")?
.await
.map(Result::ok)
.ok()
.flatten()
.flatten();
this.update_in(cx, |this, window, cx| {
this.picker.update(cx, |picker, cx| {
picker.delegate.all_worktrees = Some(all_worktrees);
picker.delegate.default_branch = default_branch;
picker.refresh(window, cx);
})
})?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
let delegate = WorktreeListDelegate::new(workspace, repository, window, cx);
let picker = cx.new(|cx| {
Picker::uniform_list(delegate, window, cx)
.show_scrollbar(true)
.modal(!embedded)
});
let picker_focus_handle = picker.focus_handle(cx);
picker.update(cx, |picker, _| {
picker.delegate.focus_handle = picker_focus_handle.clone();
});
Self {
picker,
picker_focus_handle,
width,
_subscription: None,
embedded,
}
}
fn new_embedded(
repository: Option<Entity<Repository>>,
workspace: WeakEntity<Workspace>,
width: Rems,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let mut this = Self::new_inner(repository, workspace, width, true, window, cx);
this._subscription = Some(cx.subscribe(&this.picker, |_, _, _, cx| {
cx.emit(DismissEvent);
}));
this
}
pub fn handle_modifiers_changed(
&mut self,
ev: &ModifiersChangedEvent,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.picker
.update(cx, |picker, _| picker.delegate.modifiers = ev.modifiers)
}
pub fn handle_new_worktree(
&mut self,
replace_current_window: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.picker.update(cx, |picker, cx| {
let ix = picker.delegate.selected_index();
let Some(entry) = picker.delegate.matches.get(ix) else {
return;
};
let Some(default_branch) = picker.delegate.default_branch.clone() else {
return;
};
if !entry.is_new {
return;
}
picker.delegate.create_worktree(
entry.worktree.branch(),
replace_current_window,
Some(default_branch.into()),
window,
cx,
);
})
}
}
impl ModalView for WorktreeList {}
impl EventEmitter<DismissEvent> for WorktreeList {}
impl Focusable for WorktreeList {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.picker_focus_handle.clone()
}
}
impl Render for WorktreeList {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.key_context("GitWorktreeSelector")
.w(self.width)
.on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
.on_action(cx.listener(|this, _: &WorktreeFromDefault, w, cx| {
this.handle_new_worktree(false, w, cx)
}))
.on_action(cx.listener(|this, _: &WorktreeFromDefaultOnWindow, w, cx| {
this.handle_new_worktree(true, w, cx)
}))
.child(self.picker.clone())
.when(!self.embedded, |el| {
el.on_mouse_down_out({
cx.listener(move |this, _, window, cx| {
this.picker.update(cx, |this, cx| {
this.cancel(&Default::default(), window, cx);
})
})
})
})
}
}
#[derive(Debug, Clone)]
struct WorktreeEntry {
worktree: GitWorktree,
positions: Vec<usize>,
is_new: bool,
}
pub struct WorktreeListDelegate {
matches: Vec<WorktreeEntry>,
all_worktrees: Option<Vec<GitWorktree>>,
workspace: WeakEntity<Workspace>,
repo: Option<Entity<Repository>>,
selected_index: usize,
last_query: String,
modifiers: Modifiers,
focus_handle: FocusHandle,
default_branch: Option<SharedString>,
}
impl WorktreeListDelegate {
fn new(
workspace: WeakEntity<Workspace>,
repo: Option<Entity<Repository>>,
_window: &mut Window,
cx: &mut Context<WorktreeList>,
) -> Self {
Self {
matches: vec![],
all_worktrees: None,
workspace,
selected_index: 0,
repo,
last_query: Default::default(),
modifiers: Default::default(),
focus_handle: cx.focus_handle(),
default_branch: None,
}
}
fn create_worktree(
&self,
worktree_branch: &str,
replace_current_window: bool,
commit: Option<String>,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) {
let Some(repo) = self.repo.clone() else {
return;
};
let worktree_path = self
.workspace
.clone()
.update(cx, |this, cx| {
this.prompt_for_open_path(
PathPromptOptions {
files: false,
directories: true,
multiple: false,
prompt: Some("Select directory for new worktree".into()),
},
DirectoryLister::Project(this.project().clone()),
window,
cx,
)
})
.log_err();
let Some(worktree_path) = worktree_path else {
return;
};
let branch = worktree_branch.to_string();
let window_handle = window.window_handle();
let workspace = self.workspace.clone();
cx.spawn_in(window, async move |_, cx| {
let Some(paths) = worktree_path.await? else {
return anyhow::Ok(());
};
let path = paths.get(0).cloned().context("No path selected")?;
repo.update(cx, |repo, _| {
repo.create_worktree(branch.clone(), path.clone(), commit)
})
.await??;
let new_worktree_path = path.join(branch);
workspace.update(cx, |workspace, cx| {
if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
let repo_path = &repo.read(cx).snapshot().work_directory_abs_path;
let project = workspace.project();
if let Some((parent_worktree, _)) =
project.read(cx).find_worktree(repo_path, cx)
{
let worktree_store = project.read(cx).worktree_store();
trusted_worktrees.update(cx, |trusted_worktrees, cx| {
if trusted_worktrees.can_trust(
&worktree_store,
parent_worktree.read(cx).id(),
cx,
) {
trusted_worktrees.trust(
&worktree_store,
HashSet::from_iter([PathTrust::AbsPath(
new_worktree_path.clone(),
)]),
cx,
);
}
});
}
}
})?;
let (connection_options, app_state, is_local) =
workspace.update(cx, |workspace, cx| {
let project = workspace.project().clone();
let connection_options = project.read(cx).remote_connection_options(cx);
let app_state = workspace.app_state().clone();
let is_local = project.read(cx).is_local();
(connection_options, app_state, is_local)
})?;
if is_local {
workspace
.update_in(cx, |workspace, window, cx| {
workspace.open_workspace_for_paths(
replace_current_window,
vec![new_worktree_path],
window,
cx,
)
})?
.await?;
} else if let Some(connection_options) = connection_options {
open_remote_worktree(
connection_options,
vec![new_worktree_path],
app_state,
window_handle,
replace_current_window,
cx,
)
.await?;
}
anyhow::Ok(())
})
.detach_and_prompt_err("Failed to create worktree", window, cx, |e, _, _| {
Some(e.to_string())
});
}
fn open_worktree(
&self,
worktree_path: &PathBuf,
replace_current_window: bool,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) {
let workspace = self.workspace.clone();
let path = worktree_path.clone();
let Some((connection_options, app_state, is_local)) = workspace
.update(cx, |workspace, cx| {
let project = workspace.project().clone();
let connection_options = project.read(cx).remote_connection_options(cx);
let app_state = workspace.app_state().clone();
let is_local = project.read(cx).is_local();
(connection_options, app_state, is_local)
})
.log_err()
else {
return;
};
if is_local {
let open_task = workspace.update(cx, |workspace, cx| {
workspace.open_workspace_for_paths(replace_current_window, vec![path], window, cx)
});
cx.spawn(async move |_, _| {
open_task?.await?;
anyhow::Ok(())
})
.detach_and_prompt_err(
"Failed to open worktree",
window,
cx,
|e, _, _| Some(e.to_string()),
);
} else if let Some(connection_options) = connection_options {
let window_handle = window.window_handle();
cx.spawn_in(window, async move |_, cx| {
open_remote_worktree(
connection_options,
vec![path],
app_state,
window_handle,
replace_current_window,
cx,
)
.await
})
.detach_and_prompt_err(
"Failed to open worktree",
window,
cx,
|e, _, _| Some(e.to_string()),
);
}
cx.emit(DismissEvent);
}
fn base_branch<'a>(&'a self, cx: &'a mut Context<Picker<Self>>) -> Option<&'a str> {
self.repo
.as_ref()
.and_then(|repo| repo.read(cx).branch.as_ref().map(|b| b.name()))
}
}
async fn open_remote_worktree(
connection_options: RemoteConnectionOptions,
paths: Vec<PathBuf>,
app_state: Arc<workspace::AppState>,
window: gpui::AnyWindowHandle,
replace_current_window: bool,
cx: &mut AsyncApp,
) -> anyhow::Result<()> {
let workspace_window = window
.downcast::<Workspace>()
.ok_or_else(|| anyhow::anyhow!("Window is not a Workspace window"))?;
let connect_task = workspace_window.update(cx, |workspace, window, cx| {
workspace.toggle_modal(window, cx, |window, cx| {
RemoteConnectionModal::new(&connection_options, Vec::new(), window, cx)
});
let prompt = workspace
.active_modal::<RemoteConnectionModal>(cx)
.expect("Modal just created")
.read(cx)
.prompt
.clone();
connect(
ConnectionIdentifier::setup(),
connection_options.clone(),
prompt,
window,
cx,
)
.prompt_err("Failed to connect", window, cx, |_, _, _| None)
})?;
let session = connect_task.await;
workspace_window.update(cx, |workspace, _window, cx| {
if let Some(prompt) = workspace.active_modal::<RemoteConnectionModal>(cx) {
prompt.update(cx, |prompt, cx| prompt.finished(cx))
}
})?;
let Some(Some(session)) = session else {
return Ok(());
};
let new_project: Entity<project::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 window_to_use = if replace_current_window {
workspace_window
} else {
let workspace_position = cx
.update(|cx| {
workspace::remote_workspace_position_from_db(connection_options.clone(), &paths, cx)
})
.await
.context("fetching workspace position from db")?;
let mut options =
cx.update(|cx| (app_state.build_window_options)(workspace_position.display, cx));
options.window_bounds = workspace_position.window_bounds;
cx.open_window(options, |window, cx| {
cx.new(|cx| {
let mut workspace =
Workspace::new(None, new_project.clone(), app_state.clone(), window, cx);
workspace.centered_layout = workspace_position.centered_layout;
workspace
})
})?
};
workspace::open_remote_project_with_existing_connection(
connection_options,
new_project,
paths,
app_state,
window_to_use,
cx,
)
.await?;
Ok(())
}
impl PickerDelegate for WorktreeListDelegate {
type ListItem = ListItem;
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
"Select worktree…".into()
}
fn editor_position(&self) -> PickerEditorPosition {
PickerEditorPosition::Start
}
fn match_count(&self) -> usize {
self.matches.len()
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(
&mut self,
ix: usize,
_window: &mut Window,
_: &mut Context<Picker<Self>>,
) {
self.selected_index = ix;
}
fn update_matches(
&mut self,
query: String,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Task<()> {
let Some(all_worktrees) = self.all_worktrees.clone() else {
return Task::ready(());
};
cx.spawn_in(window, async move |picker, cx| {
let mut matches: Vec<WorktreeEntry> = if query.is_empty() {
all_worktrees
.into_iter()
.map(|worktree| WorktreeEntry {
worktree,
positions: Vec::new(),
is_new: false,
})
.collect()
} else {
let candidates = all_worktrees
.iter()
.enumerate()
.map(|(ix, worktree)| StringMatchCandidate::new(ix, worktree.branch()))
.collect::<Vec<StringMatchCandidate>>();
fuzzy::match_strings(
&candidates,
&query,
true,
true,
10000,
&Default::default(),
cx.background_executor().clone(),
)
.await
.into_iter()
.map(|candidate| WorktreeEntry {
worktree: all_worktrees[candidate.candidate_id].clone(),
positions: candidate.positions,
is_new: false,
})
.collect()
};
picker
.update(cx, |picker, _| {
if !query.is_empty()
&& !matches
.first()
.is_some_and(|entry| entry.worktree.branch() == query)
{
let query = query.replace(' ', "-");
matches.push(WorktreeEntry {
worktree: GitWorktree {
path: Default::default(),
ref_name: format!("refs/heads/{query}").into(),
sha: Default::default(),
},
positions: Vec::new(),
is_new: true,
})
}
let delegate = &mut picker.delegate;
delegate.matches = matches;
if delegate.matches.is_empty() {
delegate.selected_index = 0;
} else {
delegate.selected_index =
core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
}
delegate.last_query = query;
})
.log_err();
})
}
fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
let Some(entry) = self.matches.get(self.selected_index()) else {
return;
};
if entry.is_new {
self.create_worktree(&entry.worktree.branch(), secondary, None, window, cx);
} else {
self.open_worktree(&entry.worktree.path, secondary, window, cx);
}
cx.emit(DismissEvent);
}
fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
cx.emit(DismissEvent);
}
fn render_match(
&self,
ix: usize,
selected: bool,
_window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
let entry = &self.matches.get(ix)?;
let path = entry.worktree.path.to_string_lossy().to_string();
let sha = entry
.worktree
.sha
.clone()
.chars()
.take(7)
.collect::<String>();
let (branch_name, sublabel) = if entry.is_new {
(
Label::new(format!("Create Worktree: \"{}\"…", entry.worktree.branch()))
.truncate()
.into_any_element(),
format!(
"based off {}",
self.base_branch(cx).unwrap_or("the current branch")
),
)
} else {
let branch = entry.worktree.branch();
let branch_first_line = branch.lines().next().unwrap_or(branch);
let positions: Vec<_> = entry
.positions
.iter()
.copied()
.filter(|&pos| pos < branch_first_line.len())
.collect();
(
HighlightedLabel::new(branch_first_line.to_owned(), positions)
.truncate()
.into_any_element(),
path,
)
};
Some(
ListItem::new(format!("worktree-menu-{ix}"))
.inset(true)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
.child(
v_flex()
.w_full()
.child(
h_flex()
.gap_2()
.justify_between()
.overflow_x_hidden()
.child(branch_name)
.when(!entry.is_new, |this| {
this.child(
Label::new(sha)
.size(LabelSize::Small)
.color(Color::Muted)
.buffer_font(cx)
.into_element(),
)
}),
)
.child(
Label::new(sublabel)
.size(LabelSize::Small)
.color(Color::Muted)
.truncate()
.into_any_element(),
),
),
)
}
fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
Some("No worktrees found".into())
}
fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
let focus_handle = self.focus_handle.clone();
let selected_entry = self.matches.get(self.selected_index);
let is_creating = selected_entry.is_some_and(|entry| entry.is_new);
let footer_container = h_flex()
.w_full()
.p_1p5()
.gap_0p5()
.justify_end()
.border_t_1()
.border_color(cx.theme().colors().border_variant);
if is_creating {
let from_default_button = self.default_branch.as_ref().map(|default_branch| {
Button::new(
"worktree-from-default",
format!("Create from: {default_branch}"),
)
.key_binding(
KeyBinding::for_action_in(&WorktreeFromDefault, &focus_handle, cx)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(|_, window, cx| {
window.dispatch_action(WorktreeFromDefault.boxed_clone(), cx)
})
});
let current_branch = self.base_branch(cx).unwrap_or("current branch");
Some(
footer_container
.when_some(from_default_button, |this, button| this.child(button))
.child(
Button::new(
"worktree-from-current",
format!("Create from: {current_branch}"),
)
.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 {
Some(
footer_container
.child(
Button::new("open-in-new-window", "Open in New Window")
.key_binding(
KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(|_, window, cx| {
window.dispatch_action(menu::Confirm.boxed_clone(), cx)
}),
)
.child(
Button::new("open-in-window", "Open")
.key_binding(
KeyBinding::for_action_in(
&menu::SecondaryConfirm,
&focus_handle,
cx,
)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(|_, window, cx| {
window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
}),
)
.into_any(),
)
}
}
}