Detailed changes
@@ -353,6 +353,7 @@ dependencies = [
"futures 0.3.32",
"fuzzy",
"git",
+ "git_ui",
"gpui",
"gpui_tokio",
"heapless",
@@ -7262,6 +7263,7 @@ dependencies = [
"db",
"editor",
"file_icons",
+ "fs",
"futures 0.3.32",
"fuzzy",
"git",
@@ -237,7 +237,6 @@
"shift-alt-j": "agent::ToggleNavigationMenu",
"shift-alt-i": "agent::ToggleOptionsMenu",
"ctrl-alt-shift-n": "agent::ToggleNewThreadMenu",
- "ctrl-shift-t": "agent::ToggleWorktreeSelector",
"shift-alt-escape": "agent::ExpandMessageEditor",
"ctrl->": "agent::AddSelectionToThread",
"ctrl-shift-e": "project_panel::ToggleFocus",
@@ -630,6 +629,7 @@
// "alt-ctrl-shift-o": "["projects::OpenRemote", { "from_existing_connection": true }]",
"alt-ctrl-shift-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }],
"alt-ctrl-shift-b": "branches::OpenRecent",
+ "alt-ctrl-shift-w": "git::Worktree",
"alt-shift-enter": "toast::RunAction",
"ctrl-~": "workspace::NewTerminal",
"save": "workspace::Save",
@@ -1387,15 +1387,6 @@
"ctrl-shift-enter": "workspace::OpenWithSystem",
},
},
- {
- "context": "GitWorktreeSelector || (GitWorktreeSelector > Picker > Editor)",
- "use_key_equivalents": true,
- "bindings": {
- "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow",
- "ctrl-space": "git::WorktreeFromDefault",
- "ctrl-shift-backspace": "git::DeleteWorktree",
- },
- },
{
// Handled under a more specific context to avoid conflicts with the
// `OpenCurrentFile` keybind from the settings UI
@@ -1531,9 +1522,15 @@
{
"context": "GitPicker",
"bindings": {
- "alt-1": "git_picker::ActivateWorktreesTab",
- "alt-2": "git_picker::ActivateBranchesTab",
- "alt-3": "git_picker::ActivateStashTab",
+ "alt-1": "git_picker::ActivateBranchesTab",
+ "alt-2": "git_picker::ActivateStashTab",
+ },
+ },
+ {
+ "context": "WorktreePicker || (WorktreePicker > Picker > Editor)",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-shift-backspace": "worktree_picker::DeleteWorktree",
},
},
]
@@ -275,7 +275,6 @@
"cmd-shift-j": "agent::ToggleNavigationMenu",
"cmd-alt-m": "agent::ToggleOptionsMenu",
"cmd-alt-shift-n": "agent::ToggleNewThreadMenu",
- "cmd-shift-t": "agent::ToggleWorktreeSelector",
"shift-alt-escape": "agent::ExpandMessageEditor",
"cmd->": "agent::AddSelectionToThread",
"cmd-shift-e": "project_panel::ToggleFocus",
@@ -698,6 +697,7 @@
"ctrl-cmd-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }],
"ctrl-cmd-shift-o": ["projects::OpenRemote", { "from_existing_connection": true, "create_new_window": false }],
"cmd-ctrl-b": "branches::OpenRecent",
+ "cmd-ctrl-w": "git::Worktree",
"ctrl-~": "workspace::NewTerminal",
"cmd-s": "workspace::Save",
"cmd-k s": "workspace::SaveWithoutFormat",
@@ -1484,15 +1484,6 @@
"ctrl-shift-enter": "workspace::OpenWithSystem",
},
},
- {
- "context": "GitWorktreeSelector || (GitWorktreeSelector > Picker > Editor)",
- "use_key_equivalents": true,
- "bindings": {
- "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow",
- "ctrl-space": "git::WorktreeFromDefault",
- "cmd-shift-backspace": "git::DeleteWorktree",
- },
- },
{
// Handled under a more specific context to avoid conflicts with the
// `OpenCurrentFile` keybind from the settings UI
@@ -1592,9 +1583,15 @@
{
"context": "GitPicker",
"bindings": {
- "cmd-1": "git_picker::ActivateWorktreesTab",
- "cmd-2": "git_picker::ActivateBranchesTab",
- "cmd-3": "git_picker::ActivateStashTab",
+ "cmd-1": "git_picker::ActivateBranchesTab",
+ "cmd-2": "git_picker::ActivateStashTab",
+ },
+ },
+ {
+ "context": "WorktreePicker || (WorktreePicker > Picker > Editor)",
+ "use_key_equivalents": true,
+ "bindings": {
+ "cmd-shift-backspace": "worktree_picker::DeleteWorktree",
},
},
{
@@ -238,7 +238,6 @@
"shift-alt-j": "agent::ToggleNavigationMenu",
"shift-alt-i": "agent::ToggleOptionsMenu",
"ctrl-shift-alt-n": "agent::ToggleNewThreadMenu",
- "ctrl-shift-t": "agent::ToggleWorktreeSelector",
"shift-alt-escape": "agent::ExpandMessageEditor",
"ctrl-shift-.": "agent::AddSelectionToThread",
"ctrl-shift-e": "project_panel::ToggleFocus",
@@ -626,6 +625,7 @@
// "ctrl-shift-alt-o": "["projects::OpenRemote", { "from_existing_connection": true }]",
"ctrl-shift-alt-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }],
"shift-alt-b": "branches::OpenRecent",
+ "shift-alt-w": "git::Worktree",
"shift-alt-enter": "toast::RunAction",
"ctrl-shift-`": "workspace::NewTerminal",
"ctrl-s": "workspace::Save",
@@ -1402,15 +1402,6 @@
"ctrl-5": ["welcome::OpenRecentProject", 4],
},
},
- {
- "context": "GitWorktreeSelector || (GitWorktreeSelector > Picker > Editor)",
- "use_key_equivalents": true,
- "bindings": {
- "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow",
- "ctrl-space": "git::WorktreeFromDefault",
- "ctrl-shift-backspace": "git::DeleteWorktree",
- },
- },
{
// Handled under a more specific context to avoid conflicts with the
// `OpenCurrentFile` keybind from the settings UI
@@ -1509,9 +1500,15 @@
{
"context": "GitPicker",
"bindings": {
- "alt-1": "git_picker::ActivateWorktreesTab",
- "alt-2": "git_picker::ActivateBranchesTab",
- "alt-3": "git_picker::ActivateStashTab",
+ "alt-1": "git_picker::ActivateBranchesTab",
+ "alt-2": "git_picker::ActivateStashTab",
+ },
+ },
+ {
+ "context": "WorktreePicker || (WorktreePicker > Picker > Editor)",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-shift-backspace": "worktree_picker::DeleteWorktree",
},
},
{
@@ -473,8 +473,8 @@
"use_system_window_tabs": false,
// Titlebar related settings
"title_bar": {
- // Whether to show the branch icon beside branch switcher in the titlebar.
- "show_branch_icon": false,
+ // Whether to show git status indicators on the branch icon in the titlebar.
+ "show_branch_status_icon": false,
// Whether to show the branch name button in the titlebar.
"show_branch_name": true,
// Whether to show the project host and name in the titlebar.
@@ -55,6 +55,7 @@ file_icons.workspace = true
fs.workspace = true
futures.workspace = true
git.workspace = true
+git_ui.workspace = true
fuzzy.workspace = true
gpui.workspace = true
gpui_tokio.workspace = true
@@ -1,5 +1,5 @@
use std::{
- path::{Path, PathBuf},
+ path::PathBuf,
rc::Rc,
sync::{
Arc,
@@ -32,14 +32,12 @@ use zed_actions::{
use crate::DEFAULT_THREAD_TITLE;
use crate::thread_metadata_store::{ThreadId, ThreadMetadataStore};
use crate::{
- AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, CreateWorktree,
- Follow, InlineAssistant, LoadThreadFromClipboard, NewThread, NewWorktreeBranchTarget,
- OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell,
- ShowAllSidebarThreadMetadata, ShowThreadMetadata, SwitchWorktree, ToggleNavigationMenu,
- ToggleNewThreadMenu, ToggleOptionsMenu, ToggleWorktreeSelector,
+ AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, Follow,
+ InlineAssistant, LoadThreadFromClipboard, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff,
+ OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, ShowAllSidebarThreadMetadata,
+ ShowThreadMetadata, ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu,
agent_configuration::{AgentConfiguration, AssistantConfigurationEvent},
conversation_view::{AcpThreadViewEvent, ThreadView},
- thread_worktree_picker::ThreadWorktreePicker,
ui::EndTrialUpsell,
};
use crate::{
@@ -51,7 +49,7 @@ use crate::{ManageProfiles, ThreadHistoryViewEvent};
use crate::{ThreadHistory, agent_connection_store::AgentConnectionStore};
use agent_settings::AgentSettings;
use ai_onboarding::AgentPanelOnboarding;
-use anyhow::{Context as _, Result, anyhow};
+use anyhow::{Context as _, Result};
use chrono::{DateTime, Utc};
use client::UserStore;
use cloud_api_types::Plan;
@@ -62,18 +60,14 @@ use extension_host::ExtensionStore;
use fs::Fs;
use gpui::{
Action, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem, Corner,
- DismissEvent, Entity, EntityId, EventEmitter, ExternalPaths, FocusHandle, Focusable,
- KeyContext, Pixels, Subscription, Task, UpdateGlobal, WeakEntity, prelude::*,
- pulsating_between,
+ DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels,
+ Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between,
};
use language::LanguageRegistry;
use language_model::LanguageModelRegistry;
-use project::project_settings::ProjectSettings;
-use project::trusted_worktrees::{PathTrust, TrustedWorktrees};
-use project::{Project, ProjectPath, Worktree, linked_worktree_short_name};
+use project::{Project, ProjectPath, Worktree};
use prompt_store::{PromptStore, UserPromptId};
use release_channel::ReleaseChannel;
-use remote::RemoteConnectionOptions;
use rules_library::{RulesLibrary, open_rules_library};
use settings::TerminalDockPosition;
use settings::{Settings, update_settings_file};
@@ -86,8 +80,8 @@ use ui::{
};
use util::{ResultExt as _, debug_panic};
use workspace::{
- CollaboratorId, DockStructure, DraggedSelection, DraggedTab, OpenMode, PathList,
- SerializedPathList, ToggleWorkspaceSidebar, ToggleZoom, Workspace, WorkspaceId,
+ CollaboratorId, DraggedSelection, DraggedTab, PathList, SerializedPathList,
+ ToggleWorkspaceSidebar, ToggleZoom, Workspace, WorkspaceId,
dock::{DockPosition, Panel, PanelEvent},
};
@@ -279,14 +273,6 @@ pub fn init(cx: &mut App) {
});
}
})
- .register_action(|workspace, _: &ToggleWorktreeSelector, window, cx| {
- if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
- workspace.focus_panel::<AgentPanel>(window, cx);
- panel.update(cx, |panel, cx| {
- panel.toggle_worktree_selector(&ToggleWorktreeSelector, window, cx);
- });
- }
- })
.register_action(|_workspace, _: &ResetOnboarding, window, cx| {
window.dispatch_action(workspace::RestoreBanner.boxed_clone(), cx);
window.refresh();
@@ -508,28 +494,6 @@ pub fn init(cx: &mut App) {
});
});
},
- )
- .register_action(
- |workspace: &mut Workspace, action: &CreateWorktree, window, cx| {
- let previous_state =
- AgentPanel::capture_workspace_state(workspace, window, cx);
- if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
- panel.update(cx, |panel, cx| {
- panel.create_worktree(action, previous_state, window, cx);
- });
- }
- },
- )
- .register_action(
- |workspace: &mut Workspace, action: &SwitchWorktree, window, cx| {
- let previous_state =
- AgentPanel::capture_workspace_state(workspace, window, cx);
- if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
- panel.update(cx, |panel, cx| {
- panel.switch_to_worktree(action, previous_state, window, cx);
- });
- }
- },
);
},
)
@@ -719,61 +683,6 @@ enum WhichFontSize {
None,
}
-#[derive(Clone, Debug)]
-pub enum WorktreeCreationStatus {
- Creating(SharedString),
- Loading(SharedString),
- Error(SharedString),
-}
-
-#[derive(Clone, Debug)]
-enum WorktreeCreationArgs {
- New {
- worktree_name: Option<String>,
- branch_target: NewWorktreeBranchTarget,
- },
- Linked {
- worktree_path: PathBuf,
- display_name: String,
- },
-}
-
-struct PreviousWorkspaceState {
- dock_structure: DockStructure,
- open_file_paths: Vec<PathBuf>,
- active_file_path: Option<PathBuf>,
-}
-
-#[cfg(test)]
-impl PreviousWorkspaceState {
- /// An empty state with all docks hidden and no open files.
- fn empty() -> Self {
- use workspace::DockData;
-
- Self {
- dock_structure: DockStructure {
- left: DockData {
- visible: false,
- active_panel: None,
- zoom: false,
- },
- right: DockData {
- visible: false,
- active_panel: None,
- zoom: false,
- },
- bottom: DockData {
- visible: false,
- active_panel: None,
- zoom: false,
- },
- },
- open_file_paths: Vec::new(),
- active_file_path: None,
- }
- }
-}
-
impl BaseView {
pub fn which_font_size_used(&self) -> WhichFontSize {
WhichFontSize::AgentFont
@@ -809,7 +718,6 @@ pub struct AgentPanel {
draft_thread: Option<Entity<ConversationView>>,
retained_threads: HashMap<ThreadId, Entity<ConversationView>>,
new_thread_menu_handle: PopoverMenuHandle<ContextMenu>,
- start_thread_in_menu_handle: PopoverMenuHandle<ThreadWorktreePicker>,
agent_panel_menu_handle: PopoverMenuHandle<ContextMenu>,
agent_navigation_menu_handle: PopoverMenuHandle<ContextMenu>,
agent_navigation_menu: Option<Entity<ContextMenu>>,
@@ -820,10 +728,8 @@ pub struct AgentPanel {
new_user_onboarding: Entity<AgentPanelOnboarding>,
new_user_onboarding_upsell_dismissed: AtomicBool,
selected_agent: Agent,
- worktree_creation_status: Option<(EntityId, WorktreeCreationStatus)>,
_thread_view_subscription: Option<Subscription>,
_active_thread_focus_subscription: Option<Subscription>,
- _worktree_creation_task: Option<Task<()>>,
show_trust_workspace_message: bool,
_base_view_observation: Option<Subscription>,
_draft_editor_observation: Option<Subscription>,
@@ -1176,7 +1082,6 @@ impl AgentPanel {
draft_thread: None,
retained_threads: HashMap::default(),
new_thread_menu_handle: PopoverMenuHandle::default(),
- start_thread_in_menu_handle: PopoverMenuHandle::default(),
agent_panel_menu_handle: PopoverMenuHandle::default(),
agent_navigation_menu_handle: PopoverMenuHandle::default(),
agent_navigation_menu: None,
@@ -1187,10 +1092,8 @@ impl AgentPanel {
new_user_onboarding: onboarding,
thread_store,
selected_agent: Agent::default(),
- worktree_creation_status: None,
_thread_view_subscription: None,
_active_thread_focus_subscription: None,
- _worktree_creation_task: None,
show_trust_workspace_message: false,
new_user_onboarding_upsell_dismissed: AtomicBool::new(OnboardingUpsell::dismissed(cx)),
_base_view_observation: None,
@@ -1760,15 +1663,6 @@ impl AgentPanel {
self.new_thread_menu_handle.toggle(window, cx);
}
- pub fn toggle_worktree_selector(
- &mut self,
- _: &ToggleWorktreeSelector,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- self.start_thread_in_menu_handle.toggle(window, cx);
- }
-
pub fn increase_font_size(
&mut self,
action: &IncreaseBufferFontSize,
@@ -2841,1133 +2735,272 @@ impl AgentPanel {
.is_some_and(|active| active.entity_id() == draft.entity_id())
})
}
+}
- // TODO: The mapping from workspace root paths to git repositories needs a
- // unified approach across the codebase: this method, `sidebar::is_root_repo`,
- // thread persistence (which PathList is saved to the database), and thread
- // querying (which PathList is used to read threads back). All of these need
- // to agree on how repos are resolved for a given workspace, especially in
- // multi-root and nested-repo configurations.
- /// Partitions the project's visible worktrees into git-backed repositories
- /// and plain (non-git) paths. Git repos will have worktrees created for
- /// them; non-git paths are carried over to the new workspace as-is.
- ///
- /// When multiple worktrees map to the same repository, the most specific
- /// match wins (deepest work directory path), with a deterministic
- /// tie-break on entity id. Each repository appears at most once.
- fn classify_worktrees(
- &self,
- cx: &App,
- ) -> (Vec<Entity<project::git_store::Repository>>, Vec<PathBuf>) {
- let project = &self.project;
- let repositories = project.read(cx).repositories(cx).clone();
- let mut git_repos: Vec<Entity<project::git_store::Repository>> = Vec::new();
- let mut non_git_paths: Vec<PathBuf> = Vec::new();
- let mut seen_repo_ids = std::collections::HashSet::new();
-
- for worktree in project.read(cx).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);
+impl Focusable for AgentPanel {
+ fn focus_handle(&self, cx: &App) -> FocusHandle {
+ match self.visible_surface() {
+ VisibleSurface::Uninitialized => self.focus_handle.clone(),
+ VisibleSurface::AgentThread(conversation_view) => conversation_view.focus_handle(cx),
+ VisibleSurface::History(view) => view.read(cx).focus_handle(cx),
+ VisibleSurface::Configuration(configuration) => {
+ if let Some(configuration) = configuration {
+ configuration.focus_handle(cx)
+ } else {
+ self.focus_handle.clone()
}
- } else {
- non_git_paths.push(wt_path.to_path_buf());
- }
- }
-
- (git_repos, non_git_paths)
- }
-
- fn resolve_worktree_branch_target(
- branch_target: &NewWorktreeBranchTarget,
- ) -> (Option<String>, Option<String>) {
- match branch_target {
- NewWorktreeBranchTarget::CurrentBranch => (None, None),
- NewWorktreeBranchTarget::ExistingBranch { name } => {
- (Some(name.clone()), Some(name.clone()))
- }
- NewWorktreeBranchTarget::CreateBranch { name, from_ref } => {
- (Some(name.clone()), from_ref.clone())
}
}
}
+}
- fn maybe_propagate_worktree_trust(
- this: &WeakEntity<Self>,
- new_workspace: &Entity<workspace::Workspace>,
- paths: &[PathBuf],
- cx: &mut AsyncWindowContext,
- ) {
- cx.update(|_, cx| {
- if ProjectSettings::get_global(cx).session.trust_all_worktrees {
- return;
- }
- let Some(trusted_store) = TrustedWorktrees::try_get_global(cx) else {
- return;
- };
+fn agent_panel_dock_position(cx: &App) -> DockPosition {
+ AgentSettings::get_global(cx).dock.into()
+}
- let source_is_trusted = this
- .upgrade()
- .map(|panel| {
- let source_worktree_store = panel.read(cx).project.read(cx).worktree_store();
- !trusted_store
- .read(cx)
- .has_restricted_worktrees(&source_worktree_store, cx)
- })
- .unwrap_or(false);
+pub enum AgentPanelEvent {
+ ActiveViewChanged,
+ ThreadFocused,
+ RetainedThreadChanged,
+ ThreadInteracted { thread_id: ThreadId },
+}
- if !source_is_trusted {
- return;
- }
+impl EventEmitter<PanelEvent> for AgentPanel {}
+impl EventEmitter<AgentPanelEvent> for AgentPanel {}
- 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();
+impl Panel for AgentPanel {
+ fn persistent_name() -> &'static str {
+ "AgentPanel"
+ }
- if !paths_to_trust.is_empty() {
- trusted_store.update(cx, |store, cx| {
- store.trust(&worktree_store, paths_to_trust, cx);
- });
- }
- })
- .ok();
+ fn panel_key() -> &'static str {
+ AGENT_PANEL_KEY
}
- /// Kicks off an async git-worktree creation for each repository. Returns:
- ///
- /// - `creation_infos`: a vec of `(repo, new_path, receiver)` tuplesβthe
- /// receiver resolves once the git worktree command finishes.
- /// - `path_remapping`: `(old_work_dir, new_worktree_path)` pairs used
- /// later to remap open editor tabs into the new workspace.
- fn start_worktree_creations(
- git_repos: &[Entity<project::git_store::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 Context<Self>,
- ) -> Result<(
- Vec<(
- Entity<project::git_store::Repository>,
- PathBuf,
- futures::channel::oneshot::Receiver<Result<()>>,
- )>,
- Vec<(PathBuf, PathBuf)>,
- )> {
- let mut creation_infos = Vec::new();
- let mut path_remapping = Vec::new();
-
- let worktree_name = worktree_name.unwrap_or_else(|| {
- let existing_refs: Vec<&str> =
- existing_worktree_names.iter().map(|s| s.as_str()).collect();
- crate::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 target = git::repository::CreateWorktreeTarget::Detached {
- base_sha: base_ref.clone(),
- };
- let receiver = repo.create_worktree(target, new_path.clone());
- let work_dir = repo.work_directory_abs_path.clone();
- anyhow::Ok((work_dir, new_path, receiver))
- })?;
- path_remapping.push((work_dir.to_path_buf(), new_path.clone()));
- creation_infos.push((repo.clone(), new_path, receiver));
- }
+ fn position(&self, _window: &Window, cx: &App) -> DockPosition {
+ agent_panel_dock_position(cx)
+ }
- 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.
- async fn await_and_rollback_on_failure(
- creation_infos: Vec<(
- Entity<project::git_store::Repository>,
- PathBuf,
- futures::channel::oneshot::Receiver<Result<()>>,
- )>,
- fs: Arc<dyn Fs>,
- cx: &mut AsyncWindowContext,
- ) -> Result<Vec<PathBuf>> {
- let mut created_paths: Vec<PathBuf> = Vec::new();
- let mut repos_and_paths: Vec<(Entity<project::git_store::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"));
- }
- }
- }
- }
+ fn position_is_valid(&self, position: DockPosition) -> bool {
+ position != DockPosition::Bottom
+ }
- let Some(err) = first_error else {
- return Ok(created_paths);
+ fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
+ let side = match position {
+ DockPosition::Left => "left",
+ DockPosition::Right | DockPosition::Bottom => "right",
};
+ telemetry::event!("Agent Panel Side Changed", side = side);
+ settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
+ settings
+ .agent
+ .get_or_insert_default()
+ .set_dock(position.into());
+ });
+ }
- // Rollback all attempted worktrees (both successful and failed,
- // since a failed creation may have left an orphan directory).
- 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));
+ fn default_size(&self, window: &Window, cx: &App) -> Pixels {
+ let settings = AgentSettings::get_global(cx);
+ match self.position(window, cx) {
+ DockPosition::Left | DockPosition::Right => settings.default_width,
+ DockPosition::Bottom => settings.default_height,
}
+ }
- 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;
- }
-
- // `git worktree remove` normally removes this directory, but since
- // `git worktree remove` failed (or wasn't dispatched), manually rm the directory.
- 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(", "));
+ fn min_size(&self, window: &Window, cx: &App) -> Option<Pixels> {
+ match self.position(window, cx) {
+ DockPosition::Left | DockPosition::Right => Some(MIN_PANEL_WIDTH),
+ DockPosition::Bottom => None,
}
- Err(anyhow!(error_message))
}
- /// Attempts to check out a branch in a newly created worktree.
- /// First tries checking out an existing branch, then tries creating a new
- /// branch. If both fail, the worktree stays in detached HEAD state.
- async fn try_checkout_branch_in_worktree(
- repo: &Entity<project::git_store::Repository>,
- branch_name: &str,
- worktree_path: &Path,
- cx: &mut AsyncWindowContext,
- ) {
- // First, try checking out the branch (it may already exist).
- let Ok(receiver) = cx.update(|_, cx| {
- repo.update(cx, |repo, _cx| {
- repo.checkout_branch_in_worktree(
- branch_name.to_string(),
- worktree_path.to_path_buf(),
- false,
- )
- })
- }) else {
- log::warn!(
- "Failed to check out branch {branch_name} for worktree at {}. \
- Staying in detached HEAD state.",
- worktree_path.display(),
- );
+ fn supports_flexible_size(&self) -> bool {
+ true
+ }
- return;
- };
+ fn has_flexible_size(&self, _window: &Window, cx: &App) -> bool {
+ AgentSettings::get_global(cx).flexible
+ }
- let Ok(result) = receiver.await else {
- log::warn!(
- "Branch checkout was canceled for worktree at {}. \
- Staying in detached HEAD state.",
- worktree_path.display()
- );
+ fn set_flexible_size(&mut self, flexible: bool, _window: &mut Window, cx: &mut Context<Self>) {
+ settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
+ settings
+ .agent
+ .get_or_insert_default()
+ .set_flexible_size(flexible);
+ });
+ }
- return;
- };
+ fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context<Self>) {
+ if active {
+ self.ensure_thread_initialized(window, cx);
+ }
+ }
- if let Err(err) = result {
- log::info!(
- "Failed to check out branch '{branch_name}' in worktree at {}, \
- will try creating it: {err}",
- worktree_path.display()
- );
- } else {
- log::info!(
- "Checked out branch '{branch_name}' in worktree at {}",
- worktree_path.display()
- );
+ fn remote_id() -> Option<proto::PanelId> {
+ Some(proto::PanelId::AssistantPanel)
+ }
- return;
- }
+ fn icon(&self, _window: &Window, cx: &App) -> Option<IconName> {
+ (self.enabled(cx) && AgentSettings::get_global(cx).button).then_some(IconName::ZedAssistant)
+ }
- // Checkout failed, so try creating the branch.
- let create_result = cx.update(|_, cx| {
- repo.update(cx, |repo, _cx| {
- repo.checkout_branch_in_worktree(
- branch_name.to_string(),
- worktree_path.to_path_buf(),
- true,
- )
- })
- });
+ fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
+ Some("Agent Panel")
+ }
- match create_result {
- Ok(receiver) => match receiver.await {
- Ok(Ok(())) => {
- log::info!(
- "Created and checked out branch '{branch_name}' in worktree at {}",
- worktree_path.display()
- );
- }
- Ok(Err(err)) => {
- log::warn!(
- "Failed to create branch '{branch_name}' in worktree at {}: {err}. \
- Staying in detached HEAD state.",
- worktree_path.display()
- );
- }
- Err(_) => {
- log::warn!(
- "Branch creation was canceled for worktree at {}. \
- Staying in detached HEAD state.",
- worktree_path.display()
- );
- }
- },
- Err(err) => {
- log::warn!(
- "Failed to dispatch branch creation for worktree at {}: {err}. \
- Staying in detached HEAD state.",
- worktree_path.display(),
- );
- }
- }
+ fn toggle_action(&self) -> Box<dyn Action> {
+ Box::new(ToggleFocus)
}
- fn capture_workspace_state(
- workspace: &Workspace,
- window: &Window,
- cx: &App,
- ) -> PreviousWorkspaceState {
- let dock_structure = workspace.capture_dock_state(window, cx);
- let open_file_paths = workspace.open_item_abs_paths(cx);
- let active_file_path = workspace
- .active_item(cx)
- .and_then(|item| item.project_path(cx))
- .and_then(|pp| workspace.project().read(cx).absolute_path(&pp, cx));
-
- PreviousWorkspaceState {
- dock_structure,
- open_file_paths,
- active_file_path,
- }
+ fn activation_priority(&self) -> u32 {
+ 0
}
- fn create_worktree(
- &mut self,
- action: &CreateWorktree,
- previous_workspace_state: PreviousWorkspaceState,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- if !self.project_has_git_repository(cx) {
- log::error!("create_worktree: no git repository in the project");
- return;
- }
- if self.project.read(cx).is_via_collab() {
- log::error!("create_worktree: not supported in collab projects");
- return;
- }
- if matches!(
- self.worktree_creation_status,
- Some((
- _,
- WorktreeCreationStatus::Creating(_) | WorktreeCreationStatus::Loading(_)
- ))
- ) {
- return;
- }
+ fn enabled(&self, cx: &App) -> bool {
+ AgentSettings::get_global(cx).enabled(cx)
+ }
- let content = self.take_active_initial_content(cx);
- let content_blocks = match content {
- Some(AgentInitialContent::ContentBlock { blocks, .. }) => blocks,
- _ => Vec::new(),
- };
+ fn is_agent_panel(&self) -> bool {
+ true
+ }
- self.handle_worktree_requested(
- content_blocks,
- WorktreeCreationArgs::New {
- worktree_name: action.worktree_name.clone(),
- branch_target: action.branch_target.clone(),
- },
- previous_workspace_state,
- window,
- cx,
- );
+ fn is_zoomed(&self, _window: &Window, _cx: &App) -> bool {
+ self.zoomed
}
- fn switch_to_worktree(
- &mut self,
- action: &SwitchWorktree,
- previous_workspace_state: PreviousWorkspaceState,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- if !self.project_has_git_repository(cx) {
- log::error!("switch_to_worktree: no git repository in the project");
- return;
- }
- if self.project.read(cx).is_via_collab() {
- log::error!("switch_to_worktree: not supported in collab projects");
- return;
- }
- if matches!(
- self.worktree_creation_status,
- Some((
- _,
- WorktreeCreationStatus::Creating(_) | WorktreeCreationStatus::Loading(_)
- ))
- ) {
- return;
- }
-
- let content = self.take_active_initial_content(cx);
- let content_blocks = match content {
- Some(AgentInitialContent::ContentBlock { blocks, .. }) => blocks,
- _ => Vec::new(),
- };
-
- self.handle_worktree_requested(
- content_blocks,
- WorktreeCreationArgs::Linked {
- worktree_path: action.path.clone(),
- display_name: action.display_name.clone(),
- },
- previous_workspace_state,
- window,
- cx,
- );
- }
-
- fn set_worktree_creation_error(
- &mut self,
- message: SharedString,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- if let Some((_, status)) = &mut self.worktree_creation_status {
- *status = WorktreeCreationStatus::Error(message);
- }
- if matches!(self.base_view, BaseView::Uninitialized) {
- let selected_agent = self.selected_agent.clone();
- self.new_agent_thread(selected_agent, window, cx);
- }
+ fn set_zoomed(&mut self, zoomed: bool, _window: &mut Window, cx: &mut Context<Self>) {
+ self.zoomed = zoomed;
cx.notify();
}
+}
- fn handle_worktree_requested(
- &mut self,
- content: Vec<acp::ContentBlock>,
- args: WorktreeCreationArgs,
- previous_workspace_state: PreviousWorkspaceState,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- if matches!(
- self.worktree_creation_status,
- Some((
- _,
- WorktreeCreationStatus::Creating(_) | WorktreeCreationStatus::Loading(_)
- ))
- ) {
- return;
- }
-
- let conversation_view_id = self
- .active_conversation_view()
- .map(|v| v.entity_id())
- .unwrap_or_else(|| EntityId::from(0u64));
- let display_name: SharedString = match &args {
- WorktreeCreationArgs::New {
- worktree_name: Some(name),
- ..
- } => name.clone().into(),
- WorktreeCreationArgs::New { .. } => "worktree".into(),
- WorktreeCreationArgs::Linked { display_name, .. } => display_name.clone().into(),
- };
- let status = if matches!(args, WorktreeCreationArgs::Linked { .. }) {
- WorktreeCreationStatus::Loading(display_name)
- } else {
- WorktreeCreationStatus::Creating(display_name)
- };
- self.worktree_creation_status = Some((conversation_view_id, status));
- cx.notify();
-
- let (git_repos, non_git_paths) = self.classify_worktrees(cx);
-
- if matches!(args, WorktreeCreationArgs::New { .. }) && git_repos.is_empty() {
- self.set_worktree_creation_error(
- "No git repositories found in the project".into(),
- window,
- cx,
- );
- return;
- }
-
- let remote_connection_options = self.project.read(cx).remote_connection_options(cx);
-
- if remote_connection_options.is_some() {
- let is_disconnected = self
- .project
- .read(cx)
- .remote_client()
- .is_some_and(|client| client.read(cx).is_disconnected());
- if is_disconnected {
- self.set_worktree_creation_error(
- "Cannot create worktree: remote connection is not active".into(),
- window,
- cx,
- );
- return;
- }
+impl AgentPanel {
+ fn ensure_thread_initialized(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ if matches!(self.base_view, BaseView::Uninitialized) {
+ self.activate_draft(false, window, cx);
}
-
- let workspace = self.workspace.clone();
- let window_handle = window
- .window_handle()
- .downcast::<workspace::MultiWorkspace>();
-
- let selected_agent = self.selected_agent();
-
- let git_repo_work_dirs: Vec<PathBuf> = git_repos
- .iter()
- .map(|repo| repo.read(cx).work_directory_abs_path.to_path_buf())
- .collect();
-
- let task = cx.spawn_in(window, async move |this, cx| {
- let (all_paths, path_remapping, has_non_git) = match args {
- WorktreeCreationArgs::New {
- worktree_name,
- branch_target,
- } => {
- let worktree_receivers: Vec<_> = this.update_in(cx, |_this, _window, cx| {
- git_repos
- .iter()
- .map(|repo| repo.update(cx, |repo, _cx| repo.worktrees()))
- .collect()
- })?;
- let worktree_directory_setting = this.update_in(cx, |_this, _window, 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(_) => {}
- }
- }
-
- let mut rng = rand::rng();
-
- let (branch_to_checkout, base_ref) =
- Self::resolve_worktree_branch_target(&branch_target);
-
- let (creation_infos, path_remapping) =
- match this.update_in(cx, |_this, _window, cx| {
- Self::start_worktree_creations(
- &git_repos,
- worktree_name,
- &existing_worktree_names,
- &existing_worktree_paths,
- base_ref,
- &worktree_directory_setting,
- &mut rng,
- cx,
- )
- }) {
- Ok(Ok(result)) => result,
- Ok(Err(err)) | Err(err) => {
- this.update_in(cx, |this, window, cx| {
- this.set_worktree_creation_error(
- format!("Failed to validate worktree directory: {err}")
- .into(),
- window,
- cx,
- );
- })
- .log_err();
- return anyhow::Ok(());
- }
- };
-
- let repo_paths: Vec<(Entity<project::git_store::Repository>, PathBuf)> =
- creation_infos
- .iter()
- .map(|(repo, path, _)| (repo.clone(), path.clone()))
- .collect();
-
- let fs = cx.update(|_, cx| <dyn Fs>::global(cx))?;
-
- let created_paths =
- match Self::await_and_rollback_on_failure(creation_infos, fs, cx).await {
- Ok(paths) => paths,
- Err(err) => {
- this.update_in(cx, |this, window, cx| {
- this.set_worktree_creation_error(
- format!("{err}").into(),
- window,
- cx,
- );
- })?;
- return anyhow::Ok(());
- }
- };
-
- if let Some(ref branch_name) = branch_to_checkout {
- for (repo, worktree_path) in &repo_paths {
- Self::try_checkout_branch_in_worktree(
- repo,
- branch_name,
- worktree_path,
- cx,
- )
- .await;
- }
- }
-
- let mut all_paths = created_paths;
- let has_non_git = !non_git_paths.is_empty();
- all_paths.extend(non_git_paths.iter().cloned());
- (all_paths, path_remapping, has_non_git)
- }
- WorktreeCreationArgs::Linked { worktree_path, .. } => {
- 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());
- (all_paths, path_remapping, has_non_git)
- }
- };
-
- if workspace.upgrade().is_none() {
- this.update_in(cx, |this, window, cx| {
- this.set_worktree_creation_error(
- "Workspace no longer available".into(),
- window,
- cx,
- );
- })?;
- return anyhow::Ok(());
- }
-
- let this_for_error = this.clone();
- if let Err(err) = Self::open_worktree_workspace_and_start_thread(
- this,
- all_paths,
- window_handle,
- previous_workspace_state,
- path_remapping,
- non_git_paths,
- has_non_git,
- content,
- selected_agent,
- remote_connection_options,
- cx,
- )
- .await
- {
- this_for_error
- .update_in(cx, |this, window, cx| {
- this.set_worktree_creation_error(
- format!("Failed to set up workspace: {err}").into(),
- window,
- cx,
- );
- })
- .log_err();
- }
- anyhow::Ok(())
- });
-
- self._worktree_creation_task = Some(cx.background_spawn(async move {
- task.await.log_err();
- }));
}
- async fn open_worktree_workspace_and_start_thread(
- this: WeakEntity<Self>,
- all_paths: Vec<PathBuf>,
- window_handle: Option<gpui::WindowHandle<workspace::MultiWorkspace>>,
- previous_workspace_state: PreviousWorkspaceState,
- path_remapping: Vec<(PathBuf, PathBuf)>,
- non_git_paths: Vec<PathBuf>,
- has_non_git: bool,
- content: Vec<acp::ContentBlock>,
- selected_agent: Option<Agent>,
- remote_connection_options: Option<RemoteConnectionOptions>,
- cx: &mut AsyncWindowContext,
- ) -> Result<()> {
- let window_handle = window_handle
- .ok_or_else(|| anyhow!("No window handle available for workspace creation"))?;
-
- let (workspace_task, modal_workspace) =
- window_handle.update(cx, |multi_workspace, window, cx| {
- let path_list = PathList::new(&all_paths);
- let active_workspace = multi_workspace.workspace().clone();
- let modal_workspace = active_workspace.clone();
-
- let dock_structure = previous_workspace_state.dock_structure;
- let init = Box::new(
- move |workspace: &mut Workspace,
- window: &mut Window,
- cx: &mut Context<Workspace>| {
- workspace.set_dock_structure(dock_structure, window, cx);
- },
- );
-
- let task = multi_workspace.find_or_create_workspace(
- path_list,
- remote_connection_options,
- None,
- move |connection_options, window, cx| {
- remote_connection::connect_with_modal(
- &active_workspace,
- connection_options,
- window,
- cx,
- )
- },
- &[],
- Some(init),
- OpenMode::Add,
- 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();
+ fn destination_has_meaningful_state(&self, cx: &App) -> bool {
+ if self.overlay_view.is_some() || !self.retained_threads.is_empty() {
+ return true;
}
- 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()
+ match &self.base_view {
+ BaseView::Uninitialized => false,
+ BaseView::AgentThread { conversation_view } => {
+ let has_entries = conversation_view
.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;
-
- Self::maybe_propagate_worktree_trust(&this, &new_workspace, &all_paths, cx);
-
- let initial_content = AgentInitialContent::ContentBlock {
- blocks: content,
- auto_submit: false,
- };
-
- window_handle.update(cx, |_multi_workspace, window, cx| {
- new_workspace.update(cx, |workspace, cx| {
- if has_non_git {
- let toast_id = workspace::notifications::NotificationId::unique::<AgentPanel>();
- 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.
- // Paths that can't be remapped (e.g. files that don't exist on
- // the target branch) are silently skipped β best-effort.
- 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_workspace_state
- .active_file_path
- .and_then(|p| remap_path(p));
-
- // Collect all remapped paths, deduplicating and preserving order.
- // The active file is placed last so it ends up as the focused tab.
- let mut paths_to_open: Vec<PathBuf> = Vec::new();
- let mut seen = HashSet::default();
- for path in previous_workspace_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());
- }
+ .root_thread_view()
+ .is_some_and(|tv| !tv.read(cx).thread.read(cx).entries().is_empty());
+ if has_entries {
+ return true;
}
- if !paths_to_open.is_empty() {
- let open_task = workspace.open_paths(
- paths_to_open,
- workspace::OpenOptions {
- focus: Some(false),
- ..Default::default()
- },
- None,
- window,
- cx,
- );
- cx.spawn(async move |_, _| -> anyhow::Result<()> {
- for item in open_task.await.into_iter().flatten() {
- // Best-effort: files that don't exist on the target
- // branch will fail to open and that's fine.
- item.log_err();
- }
- Ok(())
+ conversation_view
+ .read(cx)
+ .root_thread_view()
+ .is_some_and(|thread_view| {
+ let thread_view = thread_view.read(cx);
+ thread_view
+ .thread
+ .read(cx)
+ .draft_prompt()
+ .is_some_and(|draft| !draft.is_empty())
+ || !thread_view
+ .message_editor
+ .read(cx)
+ .text(cx)
+ .trim()
+ .is_empty()
})
- .detach_and_log_err(cx);
- }
- });
- })?;
-
- window_handle.update(cx, |multi_workspace, window, cx| {
- multi_workspace.activate(new_workspace.clone(), window, cx);
-
- new_workspace.update(cx, |workspace, cx| {
- workspace.run_create_worktree_tasks(window, cx);
-
- workspace.focus_panel::<AgentPanel>(window, cx);
-
- if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
- panel.update(cx, |panel, cx| {
- panel.external_thread(
- selected_agent,
- None,
- None,
- None,
- Some(initial_content),
- true,
- "agent_panel",
- window,
- cx,
- );
- });
- }
- })
- })?;
-
- this.update_in(cx, |this, window, cx| {
- this.worktree_creation_status = None;
-
- if let Some(thread_view) = this.active_thread_view(cx) {
- thread_view.update(cx, |thread_view, cx| {
- thread_view
- .message_editor
- .update(cx, |editor, cx| editor.clear(window, cx));
- });
- }
-
- this.serialize(cx);
- cx.notify();
- })?;
-
- anyhow::Ok(())
- }
-}
-
-impl Focusable for AgentPanel {
- fn focus_handle(&self, cx: &App) -> FocusHandle {
- match self.visible_surface() {
- VisibleSurface::Uninitialized => self.focus_handle.clone(),
- VisibleSurface::AgentThread(conversation_view) => conversation_view.focus_handle(cx),
- VisibleSurface::History(view) => view.read(cx).focus_handle(cx),
- VisibleSurface::Configuration(configuration) => {
- if let Some(configuration) = configuration {
- configuration.focus_handle(cx)
- } else {
- self.focus_handle.clone()
- }
}
}
}
-}
-fn agent_panel_dock_position(cx: &App) -> DockPosition {
- AgentSettings::get_global(cx).dock.into()
-}
-
-pub enum AgentPanelEvent {
- ActiveViewChanged,
- ThreadFocused,
- RetainedThreadChanged,
- ThreadInteracted { thread_id: ThreadId },
-}
-
-impl EventEmitter<PanelEvent> for AgentPanel {}
-impl EventEmitter<AgentPanelEvent> for AgentPanel {}
-
-impl Panel for AgentPanel {
- fn persistent_name() -> &'static str {
- "AgentPanel"
- }
-
- fn panel_key() -> &'static str {
- AGENT_PANEL_KEY
- }
-
- fn position(&self, _window: &Window, cx: &App) -> DockPosition {
- agent_panel_dock_position(cx)
- }
-
- fn position_is_valid(&self, position: DockPosition) -> bool {
- position != DockPosition::Bottom
+ fn active_initial_content(&self, cx: &App) -> Option<AgentInitialContent> {
+ self.active_thread_view(cx).and_then(|thread_view| {
+ thread_view
+ .read(cx)
+ .thread
+ .read(cx)
+ .draft_prompt()
+ .map(|draft| AgentInitialContent::ContentBlock {
+ blocks: draft.to_vec(),
+ auto_submit: false,
+ })
+ .filter(|initial_content| match initial_content {
+ AgentInitialContent::ContentBlock { blocks, .. } => !blocks.is_empty(),
+ _ => true,
+ })
+ .or_else(|| {
+ let text = thread_view.read(cx).message_editor.read(cx).text(cx);
+ if text.trim().is_empty() {
+ None
+ } else {
+ Some(AgentInitialContent::ContentBlock {
+ blocks: vec![acp::ContentBlock::Text(acp::TextContent::new(text))],
+ auto_submit: false,
+ })
+ }
+ })
+ })
}
- fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
- let side = match position {
- DockPosition::Left => "left",
- DockPosition::Right | DockPosition::Bottom => "right",
+ fn source_panel_initialization(
+ source_workspace: &WeakEntity<Workspace>,
+ cx: &App,
+ ) -> Option<(Agent, AgentInitialContent)> {
+ let source_workspace = source_workspace.upgrade()?;
+ let source_panel = source_workspace.read(cx).panel::<AgentPanel>(cx)?;
+ let source_panel = source_panel.read(cx);
+ let initial_content = source_panel.active_initial_content(cx)?;
+ let agent = if source_panel.project.read(cx).is_via_collab() {
+ Agent::NativeAgent
+ } else {
+ source_panel.selected_agent.clone()
};
- telemetry::event!("Agent Panel Side Changed", side = side);
- settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
- settings
- .agent
- .get_or_insert_default()
- .set_dock(position.into());
- });
+ Some((agent, initial_content))
}
- fn default_size(&self, window: &Window, cx: &App) -> Pixels {
- let settings = AgentSettings::get_global(cx);
- match self.position(window, cx) {
- DockPosition::Left | DockPosition::Right => settings.default_width,
- DockPosition::Bottom => settings.default_height,
- }
- }
-
- fn min_size(&self, window: &Window, cx: &App) -> Option<Pixels> {
- match self.position(window, cx) {
- DockPosition::Left | DockPosition::Right => Some(MIN_PANEL_WIDTH),
- DockPosition::Bottom => None,
- }
- }
-
- fn supports_flexible_size(&self) -> bool {
- true
- }
-
- fn has_flexible_size(&self, _window: &Window, cx: &App) -> bool {
- AgentSettings::get_global(cx).flexible
- }
-
- fn set_flexible_size(&mut self, flexible: bool, _window: &mut Window, cx: &mut Context<Self>) {
- settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
- settings
- .agent
- .get_or_insert_default()
- .set_flexible_size(flexible);
- });
- }
-
- fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context<Self>) {
- if active {
- self.ensure_thread_initialized(window, cx);
+ pub fn initialize_from_source_workspace_if_needed(
+ &mut self,
+ source_workspace: WeakEntity<Workspace>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> bool {
+ if self.destination_has_meaningful_state(cx) {
+ return false;
}
- }
-
- fn remote_id() -> Option<proto::PanelId> {
- Some(proto::PanelId::AssistantPanel)
- }
-
- fn icon(&self, _window: &Window, cx: &App) -> Option<IconName> {
- (self.enabled(cx) && AgentSettings::get_global(cx).button).then_some(IconName::ZedAssistant)
- }
- fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
- Some("Agent Panel")
- }
-
- fn toggle_action(&self) -> Box<dyn Action> {
- Box::new(ToggleFocus)
- }
-
- fn activation_priority(&self) -> u32 {
- 0
- }
-
- fn enabled(&self, cx: &App) -> bool {
- AgentSettings::get_global(cx).enabled(cx)
- }
+ let Some((agent, initial_content)) =
+ Self::source_panel_initialization(&source_workspace, cx)
+ else {
+ return false;
+ };
- fn is_agent_panel(&self) -> bool {
+ let agent = if self.project.read(cx).is_via_collab() {
+ Agent::NativeAgent
+ } else {
+ agent
+ };
+ let thread = self.create_agent_thread(
+ agent,
+ None,
+ None,
+ None,
+ Some(initial_content),
+ "agent_panel",
+ window,
+ cx,
+ );
+ self.draft_thread = Some(thread.conversation_view.clone());
+ self.observe_draft_editor(&thread.conversation_view, cx);
+ self.set_base_view(thread.into(), false, window, cx);
true
}
- fn is_zoomed(&self, _window: &Window, _cx: &App) -> bool {
- self.zoomed
- }
-
- fn set_zoomed(&mut self, zoomed: bool, _window: &mut Window, cx: &mut Context<Self>) {
- self.zoomed = zoomed;
- cx.notify();
- }
-}
-
-impl AgentPanel {
- fn ensure_thread_initialized(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- if matches!(self.base_view, BaseView::Uninitialized)
- && !matches!(
- self.worktree_creation_status,
- Some((_, WorktreeCreationStatus::Creating(_)))
- )
- {
- self.activate_draft(false, window, cx);
- }
- }
-
fn render_title_view(&self, _window: &mut Window, cx: &Context<Self>) -> AnyElement {
let content = match self.visible_surface() {
VisibleSurface::AgentThread(conversation_view) => {
@@ -32,12 +32,10 @@ mod thread_history_view;
mod thread_import;
pub mod thread_metadata_store;
pub mod thread_worktree_archive;
-mod thread_worktree_picker;
+
pub mod threads_archive_view;
mod ui;
-mod worktree_names;
-use std::path::PathBuf;
use std::rc::Rc;
use std::sync::Arc;
@@ -65,9 +63,7 @@ use std::any::TypeId;
use workspace::Workspace;
use crate::agent_configuration::{ConfigureContextServerModal, ManageProfilesModal};
-pub use crate::agent_panel::{
- AgentPanel, AgentPanelEvent, MaxIdleRetainedThreads, WorktreeCreationStatus,
-};
+pub use crate::agent_panel::{AgentPanel, AgentPanelEvent, MaxIdleRetainedThreads};
use crate::agent_registry_ui::AgentRegistryPage;
pub use crate::inline_assistant::InlineAssistant;
pub use crate::thread_metadata_store::ThreadId;
@@ -84,6 +80,7 @@ pub use thread_import::{
channels_with_threads, import_threads_from_other_channels,
};
use zed_actions;
+pub use zed_actions::{CreateWorktree, NewWorktreeBranchTarget, SwitchWorktree};
pub const DEFAULT_THREAD_TITLE: &str = "New Agent Thread";
const PARALLEL_AGENT_LAYOUT_BACKFILL_KEY: &str = "parallel_agent_layout_backfilled";
@@ -92,8 +89,6 @@ actions!(
[
/// Toggles the menu to create new agent threads.
ToggleNewThreadMenu,
- /// Toggles the worktree selector popover for choosing which worktree to use.
- ToggleWorktreeSelector,
/// Toggles the navigation menu for switching between threads and views.
ToggleNavigationMenu,
/// Toggles the options menu for agent settings and preferences.
@@ -348,46 +343,6 @@ impl Agent {
}
}
-/// Describes which branch to use when creating a new git worktree.
-#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
-#[serde(rename_all = "snake_case", tag = "kind")]
-pub enum NewWorktreeBranchTarget {
- /// Create a new randomly named branch from the current HEAD.
- /// Will match worktree name if the newly created worktree was also randomly named.
- #[default]
- CurrentBranch,
- /// Check out an existing branch, or create a new branch from it if it's
- /// already occupied by another worktree.
- ExistingBranch { name: String },
- /// Create a new branch with an explicit name, optionally from a specific ref.
- CreateBranch {
- name: String,
- #[serde(default)]
- from_ref: Option<String>,
- },
-}
-
-/// Creates a new git worktree and switches the workspace to it.
-/// Dispatched by the unified worktree picker when the user selects a "Create new worktree" entry.
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Action)]
-#[action(namespace = agent)]
-#[serde(deny_unknown_fields)]
-pub struct CreateWorktree {
- /// When this is None, Zed will randomly generate a worktree name.
- pub worktree_name: Option<String>,
- pub branch_target: NewWorktreeBranchTarget,
-}
-
-/// Switches the workspace to an existing linked worktree.
-/// Dispatched by the unified worktree picker when the user selects an existing worktree.
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Action)]
-#[action(namespace = agent)]
-#[serde(deny_unknown_fields)]
-pub struct SwitchWorktree {
- pub path: PathBuf,
- pub display_name: String,
-}
-
/// Content to initialize new external agent with.
pub enum AgentInitialContent {
ThreadSummary {
@@ -2560,7 +2560,12 @@ impl ConversationView {
.update(cx, |multi_workspace, window, cx| {
window.activate_window();
if let Some(workspace) = workspace_handle.upgrade() {
- multi_workspace.activate(workspace.clone(), window, cx);
+ multi_workspace.activate(
+ workspace.clone(),
+ None,
+ window,
+ cx,
+ );
workspace.update(cx, |workspace, cx| {
workspace.reveal_panel::<AgentPanel>(window, cx);
if let Some(panel) =
@@ -1,1036 +0,0 @@
-use std::path::PathBuf;
-use std::sync::Arc;
-
-use collections::HashSet;
-use fuzzy::StringMatchCandidate;
-use git::repository::Worktree as GitWorktree;
-use gpui::{
- AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
- IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window, rems,
-};
-use picker::{Picker, PickerDelegate, PickerEditorPosition};
-use project::Project;
-use project::git_store::RepositoryEvent;
-use ui::{Divider, HighlightedLabel, ListItem, ListItemSpacing, Tooltip, prelude::*};
-use util::ResultExt as _;
-use util::paths::PathExt;
-
-use crate::{CreateWorktree, NewWorktreeBranchTarget, SwitchWorktree};
-
-pub(crate) struct ThreadWorktreePicker {
- picker: Entity<Picker<ThreadWorktreePickerDelegate>>,
- focus_handle: FocusHandle,
- _subscriptions: Vec<Subscription>,
-}
-
-impl ThreadWorktreePicker {
- pub fn new(project: Entity<Project>, window: &mut Window, cx: &mut Context<Self>) -> Self {
- let project_worktree_paths: HashSet<PathBuf> = project
- .read(cx)
- .visible_worktrees(cx)
- .map(|wt| wt.read(cx).abs_path().to_path_buf())
- .collect();
-
- let has_multiple_repositories = project.read(cx).repositories(cx).len() > 1;
-
- let current_branch_name = project.read(cx).active_repository(cx).and_then(|repo| {
- repo.read(cx)
- .branch
- .as_ref()
- .map(|branch| branch.name().to_string())
- });
-
- let repository = if has_multiple_repositories {
- None
- } else {
- project.read(cx).active_repository(cx)
- };
-
- // Fetch worktrees from the git backend (includes main + all linked)
- let all_worktrees_request = repository
- .clone()
- .map(|repo| repo.update(cx, |repo, _| repo.worktrees()));
-
- let default_branch_request = repository
- .clone()
- .map(|repo| repo.update(cx, |repo, _| repo.default_branch(false)));
-
- let initial_matches = vec![ThreadWorktreeEntry::CreateFromCurrentBranch];
-
- let delegate = ThreadWorktreePickerDelegate {
- matches: initial_matches,
- all_worktrees: Vec::new(),
- project_worktree_paths,
- selected_index: 0,
- project,
- current_branch_name,
- default_branch_name: None,
- has_multiple_repositories,
- };
-
- let picker = cx.new(|cx| {
- Picker::list(delegate, window, cx)
- .list_measure_all()
- .modal(false)
- .max_height(Some(rems(20.).into()))
- });
-
- let mut subscriptions = Vec::new();
-
- // Fetch worktrees and default branch asynchronously
- {
- 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!("ThreadWorktreePicker: git worktree list failed: {err}");
- return anyhow::Ok(());
- }
- Err(_) => {
- log::warn!("ThreadWorktreePicker: 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_name =
- default_branch.map(|branch| branch.to_string());
- picker.refresh(window, cx);
- })?;
-
- anyhow::Ok(())
- })
- .detach_and_log_err(cx);
- }
-
- // Subscribe to repository events to live-update the worktree list
- 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,
- }
- }
-}
-
-impl Focusable for ThreadWorktreePicker {
- fn focus_handle(&self, _cx: &App) -> FocusHandle {
- self.focus_handle.clone()
- }
-}
-
-impl EventEmitter<DismissEvent> for ThreadWorktreePicker {}
-
-impl Render for ThreadWorktreePicker {
- fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
- v_flex()
- .w(rems(34.))
- .elevation_3(cx)
- .child(self.picker.clone())
- .on_mouse_down_out(cx.listener(|_, _, _, cx| {
- cx.emit(DismissEvent);
- }))
- }
-}
-
-#[derive(Clone)]
-enum ThreadWorktreeEntry {
- CreateFromCurrentBranch,
- CreateFromDefaultBranch {
- default_branch_name: String,
- },
- Separator,
- Worktree {
- worktree: GitWorktree,
- positions: Vec<usize>,
- },
- CreateNamed {
- name: String,
- /// When Some, create from this branch name (e.g. "main"). When None, create from current branch.
- from_branch: Option<String>,
- disabled_reason: Option<String>,
- },
-}
-
-pub(crate) struct ThreadWorktreePickerDelegate {
- matches: Vec<ThreadWorktreeEntry>,
- all_worktrees: Vec<GitWorktree>,
- project_worktree_paths: HashSet<PathBuf>,
- selected_index: usize,
- project: Entity<Project>,
- current_branch_name: Option<String>,
- default_branch_name: Option<String>,
- has_multiple_repositories: bool,
-}
-
-impl ThreadWorktreePickerDelegate {
- fn build_fixed_entries(&self) -> Vec<ThreadWorktreeEntry> {
- let mut entries = Vec::new();
-
- entries.push(ThreadWorktreeEntry::CreateFromCurrentBranch);
-
- if !self.has_multiple_repositories {
- if let Some(ref default_branch) = self.default_branch_name {
- let is_different = self
- .current_branch_name
- .as_ref()
- .is_none_or(|current| current != default_branch);
- if is_different {
- entries.push(ThreadWorktreeEntry::CreateFromDefaultBranch {
- default_branch_name: default_branch.clone(),
- });
- }
- }
- }
-
- entries
- }
-
- fn all_repo_worktrees(&self) -> &[GitWorktree] {
- if self.has_multiple_repositories {
- &[]
- } else {
- &self.all_worktrees
- }
- }
-
- fn sync_selected_index(&mut self, has_query: bool) {
- if !has_query {
- return;
- }
-
- // When filtering, prefer selecting the first worktree match
- if let Some(index) = self
- .matches
- .iter()
- .position(|entry| matches!(entry, ThreadWorktreeEntry::Worktree { .. }))
- {
- self.selected_index = index;
- } else if let Some(index) = self
- .matches
- .iter()
- .position(|entry| matches!(entry, ThreadWorktreeEntry::CreateNamed { .. }))
- {
- self.selected_index = index;
- } else {
- self.selected_index = 0;
- }
- }
-}
-
-impl PickerDelegate for ThreadWorktreePickerDelegate {
- type ListItem = AnyElement;
-
- fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
- "Select a worktree for this threadβ¦".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(ThreadWorktreeEntry::Separator))
- }
-
- 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_name.as_ref().is_some_and(|default| {
- self.current_branch_name
- .as_ref()
- .is_none_or(|current| current != default)
- });
- let default_branch_name = self.default_branch_name.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 mut sorted = repo_worktrees;
- let project_paths = &self.project_worktree_paths;
-
- sorted.sort_by(|a, b| {
- let a_is_current = project_paths.contains(&a.path);
- let b_is_current = project_paths.contains(&b.path);
- b_is_current.cmp(&a_is_current).then_with(|| {
- a.directory_name(main_worktree_path.as_deref())
- .cmp(&b.directory_name(main_worktree_path.as_deref()))
- })
- });
-
- matches.push(ThreadWorktreeEntry::Separator);
- for worktree in sorted {
- matches.push(ThreadWorktreeEntry::Worktree {
- worktree,
- positions: Vec::new(),
- });
- }
- }
-
- self.matches = matches;
- self.sync_selected_index(false);
- return Task::ready(());
- }
-
- // When the user is typing, fuzzy-match worktree names using display_name
- 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<ThreadWorktreeEntry> = Vec::new();
-
- for candidate in &fuzzy_matches {
- new_matches.push(ThreadWorktreeEntry::Worktree {
- worktree: repo_worktrees_clone[candidate.candidate_id].clone(),
- positions: candidate.positions.clone(),
- });
- }
-
- if !new_matches.is_empty() {
- new_matches.push(ThreadWorktreeEntry::Separator);
- }
- new_matches.push(ThreadWorktreeEntry::CreateNamed {
- name: normalized_query.clone(),
- from_branch: None,
- disabled_reason: create_named_disabled_reason.clone(),
- });
- if show_default_branch_create {
- if let Some(ref default_branch) = default_branch_name {
- new_matches.push(ThreadWorktreeEntry::CreateNamed {
- name: normalized_query.clone(),
- from_branch: Some(default_branch.clone()),
- 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 {
- ThreadWorktreeEntry::Separator => return,
-
- ThreadWorktreeEntry::CreateFromCurrentBranch => {
- window.dispatch_action(
- Box::new(CreateWorktree {
- worktree_name: None,
- branch_target: NewWorktreeBranchTarget::CurrentBranch,
- }),
- cx,
- );
- }
-
- ThreadWorktreeEntry::CreateFromDefaultBranch {
- default_branch_name,
- } => {
- window.dispatch_action(
- Box::new(CreateWorktree {
- worktree_name: None,
- branch_target: NewWorktreeBranchTarget::ExistingBranch {
- name: default_branch_name.clone(),
- },
- }),
- cx,
- );
- }
-
- ThreadWorktreeEntry::Worktree { worktree, .. } => {
- let is_current = self.project_worktree_paths.contains(&worktree.path);
-
- if is_current {
- // Already in this worktree β just dismiss
- } else {
- let main_worktree_path = self
- .all_worktrees
- .iter()
- .find(|wt| wt.is_main)
- .map(|wt| wt.path.as_path());
- window.dispatch_action(
- Box::new(SwitchWorktree {
- path: worktree.path.clone(),
- display_name: worktree.directory_name(main_worktree_path),
- }),
- cx,
- );
- }
- }
-
- ThreadWorktreeEntry::CreateNamed {
- name,
- from_branch,
- disabled_reason: None,
- } => {
- let branch_target = match from_branch {
- Some(branch) => NewWorktreeBranchTarget::ExistingBranch {
- name: branch.clone(),
- },
- None => NewWorktreeBranchTarget::CurrentBranch,
- };
- window.dispatch_action(
- Box::new(CreateWorktree {
- worktree_name: Some(name.clone()),
- branch_target,
- }),
- cx,
- );
- }
-
- ThreadWorktreeEntry::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)?;
- let project = self.project.read(cx);
- let is_create_disabled = project.repositories(cx).is_empty() || project.is_via_collab();
-
- let no_git_reason: SharedString = "Requires a Git repository in the project".into();
-
- let create_new_list_item = |id: SharedString,
- label: SharedString,
- disabled_tooltip: Option<SharedString>,
- selected: bool| {
- 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()
- };
-
- match entry {
- ThreadWorktreeEntry::Separator => Some(
- div()
- .py(DynamicSpacing::Base04.rems(cx))
- .child(Divider::horizontal())
- .into_any_element(),
- ),
-
- ThreadWorktreeEntry::CreateFromCurrentBranch => {
- let branch_label = if self.has_multiple_repositories {
- "current branches".to_string()
- } else {
- self.current_branch_name
- .clone()
- .unwrap_or_else(|| "HEAD".to_string())
- };
-
- let label = format!("Create new worktree based on {branch_label}");
-
- let disabled_tooltip = is_create_disabled.then(|| no_git_reason.clone());
-
- let item = create_new_list_item(
- "create-from-current".to_string().into(),
- label.into(),
- disabled_tooltip,
- selected,
- );
-
- Some(item.into_any_element())
- }
-
- ThreadWorktreeEntry::CreateFromDefaultBranch {
- default_branch_name,
- } => {
- let label = format!("Create new worktree based on {default_branch_name}");
-
- let disabled_tooltip = is_create_disabled.then(|| no_git_reason.clone());
-
- let item = create_new_list_item(
- "create-from-main".to_string().into(),
- label.into(),
- disabled_tooltip,
- selected,
- );
-
- Some(item.into_any_element())
- }
-
- ThreadWorktreeEntry::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.project_worktree_paths.contains(&worktree.path);
-
- let entry_icon = if is_current {
- IconName::Check
- } else {
- IconName::GitWorktree
- };
-
- 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(),
- ),
- ),
- ),
- )
- .into_any_element(),
- )
- }
-
- ThreadWorktreeEntry::CreateNamed {
- name,
- from_branch,
- disabled_reason,
- } => {
- let branch_label = from_branch
- .as_deref()
- .unwrap_or(self.current_branch_name.as_deref().unwrap_or("HEAD"));
- let label = format!("Create \"{name}\" based on {branch_label}");
- let element_id = match from_branch {
- Some(branch) => format!("create-named-from-{branch}"),
- 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())
- }
- }
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use fs::FakeFs;
- use gpui::TestAppContext;
- use project::Project;
- use settings::SettingsStore;
-
- 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);
- release_channel::init("0.0.0".parse().unwrap(), cx);
- crate::agent_panel::init(cx);
- });
- }
-
- fn make_worktree(path: &str, branch: &str, is_main: bool) -> GitWorktree {
- GitWorktree {
- path: PathBuf::from(path),
- ref_name: Some(format!("refs/heads/{branch}").into()),
- sha: "abc1234".into(),
- is_main,
- is_bare: false,
- }
- }
-
- fn build_delegate(
- project: Entity<Project>,
- all_worktrees: Vec<GitWorktree>,
- project_worktree_paths: HashSet<PathBuf>,
- current_branch_name: Option<String>,
- default_branch_name: Option<String>,
- has_multiple_repositories: bool,
- ) -> ThreadWorktreePickerDelegate {
- ThreadWorktreePickerDelegate {
- matches: vec![ThreadWorktreeEntry::CreateFromCurrentBranch],
- all_worktrees,
- project_worktree_paths,
- selected_index: 0,
- project,
- current_branch_name,
- default_branch_name,
- has_multiple_repositories,
- }
- }
-
- fn entry_names(delegate: &ThreadWorktreePickerDelegate) -> Vec<String> {
- delegate
- .matches
- .iter()
- .map(|entry| match entry {
- ThreadWorktreeEntry::CreateFromCurrentBranch => {
- "CreateFromCurrentBranch".to_string()
- }
- ThreadWorktreeEntry::CreateFromDefaultBranch {
- default_branch_name,
- } => format!("CreateFromDefaultBranch({default_branch_name})"),
- ThreadWorktreeEntry::Separator => "---".to_string(),
- ThreadWorktreeEntry::Worktree { worktree, .. } => {
- format!("Worktree({})", worktree.path.display())
- }
- ThreadWorktreeEntry::CreateNamed {
- name,
- from_branch,
- disabled_reason,
- } => {
- let branch = from_branch
- .as_deref()
- .map(|b| format!("from {b}"))
- .unwrap_or_else(|| "from current".to_string());
- if disabled_reason.is_some() {
- format!("CreateNamed({name}, {branch}, disabled)")
- } else {
- format!("CreateNamed({name}, {branch})")
- }
- }
- })
- .collect()
- }
-
- type PickerWindow = gpui::WindowHandle<Picker<ThreadWorktreePickerDelegate>>;
-
- async fn make_picker(
- cx: &mut TestAppContext,
- all_worktrees: Vec<GitWorktree>,
- project_worktree_paths: HashSet<PathBuf>,
- current_branch_name: Option<String>,
- default_branch_name: Option<String>,
- has_multiple_repositories: bool,
- ) -> PickerWindow {
- let fs = FakeFs::new(cx.executor());
- let project = Project::test(fs, [], cx).await;
-
- cx.add_window(|window, cx| {
- let delegate = build_delegate(
- project,
- all_worktrees,
- project_worktree_paths,
- current_branch_name,
- default_branch_name,
- has_multiple_repositories,
- );
- Picker::list(delegate, window, cx)
- .list_measure_all()
- .modal(false)
- })
- }
-
- #[gpui::test]
- async fn test_empty_query_entries(cx: &mut TestAppContext) {
- init_test(cx);
-
- // When on `main` with default branch also `main`, only CreateFromCurrentBranch
- // is shown as a fixed entry. Worktrees are listed with the current one first.
- let worktrees = vec![
- make_worktree("/repo", "main", true),
- make_worktree("/repo-feature", "feature", false),
- make_worktree("/repo-bugfix", "bugfix", false),
- ];
- let project_paths: HashSet<PathBuf> = [PathBuf::from("/repo")].into_iter().collect();
-
- let picker = make_picker(
- cx,
- worktrees,
- project_paths,
- Some("main".into()),
- Some("main".into()),
- false,
- )
- .await;
-
- picker
- .update(cx, |picker, window, cx| picker.refresh(window, cx))
- .unwrap();
- cx.run_until_parked();
-
- let names = picker
- .read_with(cx, |picker, _| entry_names(&picker.delegate))
- .unwrap();
-
- assert_eq!(
- names,
- vec![
- "CreateFromCurrentBranch",
- "---",
- "Worktree(/repo)",
- "Worktree(/repo-bugfix)",
- "Worktree(/repo-feature)",
- ]
- );
-
- // When current branch differs from default, CreateFromDefaultBranch appears.
- picker
- .update(cx, |picker, _window, cx| {
- picker.delegate.current_branch_name = Some("feature".into());
- picker.delegate.default_branch_name = Some("main".into());
- cx.notify();
- })
- .unwrap();
- picker
- .update(cx, |picker, window, cx| picker.refresh(window, cx))
- .unwrap();
- cx.run_until_parked();
-
- let names = picker
- .read_with(cx, |picker, _| entry_names(&picker.delegate))
- .unwrap();
-
- assert!(names.contains(&"CreateFromDefaultBranch(main)".to_string()));
- }
-
- #[gpui::test]
- async fn test_query_filtering_and_create_entries(cx: &mut TestAppContext) {
- init_test(cx);
-
- let picker = make_picker(
- cx,
- vec![
- make_worktree("/repo", "main", true),
- make_worktree("/repo-feature", "feature", false),
- make_worktree("/repo-bugfix", "bugfix", false),
- make_worktree("/my-worktree", "experiment", false),
- ],
- HashSet::default(),
- Some("dev".into()),
- Some("main".into()),
- false,
- )
- .await;
-
- // Partial match filters to matching worktrees and offers to create
- // from both current branch and default branch.
- picker
- .update(cx, |picker, window, cx| {
- picker.set_query("feat", window, cx)
- })
- .unwrap();
- cx.run_until_parked();
-
- let names = picker
- .read_with(cx, |picker, _| entry_names(&picker.delegate))
- .unwrap();
- assert!(names.contains(&"Worktree(/repo-feature)".to_string()));
- assert!(
- names.contains(&"CreateNamed(feat, from current)".to_string()),
- "should offer to create from current branch, got: {names:?}"
- );
- assert!(
- names.contains(&"CreateNamed(feat, from main)".to_string()),
- "should offer to create from default branch, got: {names:?}"
- );
- assert!(!names.contains(&"Worktree(/repo-bugfix)".to_string()));
-
- // Exact match: both create entries appear but are disabled.
- picker
- .update(cx, |picker, window, cx| {
- picker.set_query("repo-feature", window, cx)
- })
- .unwrap();
- cx.run_until_parked();
-
- let names = picker
- .read_with(cx, |picker, _| entry_names(&picker.delegate))
- .unwrap();
- assert!(
- names.contains(&"CreateNamed(repo-feature, from current, disabled)".to_string()),
- "exact name match should show disabled create entries, got: {names:?}"
- );
-
- // Spaces are normalized to hyphens: "my worktree" matches "my-worktree".
- picker
- .update(cx, |picker, window, cx| {
- picker.set_query("my worktree", window, cx)
- })
- .unwrap();
- cx.run_until_parked();
-
- let names = picker
- .read_with(cx, |picker, _| entry_names(&picker.delegate))
- .unwrap();
- assert!(
- names.contains(&"CreateNamed(my-worktree, from current, disabled)".to_string()),
- "spaces should normalize to hyphens and detect existing worktree, got: {names:?}"
- );
- }
-
- #[gpui::test]
- async fn test_multi_repo_hides_worktrees_and_disables_create_named(cx: &mut TestAppContext) {
- init_test(cx);
-
- let picker = make_picker(
- cx,
- vec![
- make_worktree("/repo", "main", true),
- make_worktree("/repo-feature", "feature", false),
- ],
- HashSet::default(),
- Some("main".into()),
- Some("main".into()),
- true,
- )
- .await;
-
- picker
- .update(cx, |picker, window, cx| picker.refresh(window, cx))
- .unwrap();
- cx.run_until_parked();
-
- let names = picker
- .read_with(cx, |picker, _| entry_names(&picker.delegate))
- .unwrap();
- assert_eq!(names, vec!["CreateFromCurrentBranch"]);
-
- picker
- .update(cx, |picker, window, cx| {
- picker.set_query("new-thing", window, cx)
- })
- .unwrap();
- cx.run_until_parked();
-
- let names = picker
- .read_with(cx, |picker, _| entry_names(&picker.delegate))
- .unwrap();
- assert!(
- names.contains(&"CreateNamed(new-thing, from current, disabled)".to_string()),
- "multi-repo should disable create named, got: {names:?}"
- );
- }
-}
@@ -40,7 +40,7 @@ pub fn init(client: Arc<Client>, user_store: Entity<UserStore>, cx: &mut App) {
&cx.entity(),
window,
move |multi_workspace, _, event: &MultiWorkspaceEvent, window, cx| {
- if !matches!(event, MultiWorkspaceEvent::ActiveWorkspaceChanged)
+ if !matches!(event, MultiWorkspaceEvent::ActiveWorkspaceChanged { .. })
&& window.is_window_active()
{
return;
@@ -27,6 +27,7 @@ component.workspace = true
db.workspace = true
editor.workspace = true
file_icons.workspace = true
+fs.workspace = true
futures.workspace = true
fuzzy.workspace = true
git.workspace = true
@@ -45,6 +46,7 @@ picker.workspace = true
project.workspace = true
prompt_store.workspace = true
proto.workspace = true
+rand.workspace = true
remote_connection.workspace = true
remote.workspace = true
schemars.workspace = true
@@ -97,10 +97,11 @@ pub fn create_embedded(
workspace: WeakEntity<Workspace>,
repository: Option<Entity<Repository>>,
width: Rems,
+ show_footer: bool,
window: &mut Window,
cx: &mut Context<BranchList>,
) -> BranchList {
- BranchList::new_embedded(workspace, repository, width, window, cx)
+ BranchList::new_embedded(workspace, repository, width, show_footer, window, cx)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -164,6 +165,7 @@ impl BranchList {
picker.update(cx, |picker, _| {
picker.delegate.focus_handle = picker_focus_handle.clone();
+ picker.delegate.show_footer = !embedded;
});
let mut subscriptions = Vec::new();
@@ -223,6 +225,7 @@ impl BranchList {
workspace: WeakEntity<Workspace>,
repository: Option<Entity<Repository>>,
width: Rems,
+ show_footer: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
@@ -235,6 +238,9 @@ impl BranchList {
window,
cx,
);
+ this.picker.update(cx, |picker, _| {
+ picker.delegate.show_footer = show_footer;
+ });
this._subscriptions
.push(cx.subscribe(&this.picker, |_, _, _, cx| {
cx.emit(DismissEvent);
@@ -386,6 +392,7 @@ pub struct BranchListDelegate {
state: PickerState,
focus_handle: FocusHandle,
restore_selected_branch: Option<SharedString>,
+ show_footer: bool,
}
#[derive(Debug)]
@@ -452,6 +459,7 @@ impl BranchListDelegate {
state: PickerState::List,
focus_handle: cx.focus_handle(),
restore_selected_branch: None,
+ show_footer: false,
}
}
@@ -1172,7 +1180,7 @@ impl PickerDelegate for BranchListDelegate {
}
fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
- if self.editor_position() == PickerEditorPosition::End {
+ if !self.show_footer || self.editor_position() == PickerEditorPosition::End {
return None;
}
let focus_handle = self.focus_handle.clone();
@@ -14,18 +14,11 @@ use workspace::{ModalView, Workspace, pane};
use crate::branch_picker::{self, BranchList, DeleteBranch, FilterRemotes};
use crate::stash_picker::{self, DropStashItem, ShowStashItem, StashList};
-use crate::worktree_picker::{
- self, DeleteWorktree, WorktreeFromDefault, WorktreeFromDefaultOnWindow, WorktreeList,
-};
-actions!(
- git_picker,
- [ActivateBranchesTab, ActivateWorktreesTab, ActivateStashTab,]
-);
+actions!(git_picker, [ActivateBranchesTab, ActivateStashTab,]);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum GitPickerTab {
- Worktrees,
Branches,
Stash,
}
@@ -34,7 +27,6 @@ impl Display for GitPickerTab {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let label = match self {
GitPickerTab::Branches => "Branches",
- GitPickerTab::Worktrees => "Worktrees",
GitPickerTab::Stash => "Stash",
};
write!(f, "{}", label)
@@ -47,7 +39,6 @@ pub struct GitPicker {
repository: Option<Entity<Repository>>,
width: Rems,
branch_list: Option<Entity<BranchList>>,
- worktree_list: Option<Entity<WorktreeList>>,
stash_list: Option<Entity<StashList>>,
_subscriptions: Vec<Subscription>,
popover_style: bool,
@@ -80,7 +71,6 @@ impl GitPicker {
repository,
width,
branch_list: None,
- worktree_list: None,
stash_list: None,
_subscriptions: Vec::new(),
popover_style,
@@ -95,9 +85,6 @@ impl GitPicker {
GitPickerTab::Branches => {
self.ensure_branch_list(window, cx);
}
- GitPickerTab::Worktrees => {
- self.ensure_worktree_list(window, cx);
- }
GitPickerTab::Stash => {
self.ensure_stash_list(window, cx);
}
@@ -110,11 +97,13 @@ impl GitPicker {
cx: &mut Context<Self>,
) -> Entity<BranchList> {
if self.branch_list.is_none() {
+ let show_footer = !self.popover_style;
let branch_list = cx.new(|cx| {
branch_picker::create_embedded(
self.workspace.clone(),
self.repository.clone(),
self.width,
+ show_footer,
window,
cx,
)
@@ -132,45 +121,19 @@ impl GitPicker {
self.branch_list.clone().unwrap()
}
- fn ensure_worktree_list(
- &mut self,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) -> Entity<WorktreeList> {
- if self.worktree_list.is_none() {
- let worktree_list = cx.new(|cx| {
- worktree_picker::create_embedded(
- self.repository.clone(),
- self.workspace.clone(),
- self.width,
- window,
- cx,
- )
- });
-
- let subscription = cx.subscribe(&worktree_list, |this, _, _: &DismissEvent, cx| {
- if this.tab == GitPickerTab::Worktrees {
- cx.emit(DismissEvent);
- }
- });
-
- self._subscriptions.push(subscription);
- self.worktree_list = Some(worktree_list);
- }
- self.worktree_list.clone().unwrap()
- }
-
fn ensure_stash_list(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> Entity<StashList> {
if self.stash_list.is_none() {
+ let show_footer = !self.popover_style;
let stash_list = cx.new(|cx| {
stash_picker::create_embedded(
self.repository.clone(),
self.workspace.clone(),
self.width,
+ show_footer,
window,
cx,
)
@@ -190,9 +153,8 @@ impl GitPicker {
fn activate_next_tab(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.tab = match self.tab {
- GitPickerTab::Worktrees => GitPickerTab::Branches,
GitPickerTab::Branches => GitPickerTab::Stash,
- GitPickerTab::Stash => GitPickerTab::Worktrees,
+ GitPickerTab::Stash => GitPickerTab::Branches,
};
self.ensure_active_picker(window, cx);
self.focus_active_picker(window, cx);
@@ -201,8 +163,7 @@ impl GitPicker {
fn activate_previous_tab(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.tab = match self.tab {
- GitPickerTab::Worktrees => GitPickerTab::Stash,
- GitPickerTab::Branches => GitPickerTab::Worktrees,
+ GitPickerTab::Branches => GitPickerTab::Stash,
GitPickerTab::Stash => GitPickerTab::Branches,
};
self.ensure_active_picker(window, cx);
@@ -217,11 +178,6 @@ impl GitPicker {
branch_list.focus_handle(cx).focus(window, cx);
}
}
- GitPickerTab::Worktrees => {
- if let Some(worktree_list) = &self.worktree_list {
- worktree_list.focus_handle(cx).focus(window, cx);
- }
- }
GitPickerTab::Stash => {
if let Some(stash_list) = &self.stash_list {
stash_list.focus_handle(cx).focus(window, cx);
@@ -233,30 +189,12 @@ impl GitPicker {
fn render_tab_bar(&self, cx: &mut Context<Self>) -> impl IntoElement {
let focus_handle = self.focus_handle(cx);
let branches_focus_handle = focus_handle.clone();
- let worktrees_focus_handle = focus_handle.clone();
let stash_focus_handle = focus_handle;
h_flex().p_2().pb_0p5().w_full().child(
ToggleButtonGroup::single_row(
"git-picker-tabs",
[
- ToggleButtonSimple::new(
- GitPickerTab::Worktrees.to_string(),
- cx.listener(|this, _, window, cx| {
- this.tab = GitPickerTab::Worktrees;
- this.ensure_active_picker(window, cx);
- this.focus_active_picker(window, cx);
- cx.notify();
- }),
- )
- .tooltip(move |_, cx| {
- Tooltip::for_action_in(
- "Toggle Worktree Picker",
- &ActivateWorktreesTab,
- &worktrees_focus_handle,
- cx,
- )
- }),
ToggleButtonSimple::new(
GitPickerTab::Branches.to_string(),
cx.listener(|this, _, window, cx| {
@@ -297,9 +235,8 @@ impl GitPicker {
.style(ToggleButtonGroupStyle::Outlined)
.auto_width()
.selected_index(match self.tab {
- GitPickerTab::Worktrees => 0,
- GitPickerTab::Branches => 1,
- GitPickerTab::Stash => 2,
+ GitPickerTab::Branches => 0,
+ GitPickerTab::Stash => 1,
}),
)
}
@@ -314,10 +251,6 @@ impl GitPicker {
let branch_list = self.ensure_branch_list(window, cx);
branch_list.into_any_element()
}
- GitPickerTab::Worktrees => {
- let worktree_list = self.ensure_worktree_list(window, cx);
- worktree_list.into_any_element()
- }
GitPickerTab::Stash => {
let stash_list = self.ensure_stash_list(window, cx);
stash_list.into_any_element()
@@ -339,13 +272,6 @@ impl GitPicker {
});
}
}
- GitPickerTab::Worktrees => {
- if let Some(worktree_list) = &self.worktree_list {
- worktree_list.update(cx, |list, cx| {
- list.handle_modifiers_changed(ev, window, cx);
- });
- }
- }
GitPickerTab::Stash => {
if let Some(stash_list) = &self.stash_list {
stash_list.update(cx, |list, cx| {
@@ -382,45 +308,6 @@ impl GitPicker {
}
}
- fn handle_worktree_from_default(
- &mut self,
- _: &WorktreeFromDefault,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- if let Some(worktree_list) = &self.worktree_list {
- worktree_list.update(cx, |list, cx| {
- list.handle_new_worktree(false, window, cx);
- });
- }
- }
-
- fn handle_worktree_from_default_on_window(
- &mut self,
- _: &WorktreeFromDefaultOnWindow,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- if let Some(worktree_list) = &self.worktree_list {
- worktree_list.update(cx, |list, cx| {
- list.handle_new_worktree(true, window, cx);
- });
- }
- }
-
- fn handle_worktree_delete(
- &mut self,
- _: &DeleteWorktree,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- if let Some(worktree_list) = &self.worktree_list {
- worktree_list.update(cx, |list, cx| {
- list.handle_delete(&DeleteWorktree, window, cx);
- });
- }
- }
-
fn handle_drop_stash(
&mut self,
_: &DropStashItem,
@@ -459,11 +346,6 @@ impl Focusable for GitPicker {
return branch_list.focus_handle(cx);
}
}
- GitPickerTab::Worktrees => {
- if let Some(worktree_list) = &self.worktree_list {
- return worktree_list.focus_handle(cx);
- }
- }
GitPickerTab::Stash => {
if let Some(stash_list) = &self.stash_list {
return stash_list.focus_handle(cx);
@@ -492,7 +374,6 @@ impl Render for GitPicker {
key_context.add("GitPicker");
match self.tab {
GitPickerTab::Branches => key_context.add("GitBranchSelector"),
- GitPickerTab::Worktrees => key_context.add("GitWorktreeSelector"),
GitPickerTab::Stash => key_context.add("StashList"),
}
key_context
@@ -517,12 +398,6 @@ impl Render for GitPicker {
this.focus_active_picker(window, cx);
cx.notify();
}))
- .on_action(cx.listener(|this, _: &ActivateWorktreesTab, window, cx| {
- this.tab = GitPickerTab::Worktrees;
- this.ensure_active_picker(window, cx);
- this.focus_active_picker(window, cx);
- cx.notify();
- }))
.on_action(cx.listener(|this, _: &ActivateStashTab, window, cx| {
this.tab = GitPickerTab::Stash;
this.ensure_active_picker(window, cx);
@@ -534,11 +409,6 @@ impl Render for GitPicker {
el.on_action(cx.listener(Self::handle_delete_branch))
.on_action(cx.listener(Self::handle_filter_remotes))
})
- .when(self.tab == GitPickerTab::Worktrees, |el| {
- el.on_action(cx.listener(Self::handle_worktree_from_default))
- .on_action(cx.listener(Self::handle_worktree_from_default_on_window))
- .on_action(cx.listener(Self::handle_worktree_delete))
- })
.when(self.tab == GitPickerTab::Stash, |el| {
el.on_action(cx.listener(Self::handle_drop_stash))
.on_action(cx.listener(Self::handle_show_stash))
@@ -557,15 +427,6 @@ pub fn open_branches(
open_with_tab(workspace, GitPickerTab::Branches, window, cx);
}
-pub fn open_worktrees(
- workspace: &mut Workspace,
- _: &zed_actions::git::Worktree,
- window: &mut Window,
- cx: &mut Context<Workspace>,
-) {
- open_with_tab(workspace, GitPickerTab::Worktrees, window, cx);
-}
-
pub fn open_stash(
workspace: &mut Workspace,
_: &zed_actions::git::ViewStash,
@@ -617,9 +478,6 @@ pub fn register(workspace: &mut Workspace) {
open_with_tab(workspace, GitPickerTab::Branches, window, cx);
},
);
- workspace.register_action(|workspace, _: &zed_actions::git::Worktree, window, cx| {
- open_with_tab(workspace, GitPickerTab::Worktrees, window, cx);
- });
workspace.register_action(|workspace, _: &zed_actions::git::ViewStash, window, cx| {
open_with_tab(workspace, GitPickerTab::Stash, window, cx);
});
@@ -22,7 +22,7 @@ use menu::{Cancel, Confirm};
use project::git_store::Repository;
use project_diff::ProjectDiff;
use ui::prelude::*;
-use workspace::{ModalView, Workspace, notifications::DetachAndPromptErr};
+use workspace::{ModalView, OpenMode, Workspace, notifications::DetachAndPromptErr};
use zed_actions;
use crate::{git_panel::GitPanel, text_diff_view::TextDiffView};
@@ -45,7 +45,9 @@ pub(crate) mod remote_output;
pub mod repository_selector;
pub mod stash_picker;
pub mod text_diff_view;
+pub mod worktree_names;
pub mod worktree_picker;
+pub mod worktree_service;
pub use conflict_view::MergeConflictIndicator;
@@ -65,6 +67,64 @@ pub fn init(cx: &mut App) {
repository_selector::register(workspace);
git_picker::register(workspace);
+ workspace.register_action(
+ |workspace, action: &zed_actions::CreateWorktree, window, cx| {
+ worktree_service::handle_create_worktree(workspace, action, window, None, cx);
+ },
+ );
+ workspace.register_action(
+ |workspace, action: &zed_actions::SwitchWorktree, window, cx| {
+ worktree_service::handle_switch_worktree(workspace, action, window, None, cx);
+ },
+ );
+
+ workspace.register_action(|workspace, _: &zed_actions::git::Worktree, window, cx| {
+ let focused_dock = workspace.focused_dock_position(window, cx);
+ let project = workspace.project().clone();
+ let workspace_handle = workspace.weak_handle();
+ workspace.toggle_modal(window, cx, |window, cx| {
+ worktree_picker::WorktreePicker::new_modal(
+ project,
+ workspace_handle,
+ focused_dock,
+ window,
+ cx,
+ )
+ });
+ });
+
+ workspace.register_action(
+ |workspace, action: &zed_actions::OpenWorktreeInNewWindow, window, cx| {
+ let path = action.path.clone();
+ let is_remote = !workspace.project().read(cx).is_local();
+
+ if is_remote {
+ let connection_options =
+ workspace.project().read(cx).remote_connection_options(cx);
+ let app_state = workspace.app_state().clone();
+ let workspace_handle = workspace.weak_handle();
+ cx.spawn_in(window, async move |_, cx| {
+ if let Some(connection_options) = connection_options {
+ crate::worktree_picker::open_remote_worktree(
+ connection_options,
+ vec![path],
+ app_state,
+ workspace_handle,
+ cx,
+ )
+ .await?;
+ }
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx);
+ } else {
+ workspace
+ .open_workspace_for_paths(OpenMode::NewWindow, vec![path], window, cx)
+ .detach_and_log_err(cx);
+ }
+ },
+ );
+
let project = workspace.project().read(cx);
if project.is_read_only(cx) {
return;
@@ -46,10 +46,11 @@ pub fn create_embedded(
repository: Option<Entity<Repository>>,
workspace: WeakEntity<Workspace>,
width: Rems,
+ show_footer: bool,
window: &mut Window,
cx: &mut Context<StashList>,
) -> StashList {
- StashList::new_embedded(repository, workspace, width, window, cx)
+ StashList::new_embedded(repository, workspace, width, show_footer, window, cx)
}
pub struct StashList {
@@ -133,6 +134,7 @@ impl StashList {
let picker_focus_handle = picker.focus_handle(cx);
picker.update(cx, |picker, _| {
picker.delegate.focus_handle = picker_focus_handle.clone();
+ picker.delegate.show_footer = !embedded;
});
Self {
@@ -147,10 +149,14 @@ impl StashList {
repository: Option<Entity<Repository>>,
workspace: WeakEntity<Workspace>,
width: Rems,
+ show_footer: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let mut this = Self::new_inner(repository, workspace, width, true, window, cx);
+ this.picker.update(cx, |picker, _| {
+ picker.delegate.show_footer = show_footer;
+ });
this._subscriptions
.push(cx.subscribe(&this.picker, |_, _, _, cx| {
cx.emit(DismissEvent);
@@ -236,6 +242,7 @@ pub struct StashListDelegate {
modifiers: Modifiers,
focus_handle: FocusHandle,
timezone: UtcOffset,
+ show_footer: bool,
}
impl StashListDelegate {
@@ -257,6 +264,7 @@ impl StashListDelegate {
modifiers: Default::default(),
focus_handle: cx.focus_handle(),
timezone,
+ show_footer: false,
}
}
@@ -614,7 +622,7 @@ impl PickerDelegate for StashListDelegate {
}
fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
- if self.matches.is_empty() {
+ if !self.show_footer || self.matches.is_empty() {
return None;
}
@@ -1,95 +1,88 @@
+use std::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, App, AsyncWindowContext, Context, DismissEvent, Entity, EventEmitter, FocusHandle,
- Focusable, InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement,
- Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, actions, rems,
+ Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
+ IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity,
+ Window, actions, rems,
};
use picker::{Picker, PickerDelegate, PickerEditorPosition};
-use project::project_settings::ProjectSettings;
-use project::{
- git_store::{Repository, RepositoryEvent},
- trusted_worktrees::{PathTrust, TrustedWorktrees},
+use project::Project;
+use project::git_store::RepositoryEvent;
+use ui::{
+ Button, Divider, HighlightedLabel, IconButton, KeyBinding, ListItem, ListItemSpacing, Tooltip,
+ prelude::*,
};
-use remote::{RemoteConnectionOptions, remote_client::ConnectionIdentifier};
-use remote_connection::{RemoteConnectionModal, connect};
-use settings::Settings;
-use std::{path::PathBuf, sync::Arc};
-use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*};
-use util::{ResultExt, debug_panic, paths::PathExt};
+use util::ResultExt as _;
+use util::paths::PathExt;
use workspace::{
- ModalView, MultiWorkspace, OpenMode, Workspace, notifications::DetachAndPromptErr,
+ ModalView, MultiWorkspace, Workspace, dock::DockPosition, notifications::DetachAndPromptErr,
};
use crate::git_panel::show_error_toast;
+use zed_actions::{
+ CreateWorktree, NewWorktreeBranchTarget, OpenWorktreeInNewWindow, SwitchWorktree,
+};
-actions!(
- git,
- [
- WorktreeFromDefault,
- WorktreeFromDefaultOnWindow,
- DeleteWorktree
- ]
-);
-
-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)
-}
+actions!(worktree_picker, [DeleteWorktree]);
-pub struct WorktreeList {
- width: Rems,
- pub picker: Entity<Picker<WorktreeListDelegate>>,
- picker_focus_handle: FocusHandle,
+pub struct WorktreePicker {
+ picker: Entity<Picker<WorktreePickerDelegate>>,
+ focus_handle: FocusHandle,
_subscriptions: Vec<Subscription>,
- embedded: bool,
}
-impl WorktreeList {
- fn new(
- repository: Option<Entity<Repository>>,
+impl WorktreePicker {
+ pub fn new(
+ project: Entity<Project>,
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._subscriptions
- .push(cx.subscribe(&this.picker, |_, _, _, cx| {
- cx.emit(DismissEvent);
- }));
- this
+ 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(
- repository: Option<Entity<Repository>>,
+ project: Entity<Project>,
workspace: WeakEntity<Workspace>,
- width: Rems,
- embedded: bool,
+ focused_dock: Option<DockPosition>,
+ show_footer: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
+ let project_ref = project.read(cx);
+ let project_worktree_paths: HashSet<PathBuf> = project_ref
+ .visible_worktrees(cx)
+ .map(|wt| wt.read(cx).abs_path().to_path_buf())
+ .collect();
+
+ 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()));
@@ -98,65 +91,94 @@ impl WorktreeList {
repository.update(cx, |repository, _| repository.default_branch(false))
});
- cx.spawn_in(window, async move |this, cx| {
- let all_worktrees: Vec<_> = all_worktrees_request
- .context("No active repository")?
- .await??
- .into_iter()
- .filter(|worktree| !worktree.is_bare) // hide bare repositories
- .collect();
-
- 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.delegate.refresh_forbidden_deletion_path(cx);
- picker.refresh(window, cx);
- })
- })?;
+ let initial_matches = vec![WorktreeEntry::CreateFromCurrentBranch];
- anyhow::Ok(())
- })
- .detach_and_log_err(cx);
+ 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_name: None,
+ has_multiple_repositories,
+ focus_handle: cx.focus_handle(),
+ show_footer,
+ };
- let delegate = WorktreeListDelegate::new(workspace, repository.clone(), window, cx);
let picker = cx.new(|cx| {
- Picker::uniform_list(delegate, window, cx)
+ Picker::list(delegate, window, cx)
+ .list_measure_all()
.show_scrollbar(true)
- .modal(!embedded)
+ .modal(false)
+ .max_height(Some(rems(20.).into()))
});
+
let picker_focus_handle = picker.focus_handle(cx);
picker.update(cx, |picker, _| {
- picker.delegate.focus_handle = picker_focus_handle.clone();
+ 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_name =
+ default_branch.map(|branch| branch.to_string());
+ picker.refresh(window, cx);
+ })?;
+
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx);
+ }
+
if let Some(repo) = &repository {
- let picker_entity = picker.clone();
- subscriptions.push(cx.subscribe(
+ let picker_entity = picker.downgrade();
+ subscriptions.push(cx.subscribe_in(
repo,
- move |_this, repo, event: &RepositoryEvent, cx| {
+ 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(async move |_, cx| {
+ cx.spawn_in(window, async move |_, cx| {
let all_worktrees: Vec<_> = worktrees_request
.await??
.into_iter()
- .filter(|worktree| !worktree.is_bare)
+ .filter(|wt| !wt.is_bare)
.collect();
- picker.update(cx, |picker, cx| {
- picker.delegate.all_worktrees = Some(all_worktrees);
- picker.delegate.refresh_forbidden_deletion_path(cx);
- });
+ picker.update_in(cx, |picker, window, cx| {
+ picker.delegate.all_worktrees = all_worktrees;
+ picker.refresh(window, cx);
+ })?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
@@ -165,386 +187,166 @@ impl WorktreeList {
));
}
+ subscriptions.push(cx.subscribe(&picker, |_, _, _, cx| {
+ cx.emit(DismissEvent);
+ }));
+
Self {
+ focus_handle: picker.focus_handle(cx),
picker,
- picker_focus_handle,
- width,
_subscriptions: subscriptions,
- 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._subscriptions
- .push(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.display_name(),
- replace_current_window,
- Some(default_branch.into()),
- window,
- cx,
- );
- })
- }
-
- pub fn handle_delete(
- &mut self,
- _: &DeleteWorktree,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- self.picker.update(cx, |picker, cx| {
- picker
- .delegate
- .delete_at(picker.delegate.selected_index, 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 Focusable for WorktreePicker {
+ fn focus_handle(&self, _cx: &App) -> FocusHandle {
+ self.focus_handle.clone()
}
}
-impl Render for WorktreeList {
- fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+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("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)
+ .key_context("WorktreePicker")
+ .w(rems(34.))
+ .elevation_3(cx)
+ .child(self.picker.clone())
+ .on_mouse_down_out(cx.listener(|_, _, _, cx| {
+ cx.emit(DismissEvent);
}))
.on_action(cx.listener(|this, _: &DeleteWorktree, window, cx| {
- this.handle_delete(&DeleteWorktree, window, cx)
+ this.picker.update(cx, |picker, cx| {
+ let ix = picker.delegate.selected_index;
+ picker.delegate.delete_worktree(ix, window, 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,
+#[derive(Clone)]
+enum WorktreeEntry {
+ CreateFromCurrentBranch,
+ CreateFromDefaultBranch {
+ default_branch_name: String,
+ },
+ Separator,
+ Worktree {
+ worktree: GitWorktree,
+ positions: Vec<usize>,
+ },
+ CreateNamed {
+ name: String,
+ from_branch: Option<String>,
+ disabled_reason: Option<String>,
+ },
}
-impl WorktreeEntry {
- fn can_delete(&self, forbidden_deletion_path: Option<&PathBuf>) -> bool {
- !self.is_new
- && !self.worktree.is_main
- && forbidden_deletion_path != Some(&self.worktree.path)
- }
-}
-
-pub struct WorktreeListDelegate {
+struct WorktreePickerDelegate {
matches: Vec<WorktreeEntry>,
- all_worktrees: Option<Vec<GitWorktree>>,
- workspace: WeakEntity<Workspace>,
- repo: Option<Entity<Repository>>,
+ all_worktrees: Vec<GitWorktree>,
+ project_worktree_paths: HashSet<PathBuf>,
selected_index: usize,
- last_query: String,
- modifiers: Modifiers,
+ project: Entity<Project>,
+ workspace: WeakEntity<Workspace>,
+ focused_dock: Option<DockPosition>,
+ current_branch_name: Option<String>,
+ default_branch_name: Option<String>,
+ has_multiple_repositories: bool,
focus_handle: FocusHandle,
- default_branch: Option<SharedString>,
- forbidden_deletion_path: Option<PathBuf>,
- current_worktree_path: Option<PathBuf>,
+ show_footer: bool,
}
-impl WorktreeListDelegate {
- fn new(
- workspace: WeakEntity<Workspace>,
- repo: Option<Entity<Repository>>,
- _window: &mut Window,
- cx: &mut Context<WorktreeList>,
- ) -> Self {
- let current_worktree_path = repo
- .as_ref()
- .map(|r| r.read(cx).work_directory_abs_path.to_path_buf());
+impl WorktreePickerDelegate {
+ fn build_fixed_entries(&self) -> Vec<WorktreeEntry> {
+ let mut entries = Vec::new();
- 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,
- forbidden_deletion_path: None,
- current_worktree_path,
- }
- }
+ entries.push(WorktreeEntry::CreateFromCurrentBranch);
- 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 branch = worktree_branch.to_string();
- let workspace = self.workspace.clone();
- cx.spawn_in(window, async move |_, cx| {
- let (receiver, new_worktree_path) = repo.update(cx, |repo, cx| {
- let worktree_directory_setting = ProjectSettings::get_global(cx)
- .git
- .worktree_directory
- .clone();
- let new_worktree_path =
- repo.path_for_new_linked_worktree(&branch, &worktree_directory_setting)?;
- let receiver = repo.create_worktree(
- git::repository::CreateWorktreeTarget::NewBranch {
- branch_name: branch.clone(),
- base_sha: commit,
- },
- new_worktree_path.clone(),
- );
- anyhow::Ok((receiver, new_worktree_path))
- })?;
- receiver.await??;
-
- 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,
- );
- }
- });
- }
+ if !self.has_multiple_repositories {
+ if let Some(ref default_branch) = self.default_branch_name {
+ let is_different = self
+ .current_branch_name
+ .as_ref()
+ .is_none_or(|current| current != default_branch);
+ if is_different {
+ entries.push(WorktreeEntry::CreateFromDefaultBranch {
+ default_branch_name: default_branch.clone(),
+ });
}
- })?;
-
- 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(
- OpenMode::Activate,
- 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,
- workspace.clone(),
- replace_current_window,
- cx,
- )
- .await?;
}
+ }
- anyhow::Ok(())
- })
- .detach_and_prompt_err("Failed to create worktree", window, cx, |e, _, _| {
- let msg = e.to_string();
- if msg.contains("git.worktree_directory") {
- Some(format!("Invalid git.worktree_directory setting: {}", e))
- } else {
- Some(msg)
- }
- });
+ entries
}
- 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;
- };
- let open_mode = if replace_current_window {
- OpenMode::Activate
- } else {
- OpenMode::NewWindow
- };
+ fn all_repo_worktrees(&self) -> &[GitWorktree] {
+ &self.all_worktrees
+ }
- if is_local {
- let open_task = workspace.update(cx, |workspace, cx| {
- workspace.open_workspace_for_paths(open_mode, 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 {
- cx.spawn_in(window, async move |_, cx| {
- open_remote_worktree(
- connection_options,
- vec![path],
- app_state,
- workspace,
- replace_current_window,
- cx,
- )
- .await
- })
- .detach_and_prompt_err(
- "Failed to open worktree",
- window,
- cx,
- |e, _, _| Some(e.to_string()),
- );
+ 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
}
-
- 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()))
+ fn can_delete_worktree(&self, worktree: &GitWorktree) -> bool {
+ !worktree.is_main && !self.project_worktree_paths.contains(&worktree.path)
}
- fn delete_at(&self, idx: usize, window: &mut Window, cx: &mut Context<Picker<Self>>) {
- let Some(entry) = self.matches.get(idx).cloned() else {
+ fn delete_worktree(&self, ix: usize, 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 !entry.can_delete(self.forbidden_deletion_path.as_ref()) {
+ if !self.can_delete_worktree(worktree) {
return;
}
- let Some(repo) = self.repo.clone() else {
+
+ let repo = self.project.read(cx).active_repository(cx);
+ let Some(repo) = repo else {
return;
};
+ let path = worktree.path.clone();
let workspace = self.workspace.clone();
- let path = entry.worktree.path;
cx.spawn_in(window, async move |picker, cx| {
let result = repo
.update(cx, |repo, _| repo.remove_worktree(path.clone(), false))
.await?;
- if let Err(e) = result {
- log::error!("Failed to remove worktree: {}", e);
+ if let Err(error) = result {
+ log::error!("Failed to remove worktree: {}", error);
+
if let Some(workspace) = workspace.upgrade() {
cx.update(|_window, cx| {
show_error_toast(
workspace,
format!("worktree remove {}", path.display()),
- e,
+ error,
cx,
)
})?;
}
+
return Ok(());
}
- picker.update_in(cx, |picker, _, cx| {
- picker.delegate.matches.retain(|e| e.worktree.path != path);
- if let Some(all_worktrees) = &mut picker.delegate.all_worktrees {
- all_worktrees.retain(|w| w.path != path);
- }
- picker.delegate.refresh_forbidden_deletion_path(cx);
+ picker.update_in(cx, |picker, _window, cx| {
+ 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() {
@@ -555,139 +357,37 @@ impl WorktreeListDelegate {
anyhow::Ok(())
})
- .detach();
+ .detach_and_log_err(cx);
}
- fn refresh_forbidden_deletion_path(&mut self, cx: &App) {
- let Some(workspace) = self.workspace.upgrade() else {
- debug_panic!("Workspace should always be available or else the picker would be closed");
- self.forbidden_deletion_path = None;
+ fn sync_selected_index(&mut self, has_query: bool) {
+ if !has_query {
return;
- };
-
- let visible_worktree_paths = workspace.read_with(cx, |workspace, cx| {
- workspace
- .project()
- .read(cx)
- .visible_worktrees(cx)
- .map(|worktree| worktree.read(cx).abs_path().to_path_buf())
- .collect::<Vec<_>>()
- });
+ }
- self.forbidden_deletion_path = if visible_worktree_paths.len() == 1 {
- visible_worktree_paths.into_iter().next()
+ 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 {
- None
- };
+ self.selected_index = 0;
+ }
}
}
-async fn open_remote_worktree(
- connection_options: RemoteConnectionOptions,
- paths: Vec<PathBuf>,
- app_state: Arc<workspace::AppState>,
- workspace: WeakEntity<Workspace>,
- replace_current_window: bool,
- cx: &mut AsyncWindowContext,
-) -> anyhow::Result<()> {
- let workspace_window = cx
- .window_handle()
- .downcast::<MultiWorkspace>()
- .ok_or_else(|| anyhow::anyhow!("Window is not a Workspace window"))?;
-
- let connect_task = workspace.update_in(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
- .update_in(cx, |workspace, _window, cx| {
- if let Some(prompt) = workspace.active_modal::<RemoteConnectionModal>(cx) {
- prompt.update(cx, |prompt, cx| prompt.finished(cx))
- }
- })
- .ok();
-
- 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| {
- 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,
- window_to_use,
- None,
- cx,
- )
- .await?;
-
- Ok(())
-}
-
-impl PickerDelegate for WorktreeListDelegate {
- type ListItem = ListItem;
+impl PickerDelegate for WorktreePickerDelegate {
+ type ListItem = AnyElement;
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
- "Select worktreeβ¦".into()
+ "Select a worktreeβ¦".into()
}
fn editor_position(&self) -> PickerEditorPosition {
@@ -706,115 +406,276 @@ impl PickerDelegate for WorktreeListDelegate {
&mut self,
ix: usize,
_window: &mut Window,
- _: &mut Context<Picker<Self>>,
+ _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))
+ }
+
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(());
+ 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
};
- cx.spawn_in(window, async move |picker, cx| {
- let main_worktree_path = all_worktrees
- .iter()
- .find(|wt| wt.is_main)
- .map(|wt| wt.path.clone());
-
- 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
+ let show_default_branch_create = !self.has_multiple_repositories
+ && self.default_branch_name.as_ref().is_some_and(|default| {
+ self.current_branch_name
+ .as_ref()
+ .is_none_or(|current| current != default)
+ });
+ let default_branch_name = self.default_branch_name.clone();
+
+ if query.is_empty() {
+ let mut matches = self.build_fixed_entries();
+
+ if !repo_worktrees.is_empty() {
+ let main_worktree_path = repo_worktrees
.iter()
- .enumerate()
- .map(|(ix, worktree)| {
- StringMatchCandidate::new(
- ix,
- &worktree.directory_name(main_worktree_path.as_deref()),
- )
+ .find(|wt| wt.is_main)
+ .map(|wt| wt.path.clone());
+
+ let mut sorted = repo_worktrees;
+ let project_paths = &self.project_worktree_paths;
+
+ sorted.sort_by(|a, b| {
+ let a_is_current = project_paths.contains(&a.path);
+ let b_is_current = project_paths.contains(&b.path);
+ b_is_current.cmp(&a_is_current).then_with(|| {
+ a.directory_name(main_worktree_path.as_deref())
+ .cmp(&b.directory_name(main_worktree_path.as_deref()))
})
- .collect::<Vec<StringMatchCandidate>>();
- fuzzy::match_strings(
- &candidates,
- &query,
- true,
- true,
- 10000,
- &Default::default(),
- cx.background_executor().clone(),
+ });
+
+ matches.push(WorktreeEntry::Separator);
+ for worktree in sorted {
+ 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()),
)
- .await
- .into_iter()
- .map(|candidate| WorktreeEntry {
- worktree: all_worktrees[candidate.candidate_id].clone(),
- positions: candidate.positions,
- is_new: false,
- })
- .collect()
- };
+ })
+ .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(cx, |picker, _| {
- if !query.is_empty()
- && !matches.first().is_some_and(|entry| {
- entry.worktree.directory_name(main_worktree_path.as_deref()) == query
- })
- {
- let query = query.replace(' ', "-");
- matches.push(WorktreeEntry {
- worktree: GitWorktree {
- path: Default::default(),
- ref_name: Some(format!("refs/heads/{query}").into()),
- sha: Default::default(),
- is_main: false,
- is_bare: false,
- },
- positions: Vec::new(),
- is_new: true,
- })
+ .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(),
+ });
}
- 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);
+
+ if !new_matches.is_empty() {
+ new_matches.push(WorktreeEntry::Separator);
}
- delegate.last_query = query;
+ new_matches.push(WorktreeEntry::CreateNamed {
+ name: normalized_query.clone(),
+ from_branch: None,
+ disabled_reason: create_named_disabled_reason.clone(),
+ });
+ if show_default_branch_create {
+ if let Some(ref default_branch) = default_branch_name {
+ new_matches.push(WorktreeEntry::CreateNamed {
+ name: normalized_query.clone(),
+ from_branch: Some(default_branch.clone()),
+ 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 {
+ let Some(entry) = self.matches.get(self.selected_index) else {
return;
};
- if entry.is_new {
- self.create_worktree(&entry.worktree.display_name(), secondary, None, window, cx);
- } else {
- self.open_worktree(&entry.worktree.path, !secondary, window, cx);
+
+ match entry {
+ WorktreeEntry::Separator => 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_name,
+ } => {
+ 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::ExistingBranch {
+ name: default_branch_name.clone(),
+ },
+ },
+ window,
+ self.focused_dock,
+ cx,
+ );
+ });
+ }
+ }
+ WorktreeEntry::Worktree { worktree, .. } => {
+ let is_current = self.project_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::ExistingBranch {
+ name: branch.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, _: &mut Window, cx: &mut Context<Picker<Self>>) {
- cx.emit(DismissEvent);
- }
+ fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
fn render_match(
&self,
@@ -0,0 +1,809 @@
+use std::path::PathBuf;
+use std::sync::Arc;
+
+use anyhow::anyhow;
+use collections::HashSet;
+use fs::Fs;
+use gpui::{AsyncWindowContext, Entity, SharedString, 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 workspace::{MultiWorkspace, OpenMode, PreviousWorkspaceState, Workspace, dock::DockPosition};
+use zed_actions::NewWorktreeBranchTarget;
+
+use util::ResultExt as _;
+
+use crate::git_panel::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,
+}
+
+/// 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()),
+ }
+}
+
+/// 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.
+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 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 target = git::repository::CreateWorktreeTarget::Detached {
+ base_sha: base_ref.clone(),
+ };
+ let receiver = repo.create_worktree(target, new_path.clone());
+ let work_dir = repo.work_directory_abs_path.clone();
+ anyhow::Ok((work_dir, new_path, receiver))
+ })?;
+ path_remapping.push((work_dir.to_path_buf(), 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 Some(trusted_store) = TrustedWorktrees::try_get_global(cx) else {
+ return;
+ };
+
+ let source_is_trusted = source_workspace
+ .upgrade()
+ .map(|workspace| {
+ let source_worktree_store = workspace.read(cx).project().read(cx).worktree_store();
+ !trusted_store
+ .read(cx)
+ .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() {
+ trusted_store.update(cx, |store, cx| {
+ store.trust(&worktree_store, paths_to_trust, cx);
+ });
+ }
+ })
+ .ok();
+}
+
+/// Handles the `CreateWorktree` action generically, without any agent panel involvement.
+/// Creates a new git worktree, opens the workspace, restores layout and files.
+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 project = workspace.project().clone();
+
+ if project.read(cx).repositories(cx).is_empty() {
+ log::error!("create_worktree: no git repository in the project");
+ return;
+ }
+ if project.read(cx).is_via_collab() {
+ log::error!("create_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);
+
+ if git_repos.is_empty() {
+ show_error_toast(
+ cx.entity(),
+ "worktree create",
+ anyhow!("No git repositories found in the project"),
+ cx,
+ );
+ return;
+ }
+
+ 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 {
+ show_error_toast(
+ cx.entity(),
+ "worktree create",
+ anyhow!("Cannot create worktree: remote connection is not active"),
+ cx,
+ );
+ return;
+ }
+ }
+
+ let worktree_name = action.worktree_name.clone();
+ let branch_target = action.branch_target.clone();
+ 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,
+ branch_target,
+ previous_state,
+ workspace_handle.clone(),
+ window_handle,
+ remote_connection_options,
+ &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);
+ show_error_toast(cx.entity(), "worktree create", anyhow!("{err:#}"), cx);
+ })
+ .ok();
+ }
+
+ result
+ })
+ .detach_and_log_err(cx);
+}
+
+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,
+ previous_state: PreviousWorkspaceState,
+ workspace: WeakEntity<Workspace>,
+ window_handle: Option<gpui::WindowHandle<MultiWorkspace>>,
+ remote_connection_options: Option<RemoteConnectionOptions>,
+ cx: &mut AsyncWindowContext,
+) -> anyhow::Result<()> {
+ // 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(_) => {}
+ }
+ }
+
+ 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?;
+
+ let mut all_paths = created_paths;
+ 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::Create,
+ cx,
+ )
+ .await
+}
+
+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<()> {
+ 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,
+ cx,
+ )
+ .await
+}
+
+/// Core workspace opening logic shared by both create and switch flows.
+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,
+ cx: &mut AsyncWindowContext,
+) -> anyhow::Result<()> {
+ 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);
+
+ let source_for_transfer = if is_creating_new_worktree {
+ 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 is_creating_new_worktree {
+ 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 is_creating_new_worktree {
+ 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| {
+ multi_workspace.activate(new_workspace.clone(), source_for_transfer, window, cx);
+
+ new_workspace.update(cx, |workspace, cx| {
+ workspace.run_create_worktree_tasks(window, cx);
+ });
+ })?;
+
+ if is_creating_new_worktree {
+ if let Some(dock_position) = focused_dock {
+ window_handle.update(cx, |_multi_workspace, window, cx| {
+ new_workspace.update(cx, |workspace, cx| {
+ 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);
+ }
+ });
+ })?;
+ }
+ }
+
+ anyhow::Ok(())
+}
@@ -340,3 +340,9 @@ pub(crate) mod m_2026_04_15 {
pub(crate) use settings::remove_settings_from_http_context_servers;
}
+
+pub(crate) mod m_2026_04_17 {
+ mod settings;
+
+ pub(crate) use settings::promote_show_branch_icon_true_to_show_branch_status_icon;
+}
@@ -0,0 +1,47 @@
+use anyhow::Result;
+use serde_json::Value;
+
+use crate::migrations::migrate_settings;
+
+const SETTINGS_KEY: &str = "settings";
+const TITLE_BAR_KEY: &str = "title_bar";
+const OLD_KEY: &str = "show_branch_icon";
+const NEW_KEY: &str = "show_branch_status_icon";
+
+pub fn promote_show_branch_icon_true_to_show_branch_status_icon(value: &mut Value) -> Result<()> {
+ migrate_settings(value, &mut migrate_one)
+}
+
+fn migrate_one(object: &mut serde_json::Map<String, Value>) -> Result<()> {
+ migrate_title_bar_value(object);
+
+ if let Some(settings) = object
+ .get_mut(SETTINGS_KEY)
+ .and_then(|value| value.as_object_mut())
+ {
+ migrate_title_bar_value(settings);
+ }
+
+ Ok(())
+}
+
+fn migrate_title_bar_value(object: &mut serde_json::Map<String, Value>) {
+ let Some(title_bar) = object
+ .get_mut(TITLE_BAR_KEY)
+ .and_then(|value| value.as_object_mut())
+ else {
+ return;
+ };
+
+ let Some(old_value) = title_bar.remove(OLD_KEY) else {
+ return;
+ };
+
+ if title_bar.contains_key(NEW_KEY) {
+ return;
+ }
+
+ if old_value == Value::Bool(true) {
+ title_bar.insert(NEW_KEY.to_string(), Value::Bool(true));
+ }
+}
@@ -251,6 +251,9 @@ pub fn migrate_settings(text: &str) -> Result<Option<String>> {
MigrationType::Json(migrations::m_2026_04_01::restructure_profiles_with_settings_key),
MigrationType::Json(migrations::m_2026_04_10::rename_web_search_to_search_web),
MigrationType::Json(migrations::m_2026_04_15::remove_settings_from_http_context_servers),
+ MigrationType::Json(
+ migrations::m_2026_04_17::promote_show_branch_icon_true_to_show_branch_status_icon,
+ ),
];
run_migrations(text, migrations)
}
@@ -5041,4 +5044,252 @@ mod tests {
),
);
}
+
+ #[test]
+ fn test_promote_show_branch_icon_true_to_show_branch_status_icon_at_root() {
+ assert_migrate_settings(
+ &r#"
+ {
+ "title_bar": {
+ "show_branch_icon": true,
+ "show_branch_name": true
+ }
+ }
+ "#
+ .unindent(),
+ Some(
+ &r#"
+ {
+ "title_bar": {
+ "show_branch_status_icon": true,
+ "show_branch_name": true
+ }
+ }
+ "#
+ .unindent(),
+ ),
+ );
+ }
+
+ #[test]
+ fn test_drop_show_branch_icon_false_without_setting_status_icon() {
+ assert_migrate_settings(
+ &r#"
+ {
+ "title_bar": {
+ "show_branch_icon": false,
+ "show_branch_name": true
+ }
+ }
+ "#
+ .unindent(),
+ Some(
+ &r#"
+ {
+ "title_bar": {
+ "show_branch_name": true
+ }
+ }
+ "#
+ .unindent(),
+ ),
+ );
+ }
+
+ #[test]
+ fn test_promote_show_branch_icon_true_to_show_branch_status_icon_in_platform_override() {
+ assert_migrate_settings(
+ &r#"
+ {
+ "macos": {
+ "title_bar": {
+ "show_branch_icon": true,
+ "show_branch_name": true
+ }
+ }
+ }
+ "#
+ .unindent(),
+ Some(
+ &r#"
+ {
+ "macos": {
+ "title_bar": {
+ "show_branch_status_icon": true,
+ "show_branch_name": true
+ }
+ }
+ }
+ "#
+ .unindent(),
+ ),
+ );
+ }
+
+ #[test]
+ fn test_promote_show_branch_icon_true_to_show_branch_status_icon_in_release_override() {
+ assert_migrate_settings(
+ &r#"
+ {
+ "preview": {
+ "title_bar": {
+ "show_branch_icon": true,
+ "show_branch_name": true
+ }
+ }
+ }
+ "#
+ .unindent(),
+ Some(
+ &r#"
+ {
+ "preview": {
+ "title_bar": {
+ "show_branch_status_icon": true,
+ "show_branch_name": true
+ }
+ }
+ }
+ "#
+ .unindent(),
+ ),
+ );
+ }
+
+ #[test]
+ fn test_promote_show_branch_icon_true_to_show_branch_status_icon_in_profiles() {
+ assert_migrate_settings(
+ &r#"
+ {
+ "profiles": {
+ "work": {
+ "title_bar": {
+ "show_branch_icon": true,
+ "show_branch_name": true
+ }
+ }
+ }
+ }
+ "#
+ .unindent(),
+ Some(
+ &r#"
+ {
+ "profiles": {
+ "work": {
+ "settings": {
+ "title_bar": {
+ "show_branch_status_icon": true,
+ "show_branch_name": true
+ }
+ }
+ }
+ }
+ }
+ "#
+ .unindent(),
+ ),
+ );
+ }
+
+ #[test]
+ fn test_promote_show_branch_icon_true_to_show_branch_status_icon_across_all_scopes() {
+ assert_migrate_settings(
+ &r#"
+ {
+ "title_bar": {
+ "show_branch_icon": true,
+ "show_branch_name": true
+ },
+ "macos": {
+ "title_bar": {
+ "show_branch_icon": true,
+ "show_branch_name": true
+ }
+ },
+ "preview": {
+ "title_bar": {
+ "show_branch_icon": true,
+ "show_branch_name": true
+ }
+ },
+ "profiles": {
+ "work": {
+ "title_bar": {
+ "show_branch_icon": true,
+ "show_branch_name": true
+ }
+ }
+ }
+ }
+ "#
+ .unindent(),
+ Some(
+ &r#"
+ {
+ "title_bar": {
+ "show_branch_status_icon": true,
+ "show_branch_name": true
+ },
+ "macos": {
+ "title_bar": {
+ "show_branch_status_icon": true,
+ "show_branch_name": true
+ }
+ },
+ "preview": {
+ "title_bar": {
+ "show_branch_status_icon": true,
+ "show_branch_name": true
+ }
+ },
+ "profiles": {
+ "work": {
+ "settings": {
+ "title_bar": {
+ "show_branch_status_icon": true,
+ "show_branch_name": true
+ }
+ }
+ }
+ }
+ }
+ "#
+ .unindent(),
+ ),
+ );
+ }
+
+ #[test]
+ fn test_promote_show_branch_icon_true_to_show_branch_status_icon_no_change_when_already_migrated()
+ {
+ assert_migrate_settings(
+ &r#"
+ {
+ "title_bar": {
+ "show_branch_status_icon": true,
+ "show_branch_name": true
+ }
+ }
+ "#
+ .unindent(),
+ None,
+ );
+
+ // No title_bar key β should be unchanged
+ assert_migrate_settings(&r#"{ "theme": "One Dark" }"#.unindent(), None);
+
+ // title_bar without show_branch_icon β should be unchanged
+ assert_migrate_settings(
+ &r#"
+ {
+ "title_bar": {
+ "show_branch_name": true
+ }
+ }
+ "#
+ .unindent(),
+ None,
+ );
+ }
}
@@ -160,7 +160,7 @@ pub async fn open_remote_project(
let open_results = existing_window
.update(cx, |multi_workspace, window, cx| {
window.activate_window();
- multi_workspace.activate(existing_workspace.clone(), window, cx);
+ multi_workspace.activate(existing_workspace.clone(), None, window, cx);
existing_workspace.update(cx, |workspace, cx| {
workspace.open_paths(
resolved_paths,
@@ -505,7 +505,7 @@ impl ProjectPicker {
}?;
let items = open_remote_project_with_existing_connection(
- connection, project, paths, app_state, window, None, cx,
+ connection, project, paths, app_state, window, None, None, cx,
)
.await
.log_err();
@@ -81,10 +81,12 @@ impl From<String> for WindowButtonLayoutContent {
#[with_fallible_options]
#[derive(Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom, Debug)]
pub struct TitleBarSettingsContent {
- /// Whether to show the branch icon beside branch switcher in the title bar.
+ /// Whether to show git status indicators on the branch icon in the title bar.
+ /// When enabled, the branch icon changes to reflect the current repository
+ /// status (e.g. modified, added, deleted, or conflict).
///
/// Default: false
- pub show_branch_icon: Option<bool>,
+ pub show_branch_status_icon: Option<bool>,
/// Whether to show onboarding banners in the title bar.
///
/// Default: true
@@ -3631,22 +3631,22 @@ fn window_and_layout_page() -> SettingsPage {
[
SettingsPageItem::SectionHeader("Title Bar"),
SettingsPageItem::SettingItem(SettingItem {
- title: "Show Branch Icon",
- description: "Show the branch icon beside branch switcher in the titlebar.",
+ title: "Show Branch Status Icon",
+ description: "Show git status indicators on the branch icon in the titlebar.",
field: Box::new(SettingField {
- json_path: Some("title_bar.show_branch_icon"),
+ json_path: Some("title_bar.show_branch_status_icon"),
pick: |settings_content| {
settings_content
.title_bar
.as_ref()?
- .show_branch_icon
+ .show_branch_status_icon
.as_ref()
},
write: |settings_content, value| {
settings_content
.title_bar
.get_or_insert_default()
- .show_branch_icon = value;
+ .show_branch_status_icon = value;
},
}),
metadata: None,
@@ -494,7 +494,7 @@ impl Sidebar {
&multi_workspace,
window,
|this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event {
- MultiWorkspaceEvent::ActiveWorkspaceChanged => {
+ MultiWorkspaceEvent::ActiveWorkspaceChanged { .. } => {
this.sync_active_entry_from_active_workspace(cx);
this.replace_archived_panel_thread(window, cx);
this.update_entries(cx);
@@ -1989,6 +1989,7 @@ impl Sidebar {
.update(cx, |multi_workspace, cx| {
multi_workspace.activate(
activate_workspace.clone(),
+ None,
window,
cx,
);
@@ -2475,7 +2476,7 @@ impl Sidebar {
}
multi_workspace.update(cx, |multi_workspace, cx| {
- multi_workspace.activate(workspace.clone(), window, cx);
+ multi_workspace.activate(workspace.clone(), None, window, cx);
if retain {
multi_workspace.retain_active_workspace(cx);
}
@@ -2515,7 +2516,7 @@ impl Sidebar {
let activated = target_window
.update(cx, |multi_workspace, window, cx| {
window.activate_window();
- multi_workspace.activate(workspace.clone(), window, cx);
+ multi_workspace.activate(workspace.clone(), None, window, cx);
Self::load_agent_thread_in_workspace(&workspace, &metadata, true, window, cx);
})
.log_err()
@@ -3537,7 +3538,7 @@ impl Sidebar {
) {
if let Some(multi_workspace) = self.multi_workspace.upgrade() {
multi_workspace.update(cx, |mw, cx| {
- mw.activate(workspace.clone(), window, cx);
+ mw.activate(workspace.clone(), None, window, cx);
});
}
}
@@ -3729,7 +3730,7 @@ impl Sidebar {
} => {
if let Some(mw) = weak_multi_workspace.upgrade() {
mw.update(cx, |mw, cx| {
- mw.activate(workspace.clone(), window, cx);
+ mw.activate(workspace.clone(), None, window, cx);
});
}
this.active_entry = Some(ActiveEntry {
@@ -3748,7 +3749,7 @@ impl Sidebar {
} => {
if let Some(mw) = weak_multi_workspace.upgrade() {
mw.update(cx, |mw, cx| {
- mw.activate(workspace.clone(), window, cx);
+ mw.activate(workspace.clone(), None, window, cx);
mw.retain_active_workspace(cx);
});
}
@@ -3766,7 +3767,7 @@ impl Sidebar {
if let Some(mw) = weak_multi_workspace.upgrade() {
if let Some(original_ws) = &original_workspace {
mw.update(cx, |mw, cx| {
- mw.activate(original_ws.clone(), window, cx);
+ mw.activate(original_ws.clone(), None, window, cx);
});
}
}
@@ -3823,7 +3824,7 @@ impl Sidebar {
if let Some((metadata, workspace)) = initial_preview {
if let Some(mw) = self.multi_workspace.upgrade() {
mw.update(cx, |mw, cx| {
- mw.activate(workspace.clone(), window, cx);
+ mw.activate(workspace.clone(), None, window, cx);
});
}
self.active_entry = Some(ActiveEntry {
@@ -4070,7 +4071,7 @@ impl Sidebar {
};
multi_workspace.update(cx, |multi_workspace, cx| {
- multi_workspace.activate(workspace.clone(), window, cx);
+ multi_workspace.activate(workspace.clone(), None, window, cx);
});
let draft_id = workspace.update(cx, |workspace, cx| {
@@ -4197,7 +4198,7 @@ impl Sidebar {
.workspace_for_paths(key.path_list(), key.host().as_ref(), cx)
}) {
multi_workspace.update(cx, |multi_workspace, cx| {
- multi_workspace.activate(workspace, window, cx);
+ multi_workspace.activate(workspace, None, window, cx);
multi_workspace.retain_active_workspace(cx);
});
} else {
@@ -2098,7 +2098,7 @@ async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppC
// Switch to workspace 1 so we can verify the confirm switches back.
multi_workspace.update_in(cx, |mw, window, cx| {
let workspace = mw.workspaces().nth(1).unwrap().clone();
- mw.activate(workspace, window, cx);
+ mw.activate(workspace, None, window, cx);
});
cx.run_until_parked();
assert_eq!(
@@ -2597,7 +2597,7 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
multi_workspace.update_in(cx, |mw, window, cx| {
let workspace = mw.workspaces().next().unwrap().clone();
- mw.activate(workspace, window, cx);
+ mw.activate(workspace, None, window, cx);
});
cx.run_until_parked();
@@ -2653,7 +2653,7 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
multi_workspace.update_in(cx, |mw, window, cx| {
let workspace = mw.workspaces().find(|w| *w == &workspace_b).cloned();
if let Some(workspace) = workspace {
- mw.activate(workspace, window, cx);
+ mw.activate(workspace, None, window, cx);
}
});
cx.run_until_parked();
@@ -2917,7 +2917,7 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp
// Switch to the worktree workspace.
multi_workspace.update_in(cx, |mw, window, cx| {
let workspace = mw.workspaces().nth(1).unwrap().clone();
- mw.activate(workspace, window, cx);
+ mw.activate(workspace, None, window, cx);
});
// Create a non-empty thread in the worktree workspace.
@@ -3521,7 +3521,7 @@ async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAp
// Switch back to the main workspace before setting up the sidebar.
multi_workspace.update_in(cx, |mw, window, cx| {
let workspace = mw.workspaces().next().unwrap().clone();
- mw.activate(workspace, window, cx);
+ mw.activate(workspace, None, window, cx);
});
// Start a thread in the worktree workspace's panel and keep it
@@ -3614,7 +3614,7 @@ async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAp
multi_workspace.update_in(cx, |mw, window, cx| {
let workspace = mw.workspaces().next().unwrap().clone();
- mw.activate(workspace, window, cx);
+ mw.activate(workspace, None, window, cx);
});
let connection = StubAgentConnection::new();
@@ -3937,7 +3937,7 @@ async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace(
// Activate the main workspace before setting up the sidebar.
let main_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
let workspace = mw.workspaces().next().unwrap().clone();
- mw.activate(workspace.clone(), window, cx);
+ mw.activate(workspace.clone(), None, window, cx);
workspace
});
@@ -4018,7 +4018,7 @@ async fn test_activate_archived_thread_with_saved_paths_activates_matching_works
// Ensure workspace A is active.
multi_workspace.update_in(cx, |mw, window, cx| {
let workspace = mw.workspaces().next().unwrap().clone();
- mw.activate(workspace, window, cx);
+ mw.activate(workspace, None, window, cx);
});
cx.run_until_parked();
assert_eq!(
@@ -4088,7 +4088,7 @@ async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace(
// Start with workspace A active.
multi_workspace.update_in(cx, |mw, window, cx| {
let workspace = mw.workspaces().next().unwrap().clone();
- mw.activate(workspace, window, cx);
+ mw.activate(workspace, None, window, cx);
});
cx.run_until_parked();
assert_eq!(
@@ -4155,7 +4155,7 @@ async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace(
// Activate workspace B (index 1) to make it the active one.
multi_workspace.update_in(cx, |mw, window, cx| {
let workspace = mw.workspaces().nth(1).unwrap().clone();
- mw.activate(workspace, window, cx);
+ mw.activate(workspace, None, window, cx);
});
cx.run_until_parked();
assert_eq!(
@@ -4557,7 +4557,7 @@ async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppCon
// Activate main workspace so the sidebar tracks the main panel.
multi_workspace.update_in(cx, |mw, window, cx| {
let workspace = mw.workspaces().next().unwrap().clone();
- mw.activate(workspace, window, cx);
+ mw.activate(workspace, None, window, cx);
});
let main_workspace =
@@ -6461,7 +6461,7 @@ async fn test_unarchive_into_inactive_existing_workspace_does_not_leave_active_d
cx.run_until_parked();
multi_workspace.update_in(cx, |mw, window, cx| {
- mw.activate(workspace_a.clone(), window, cx);
+ mw.activate(workspace_a.clone(), None, window, cx);
});
cx.run_until_parked();
@@ -6853,7 +6853,7 @@ async fn test_switch_to_workspace_with_archived_thread_shows_no_active_entry(
let workspace_a =
multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
multi_workspace.update_in(cx, |mw, window, cx| {
- mw.activate(workspace_a.clone(), window, cx);
+ mw.activate(workspace_a.clone(), None, window, cx);
});
cx.run_until_parked();
@@ -7014,7 +7014,7 @@ async fn test_archive_last_thread_on_linked_worktree_does_not_create_new_thread_
// Activate the linked worktree workspace so the sidebar tracks it.
multi_workspace.update_in(cx, |mw, window, cx| {
- mw.activate(worktree_workspace.clone(), window, cx);
+ mw.activate(worktree_workspace.clone(), None, window, cx);
});
// Open a thread in the linked worktree panel and send a message
@@ -7185,7 +7185,7 @@ async fn test_archive_last_thread_on_linked_worktree_with_no_siblings_leaves_gro
// Activate the linked worktree workspace.
multi_workspace.update_in(cx, |mw, window, cx| {
- mw.activate(worktree_workspace.clone(), window, cx);
+ mw.activate(worktree_workspace.clone(), None, window, cx);
});
// Open a thread on the linked worktree β this is the ONLY thread.
@@ -7486,7 +7486,7 @@ async fn test_archive_thread_on_linked_worktree_selects_sibling_thread(cx: &mut
// Activate the linked worktree workspace.
multi_workspace.update_in(cx, |mw, window, cx| {
- mw.activate(worktree_workspace.clone(), window, cx);
+ mw.activate(worktree_workspace.clone(), None, window, cx);
});
// Open a thread on the linked worktree.
@@ -7641,7 +7641,7 @@ async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestA
// Switch back to the main workspace.
multi_workspace.update_in(cx, |mw, window, cx| {
let main_ws = mw.workspaces().next().unwrap().clone();
- mw.activate(main_ws, window, cx);
+ mw.activate(main_ws, None, window, cx);
});
cx.run_until_parked();
@@ -7850,7 +7850,9 @@ async fn test_transient_workspace_retained(cx: &mut TestAppContext) {
);
// Switch to A β B survives. (Switching from one internal workspace, to another)
- multi_workspace.update_in(cx, |mw, window, cx| mw.activate(workspace_a, window, cx));
+ multi_workspace.update_in(cx, |mw, window, cx| {
+ mw.activate(workspace_a, None, window, cx)
+ });
cx.run_until_parked();
assert_eq!(
multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
@@ -8318,7 +8320,7 @@ async fn test_project_header_click_restores_last_viewed(cx: &mut TestAppContext)
.clone()
});
multi_workspace.update_in(cx, |mw, window, cx| {
- mw.activate(workspace_a.clone(), window, cx);
+ mw.activate(workspace_a.clone(), None, window, cx);
});
cx.run_until_parked();
@@ -8406,11 +8408,11 @@ async fn test_activating_workspace_with_draft_does_not_create_extras(cx: &mut Te
// Switch away from project-b, then back.
multi_workspace.update_in(cx, |mw, window, cx| {
- mw.activate(workspace_a.clone(), window, cx);
+ mw.activate(workspace_a.clone(), None, window, cx);
});
cx.run_until_parked();
multi_workspace.update_in(cx, |mw, window, cx| {
- mw.activate(workspace_b.clone(), window, cx);
+ mw.activate(workspace_b.clone(), None, window, cx);
});
cx.run_until_parked();
@@ -9146,7 +9148,7 @@ mod property_test {
.unwrap_or_else(|| mw.workspace().clone())
});
multi_workspace.update_in(cx, |mw, window, cx| {
- mw.activate(workspace, window, cx);
+ mw.activate(workspace, None, window, cx);
});
}
Operation::AddLinkedWorktree {
@@ -10921,7 +10923,7 @@ async fn test_cmd_click_project_header_returns_to_last_active_linked_worktree_wo
// workspaces β what matters for this test is the explicit sequence of
// activations below.)
multi_workspace.update_in(cx, |mw, window, cx| {
- mw.activate(worktree_workspace_a.clone(), window, cx);
+ mw.activate(worktree_workspace_a.clone(), None, window, cx);
});
cx.run_until_parked();
assert_eq!(
@@ -10934,7 +10936,7 @@ async fn test_cmd_click_project_header_returns_to_last_active_linked_worktree_wo
// workspace remains the linked-worktree one (group B getting activated
// records *its own* last-active workspace, not group A's).
multi_workspace.update_in(cx, |mw, window, cx| {
- mw.activate(workspace_b.clone(), window, cx);
+ mw.activate(workspace_b.clone(), None, window, cx);
});
cx.run_until_parked();
assert_eq!(
@@ -7,6 +7,7 @@ mod update_version;
use crate::application_menu::{ApplicationMenu, show_menus};
use crate::plan_chip::PlanChip;
+use git_ui::worktree_picker::WorktreePicker;
pub use platform_title_bar::{
self, DraggedWindowTab, MergeAllWindows, MoveTabToNewWindow, PlatformTitleBar,
ShowNextWindowTab, ShowPreviousWindowTab,
@@ -389,6 +390,16 @@ impl TitleBar {
}),
);
subscriptions.push(cx.observe(&user_store, |_a, _, cx| cx.notify()));
+ if let Some(workspace_entity) = workspace.weak_handle().upgrade() {
+ subscriptions.push(cx.subscribe(
+ &workspace_entity,
+ |_, _, event: &workspace::Event, cx| {
+ if matches!(event, workspace::Event::WorktreeCreationChanged) {
+ cx.notify();
+ }
+ },
+ ));
+ }
subscriptions.push(cx.observe_button_layout_changed(window, |_, _, cx| cx.notify()));
if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
subscriptions.push(cx.subscribe(&trusted_worktrees, |_, _, _, cx| {
@@ -816,12 +827,14 @@ impl TitleBar {
repository: Entity<project::git_store::Repository>,
linked_worktree_name: Option<SharedString>,
cx: &mut Context<Self>,
- ) -> Option<impl IntoElement> {
+ ) -> Option<AnyElement> {
let workspace = self.workspace.upgrade()?;
- let (branch_name, icon_info) = {
+ let (branch_name, icon_info, is_detached_head) = {
let repo = repository.read(cx);
+ let is_detached_head = repo.branch.is_none();
+
let branch_name = repo
.branch
.as_ref()
@@ -851,67 +864,131 @@ impl TitleBar {
(IconName::GitBranch, Color::Muted)
};
- (branch_name, icon_info)
+ (branch_name, icon_info, is_detached_head)
};
let branch_name = branch_name?;
let settings = TitleBarSettings::get_global(cx);
let effective_repository = Some(repository);
- Some(
- PopoverMenu::new("branch-menu")
+ let worktree_label: SharedString = linked_worktree_name.unwrap_or_else(|| "main".into());
+
+ let (creation_in_progress, is_switch) = self
+ .workspace
+ .upgrade()
+ .map(|ws| {
+ let creation = ws.read(cx).active_worktree_creation();
+ (creation.label.clone(), creation.is_switch)
+ })
+ .unwrap_or((None, false));
+ let is_creating = creation_in_progress.is_some();
+
+ let display_label: SharedString = if let Some(ref name) = creation_in_progress {
+ if is_switch {
+ format!("Loading {}β¦", name).into()
+ } else {
+ format!("Creating {}β¦", name).into()
+ }
+ } else {
+ worktree_label.clone()
+ };
+
+ let worktree_button = {
+ let project = self.project.clone();
+ let workspace_handle = workspace.downgrade();
+ PopoverMenu::new("worktree-picker-menu")
.menu(move |window, cx| {
- Some(git_ui::git_picker::popover(
- workspace.downgrade(),
- effective_repository.clone(),
- git_ui::git_picker::GitPickerTab::Branches,
- gpui::rems(34.),
- window,
- cx,
- ))
+ // When opened from the title bar, focus is on the trigger
+ // button (not a dock), so `focused_dock` is `None`. That's
+ // fine β there's no prior dock focus to restore.
+ Some(cx.new(|cx| {
+ WorktreePicker::new(project.clone(), workspace_handle.clone(), window, cx)
+ }))
})
.trigger_with_tooltip(
- ButtonLike::new("project_branch_trigger")
+ Button::new("worktree_picker_trigger", display_label)
.selected_style(ButtonStyle::Tinted(TintColor::Accent))
- .child(
- h_flex()
- .gap_0p5()
- .when(settings.show_branch_icon, |this| {
- let (icon, icon_color) = icon_info;
- this.child(
- Icon::new(icon).size(IconSize::XSmall).color(icon_color),
- )
- })
- .when_some(linked_worktree_name.as_ref(), |this, worktree_name| {
- this.child(
- Label::new(worktree_name)
- .size(LabelSize::Small)
- .color(Color::Muted),
- )
- .child(
- Label::new("/").size(LabelSize::Small).color(
- Color::Custom(
- cx.theme().colors().text_muted.opacity(0.4),
- ),
- ),
- )
- })
- .child(
- Label::new(branch_name)
- .size(LabelSize::Small)
- .color(Color::Muted),
- ),
+ .label_size(LabelSize::Small)
+ .color(Color::Muted)
+ .loading(is_creating)
+ .start_icon(
+ Icon::new(IconName::GitWorktree)
+ .size(IconSize::XSmall)
+ .color(Color::Muted),
),
move |_window, cx| {
Tooltip::with_meta(
- "Git Switcher",
- Some(&zed_actions::git::Branch),
- "Worktrees, Branches, and Stashes",
+ "Worktree",
+ Some(&zed_actions::git::Worktree),
+ format!("Currently In Use: {}", worktree_label),
cx,
)
},
)
- .anchor(gpui::Corner::TopLeft),
+ .anchor(gpui::Corner::TopLeft)
+ };
+
+ let branch_tooltip_label = branch_name.clone();
+ let (branch_icon, branch_icon_color) = if settings.show_branch_status_icon {
+ icon_info
+ } else {
+ (IconName::GitBranch, Color::Muted)
+ };
+
+ let trigger = if is_detached_head {
+ Button::new("project_branch_trigger", "Create Branch")
+ .selected_style(ButtonStyle::Tinted(TintColor::Accent))
+ .label_size(LabelSize::Small)
+ .start_icon(
+ Icon::new(IconName::GitBranchPlus)
+ .size(IconSize::XSmall)
+ .color(Color::Muted),
+ )
+ } else {
+ Button::new("project_branch_trigger", branch_name)
+ .selected_style(ButtonStyle::Tinted(TintColor::Accent))
+ .label_size(LabelSize::Small)
+ .color(Color::Muted)
+ .start_icon(
+ Icon::new(branch_icon)
+ .size(IconSize::XSmall)
+ .color(branch_icon_color),
+ )
+ };
+
+ let git_picker_button = PopoverMenu::new("branch-menu")
+ .menu(move |window, cx| {
+ Some(git_ui::git_picker::popover(
+ workspace.downgrade(),
+ effective_repository.clone(),
+ git_ui::git_picker::GitPickerTab::Branches,
+ gpui::rems(34.),
+ window,
+ cx,
+ ))
+ })
+ .trigger_with_tooltip(trigger, move |_window, cx| {
+ let meta = if is_detached_head {
+ format!("Detached HEAD: {}", branch_tooltip_label)
+ } else {
+ format!("Currently Checked Out: {}", branch_tooltip_label)
+ };
+ Tooltip::with_meta("Branch & Stash", Some(&zed_actions::git::Branch), meta, cx)
+ })
+ .anchor(gpui::Corner::TopLeft);
+
+ Some(
+ h_flex()
+ .gap_px()
+ .child(worktree_button)
+ .child(
+ Label::new("/")
+ .size(LabelSize::Small)
+ .color(Color::Muted)
+ .alpha(0.25),
+ )
+ .child(git_picker_button)
+ .into_any_element(),
)
}
@@ -3,7 +3,7 @@ use settings::{RegisterSetting, Settings, SettingsContent};
#[derive(Copy, Clone, Debug, RegisterSetting)]
pub struct TitleBarSettings {
- pub show_branch_icon: bool,
+ pub show_branch_status_icon: bool,
pub show_onboarding_banner: bool,
pub show_user_picture: bool,
pub show_branch_name: bool,
@@ -18,7 +18,7 @@ impl Settings for TitleBarSettings {
fn from_settings(s: &SettingsContent) -> Self {
let content = s.title_bar.clone().unwrap();
TitleBarSettings {
- show_branch_icon: content.show_branch_icon.unwrap(),
+ show_branch_status_icon: content.show_branch_status_icon.unwrap(),
show_onboarding_banner: content.show_onboarding_banner.unwrap(),
show_user_picture: content.show_user_picture.unwrap(),
show_branch_name: content.show_branch_name.unwrap(),
@@ -103,7 +103,9 @@ pub fn sidebar_side_context_menu(
}
pub enum MultiWorkspaceEvent {
- ActiveWorkspaceChanged,
+ ActiveWorkspaceChanged {
+ source_workspace: Option<WeakEntity<Workspace>>,
+ },
WorkspaceAdded(Entity<Workspace>),
WorkspaceRemoved(EntityId),
ProjectGroupsChanged,
@@ -578,7 +580,7 @@ impl MultiWorkspace {
cx.subscribe_in(workspace, window, |this, workspace, event, window, cx| {
if let WorkspaceEvent::Activate = event {
- this.activate(workspace.clone(), window, cx);
+ this.activate(workspace.clone(), None, window, cx);
}
})
.detach();
@@ -730,7 +732,7 @@ impl MultiWorkspace {
self.retained_workspaces.push(workspace.clone());
}
- self.activate(workspace.clone(), window, cx);
+ self.activate(workspace.clone(), None, window, cx);
cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace));
}
@@ -1137,19 +1139,52 @@ impl MultiWorkspace {
open_mode: OpenMode,
window: &mut Window,
cx: &mut Context<Self>,
+ ) -> Task<Result<Entity<Workspace>>> {
+ self.find_or_create_workspace_with_source_workspace(
+ paths,
+ host,
+ provisional_project_group_key,
+ connect_remote,
+ excluding,
+ init,
+ open_mode,
+ None,
+ window,
+ cx,
+ )
+ }
+
+ pub fn find_or_create_workspace_with_source_workspace(
+ &mut self,
+ paths: PathList,
+ host: Option<RemoteConnectionOptions>,
+ provisional_project_group_key: Option<ProjectGroupKey>,
+ connect_remote: impl FnOnce(
+ RemoteConnectionOptions,
+ &mut Window,
+ &mut Context<Self>,
+ ) -> Task<Result<Option<Entity<remote::RemoteClient>>>>
+ + 'static,
+ excluding: &[Entity<Workspace>],
+ init: Option<Box<dyn FnOnce(&mut Workspace, &mut Window, &mut Context<Workspace>) + Send>>,
+ open_mode: OpenMode,
+ source_workspace: Option<WeakEntity<Workspace>>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
) -> Task<Result<Entity<Workspace>>> {
if let Some(workspace) = self.workspace_for_paths(&paths, host.as_ref(), cx) {
- self.activate(workspace.clone(), window, cx);
+ self.activate(workspace.clone(), source_workspace, window, cx);
return Task::ready(Ok(workspace));
}
let Some(connection_options) = host else {
- return self.find_or_create_local_workspace(
+ return self.find_or_create_local_workspace_with_source_workspace(
paths,
provisional_project_group_key,
excluding,
init,
open_mode,
+ source_workspace,
window,
cx,
);
@@ -1215,6 +1250,7 @@ impl MultiWorkspace {
app_state,
window_handle,
provisional_project_group_key,
+ source_workspace,
cx,
)
.await?;
@@ -1243,10 +1279,33 @@ impl MultiWorkspace {
open_mode: OpenMode,
window: &mut Window,
cx: &mut Context<Self>,
+ ) -> Task<Result<Entity<Workspace>>> {
+ self.find_or_create_local_workspace_with_source_workspace(
+ path_list,
+ project_group,
+ excluding,
+ init,
+ open_mode,
+ None,
+ window,
+ cx,
+ )
+ }
+
+ pub fn find_or_create_local_workspace_with_source_workspace(
+ &mut self,
+ path_list: PathList,
+ project_group: Option<ProjectGroupKey>,
+ excluding: &[Entity<Workspace>],
+ init: Option<Box<dyn FnOnce(&mut Workspace, &mut Window, &mut Context<Workspace>) + Send>>,
+ open_mode: OpenMode,
+ source_workspace: Option<WeakEntity<Workspace>>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
) -> Task<Result<Entity<Workspace>>> {
if let Some(workspace) = self.workspace_for_paths_excluding(&path_list, None, excluding, cx)
{
- self.activate(workspace.clone(), window, cx);
+ self.activate(workspace.clone(), source_workspace, window, cx);
return Task::ready(Ok(workspace));
}
@@ -1291,7 +1350,12 @@ impl MultiWorkspace {
cx,
)
.inspect(|workspace| {
- multi_workspace.activate(workspace.clone(), window, cx);
+ multi_workspace.activate(
+ workspace.clone(),
+ source_workspace.clone(),
+ window,
+ cx,
+ );
})
})
.ok()
@@ -1354,6 +1418,7 @@ impl MultiWorkspace {
pub fn activate(
&mut self,
workspace: Entity<Workspace>,
+ source_workspace: Option<WeakEntity<Workspace>>,
window: &mut Window,
cx: &mut Context<Self>,
) {
@@ -1386,7 +1451,7 @@ impl MultiWorkspace {
self.detach_workspace(&old_active_workspace, cx);
}
- cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged);
+ cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged { source_workspace });
self.serialize(cx);
self.focus_active_workspace(window, cx);
cx.notify();
@@ -1671,7 +1736,7 @@ impl MultiWorkspace {
cx: &mut Context<Self>,
) -> Entity<Workspace> {
let workspace = cx.new(|cx| Workspace::test_new(project, window, cx));
- self.activate(workspace.clone(), window, cx);
+ self.activate(workspace.clone(), None, window, cx);
workspace
}
@@ -1702,7 +1767,7 @@ impl MultiWorkspace {
cx,
);
let new_workspace = cx.new(|cx| Workspace::new(None, project, app_state, window, cx));
- self.activate(new_workspace.clone(), window, cx);
+ self.activate(new_workspace.clone(), None, window, cx);
let weak_workspace = new_workspace.downgrade();
let db = crate::persistence::WorkspaceDb::global(cx);
@@ -1827,12 +1892,12 @@ impl MultiWorkspace {
!workspaces.contains(&new_active),
"fallback workspace must not be one of the workspaces being removed"
);
- this.activate(new_active, window, cx);
+ this.activate(new_active, None, window, cx);
})?;
} else {
this.update_in(cx, |this, window, cx| {
if *this.workspace() != original_active {
- this.activate(original_active, window, cx);
+ this.activate(original_active, None, window, cx);
}
})?;
}
@@ -555,7 +555,7 @@ async fn test_close_workspace_prefers_already_loaded_neighboring_workspace(
});
multi_workspace.update_in(cx, |multi_workspace, window, cx| {
- multi_workspace.activate(workspace_a.clone(), window, cx);
+ multi_workspace.activate(workspace_a.clone(), None, window, cx);
multi_workspace.test_add_project_group(ProjectGroup {
key: project_c_key.clone(),
workspaces: Vec::new(),
@@ -2568,7 +2568,7 @@ mod tests {
let workspace2 = multi_workspace.update_in(cx, |mw, window, cx| {
let workspace = cx.new(|cx| crate::Workspace::test_new(project2.clone(), window, cx));
workspace.update(cx, |ws, _cx| ws.set_random_database_id());
- mw.activate(workspace.clone(), window, cx);
+ mw.activate(workspace.clone(), None, window, cx);
workspace
});
@@ -4947,7 +4947,7 @@ mod tests {
// Activate workspace B so removing its group exercises the fallback.
multi_workspace.update_in(cx, |mw, window, cx| {
- mw.activate(workspace_b.clone(), window, cx);
+ mw.activate(workspace_b.clone(), None, window, cx);
});
cx.run_until_parked();
@@ -4976,7 +4976,7 @@ mod tests {
let workspace_a =
multi_workspace.read_with(cx, |mw, _cx| mw.workspaces().next().unwrap().clone());
multi_workspace.update_in(cx, |mw, window, cx| {
- mw.activate(workspace_a.clone(), window, cx);
+ mw.activate(workspace_a.clone(), None, window, cx);
});
cx.run_until_parked();
@@ -5052,7 +5052,7 @@ mod tests {
// Activate workspace_a so removing it triggers the fallback path.
multi_workspace.update_in(cx, |mw, window, cx| {
- mw.activate(workspace_a.clone(), window, cx);
+ mw.activate(workspace_a.clone(), None, window, cx);
});
cx.run_until_parked();
@@ -1109,6 +1109,23 @@ struct GlobalAppState(Arc<AppState>);
impl Global for GlobalAppState {}
+/// Tracks worktree creation progress for the workspace.
+/// Read by the title bar to show a loading indicator on the worktree button.
+#[derive(Default)]
+pub struct ActiveWorktreeCreation {
+ pub label: Option<SharedString>,
+ pub is_switch: bool,
+}
+
+/// Captured workspace state used when switching between worktrees.
+/// Stores the layout and open files so they can be restored in the new workspace.
+pub struct PreviousWorkspaceState {
+ pub dock_structure: DockStructure,
+ pub open_file_paths: Vec<PathBuf>,
+ pub active_file_path: Option<PathBuf>,
+ pub focused_dock: Option<DockPosition>,
+}
+
pub struct WorkspaceStore {
workspaces: HashSet<(gpui::AnyWindowHandle, WeakEntity<Workspace>)>,
client: Arc<Client>,
@@ -1269,6 +1286,7 @@ pub enum Event {
ModalOpened,
Activate,
PanelAdded(AnyView),
+ WorktreeCreationChanged,
}
#[derive(Debug, Clone)]
@@ -1377,6 +1395,7 @@ pub struct Workspace {
_panels_task: Option<Task<Result<()>>>,
sidebar_focus_handle: Option<FocusHandle>,
multi_workspace: Option<WeakEntity<MultiWorkspace>>,
+ active_worktree_creation: ActiveWorktreeCreation,
}
impl EventEmitter<Event> for Workspace {}
@@ -1805,6 +1824,7 @@ impl Workspace {
removing: false,
sidebar_focus_handle: None,
multi_workspace,
+ active_worktree_creation: ActiveWorktreeCreation::default(),
open_in_dev_container: false,
_dev_container_task: None,
}
@@ -1947,7 +1967,7 @@ impl Workspace {
});
match open_mode {
OpenMode::Activate => {
- multi_workspace.activate(workspace.clone(), window, cx);
+ multi_workspace.activate(workspace.clone(), None, window, cx);
}
OpenMode::Add => {
multi_workspace.add(workspace.clone(), &*window, cx);
@@ -2178,6 +2198,64 @@ impl Workspace {
}
}
+ /// Returns which dock currently has focus, or `None` if focus is in the
+ /// center pane or elsewhere. Does NOT fall back to any global state.
+ pub fn focused_dock_position(&self, window: &Window, cx: &App) -> Option<DockPosition> {
+ [
+ (DockPosition::Left, &self.left_dock),
+ (DockPosition::Right, &self.right_dock),
+ (DockPosition::Bottom, &self.bottom_dock),
+ ]
+ .into_iter()
+ .find(|(_, dock)| {
+ dock.read(cx).is_open() && dock.focus_handle(cx).contains_focused(window, cx)
+ })
+ .map(|(position, _)| position)
+ }
+
+ pub fn active_worktree_creation(&self) -> &ActiveWorktreeCreation {
+ &self.active_worktree_creation
+ }
+
+ pub fn set_active_worktree_creation(
+ &mut self,
+ label: Option<SharedString>,
+ is_switch: bool,
+ cx: &mut Context<Self>,
+ ) {
+ self.active_worktree_creation.label = label;
+ self.active_worktree_creation.is_switch = is_switch;
+ cx.emit(Event::WorktreeCreationChanged);
+ cx.notify();
+ }
+
+ /// Captures the current workspace state for restoring after a worktree switch.
+ /// This includes dock layout, open file paths, and the active file path.
+ pub fn capture_state_for_worktree_switch(
+ &self,
+ window: &Window,
+ fallback_focused_dock: Option<DockPosition>,
+ cx: &App,
+ ) -> PreviousWorkspaceState {
+ let dock_structure = self.capture_dock_state(window, cx);
+ let open_file_paths = self.open_item_abs_paths(cx);
+ let active_file_path = self
+ .active_item(cx)
+ .and_then(|item| item.project_path(cx))
+ .and_then(|pp| self.project().read(cx).absolute_path(&pp, cx));
+
+ let focused_dock = self
+ .focused_dock_position(window, cx)
+ .or(fallback_focused_dock);
+
+ PreviousWorkspaceState {
+ dock_structure,
+ open_file_paths,
+ active_file_path,
+ focused_dock,
+ }
+ }
+
pub fn open_item_abs_paths(&self, cx: &App) -> Vec<PathBuf> {
self.items(cx)
.filter_map(|item| {
@@ -3449,6 +3527,11 @@ impl Workspace {
OpenOptions {
requesting_window,
open_mode,
+ workspace_matching: if open_mode == OpenMode::NewWindow {
+ WorkspaceMatching::None
+ } else {
+ WorkspaceMatching::default()
+ },
..Default::default()
},
cx,
@@ -9608,7 +9691,7 @@ pub fn open_paths(
let open_task = existing
.update(cx, |multi_workspace, window, cx| {
window.activate_window();
- multi_workspace.activate(target_workspace.clone(), window, cx);
+ multi_workspace.activate(target_workspace.clone(), None, window, cx);
target_workspace.update(cx, |workspace, cx| {
if open_in_dev_container {
workspace.set_open_in_dev_container(true);
@@ -9834,6 +9917,7 @@ pub fn open_remote_project_with_new_connection(
app_state,
window,
None,
+ None,
cx,
)
.await
@@ -9847,6 +9931,7 @@ pub fn open_remote_project_with_existing_connection(
app_state: Arc<AppState>,
window: WindowHandle<MultiWorkspace>,
provisional_project_group_key: Option<ProjectGroupKey>,
+ source_workspace: Option<WeakEntity<Workspace>>,
cx: &mut AsyncApp,
) -> Task<Result<Vec<Option<Box<dyn ItemHandle>>>>> {
cx.spawn(async move |cx| {
@@ -9861,6 +9946,7 @@ pub fn open_remote_project_with_existing_connection(
app_state,
window,
provisional_project_group_key,
+ source_workspace,
cx,
)
.await
@@ -9875,6 +9961,7 @@ async fn open_remote_project_inner(
app_state: Arc<AppState>,
window: WindowHandle<MultiWorkspace>,
provisional_project_group_key: Option<ProjectGroupKey>,
+ source_workspace: Option<WeakEntity<Workspace>>,
cx: &mut AsyncApp,
) -> Result<Vec<Option<Box<dyn ItemHandle>>>> {
let db = cx.update(|cx| WorkspaceDb::global(cx));
@@ -9945,7 +10032,7 @@ async fn open_remote_project_inner(
cx,
);
} else {
- multi_workspace.activate(new_workspace.clone(), window, cx);
+ multi_workspace.activate(new_workspace.clone(), source_workspace, window, cx);
}
new_workspace
})?;
@@ -10033,7 +10120,7 @@ pub fn join_in_room_project(
{
existing_window
.update(cx, |multi_workspace, window, cx| {
- multi_workspace.activate(target_workspace, window, cx);
+ multi_workspace.activate(target_workspace, None, window, cx);
})
.ok();
existing_window
@@ -10973,7 +11060,7 @@ mod tests {
// Activate workspace A
multi_workspace_handle
.update(cx, |mw, window, cx| {
- mw.activate(workspace_a.clone(), window, cx);
+ mw.activate(workspace_a.clone(), None, window, cx);
})
.unwrap();
@@ -11058,7 +11145,7 @@ mod tests {
// Activate workspace A.
multi_workspace_handle
.update(cx, |mw, window, cx| {
- mw.activate(workspace_a.clone(), window, cx);
+ mw.activate(workspace_a.clone(), None, window, cx);
})
.unwrap();
@@ -11110,7 +11197,7 @@ mod tests {
let remove_task = multi_workspace_handle
.update(cx, |mw, window, cx| {
// First switch back to A.
- mw.activate(workspace_a.clone(), window, cx);
+ mw.activate(workspace_a.clone(), None, window, cx);
mw.remove([workspace_b.clone()], |_, _, _| unreachable!(), window, cx)
})
.unwrap();
@@ -14836,7 +14923,7 @@ mod tests {
multi_workspace_handle
.update(cx, |mw, window, cx| {
let workspace = mw.workspaces().next().unwrap().clone();
- mw.activate(workspace, window, cx);
+ mw.activate(workspace, None, window, cx);
})
.unwrap();
@@ -14882,7 +14969,7 @@ mod tests {
multi_workspace_handle
.update(cx, |mw, window, cx| {
let workspace = mw.workspaces().nth(1).unwrap().clone();
- mw.activate(workspace, window, cx);
+ mw.activate(workspace, None, window, cx);
})
.unwrap();
cx.run_until_parked();
@@ -14891,7 +14978,7 @@ mod tests {
multi_workspace_handle
.update(cx, |mw, window, cx| {
let workspace = mw.workspaces().next().unwrap().clone();
- mw.activate(workspace, window, cx);
+ mw.activate(workspace, None, window, cx);
})
.unwrap();
cx.run_until_parked();
@@ -2605,7 +2605,7 @@ fn run_multi_workspace_sidebar_visual_tests(
});
cx.new(|cx| {
let mut multi_workspace = MultiWorkspace::new(workspace1, window, cx);
- multi_workspace.activate(workspace2, window, cx);
+ multi_workspace.activate(workspace2, None, window, cx);
multi_workspace
})
},
@@ -2657,7 +2657,7 @@ fn run_multi_workspace_sidebar_visual_tests(
multi_workspace_window
.update(cx, |multi_workspace, window, cx| {
let workspace = multi_workspace.workspaces().next().unwrap().clone();
- multi_workspace.activate(workspace, window, cx);
+ multi_workspace.activate(workspace, None, window, cx);
})
.context("Failed to activate workspace 1")?;
@@ -3393,7 +3393,7 @@ fn open_sidebar_test_window(
let ws = cx.new(|cx| {
Workspace::new(None, project, app_state.clone(), window, cx)
});
- mw.activate(ws, window, cx);
+ mw.activate(ws, None, window, cx);
}
mw
})
@@ -423,6 +423,38 @@ pub fn initialize_workspace(app_state: Arc<AppState>, cx: &mut App) {
let window_handle = window.window_handle();
let multi_workspace_handle = cx.entity();
+ cx.subscribe_in(
+ &multi_workspace_handle,
+ window,
+ |this, _multi_workspace, event: &workspace::MultiWorkspaceEvent, window, cx| {
+ let workspace::MultiWorkspaceEvent::ActiveWorkspaceChanged { source_workspace } =
+ event
+ else {
+ return;
+ };
+
+ let active_workspace = this.workspace().clone();
+ let source_workspace = source_workspace.clone();
+ active_workspace.update(cx, |workspace, cx| {
+ if let Some(ref source) = source_workspace {
+ if let Some(panel) = workspace.panel::<agent_ui::AgentPanel>(cx) {
+ panel.update(cx, |panel, cx| {
+ panel.initialize_from_source_workspace_if_needed(
+ source.clone(),
+ window,
+ cx,
+ );
+ });
+ }
+ }
+
+ ensure_agent_panel_for_workspace(workspace, source_workspace, window, cx)
+ .detach_and_log_err(cx);
+ });
+ },
+ )
+ .detach();
+
cx.defer(move |cx| {
window_handle
.update(cx, |_, window, cx| {
@@ -735,24 +767,43 @@ fn setup_or_teardown_ai_panel<P: Panel>(
}
}
+fn ensure_agent_panel_for_workspace(
+ workspace: &mut Workspace,
+ source_workspace: Option<WeakEntity<Workspace>>,
+ window: &mut Window,
+ cx: &mut Context<Workspace>,
+) -> Task<anyhow::Result<()>> {
+ let task = setup_or_teardown_ai_panel(workspace, window, cx, move |workspace, cx| {
+ agent_ui::AgentPanel::load(workspace, cx)
+ });
+
+ cx.spawn_in(window, async move |workspace, cx| {
+ task.await?;
+ workspace.update_in(cx, |workspace, window, cx| {
+ if let Some(source_workspace) = source_workspace.clone()
+ && let Some(panel) = workspace.panel::<agent_ui::AgentPanel>(cx)
+ {
+ panel.update(cx, |panel, cx| {
+ panel.initialize_from_source_workspace_if_needed(source_workspace, window, cx);
+ });
+ }
+ })
+ })
+}
+
async fn initialize_agent_panel(
workspace_handle: WeakEntity<Workspace>,
mut cx: AsyncWindowContext,
) -> anyhow::Result<()> {
workspace_handle
.update_in(&mut cx, |workspace, window, cx| {
- setup_or_teardown_ai_panel(workspace, window, cx, move |workspace, cx| {
- agent_ui::AgentPanel::load(workspace, cx)
- })
+ ensure_agent_panel_for_workspace(workspace, None, window, cx)
})?
.await?;
workspace_handle.update_in(&mut cx, |workspace, window, cx| {
cx.observe_global_in::<SettingsStore>(window, move |workspace, window, cx| {
- setup_or_teardown_ai_panel(workspace, window, cx, move |workspace, cx| {
- agent_ui::AgentPanel::load(workspace, cx)
- })
- .detach_and_log_err(cx);
+ ensure_agent_panel_for_workspace(workspace, None, window, cx).detach_and_log_err(cx);
})
.detach();
@@ -1542,7 +1593,7 @@ fn quit(_: &Quit, cx: &mut App) {
for workspace in workspaces {
if let Some(should_close) = window
.update(cx, |multi_workspace, window, cx| {
- multi_workspace.activate(workspace.clone(), window, cx);
+ multi_workspace.activate(workspace.clone(), None, window, cx);
window.activate_window();
workspace.update(cx, |workspace, cx| {
workspace.prepare_to_close(CloseIntent::Quit, window, cx)
@@ -5113,6 +5164,7 @@ mod tests {
"vim",
"window",
"workspace",
+ "worktree_picker",
"zed",
"zed_actions",
"zed_predict_onboarding",
@@ -5657,10 +5709,10 @@ mod tests {
window
.update(cx, |multi_workspace, window, cx| {
- multi_workspace.activate(workspace2.clone(), window, cx);
- multi_workspace.activate(workspace3.clone(), window, cx);
+ multi_workspace.activate(workspace2.clone(), None, window, cx);
+ multi_workspace.activate(workspace3.clone(), None, window, cx);
// Switch back to workspace1 for test setup
- multi_workspace.activate(workspace1.clone(), window, cx);
+ multi_workspace.activate(workspace1.clone(), None, window, cx);
assert_eq!(multi_workspace.workspace(), &workspace1);
})
.unwrap();
@@ -5844,8 +5896,8 @@ mod tests {
window1
.update(cx, |multi_workspace, window, cx| {
- multi_workspace.activate(workspace1_2.clone(), window, cx);
- multi_workspace.activate(workspace1_1.clone(), window, cx);
+ multi_workspace.activate(workspace1_2.clone(), None, window, cx);
+ multi_workspace.activate(workspace1_1.clone(), None, window, cx);
})
.unwrap();
@@ -6164,7 +6216,7 @@ mod tests {
window_a
.update(cx, |multi_workspace, window, cx| {
let workspace = multi_workspace.workspaces().next().unwrap().clone();
- multi_workspace.activate(workspace, window, cx);
+ multi_workspace.activate(workspace, None, window, cx);
})
.unwrap();
@@ -6372,7 +6424,7 @@ mod tests {
})
.expect("workspace_a should exist")
.clone();
- mw.activate(workspace_a, window, cx);
+ mw.activate(workspace_a, None, window, cx);
})
.unwrap();
cx.run_until_parked();
@@ -1,6 +1,7 @@
use gpui::{Action, actions};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
+use std::path::PathBuf;
// If the zed binary doesn't use anything in this crate, it will be optimized away
// and the actions won't initialize. So we just provide an empty initialization function
@@ -251,6 +252,49 @@ pub mod workspace {
);
}
+/// Describes which ref to base a new git worktree on. The worktree is
+/// always created in a detached HEAD state; users can opt into creating
+/// a branch afterwards from the worktree itself.
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case", tag = "kind")]
+pub enum NewWorktreeBranchTarget {
+ /// Create a detached worktree from the current HEAD.
+ #[default]
+ CurrentBranch,
+ /// Create a detached worktree at the tip of an existing branch.
+ ExistingBranch { name: String },
+}
+
+/// Creates a new git worktree and switches the workspace to it.
+/// Dispatched by the unified worktree picker when the user selects a "Create new worktree" entry.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Action)]
+#[action(namespace = git)]
+#[serde(deny_unknown_fields)]
+pub struct CreateWorktree {
+ /// When this is None, Zed will randomly generate a worktree name.
+ pub worktree_name: Option<String>,
+ pub branch_target: NewWorktreeBranchTarget,
+}
+
+/// Switches the workspace to an existing linked worktree.
+/// Dispatched by the unified worktree picker when the user selects an existing worktree.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Action)]
+#[action(namespace = git)]
+#[serde(deny_unknown_fields)]
+pub struct SwitchWorktree {
+ pub path: PathBuf,
+ pub display_name: String,
+}
+
+/// Opens an existing worktree in a new window.
+/// Dispatched by the worktree picker's "Open in New Window" button.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Action)]
+#[action(namespace = git)]
+#[serde(deny_unknown_fields)]
+pub struct OpenWorktreeInNewWindow {
+ pub path: PathBuf,
+}
+
pub mod git {
use gpui::actions;
@@ -762,7 +762,7 @@ You can also set a custom endpoint for Vercel AI Gateway in your settings file:
[Vercel v0](https://v0.app/docs/api/model) is a model for generating full-stack apps, with framework-aware completions for stacks like Next.js and Vercel.
It supports text and image inputs and provides fast streaming responses.
-The v0 models are [OpenAI-compatible models](/#openai-api-compatible), and Vercel appears as a dedicated provider in the panel's settings view.
+The v0 models are [OpenAI-compatible models](#openai-api-compatible), and Vercel appears as a dedicated provider in the panel's settings view.
To start using it with Zed, ensure you have first created a [v0 API key](https://v0.dev/chat/settings/keys).
Once you have it, paste it directly into the Vercel provider section in the panel's settings view.
@@ -4659,7 +4659,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a
```json [settings]
{
"title_bar": {
- "show_branch_icon": false,
+ "show_branch_status_icon": false,
"show_branch_name": true,
"show_project_items": true,
"show_onboarding_banner": true,
@@ -4674,7 +4674,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a
**Options**
-- `show_branch_icon`: Whether to show the branch icon beside branch switcher in the titlebar
+- `show_branch_status_icon`: Whether to show git status indicators on the branch icon in the titlebar
- `show_branch_name`: Whether to show the branch name button in the titlebar
- `show_project_items`: Whether to show the project host and name in the titlebar
- `show_onboarding_banner`: Whether to show onboarding banners in the titlebar
@@ -118,7 +118,7 @@ To disable this behavior use:
```json [settings]
// Control which items are shown/hidden in the title bar
"title_bar": {
- "show_branch_icon": false, // Show/hide branch icon beside branch switcher
+ "show_branch_status_icon": false, // Show git status on branch icon
"show_branch_name": true, // Show/hide branch name
"show_project_items": true, // Show/hide project host and name
"show_onboarding_banner": true, // Show/hide onboarding banners