Move the worktree picker to the title bar + make it always visible (#54183)

This PR makes Zed only have one worktree picker, as opposed to a flavor
of it in the title bar and another in the agent panel. It then moves it
to the title bar, making it always present, so that its trigger is
separate from the branch picker (which now contains only two views:
branches and stashes). For the worktree picker, I'm mostly favoring the
behavior we've introduced in the agent-panel-flavored version.

It also updates the title bar settings migration to use the JSON
`migrate_settings` helper instead of a shallow Tree-sitter rewrite, so
old `show_branch_icon = true` values are promoted to
`show_branch_status_icon = true` across root, platform, release-channel,
and profile settings scopes.

- [x] Move worktree creation logic to the `git_ui` crate to make this
more generic and less agent-specific
- [x] Double-check the remote use case and ensure nothing broke there
- [x] Improve the UX for the detached HEAD state; better invite people
to create a branch
- [x] Migrate `show_branch_icon = true` to `show_branch_status_icon =
true` across nested settings scopes

Suggested .rules additions

When migrating renamed settings keys that can appear in platform
overrides, release-channel overrides, or profiles, prefer the JSON
`migrations::migrate_settings` helper over shallow Tree-sitter key
rewrites unless tests explicitly cover every nested scope that can
contain the key.

Release Notes:

- Improved migration of the title bar branch status icon setting.

---------

Co-authored-by: Nathan Sobo <nathan@zed.dev>
Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-04-20 09:29:51 +00:00
1 parent 2737fd83d2
commit cd387ee5a7
40 files changed
+2926 -4249

No files matched your search

Generated
+2
View File
@@ -353,6 +353,7 @@ dependencies = [
"futures 0.3.32",
"fuzzy",
"git",
"git_ui",
"gpui",
"gpui_tokio",
"heapless",
@@ -7318,6 +7319,7 @@ dependencies = [
"db",
"editor",
"file_icons",
"fs",
"futures 0.3.32",
"fuzzy",
"git",
+10 -13
View File
@@ -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",
@@ -1386,15 +1386,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
@@ -1530,9 +1521,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",
},
},
]
+10 -13
View File
@@ -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",
@@ -691,6 +690,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",
@@ -1476,15 +1476,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
@@ -1584,9 +1575,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",
},
},
{
+10 -13
View File
@@ -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",
@@ -1401,15 +1401,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
@@ -1508,9 +1499,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",
},
},
{
+2 -2
View File
@@ -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.
+1
View File
@@ -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
File diff suppressed because it is too large. Load diff
+3 -48
View File
@@ -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.
@@ -338,46 +333,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 {
+6 -1
View File
@@ -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) =
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -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;
+2
View File
@@ -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
+10 -2
View File
@@ -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();
+9 -151
View File
@@ -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);
});
+61 -1
View File
@@ -25,7 +25,7 @@ use project::git_store::Repository;
use project_diff::ProjectDiff;
use time::OffsetDateTime;
use ui::prelude::*;
use workspace::{ModalView, Workspace, notifications::DetachAndPromptErr};
use workspace::{ModalView, OpenMode, Workspace, notifications::DetachAndPromptErr};
use zed_actions;
use crate::{commit_view::CommitView, git_panel::GitPanel, text_diff_view::TextDiffView};
@@ -48,7 +48,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;
@@ -68,6 +70,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;
+10 -2
View File
@@ -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;
}
File renamed without changes.
File diff suppressed because it is too large. Load diff
+809
View File
@@ -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(())
}
+6
View File
@@ -334,3 +334,9 @@ pub(crate) mod m_2026_04_10 {
pub(crate) use settings::rename_web_search_to_search_web;
}
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
View File
@@ -250,6 +250,9 @@ pub fn migrate_settings(text: &str) -> Result<Option<String>> {
MigrationType::Json(migrations::m_2026_03_30::make_play_sound_when_agent_done_an_enum),
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_17::promote_show_branch_icon_true_to_show_branch_status_icon,
),
];
run_migrations(text, migrations)
}
@@ -5026,4 +5029,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,
+1 -1
View File
@@ -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();
+4 -2
View File
@@ -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
+5 -5
View File
@@ -3654,22 +3654,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,
+11 -10
View File
@@ -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);
@@ -1978,6 +1978,7 @@ impl Sidebar {
.update(cx, |multi_workspace, cx| {
multi_workspace.activate(
activate_workspace.clone(),
None,
window,
cx,
);
@@ -2464,7 +2465,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);
}
@@ -2504,7 +2505,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()
@@ -3526,7 +3527,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);
});
}
}
@@ -3722,7 +3723,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 {
@@ -3741,7 +3742,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);
});
}
@@ -3759,7 +3760,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);
});
}
}
@@ -3816,7 +3817,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 {
@@ -4063,7 +4064,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| {
@@ -4190,7 +4191,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 {
+26 -24
View File
@@ -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!(
+123 -46
View File
@@ -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(),
)
}
+2 -2
View File
@@ -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(),
+77 -12
View File
@@ -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,
);
@@ -1188,6 +1223,7 @@ impl MultiWorkspace {
app_state,
window_handle,
provisional_project_group_key,
source_workspace,
cx,
)
.await?;
@@ -1216,10 +1252,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));
}
@@ -1264,7 +1323,12 @@ impl MultiWorkspace {
cx,
)
.inspect(|workspace| {
multi_workspace.activate(workspace.clone(), window, cx);
multi_workspace.activate(
workspace.clone(),
source_workspace.clone(),
window,
cx,
);
})
})
.ok()
@@ -1327,6 +1391,7 @@ impl MultiWorkspace {
pub fn activate(
&mut self,
workspace: Entity<Workspace>,
source_workspace: Option<WeakEntity<Workspace>>,
window: &mut Window,
cx: &mut Context<Self>,
) {
@@ -1359,7 +1424,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();
@@ -1644,7 +1709,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
}
@@ -1675,7 +1740,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);
@@ -1800,12 +1865,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(),
+4 -4
View File
@@ -2663,7 +2663,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
});
@@ -5066,7 +5066,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();
@@ -5095,7 +5095,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();
@@ -5171,7 +5171,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();
+97 -10
View File
@@ -1111,6 +1111,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>,
@@ -1271,6 +1288,7 @@ pub enum Event {
ModalOpened,
Activate,
PanelAdded(AnyView),
WorktreeCreationChanged,
}
#[derive(Debug, Clone)]
@@ -1379,6 +1397,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 {}
@@ -1807,6 +1826,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,
}
@@ -1949,7 +1969,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);
@@ -2180,6 +2200,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| {
@@ -3451,6 +3529,11 @@ impl Workspace {
OpenOptions {
requesting_window,
open_mode,
workspace_matching: if open_mode == OpenMode::NewWindow {
WorkspaceMatching::None
} else {
WorkspaceMatching::default()
},
..Default::default()
},
cx,
@@ -9636,7 +9719,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);
@@ -9862,6 +9945,7 @@ pub fn open_remote_project_with_new_connection(
app_state,
window,
None,
None,
cx,
)
.await
@@ -9875,6 +9959,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| {
@@ -9889,6 +9974,7 @@ pub fn open_remote_project_with_existing_connection(
app_state,
window,
provisional_project_group_key,
source_workspace,
cx,
)
.await
@@ -9903,6 +9989,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));
@@ -9973,7 +10060,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
})?;
@@ -10061,7 +10148,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
@@ -11001,7 +11088,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();
@@ -11086,7 +11173,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();
@@ -11138,7 +11225,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();
@@ -14864,7 +14951,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();
@@ -14910,7 +14997,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();
@@ -14919,7 +15006,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();
+3 -3
View File
@@ -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
})
+67 -15
View File
@@ -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();
@@ -1558,7 +1609,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)
@@ -5129,6 +5180,7 @@ mod tests {
"vim",
"window",
"workspace",
"worktree_picker",
"zed",
"zed_actions",
"zed_predict_onboarding",
@@ -5673,10 +5725,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();
@@ -5860,8 +5912,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();
@@ -6180,7 +6232,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();
@@ -6388,7 +6440,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();
+44
View File
@@ -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;
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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