Move threads sidebar into agent panel (#51241)

* [x] Put back persistence of sidebar open state
* [x] when agent panel is docked right, put sidebar on the right side
* [x] remove stale entries from `SidebarsByWindow`

Release Notes:

- N/A

---------

Co-authored-by: Eric Holk <eric@zed.dev>
Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>
Co-authored-by: Anthony Eid <hello@anthonyeid.me>
This commit is contained in:
Max Brunsfeld 2026-03-10 23:45:55 -07:00 committed by GitHub
parent f0e301cea0
commit b5666319b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 524 additions and 1019 deletions

29
Cargo.lock generated
View file

@ -15807,33 +15807,6 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "sidebar"
version = "0.1.0"
dependencies = [
"acp_thread",
"agent",
"agent-client-protocol",
"agent_ui",
"assistant_text_thread",
"chrono",
"editor",
"feature_flags",
"fs",
"gpui",
"language_model",
"menu",
"project",
"recent_projects",
"serde_json",
"settings",
"theme",
"ui",
"util",
"workspace",
"zed_actions",
]
[[package]]
name = "signal-hook"
version = "0.3.18"
@ -17660,7 +17633,6 @@ dependencies = [
"client",
"cloud_api_types",
"db",
"feature_flags",
"git_ui",
"gpui",
"notifications",
@ -21887,7 +21859,6 @@ dependencies = [
"settings_profile_selector",
"settings_ui",
"shellexpand 2.1.2",
"sidebar",
"smol",
"snippet_provider",
"snippets_ui",

View file

@ -173,7 +173,6 @@ members = [
"crates/settings_profile_selector",
"crates/settings_ui",
"crates/shell_command_parser",
"crates/sidebar",
"crates/snippet",
"crates/snippet_provider",
"crates/snippets_ui",
@ -412,7 +411,6 @@ rules_library = { path = "crates/rules_library" }
scheduler = { path = "crates/scheduler" }
search = { path = "crates/search" }
session = { path = "crates/session" }
sidebar = { path = "crates/sidebar" }
settings = { path = "crates/settings" }
settings_content = { path = "crates/settings_content" }
settings_json = { path = "crates/settings_json" }
@ -907,7 +905,6 @@ refineable = { codegen-units = 1 }
release_channel = { codegen-units = 1 }
reqwest_client = { codegen-units = 1 }
session = { codegen-units = 1 }
sidebar = { codegen-units = 1 }
snippet = { codegen-units = 1 }
snippets_ui = { codegen-units = 1 }
story = { codegen-units = 1 }

View file

@ -132,7 +132,6 @@ languages = { workspace = true, features = ["test-support"] }
language_model = { workspace = true, "features" = ["test-support"] }
pretty_assertions.workspace = true
project = { workspace = true, features = ["test-support"] }
semver.workspace = true
reqwest_client.workspace = true

View file

@ -65,9 +65,10 @@ use extension_host::ExtensionStore;
use fs::Fs;
use git::repository::validate_worktree_directory;
use gpui::{
Action, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem, Corner,
DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels,
Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between,
Action, Animation, AnimationExt, AnyElement, AnyView, App, AsyncWindowContext, ClipboardItem,
Corner, DismissEvent, DragMoveEvent, Entity, EventEmitter, ExternalPaths, FocusHandle,
Focusable, KeyContext, MouseButton, Pixels, Subscription, Task, UpdateGlobal, WeakEntity,
deferred, prelude::*, pulsating_between,
};
use language::LanguageRegistry;
use language_model::{ConfigurationError, LanguageModelRegistry};
@ -79,15 +80,17 @@ use search::{BufferSearchBar, buffer_search};
use settings::{Settings, update_settings_file};
use theme::ThemeSettings;
use ui::{
Button, ButtonLike, Callout, ContextMenu, ContextMenuEntry, DocumentationSide, KeyBinding,
PopoverMenu, PopoverMenuHandle, SpinnerLabel, Tab, TintColor, Tooltip, prelude::*,
Button, ButtonLike, Callout, ContextMenu, ContextMenuEntry, DocumentationSide, Indicator,
KeyBinding, PopoverMenu, PopoverMenuHandle, SpinnerLabel, Tab, TintColor, Tooltip, prelude::*,
utils::WithRemSize,
};
use util::ResultExt as _;
use workspace::{
CollaboratorId, DraggedSelection, DraggedTab, ToggleZoom, ToolbarItemView, Workspace,
WorkspaceId,
CollaboratorId, DraggedSelection, DraggedSidebar, DraggedTab, FocusWorkspaceSidebar,
MultiWorkspace, SIDEBAR_RESIZE_HANDLE_SIZE, ToggleWorkspaceSidebar, ToggleZoom,
ToolbarItemView, Workspace, WorkspaceId,
dock::{DockPosition, Panel, PanelEvent},
multi_workspace_enabled,
};
use zed_actions::{
DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize,
@ -99,6 +102,55 @@ const AGENT_PANEL_KEY: &str = "agent_panel";
const RECENTLY_UPDATED_MENU_LIMIT: usize = 6;
const DEFAULT_THREAD_TITLE: &str = "New Thread";
#[derive(Default)]
struct SidebarsByWindow(
collections::HashMap<gpui::WindowId, gpui::WeakEntity<crate::sidebar::Sidebar>>,
);
impl gpui::Global for SidebarsByWindow {}
pub(crate) fn sidebar_is_open(window: &Window, cx: &App) -> bool {
if !multi_workspace_enabled(cx) {
return false;
}
let window_id = window.window_handle().window_id();
cx.try_global::<SidebarsByWindow>()
.and_then(|sidebars| sidebars.0.get(&window_id)?.upgrade())
.is_some_and(|sidebar| sidebar.read(cx).is_open())
}
fn find_or_create_sidebar_for_window(
window: &mut Window,
cx: &mut App,
) -> Option<Entity<crate::sidebar::Sidebar>> {
let window_id = window.window_handle().window_id();
let multi_workspace = window.root::<MultiWorkspace>().flatten()?;
if !cx.has_global::<SidebarsByWindow>() {
cx.set_global(SidebarsByWindow::default());
}
cx.global_mut::<SidebarsByWindow>()
.0
.retain(|_, weak| weak.upgrade().is_some());
let existing = cx
.global::<SidebarsByWindow>()
.0
.get(&window_id)
.and_then(|weak| weak.upgrade());
if let Some(sidebar) = existing {
return Some(sidebar);
}
let sidebar = cx.new(|cx| crate::sidebar::Sidebar::new(multi_workspace, window, cx));
cx.global_mut::<SidebarsByWindow>()
.0
.insert(window_id, sidebar.downgrade());
Some(sidebar)
}
fn read_serialized_panel(workspace_id: workspace::WorkspaceId) -> Option<SerializedAgentPanel> {
let scope = KEY_VALUE_STORE.scoped(AGENT_PANEL_KEY);
let key = i64::from(workspace_id).to_string();
@ -424,6 +476,30 @@ pub fn init(cx: &mut App) {
panel.set_start_thread_in(action, cx);
});
}
})
.register_action(|workspace, _: &ToggleWorkspaceSidebar, window, cx| {
if !multi_workspace_enabled(cx) {
return;
}
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
if let Some(sidebar) = panel.read(cx).sidebar.clone() {
sidebar.update(cx, |sidebar, cx| {
sidebar.toggle(window, cx);
});
}
}
})
.register_action(|workspace, _: &FocusWorkspaceSidebar, window, cx| {
if !multi_workspace_enabled(cx) {
return;
}
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
if let Some(sidebar) = panel.read(cx).sidebar.clone() {
sidebar.update(cx, |sidebar, cx| {
sidebar.focus_or_unfocus(workspace, window, cx);
});
}
}
});
},
)
@ -820,6 +896,7 @@ pub struct AgentPanel {
last_configuration_error_telemetry: Option<String>,
on_boarding_upsell_dismissed: AtomicBool,
_active_view_observation: Option<Subscription>,
pub(crate) sidebar: Option<Entity<crate::sidebar::Sidebar>>,
}
impl AgentPanel {
@ -991,7 +1068,6 @@ impl AgentPanel {
let client = workspace.client().clone();
let workspace_id = workspace.database_id();
let workspace = workspace.weak_handle();
let context_server_registry =
cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
@ -1149,10 +1225,17 @@ impl AgentPanel {
last_configuration_error_telemetry: None,
on_boarding_upsell_dismissed: AtomicBool::new(OnboardingUpsell::dismissed()),
_active_view_observation: None,
sidebar: None,
};
// Initial sync of agent servers from extensions
panel.sync_agent_servers_from_extensions(cx);
cx.defer_in(window, move |this, window, cx| {
this.sidebar = find_or_create_sidebar_for_window(window, cx);
cx.notify();
});
panel
}
@ -3526,9 +3609,109 @@ impl AgentPanel {
})
}
fn sidebar_info(&self, cx: &App) -> Option<(AnyView, Pixels, bool)> {
if !multi_workspace_enabled(cx) {
return None;
}
let sidebar = self.sidebar.as_ref()?;
let is_open = sidebar.read(cx).is_open();
let width = sidebar.read(cx).width(cx);
let view: AnyView = sidebar.clone().into();
Some((view, width, is_open))
}
fn render_sidebar_toggle(&self, cx: &Context<Self>) -> Option<AnyElement> {
if !multi_workspace_enabled(cx) {
return None;
}
let sidebar = self.sidebar.as_ref()?;
let sidebar_read = sidebar.read(cx);
if sidebar_read.is_open() {
return None;
}
let has_notifications = sidebar_read.has_notifications(cx);
Some(
IconButton::new("toggle-workspace-sidebar", IconName::WorkspaceNavClosed)
.icon_size(IconSize::Small)
.when(has_notifications, |button| {
button
.indicator(Indicator::dot().color(Color::Accent))
.indicator_border_color(Some(cx.theme().colors().tab_bar_background))
})
.tooltip(move |_, cx| {
Tooltip::for_action("Open Threads Sidebar", &ToggleWorkspaceSidebar, cx)
})
.on_click(|_, window, cx| {
window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx);
})
.into_any_element(),
)
}
fn render_sidebar(&self, cx: &Context<Self>) -> Option<AnyElement> {
let (sidebar_view, sidebar_width, is_open) = self.sidebar_info(cx)?;
if !is_open {
return None;
}
let docked_right = agent_panel_dock_position(cx) == DockPosition::Right;
let sidebar = self.sidebar.as_ref()?.downgrade();
let resize_handle = deferred(
div()
.id("sidebar-resize-handle")
.absolute()
.when(docked_right, |this| {
this.left(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.)
})
.when(!docked_right, |this| {
this.right(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.)
})
.top(px(0.))
.h_full()
.w(SIDEBAR_RESIZE_HANDLE_SIZE)
.cursor_col_resize()
.on_drag(DraggedSidebar, |dragged, _, _, cx| {
cx.stop_propagation();
cx.new(|_| dragged.clone())
})
.on_mouse_down(MouseButton::Left, |_, _, cx| {
cx.stop_propagation();
})
.on_mouse_up(MouseButton::Left, move |event, _, cx| {
if event.click_count == 2 {
sidebar
.update(cx, |sidebar, cx| {
sidebar.set_width(None, cx);
})
.ok();
cx.stop_propagation();
}
})
.occlude(),
);
Some(
div()
.id("sidebar-container")
.relative()
.h_full()
.w(sidebar_width)
.flex_shrink_0()
.when(docked_right, |this| this.border_l_1())
.when(!docked_right, |this| this.border_r_1())
.border_color(cx.theme().colors().border)
.child(sidebar_view)
.child(resize_handle)
.into_any_element(),
)
}
fn render_toolbar(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let agent_server_store = self.project.read(cx).agent_server_store().clone();
let focus_handle = self.focus_handle(cx);
let docked_right = agent_panel_dock_position(cx) == DockPosition::Right;
let (selected_agent_custom_icon, selected_agent_label) =
if let AgentType::Custom { name, .. } = &self.selected_agent {
@ -3991,6 +4174,9 @@ impl AgentPanel {
.size_full()
.gap(DynamicSpacing::Base04.rems(cx))
.pl(DynamicSpacing::Base04.rems(cx))
.when(!docked_right, |this| {
this.children(self.render_sidebar_toggle(cx))
})
.child(agent_selector_menu)
.child(self.render_start_thread_in_selector(cx)),
)
@ -4007,7 +4193,10 @@ impl AgentPanel {
cx,
))
})
.child(self.render_panel_options_menu(window, cx)),
.child(self.render_panel_options_menu(window, cx))
.when(docked_right, |this| {
this.children(self.render_sidebar_toggle(cx))
}),
)
.into_any_element()
} else {
@ -4045,6 +4234,9 @@ impl AgentPanel {
.size_full()
.gap(DynamicSpacing::Base04.rems(cx))
.pl(DynamicSpacing::Base04.rems(cx))
.when(!docked_right, |this| {
this.children(self.render_sidebar_toggle(cx))
})
.child(match &self.active_view {
ActiveView::History { .. } | ActiveView::Configuration => {
self.render_toolbar_back_button(cx).into_any_element()
@ -4067,7 +4259,10 @@ impl AgentPanel {
cx,
))
})
.child(self.render_panel_options_menu(window, cx)),
.child(self.render_panel_options_menu(window, cx))
.when(docked_right, |this| {
this.children(self.render_sidebar_toggle(cx))
}),
)
.into_any_element()
}
@ -4607,14 +4802,44 @@ impl Render for AgentPanel {
})
.children(self.render_trial_end_upsell(window, cx));
let sidebar = self.render_sidebar(cx);
let has_sidebar = sidebar.is_some();
let docked_right = agent_panel_dock_position(cx) == DockPosition::Right;
let panel = h_flex()
.size_full()
.when(has_sidebar, |this| {
this.on_drag_move(cx.listener(
move |this, e: &DragMoveEvent<DraggedSidebar>, _window, cx| {
if let Some(sidebar) = &this.sidebar {
let width = if docked_right {
e.bounds.right() - e.event.position.x
} else {
e.event.position.x
};
sidebar.update(cx, |sidebar, cx| {
sidebar.set_width(Some(width), cx);
});
}
},
))
})
.map(|this| {
if docked_right {
this.child(content).children(sidebar)
} else {
this.children(sidebar).child(content)
}
});
match self.active_view.which_font_size_used() {
WhichFontSize::AgentFont => {
WithRemSize::new(ThemeSettings::get_global(cx).agent_ui_font_size(cx))
.size_full()
.child(content)
.child(panel)
.into_any()
}
_ => content.into_any(),
_ => panel.into_any(),
}
}
}

View file

@ -23,6 +23,7 @@ mod mode_selector;
mod model_selector;
mod model_selector_popover;
mod profile_selector;
pub mod sidebar;
mod slash_command;
mod slash_command_picker;
mod terminal_codegen;

View file

@ -2340,7 +2340,7 @@ impl ConnectionView {
}
if let Some(multi_workspace) = window.root::<MultiWorkspace>().flatten() {
multi_workspace.read(cx).is_sidebar_open()
crate::agent_panel::sidebar_is_open(window, cx)
|| self.agent_panel_visible(&multi_workspace, cx)
} else {
self.workspace

View file

@ -1,33 +1,32 @@
use crate::{AgentPanel, AgentPanelEvent, NewThread};
use acp_thread::ThreadStatus;
use agent::ThreadStore;
use agent_client_protocol as acp;
use agent_ui::{AgentPanel, AgentPanelEvent, NewThread};
use agent_settings::AgentSettings;
use chrono::Utc;
use db::kvp::KEY_VALUE_STORE;
use editor::{Editor, EditorElement, EditorStyle};
use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _};
use gpui::{
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, FontStyle, ListState,
Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, FontStyle, ListState,
Pixels, Render, SharedString, TextStyle, WeakEntity, Window, actions, list, prelude::*, px,
relative, rems,
};
use menu::{Cancel, Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
use project::Event as ProjectEvent;
use recent_projects::RecentProjects;
use settings::Settings;
use std::collections::{HashMap, HashSet};
use std::mem;
use theme::{ActiveTheme, ThemeSettings};
use ui::utils::TRAFFIC_LIGHT_PADDING;
use ui::{
AgentThreadStatus, ButtonStyle, HighlightedLabel, IconButtonShape, KeyBinding, ListItem,
PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, TintColor, Tooltip, WithScrollbar, prelude::*,
AgentThreadStatus, ButtonStyle, HighlightedLabel, IconButtonShape, ListItem, Tab, ThreadItem,
Tooltip, WithScrollbar, prelude::*,
};
use util::ResultExt as _;
use util::path_list::PathList;
use workspace::{
FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, Sidebar as WorkspaceSidebar,
SidebarEvent, ToggleWorkspaceSidebar, Workspace,
MultiWorkspace, MultiWorkspaceEvent, ToggleWorkspaceSidebar, Workspace, multi_workspace_enabled,
};
use zed_actions::OpenRecent;
use zed_actions::editor::{MoveDown, MoveUp};
actions!(
@ -44,6 +43,27 @@ const DEFAULT_WIDTH: Pixels = px(320.0);
const MIN_WIDTH: Pixels = px(200.0);
const MAX_WIDTH: Pixels = px(800.0);
const DEFAULT_THREADS_SHOWN: usize = 5;
const SIDEBAR_STATE_KEY: &str = "sidebar_state";
fn read_sidebar_open_state(multi_workspace_id: u64) -> bool {
KEY_VALUE_STORE
.scoped(SIDEBAR_STATE_KEY)
.read(&multi_workspace_id.to_string())
.log_err()
.flatten()
.and_then(|json| serde_json::from_str::<bool>(&json).ok())
.unwrap_or(false)
}
async fn save_sidebar_open_state(multi_workspace_id: u64, is_open: bool) {
if let Ok(json) = serde_json::to_string(&is_open) {
KEY_VALUE_STORE
.scoped(SIDEBAR_STATE_KEY)
.write(multi_workspace_id.to_string(), json)
.await
.log_err();
}
}
#[derive(Clone, Debug)]
struct ActiveThreadInfo {
@ -173,6 +193,8 @@ fn workspace_path_list_and_label(
pub struct Sidebar {
multi_workspace: WeakEntity<MultiWorkspace>,
persistence_key: Option<u64>,
is_open: bool,
width: Pixels,
focus_handle: FocusHandle,
filter_editor: Entity<Editor>,
@ -186,11 +208,8 @@ pub struct Sidebar {
active_entry_index: Option<usize>,
collapsed_groups: HashSet<PathList>,
expanded_groups: HashMap<PathList, usize>,
recent_projects_popover_handle: PopoverMenuHandle<RecentProjects>,
}
impl EventEmitter<SidebarEvent> for Sidebar {}
impl Sidebar {
pub fn new(
multi_workspace: Entity<MultiWorkspace>,
@ -212,7 +231,6 @@ impl Sidebar {
window,
|this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event {
MultiWorkspaceEvent::ActiveWorkspaceChanged => {
this.focused_thread = None;
this.update_entries(cx);
}
MultiWorkspaceEvent::WorkspaceAdded(workspace) => {
@ -270,8 +288,15 @@ impl Sidebar {
this.update_entries(cx);
});
let persistence_key = multi_workspace.read(cx).database_id().map(|id| id.0);
let is_open = persistence_key
.map(read_sidebar_open_state)
.unwrap_or(false);
Self {
multi_workspace: multi_workspace.downgrade(),
persistence_key,
is_open,
width: DEFAULT_WIDTH,
focus_handle,
filter_editor,
@ -282,7 +307,6 @@ impl Sidebar {
active_entry_index: None,
collapsed_groups: HashSet::new(),
expanded_groups: HashMap::new(),
recent_projects_popover_handle: PopoverMenuHandle::default(),
}
}
@ -334,31 +358,10 @@ impl Sidebar {
cx.subscribe_in(
agent_panel,
window,
|this, agent_panel, event: &AgentPanelEvent, _window, cx| match event {
AgentPanelEvent::ActiveViewChanged => {
match agent_panel.read(cx).active_connection_view() {
Some(thread) => {
if let Some(session_id) = thread.read(cx).parent_id(cx) {
this.focused_thread = Some(session_id);
}
}
None => {
this.focused_thread = None;
}
}
this.update_entries(cx);
}
AgentPanelEvent::ThreadFocused => {
let new_focused = agent_panel
.read(cx)
.active_connection_view()
.and_then(|thread| thread.read(cx).parent_id(cx));
if new_focused.is_some() && new_focused != this.focused_thread {
this.focused_thread = new_focused;
this.update_entries(cx);
}
}
AgentPanelEvent::BackgroundThreadChanged => {
|this, _agent_panel, event: &AgentPanelEvent, _window, cx| match event {
AgentPanelEvent::ActiveViewChanged
| AgentPanelEvent::ThreadFocused
| AgentPanelEvent::BackgroundThreadChanged => {
this.update_entries(cx);
}
},
@ -419,6 +422,12 @@ impl Sidebar {
let workspaces = mw.workspaces().to_vec();
let active_workspace = mw.workspaces().get(mw.active_workspace_index()).cloned();
self.focused_thread = active_workspace
.as_ref()
.and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
.and_then(|panel| panel.read(cx).active_connection_view().cloned())
.and_then(|cv| cv.read(cx).parent_id(cx));
let thread_store = ThreadStore::try_global(cx);
let query = self.filter_editor.read(cx).text(cx);
@ -657,7 +666,7 @@ impl Sidebar {
let Some(multi_workspace) = self.multi_workspace.upgrade() else {
return;
};
if !multi_workspace.read(cx).multi_workspace_enabled(cx) {
if !multi_workspace_enabled(cx) {
return;
}
@ -885,8 +894,6 @@ impl Sidebar {
return;
};
self.focused_thread = None;
multi_workspace.update(cx, |multi_workspace, cx| {
multi_workspace.activate(workspace.clone(), cx);
});
@ -1173,48 +1180,6 @@ impl Sidebar {
.into_any_element()
}
fn render_recent_projects_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
let workspace = self
.multi_workspace
.upgrade()
.map(|mw| mw.read(cx).workspace().downgrade());
let focus_handle = workspace
.as_ref()
.and_then(|ws| ws.upgrade())
.map(|w| w.read(cx).focus_handle(cx))
.unwrap_or_else(|| cx.focus_handle());
let popover_handle = self.recent_projects_popover_handle.clone();
PopoverMenu::new("sidebar-recent-projects-menu")
.with_handle(popover_handle)
.menu(move |window, cx| {
workspace.as_ref().map(|ws| {
RecentProjects::popover(ws.clone(), false, focus_handle.clone(), window, cx)
})
})
.trigger_with_tooltip(
IconButton::new("open-project", IconName::OpenFolder)
.icon_size(IconSize::Small)
.selected_style(ButtonStyle::Tinted(TintColor::Accent)),
|_window, cx| {
Tooltip::for_action(
"Recent Projects",
&OpenRecent {
create_new_window: false,
},
cx,
)
},
)
.anchor(gpui::Corner::TopLeft)
.offset(gpui::Point {
x: px(0.0),
y: px(2.0),
})
}
fn render_filter_input(&self, cx: &mut Context<Self>) -> impl IntoElement {
let settings = ThemeSettings::get_global(cx);
let text_style = TextStyle {
@ -1343,27 +1308,67 @@ impl Sidebar {
}
}
impl WorkspaceSidebar for Sidebar {
fn width(&self, _cx: &App) -> Pixels {
impl Sidebar {
pub fn is_open(&self) -> bool {
self.is_open
}
pub fn set_open(&mut self, open: bool, cx: &mut Context<Self>) {
if self.is_open == open {
return;
}
self.is_open = open;
cx.notify();
if let Some(key) = self.persistence_key {
let is_open = self.is_open;
cx.background_spawn(async move {
save_sidebar_open_state(key, is_open).await;
})
.detach();
}
}
pub fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let new_state = !self.is_open;
self.set_open(new_state, cx);
if new_state {
cx.focus_self(window);
}
}
pub fn focus_or_unfocus(
&mut self,
workspace: &mut Workspace,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.is_open {
let sidebar_is_focused = self.focus_handle(cx).contains_focused(window, cx);
if sidebar_is_focused {
let active_pane = workspace.active_pane().clone();
let pane_focus = active_pane.read(cx).focus_handle(cx);
window.focus(&pane_focus, cx);
} else {
cx.focus_self(window);
}
} else {
self.set_open(true, cx);
cx.focus_self(window);
}
}
pub fn width(&self, _cx: &App) -> Pixels {
self.width
}
fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
pub fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH);
cx.notify();
}
fn has_notifications(&self, _cx: &App) -> bool {
pub fn has_notifications(&self, _cx: &App) -> bool {
!self.contents.notified_threads.is_empty()
}
fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App) {
self.recent_projects_popover_handle.toggle(window, cx);
}
fn is_recent_projects_popover_deployed(&self) -> bool {
self.recent_projects_popover_handle.is_deployed()
}
}
impl Focusable for Sidebar {
@ -1374,18 +1379,9 @@ impl Focusable for Sidebar {
impl Render for Sidebar {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let titlebar_height = ui::utils::platform_title_bar_height(window);
let ui_font = theme::setup_ui_font(window, cx);
let is_focused = self.focus_handle.is_focused(window)
|| self.filter_editor.focus_handle(cx).is_focused(window);
let has_query = self.has_filter_query(cx);
let focus_tooltip_label = if is_focused {
"Focus Workspace"
} else {
"Focus Sidebar"
};
v_flex()
.id("workspace-sidebar")
.key_context("WorkspaceSidebar")
@ -1401,69 +1397,26 @@ impl Render for Sidebar {
.on_action(cx.listener(Self::collapse_selected_entry))
.on_action(cx.listener(Self::cancel))
.font(ui_font)
.h_full()
.w(self.width)
.size_full()
.bg(cx.theme().colors().surface_background)
.border_r_1()
.border_color(cx.theme().colors().border)
.child(
h_flex()
.flex_none()
.h(titlebar_height)
.w_full()
.mt_px()
.pb_px()
.pr_1()
.when_else(
cfg!(target_os = "macos") && !window.is_fullscreen(),
|this| this.pl(px(TRAFFIC_LIGHT_PADDING)),
|this| this.pl_2(),
)
.justify_between()
.border_b_1()
.border_color(cx.theme().colors().border)
.child({
let focus_handle_toggle = self.focus_handle.clone();
let focus_handle_focus = self.focus_handle.clone();
IconButton::new("close-sidebar", IconName::WorkspaceNavOpen)
.icon_size(IconSize::Small)
.tooltip(Tooltip::element(move |_, cx| {
v_flex()
.gap_1()
.child(
h_flex()
.gap_2()
.justify_between()
.child(Label::new("Close Sidebar"))
.child(KeyBinding::for_action_in(
&ToggleWorkspaceSidebar,
&focus_handle_toggle,
cx,
)),
)
.child(
h_flex()
.pt_1()
.gap_2()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.justify_between()
.child(Label::new(focus_tooltip_label))
.child(KeyBinding::for_action_in(
&FocusWorkspaceSidebar,
&focus_handle_focus,
cx,
)),
)
.into_any_element()
}))
.on_click(cx.listener(|_this, _, _window, cx| {
cx.emit(SidebarEvent::Close);
}))
})
.child(self.render_recent_projects_button(cx)),
)
.child(
.child({
let docked_right =
AgentSettings::get_global(cx).dock == settings::DockPosition::Right;
let render_close_button = || {
IconButton::new("sidebar-close-toggle", IconName::WorkspaceNavOpen)
.icon_size(IconSize::Small)
.tooltip(move |_, cx| {
Tooltip::for_action(
"Close Threads Sidebar",
&ToggleWorkspaceSidebar,
cx,
)
})
.on_click(|_, window, cx| {
window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx);
})
};
h_flex()
.flex_none()
.px_2p5()
@ -1471,6 +1424,7 @@ impl Render for Sidebar {
.gap_2()
.border_b_1()
.border_color(cx.theme().colors().border)
.when(!docked_right, |this| this.child(render_close_button()))
.child(
Icon::new(IconName::MagnifyingGlass)
.size(IconSize::Small)
@ -1487,8 +1441,9 @@ impl Render for Sidebar {
this.update_entries(cx);
})),
)
}),
)
})
.when(docked_right, |this| this.child(render_close_button()))
})
.child(
v_flex()
.flex_1()
@ -1509,26 +1464,24 @@ impl Render for Sidebar {
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{active_session_id, open_thread_with_connection, send_message};
use acp_thread::StubAgentConnection;
use agent::ThreadStore;
use agent_ui::test_support::{active_session_id, open_thread_with_connection, send_message};
use assistant_text_thread::TextThreadStore;
use chrono::DateTime;
use feature_flags::FeatureFlagAppExt as _;
use fs::FakeFs;
use gpui::TestAppContext;
use settings::SettingsStore;
use std::sync::Arc;
use util::path_list::PathList;
fn init_test(cx: &mut TestAppContext) {
crate::test_support::init_test(cx);
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
theme::init(theme::LoadThemes::JustBase, cx);
editor::init(cx);
cx.update_flags(false, vec!["agent-v2".into()]);
ThreadStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
prompt_store::init(cx);
});
}
@ -1569,14 +1522,33 @@ mod tests {
multi_workspace: &Entity<MultiWorkspace>,
cx: &mut gpui::VisualTestContext,
) -> Entity<Sidebar> {
let multi_workspace = multi_workspace.clone();
let sidebar =
cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
multi_workspace.update_in(cx, |mw, window, cx| {
mw.register_sidebar(sidebar.clone(), window, cx);
let (sidebar, _panel) = setup_sidebar_with_agent_panel(multi_workspace, cx);
sidebar
}
fn setup_sidebar_with_agent_panel(
multi_workspace: &Entity<MultiWorkspace>,
cx: &mut gpui::VisualTestContext,
) -> (Entity<Sidebar>, Entity<AgentPanel>) {
let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
let project = workspace.read_with(cx, |ws, _cx| ws.project().clone());
let panel = add_agent_panel(&workspace, &project, cx);
workspace.update_in(cx, |workspace, window, cx| {
workspace.right_dock().update(cx, |dock, cx| {
if let Some(panel_ix) = dock.panel_index_for_type::<AgentPanel>() {
dock.activate_panel(panel_ix, window, cx);
}
dock.set_open(true, window, cx);
});
});
cx.run_until_parked();
sidebar
let sidebar = panel.read_with(cx, |panel, _cx| {
panel
.sidebar
.clone()
.expect("AgentPanel should have created a sidebar")
});
(sidebar, panel)
}
async fn save_n_test_threads(
@ -1623,16 +1595,10 @@ mod tests {
cx.run_until_parked();
}
fn open_and_focus_sidebar(
sidebar: &Entity<Sidebar>,
multi_workspace: &Entity<MultiWorkspace>,
cx: &mut gpui::VisualTestContext,
) {
multi_workspace.update_in(cx, |mw, window, cx| {
mw.toggle_sidebar(window, cx);
});
fn open_and_focus_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
cx.run_until_parked();
sidebar.update_in(cx, |_, window, cx| {
sidebar.update_in(cx, |sidebar, window, cx| {
sidebar.set_open(true, cx);
cx.focus_self(window);
});
cx.run_until_parked();
@ -1886,7 +1852,7 @@ mod tests {
assert!(entries.iter().any(|e| e.contains("View More (12)")));
// Focus and navigate to View More, then confirm to expand by one batch
open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
open_and_focus_sidebar(&sidebar, cx);
for _ in 0..7 {
cx.dispatch_action(SelectNext);
}
@ -2169,7 +2135,7 @@ mod tests {
// Entries: [header, thread3, thread2, thread1]
// Focusing the sidebar does not set a selection; select_next/select_previous
// handle None gracefully by starting from the first or last entry.
open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
open_and_focus_sidebar(&sidebar, cx);
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
// First SelectNext from None starts at index 0
@ -2218,7 +2184,7 @@ mod tests {
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
open_and_focus_sidebar(&sidebar, cx);
// SelectLast jumps to the end
cx.dispatch_action(SelectLast);
@ -2241,7 +2207,7 @@ mod tests {
// Open the sidebar so it's rendered, then focus it to trigger focus_in.
// focus_in no longer sets a default selection.
open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
open_and_focus_sidebar(&sidebar, cx);
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
// Manually set a selection, blur, then refocus — selection should be preserved
@ -2273,6 +2239,9 @@ mod tests {
});
cx.run_until_parked();
// Add an agent panel to workspace 1 so the sidebar renders when it's active.
setup_sidebar_with_agent_panel(&multi_workspace, cx);
let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
save_n_test_threads(1, &path_list, cx).await;
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
@ -2299,7 +2268,7 @@ mod tests {
);
// Focus the sidebar and manually select the header (index 0)
open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
open_and_focus_sidebar(&sidebar, cx);
sidebar.update_in(cx, |sidebar, _window, _cx| {
sidebar.selection = Some(0);
});
@ -2342,7 +2311,7 @@ mod tests {
assert!(entries.iter().any(|e| e.contains("View More (3)")));
// Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6)
open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
open_and_focus_sidebar(&sidebar, cx);
for _ in 0..7 {
cx.dispatch_action(SelectNext);
}
@ -2377,7 +2346,7 @@ mod tests {
);
// Focus sidebar and manually select the header (index 0). Press left to collapse.
open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
open_and_focus_sidebar(&sidebar, cx);
sidebar.update_in(cx, |sidebar, _window, _cx| {
sidebar.selection = Some(0);
});
@ -2417,7 +2386,7 @@ mod tests {
cx.run_until_parked();
// Focus sidebar (selection starts at None), then navigate down to the thread (child)
open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
open_and_focus_sidebar(&sidebar, cx);
cx.dispatch_action(SelectNext);
cx.dispatch_action(SelectNext);
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
@ -2452,7 +2421,7 @@ mod tests {
);
// Focus sidebar — focus_in does not set a selection
open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
open_and_focus_sidebar(&sidebar, cx);
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
// First SelectNext from None starts at index 0 (header)
@ -2485,7 +2454,7 @@ mod tests {
cx.run_until_parked();
// Focus sidebar (selection starts at None), navigate down to the thread (index 1)
open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
open_and_focus_sidebar(&sidebar, cx);
cx.dispatch_action(SelectNext);
cx.dispatch_action(SelectNext);
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
@ -2505,24 +2474,6 @@ mod tests {
);
}
async fn init_test_project_with_agent_panel(
worktree_path: &str,
cx: &mut TestAppContext,
) -> Entity<project::Project> {
agent_ui::test_support::init_test(cx);
cx.update(|cx| {
cx.update_flags(false, vec!["agent-v2".into()]);
ThreadStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
.await;
cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
project::Project::test(fs, [worktree_path.as_ref()], cx).await
}
fn add_agent_panel(
workspace: &Entity<Workspace>,
project: &Entity<project::Project>,
@ -2536,23 +2487,12 @@ mod tests {
})
}
fn setup_sidebar_with_agent_panel(
multi_workspace: &Entity<MultiWorkspace>,
project: &Entity<project::Project>,
cx: &mut gpui::VisualTestContext,
) -> (Entity<Sidebar>, Entity<AgentPanel>) {
let sidebar = setup_sidebar(multi_workspace, cx);
let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
let panel = add_agent_panel(&workspace, project, cx);
(sidebar, panel)
}
#[gpui::test]
async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
let project = init_test_project_with_agent_panel("/my-project", cx).await;
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
@ -2595,10 +2535,10 @@ mod tests {
#[gpui::test]
async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) {
let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
let project_a = init_test_project("/project-a", cx).await;
let (multi_workspace, cx) = cx
.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx);
let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
@ -2802,7 +2742,7 @@ mod tests {
);
// User types a search query to filter down.
open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
open_and_focus_sidebar(&sidebar, cx);
type_in_search(&sidebar, "alpha", cx);
assert_eq!(
visible_entries_as_strings(&sidebar, cx),
@ -3125,7 +3065,7 @@ mod tests {
// User focuses the sidebar and collapses the group using keyboard:
// manually select the header, then press CollapseSelectedEntry to collapse.
open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
open_and_focus_sidebar(&sidebar, cx);
sidebar.update_in(cx, |sidebar, _window, _cx| {
sidebar.selection = Some(0);
});
@ -3175,7 +3115,7 @@ mod tests {
}
cx.run_until_parked();
open_and_focus_sidebar(&sidebar, &multi_workspace, cx);
open_and_focus_sidebar(&sidebar, cx);
// User types "fix" — two threads match.
type_in_search(&sidebar, "fix", cx);
@ -3352,10 +3292,10 @@ mod tests {
#[gpui::test]
async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) {
let project = init_test_project_with_agent_panel("/my-project", cx).await;
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
@ -3400,10 +3340,10 @@ mod tests {
#[gpui::test]
async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
let project_a = init_test_project("/project-a", cx).await;
let (multi_workspace, cx) = cx
.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx);
let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
@ -3432,7 +3372,8 @@ mod tests {
let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone());
// ── 1. Initial state: no focused thread ──────────────────────────────
// Workspace B is active (just added), so its header is the active entry.
// Workspace B is active (just added) and has no thread, so its header
// is the active entry.
sidebar.read_with(cx, |sidebar, _cx| {
assert_eq!(
sidebar.focused_thread, None,
@ -3447,6 +3388,7 @@ mod tests {
);
});
// ── 2. Click thread in workspace A via sidebar ───────────────────────
sidebar.update_in(cx, |sidebar, window, cx| {
sidebar.activate_thread(
acp_thread::AgentSessionInfo {
@ -3490,6 +3432,7 @@ mod tests {
);
});
// ── 3. Open thread in workspace B, then click it via sidebar ─────────
let connection_b = StubAgentConnection::new();
connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
acp::ContentChunk::new("Thread B".into()),
@ -3501,6 +3444,16 @@ mod tests {
save_thread_to_store(&session_id_b, &path_list_b, cx).await;
cx.run_until_parked();
// Opening a thread in a non-active workspace should NOT change
// focused_thread — it's derived from the active workspace.
sidebar.read_with(cx, |sidebar, _cx| {
assert_eq!(
sidebar.focused_thread.as_ref(),
Some(&session_id_a),
"Opening a thread in a non-active workspace should not affect focused_thread"
);
});
// Workspace A is currently active. Click a thread in workspace B,
// which also triggers a workspace switch.
sidebar.update_in(cx, |sidebar, window, cx| {
@ -3535,25 +3488,30 @@ mod tests {
);
});
// ── 4. Switch workspace → focused_thread reflects new workspace ──────
multi_workspace.update_in(cx, |mw, window, cx| {
mw.activate_next_workspace(window, cx);
});
cx.run_until_parked();
// Workspace A is now active. Its agent panel still has session_id_a
// loaded, so focused_thread should reflect that.
sidebar.read_with(cx, |sidebar, _cx| {
assert_eq!(
sidebar.focused_thread, None,
"External workspace switch should clear focused_thread"
sidebar.focused_thread.as_ref(),
Some(&session_id_a),
"Switching workspaces should derive focused_thread from the new active workspace"
);
let active_entry = sidebar
.active_entry_index
.and_then(|ix| sidebar.contents.entries.get(ix));
assert!(
matches!(active_entry, Some(ListEntry::ProjectHeader { .. })),
"Active entry should be the workspace header after external switch"
matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_a),
"Active entry should be workspace_a's active thread"
);
});
// ── 5. Opening a thread in a non-active workspace is ignored ──────────
let connection_b2 = StubAgentConnection::new();
connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
acp::ContentChunk::new("New thread".into()),
@ -3564,69 +3522,48 @@ mod tests {
save_thread_to_store(&session_id_b2, &path_list_b, cx).await;
cx.run_until_parked();
// Workspace A is still active, so focused_thread stays on session_id_a.
sidebar.read_with(cx, |sidebar, _cx| {
assert_eq!(
sidebar.focused_thread.as_ref(),
Some(&session_id_b2),
"Opening a thread externally should set focused_thread"
);
});
workspace_b.update_in(cx, |workspace, window, cx| {
workspace.focus_handle(cx).focus(window, cx);
});
cx.run_until_parked();
sidebar.read_with(cx, |sidebar, _cx| {
assert_eq!(
sidebar.focused_thread.as_ref(),
Some(&session_id_b2),
"Defocusing the sidebar should not clear focused_thread"
Some(&session_id_a),
"Opening a thread in a non-active workspace should not affect focused_thread"
);
});
// ── 6. Activating workspace B shows its active thread ────────────────
sidebar.update_in(cx, |sidebar, window, cx| {
sidebar.activate_workspace(&workspace_b, window, cx);
});
cx.run_until_parked();
sidebar.read_with(cx, |sidebar, _cx| {
assert_eq!(
sidebar.focused_thread, None,
"Clicking a workspace header should clear focused_thread"
);
let active_entry = sidebar
.active_entry_index
.and_then(|ix| sidebar.contents.entries.get(ix));
assert!(
matches!(active_entry, Some(ListEntry::ProjectHeader { .. })),
"Active entry should be the workspace header"
);
});
// ── 8. Focusing the agent panel thread restores focused_thread ────
// Workspace B still has session_id_b2 loaded in the agent panel.
// Clicking into the thread (simulated by focusing its view) should
// set focused_thread via the ThreadFocused event.
panel_b.update_in(cx, |panel, window, cx| {
if let Some(thread_view) = panel.active_connection_view() {
thread_view.read(cx).focus_handle(cx).focus(window, cx);
}
});
cx.run_until_parked();
// Workspace B is now active with session_id_b2 loaded.
sidebar.read_with(cx, |sidebar, _cx| {
assert_eq!(
sidebar.focused_thread.as_ref(),
Some(&session_id_b2),
"Focusing the agent panel thread should set focused_thread"
"Activating workspace_b should show workspace_b's active thread"
);
let active_entry = sidebar
.active_entry_index
.and_then(|ix| sidebar.contents.entries.get(ix));
assert!(
matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_b2),
"Active entry should be the focused thread"
"Active entry should be workspace_b's active thread"
);
});
// ── 7. Switching back to workspace A reflects its thread ─────────────
multi_workspace.update_in(cx, |mw, window, cx| {
mw.activate_next_workspace(window, cx);
});
cx.run_until_parked();
sidebar.read_with(cx, |sidebar, _cx| {
assert_eq!(
sidebar.focused_thread.as_ref(),
Some(&session_id_a),
"Switching back to workspace_a should show its active thread"
);
});
}

View file

@ -1211,7 +1211,9 @@ async fn test_stack_frame_filter_persistence(
cx.run_until_parked();
let workspace_id = workspace
.update(cx, |workspace, _window, cx| workspace.database_id(cx))
.update(cx, |workspace, _window, cx| {
workspace.active_workspace_database_id(cx)
})
.ok()
.flatten()
.expect("workspace id has to be some for this test to work properly");

View file

@ -31,8 +31,6 @@ pub struct PlatformTitleBar {
children: SmallVec<[AnyElement; 2]>,
should_move: bool,
system_window_tabs: Entity<SystemWindowTabs>,
workspace_sidebar_open: bool,
sidebar_has_notifications: bool,
}
impl PlatformTitleBar {
@ -46,8 +44,6 @@ impl PlatformTitleBar {
children: SmallVec::new(),
should_move: false,
system_window_tabs,
workspace_sidebar_open: false,
sidebar_has_notifications: false,
}
}
@ -74,28 +70,6 @@ impl PlatformTitleBar {
SystemWindowTabs::init(cx);
}
pub fn is_workspace_sidebar_open(&self) -> bool {
self.workspace_sidebar_open
}
pub fn set_workspace_sidebar_open(&mut self, open: bool, cx: &mut Context<Self>) {
self.workspace_sidebar_open = open;
cx.notify();
}
pub fn sidebar_has_notifications(&self) -> bool {
self.sidebar_has_notifications
}
pub fn set_sidebar_has_notifications(
&mut self,
has_notifications: bool,
cx: &mut Context<Self>,
) {
self.sidebar_has_notifications = has_notifications;
cx.notify();
}
pub fn is_multi_workspace_enabled(cx: &App) -> bool {
cx.has_flag::<AgentV2FeatureFlag>() && !DisableAiSettings::get_global(cx).disable_ai
}
@ -110,9 +84,6 @@ impl Render for PlatformTitleBar {
let close_action = Box::new(workspace::CloseWindow);
let children = mem::take(&mut self.children);
let is_multiworkspace_sidebar_open =
PlatformTitleBar::is_multi_workspace_enabled(cx) && self.is_workspace_sidebar_open();
let title_bar = h_flex()
.window_control_area(WindowControlArea::Drag)
.w_full()
@ -161,9 +132,7 @@ impl Render for PlatformTitleBar {
.map(|this| {
if window.is_fullscreen() {
this.pl_2()
} else if self.platform_style == PlatformStyle::Mac
&& !is_multiworkspace_sidebar_open
{
} else if self.platform_style == PlatformStyle::Mac {
this.pl(px(TRAFFIC_LIGHT_PADDING))
} else {
this.pl_2()
@ -175,10 +144,9 @@ impl Render for PlatformTitleBar {
.when(!(tiling.top || tiling.right), |el| {
el.rounded_tr(theme::CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(
!(tiling.top || tiling.left) && !is_multiworkspace_sidebar_open,
|el| el.rounded_tl(theme::CLIENT_SIDE_DECORATION_ROUNDING),
)
.when(!(tiling.top || tiling.left), |el| {
el.rounded_tl(theme::CLIENT_SIDE_DECORATION_ROUNDING)
})
// this border is to avoid a transparent gap in the rounded corners
.mt(px(-1.))
.mb(px(-1.))

View file

@ -1,51 +0,0 @@
[package]
name = "sidebar"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
path = "src/sidebar.rs"
[features]
default = []
[dependencies]
acp_thread.workspace = true
agent.workspace = true
agent-client-protocol.workspace = true
agent_ui.workspace = true
chrono.workspace = true
editor.workspace = true
feature_flags.workspace = true
fs.workspace = true
gpui.workspace = true
menu.workspace = true
project.workspace = true
recent_projects.workspace = true
settings.workspace = true
theme.workspace = true
ui.workspace = true
util.workspace = true
workspace.workspace = true
zed_actions.workspace = true
[dev-dependencies]
acp_thread = { workspace = true, features = ["test-support"] }
agent = { workspace = true, features = ["test-support"] }
agent_ui = { workspace = true, features = ["test-support"] }
assistant_text_thread = { workspace = true, features = ["test-support"] }
editor.workspace = true
language_model = { workspace = true, features = ["test-support"] }
serde_json.workspace = true
feature_flags.workspace = true
fs = { workspace = true, features = ["test-support"] }
gpui = { workspace = true, features = ["test-support"] }
project = { workspace = true, features = ["test-support"] }
settings = { workspace = true, features = ["test-support"] }
workspace = { workspace = true, features = ["test-support"] }
recent_projects = { workspace = true, features = ["test-support"] }

View file

@ -1 +0,0 @@
../../LICENSE-GPL

View file

@ -38,7 +38,6 @@ chrono.workspace = true
client.workspace = true
cloud_api_types.workspace = true
db.workspace = true
feature_flags.workspace = true
git_ui.workspace = true
gpui = { workspace = true, features = ["screen-capture"] }
notifications.workspace = true

View file

@ -24,16 +24,13 @@ use auto_update::AutoUpdateStatus;
use call::ActiveCall;
use client::{Client, UserStore, zed_urls};
use cloud_api_types::Plan;
use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt};
use gpui::{
Action, AnyElement, App, Context, Corner, Element, Empty, Entity, Focusable,
InteractiveElement, IntoElement, MouseButton, ParentElement, Render,
StatefulInteractiveElement, Styled, Subscription, WeakEntity, Window, actions, div,
};
use onboarding_banner::OnboardingBanner;
use project::{
DisableAiSettings, Project, git_store::GitStoreEvent, trusted_worktrees::TrustedWorktrees,
};
use project::{Project, git_store::GitStoreEvent, trusted_worktrees::TrustedWorktrees};
use remote::RemoteConnectionOptions;
use settings::Settings;
use settings::WorktreeId;
@ -47,8 +44,7 @@ use ui::{
use update_version::UpdateVersion;
use util::ResultExt;
use workspace::{
MultiWorkspace, ToggleWorkspaceSidebar, ToggleWorktreeSecurity, Workspace,
notifications::NotifyResultExt,
MultiWorkspace, ToggleWorktreeSecurity, Workspace, notifications::NotifyResultExt,
};
use zed_actions::OpenRemote;
@ -174,7 +170,6 @@ impl Render for TitleBar {
let mut render_project_items = title_bar_settings.show_branch_name
|| title_bar_settings.show_project_items;
title_bar
.children(self.render_workspace_sidebar_toggle(window, cx))
.when_some(
self.application_menu.clone().filter(|_| !show_menus),
|title_bar, menu| {
@ -357,7 +352,6 @@ impl TitleBar {
// Set up observer to sync sidebar state from MultiWorkspace to PlatformTitleBar.
{
let platform_titlebar = platform_titlebar.clone();
let window_handle = window.window_handle();
cx.spawn(async move |this: WeakEntity<TitleBar>, cx| {
let Some(multi_workspace_handle) = window_handle.downcast::<MultiWorkspace>()
@ -370,26 +364,8 @@ impl TitleBar {
return;
};
let is_open = multi_workspace.read(cx).is_sidebar_open();
let has_notifications = multi_workspace.read(cx).sidebar_has_notifications(cx);
platform_titlebar.update(cx, |titlebar, cx| {
titlebar.set_workspace_sidebar_open(is_open, cx);
titlebar.set_sidebar_has_notifications(has_notifications, cx);
});
let platform_titlebar = platform_titlebar.clone();
let subscription = cx.observe(&multi_workspace, move |mw, cx| {
let is_open = mw.read(cx).is_sidebar_open();
let has_notifications = mw.read(cx).sidebar_has_notifications(cx);
platform_titlebar.update(cx, |titlebar, cx| {
titlebar.set_workspace_sidebar_open(is_open, cx);
titlebar.set_sidebar_has_notifications(has_notifications, cx);
});
});
if let Some(this) = this.upgrade() {
this.update(cx, |this, _| {
this._subscriptions.push(subscription);
this.multi_workspace = Some(multi_workspace.downgrade());
});
}
@ -686,46 +662,7 @@ impl TitleBar {
)
}
fn render_workspace_sidebar_toggle(
&self,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Option<AnyElement> {
if !cx.has_flag::<AgentV2FeatureFlag>() || DisableAiSettings::get_global(cx).disable_ai {
return None;
}
let is_sidebar_open = self.platform_titlebar.read(cx).is_workspace_sidebar_open();
if is_sidebar_open {
return None;
}
let has_notifications = self.platform_titlebar.read(cx).sidebar_has_notifications();
Some(
IconButton::new("toggle-workspace-sidebar", IconName::WorkspaceNavClosed)
.icon_size(IconSize::Small)
.when(has_notifications, |button| {
button
.indicator(Indicator::dot().color(Color::Accent))
.indicator_border_color(Some(cx.theme().colors().title_bar_background))
})
.tooltip(move |_, cx| {
Tooltip::for_action("Open Threads Sidebar", &ToggleWorkspaceSidebar, cx)
})
.on_click(|_, window, cx| {
window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx);
})
.into_any_element(),
)
}
pub fn render_project_name(
&self,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
pub fn render_project_name(&self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let workspace = self.workspace.clone();
let name = self.effective_active_worktree(cx).map(|worktree| {
@ -741,19 +678,6 @@ impl TitleBar {
"Open Recent Project".to_string()
};
let is_sidebar_open = self.platform_titlebar.read(cx).is_workspace_sidebar_open();
if is_sidebar_open {
return self
.render_project_name_with_sidebar_popover(
window,
display_name,
is_project_selected,
cx,
)
.into_any_element();
}
let focus_handle = workspace
.upgrade()
.map(|w| w.read(cx).focus_handle(cx))
@ -793,49 +717,6 @@ impl TitleBar {
.into_any_element()
}
fn render_project_name_with_sidebar_popover(
&self,
_window: &Window,
display_name: String,
is_project_selected: bool,
cx: &mut Context<Self>,
) -> impl IntoElement {
let multi_workspace = self.multi_workspace.clone();
let is_popover_deployed = multi_workspace
.as_ref()
.and_then(|mw| mw.upgrade())
.map(|mw| mw.read(cx).is_recent_projects_popover_deployed(cx))
.unwrap_or(false);
Button::new("project_name_trigger", display_name)
.label_size(LabelSize::Small)
.when(self.worktree_count(cx) > 1, |this| {
this.icon(IconName::ChevronDown)
.icon_color(Color::Muted)
.icon_size(IconSize::XSmall)
})
.toggle_state(is_popover_deployed)
.selected_style(ButtonStyle::Tinted(TintColor::Accent))
.when(!is_project_selected, |s| s.color(Color::Muted))
.tooltip(move |_window, cx| {
Tooltip::for_action(
"Recent Projects",
&zed_actions::OpenRecent {
create_new_window: false,
},
cx,
)
})
.on_click(move |_, window, cx| {
if let Some(mw) = multi_workspace.as_ref().and_then(|mw| mw.upgrade()) {
mw.update(cx, |mw, cx| {
mw.toggle_recent_projects_popover(window, cx);
});
}
})
}
pub fn render_project_branch(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
let effective_worktree = self.effective_active_worktree(cx)?;
let repository = self.get_repository_for_worktree(&effective_worktree, cx)?;

View file

@ -1,9 +1,8 @@
use anyhow::Result;
use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt};
use gpui::{
AnyView, App, Context, DragMoveEvent, Entity, EntityId, EventEmitter, FocusHandle, Focusable,
ManagedView, MouseButton, Pixels, Render, Subscription, Task, Tiling, Window, WindowId,
actions, deferred, px,
App, Context, Entity, EntityId, EventEmitter, Focusable, ManagedView, Pixels, Render,
Subscription, Task, Tiling, Window, WindowId, actions, px,
};
use project::{DisableAiSettings, Project};
use settings::Settings;
@ -12,11 +11,12 @@ use std::path::PathBuf;
use ui::prelude::*;
use util::ResultExt;
const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0);
pub const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0);
use crate::{
CloseIntent, CloseWindow, DockPosition, Event as WorkspaceEvent, Item, ModalView, Panel, Toast,
Workspace, WorkspaceId, client_side_decorations, notifications::NotificationId,
persistence::model::MultiWorkspaceId,
};
actions!(
@ -41,31 +41,6 @@ pub enum MultiWorkspaceEvent {
WorkspaceRemoved(EntityId),
}
pub enum SidebarEvent {
Open,
Close,
}
pub trait Sidebar: EventEmitter<SidebarEvent> + Focusable + Render + Sized {
fn width(&self, cx: &App) -> Pixels;
fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>);
fn has_notifications(&self, cx: &App) -> bool;
fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App);
fn is_recent_projects_popover_deployed(&self) -> bool;
}
pub trait SidebarHandle: 'static + Send + Sync {
fn width(&self, cx: &App) -> Pixels;
fn set_width(&self, width: Option<Pixels>, cx: &mut App);
fn focus_handle(&self, cx: &App) -> FocusHandle;
fn focus(&self, window: &mut Window, cx: &mut App);
fn has_notifications(&self, cx: &App) -> bool;
fn to_any(&self) -> AnyView;
fn entity_id(&self) -> EntityId;
fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App);
fn is_recent_projects_popover_deployed(&self, cx: &App) -> bool;
}
#[derive(Clone)]
pub struct DraggedSidebar;
@ -75,54 +50,11 @@ impl Render for DraggedSidebar {
}
}
impl<T: Sidebar> SidebarHandle for Entity<T> {
fn width(&self, cx: &App) -> Pixels {
self.read(cx).width(cx)
}
fn set_width(&self, width: Option<Pixels>, cx: &mut App) {
self.update(cx, |this, cx| this.set_width(width, cx))
}
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.read(cx).focus_handle(cx)
}
fn focus(&self, window: &mut Window, cx: &mut App) {
let handle = self.read(cx).focus_handle(cx);
window.focus(&handle, cx);
}
fn has_notifications(&self, cx: &App) -> bool {
self.read(cx).has_notifications(cx)
}
fn to_any(&self) -> AnyView {
self.clone().into()
}
fn entity_id(&self) -> EntityId {
Entity::entity_id(self)
}
fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App) {
self.update(cx, |this, cx| {
this.toggle_recent_projects_popover(window, cx);
});
}
fn is_recent_projects_popover_deployed(&self, cx: &App) -> bool {
self.read(cx).is_recent_projects_popover_deployed()
}
}
pub struct MultiWorkspace {
window_id: WindowId,
workspaces: Vec<Entity<Workspace>>,
database_id: Option<MultiWorkspaceId>,
active_workspace_index: usize,
sidebar: Option<Box<dyn SidebarHandle>>,
sidebar_open: bool,
_sidebar_subscription: Option<Subscription>,
pending_removal_tasks: Vec<Task<()>>,
_serialize_task: Option<Task<()>>,
_create_task: Option<Task<()>>,
@ -131,6 +63,10 @@ pub struct MultiWorkspace {
impl EventEmitter<MultiWorkspaceEvent> for MultiWorkspace {}
pub fn multi_workspace_enabled(cx: &App) -> bool {
cx.has_flag::<AgentV2FeatureFlag>() && !DisableAiSettings::get_global(cx).disable_ai
}
impl MultiWorkspace {
pub fn new(workspace: Entity<Workspace>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let release_subscription = cx.on_release(|this: &mut MultiWorkspace, _cx| {
@ -145,144 +81,19 @@ impl MultiWorkspace {
}
});
let quit_subscription = cx.on_app_quit(Self::app_will_quit);
let settings_subscription =
cx.observe_global_in::<settings::SettingsStore>(window, |this, window, cx| {
if DisableAiSettings::get_global(cx).disable_ai && this.sidebar_open {
this.close_sidebar(window, cx);
}
});
Self::subscribe_to_workspace(&workspace, cx);
Self {
window_id: window.window_handle().window_id(),
database_id: None,
workspaces: vec![workspace],
active_workspace_index: 0,
sidebar: None,
sidebar_open: false,
_sidebar_subscription: None,
pending_removal_tasks: Vec::new(),
_serialize_task: None,
_create_task: None,
_subscriptions: vec![
release_subscription,
quit_subscription,
settings_subscription,
],
_subscriptions: vec![release_subscription, quit_subscription],
}
}
pub fn register_sidebar<T: Sidebar>(
&mut self,
sidebar: Entity<T>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let subscription =
cx.subscribe_in(&sidebar, window, |this, _, event, window, cx| match event {
SidebarEvent::Open => this.toggle_sidebar(window, cx),
SidebarEvent::Close => {
this.close_sidebar(window, cx);
}
});
self.sidebar = Some(Box::new(sidebar));
self._sidebar_subscription = Some(subscription);
}
pub fn sidebar(&self) -> Option<&dyn SidebarHandle> {
self.sidebar.as_deref()
}
pub fn sidebar_open(&self) -> bool {
self.sidebar_open && self.sidebar.is_some()
}
pub fn sidebar_has_notifications(&self, cx: &App) -> bool {
self.sidebar
.as_ref()
.map_or(false, |s| s.has_notifications(cx))
}
pub fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App) {
if let Some(sidebar) = &self.sidebar {
sidebar.toggle_recent_projects_popover(window, cx);
}
}
pub fn is_recent_projects_popover_deployed(&self, cx: &App) -> bool {
self.sidebar
.as_ref()
.map_or(false, |s| s.is_recent_projects_popover_deployed(cx))
}
pub fn multi_workspace_enabled(&self, cx: &App) -> bool {
cx.has_flag::<AgentV2FeatureFlag>() && !DisableAiSettings::get_global(cx).disable_ai
}
pub fn toggle_sidebar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if !self.multi_workspace_enabled(cx) {
return;
}
if self.sidebar_open {
self.close_sidebar(window, cx);
} else {
self.open_sidebar(cx);
if let Some(sidebar) = &self.sidebar {
sidebar.focus(window, cx);
}
}
}
pub fn focus_sidebar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if !self.multi_workspace_enabled(cx) {
return;
}
if self.sidebar_open {
let sidebar_is_focused = self
.sidebar
.as_ref()
.is_some_and(|s| s.focus_handle(cx).contains_focused(window, cx));
if sidebar_is_focused {
let pane = self.workspace().read(cx).active_pane().clone();
let pane_focus = pane.read(cx).focus_handle(cx);
window.focus(&pane_focus, cx);
} else if let Some(sidebar) = &self.sidebar {
sidebar.focus(window, cx);
}
} else {
self.open_sidebar(cx);
if let Some(sidebar) = &self.sidebar {
sidebar.focus(window, cx);
}
}
}
pub fn open_sidebar(&mut self, cx: &mut Context<Self>) {
self.sidebar_open = true;
for workspace in &self.workspaces {
workspace.update(cx, |workspace, cx| {
workspace.set_workspace_sidebar_open(true, cx);
});
}
self.serialize(cx);
cx.notify();
}
fn close_sidebar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.sidebar_open = false;
for workspace in &self.workspaces {
workspace.update(cx, |workspace, cx| {
workspace.set_workspace_sidebar_open(false, cx);
});
}
let pane = self.workspace().read(cx).active_pane().clone();
let pane_focus = pane.read(cx).focus_handle(cx);
window.focus(&pane_focus, cx);
self.serialize(cx);
cx.notify();
}
pub fn close_window(&mut self, _: &CloseWindow, window: &mut Window, cx: &mut Context<Self>) {
cx.spawn_in(window, async move |this, cx| {
let workspaces = this.update(cx, |multi_workspace, _cx| {
@ -318,10 +129,6 @@ impl MultiWorkspace {
.detach();
}
pub fn is_sidebar_open(&self) -> bool {
self.sidebar_open
}
pub fn workspace(&self) -> &Entity<Workspace> {
&self.workspaces[self.active_workspace_index]
}
@ -335,7 +142,7 @@ impl MultiWorkspace {
}
pub fn activate(&mut self, workspace: Entity<Workspace>, cx: &mut Context<Self>) {
if !self.multi_workspace_enabled(cx) {
if !multi_workspace_enabled(cx) {
self.workspaces[0] = workspace;
self.active_workspace_index = 0;
cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged);
@ -371,11 +178,6 @@ impl MultiWorkspace {
if let Some(index) = self.workspaces.iter().position(|w| *w == workspace) {
index
} else {
if self.sidebar_open {
workspace.update(cx, |workspace, cx| {
workspace.set_workspace_sidebar_open(true, cx);
});
}
Self::subscribe_to_workspace(&workspace, cx);
self.workspaces.push(workspace.clone());
cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace));
@ -384,6 +186,14 @@ impl MultiWorkspace {
}
}
pub fn database_id(&self) -> Option<MultiWorkspaceId> {
self.database_id
}
pub fn set_database_id(&mut self, id: Option<MultiWorkspaceId>) {
self.database_id = id;
}
pub fn activate_index(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
debug_assert!(
index < self.workspaces.len(),
@ -421,7 +231,6 @@ impl MultiWorkspace {
let window_id = self.window_id;
let state = crate::persistence::model::MultiWorkspaceState {
active_workspace_id: self.workspace().read(cx).database_id(),
sidebar_open: self.sidebar_open,
};
self._serialize_task = Some(cx.background_spawn(async move {
crate::persistence::write_multi_workspace_state(window_id, state).await;
@ -540,7 +349,7 @@ impl MultiWorkspace {
self.workspace().read(cx).items_of_type::<T>(cx)
}
pub fn database_id(&self, cx: &App) -> Option<WorkspaceId> {
pub fn active_workspace_database_id(&self, cx: &App) -> Option<WorkspaceId> {
self.workspace().read(cx).database_id()
}
@ -583,7 +392,7 @@ impl MultiWorkspace {
}
pub fn create_workspace(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if !self.multi_workspace_enabled(cx) {
if !multi_workspace_enabled(cx) {
return;
}
let app_state = self.workspace().read(cx).app_state().clone();
@ -692,7 +501,7 @@ impl MultiWorkspace {
) -> Task<Result<()>> {
let workspace = self.workspace().clone();
if self.multi_workspace_enabled(cx) {
if multi_workspace_enabled(cx) {
workspace.update(cx, |workspace, cx| {
workspace.open_workspace_for_paths(true, paths, window, cx)
})
@ -719,57 +528,6 @@ impl MultiWorkspace {
impl Render for MultiWorkspace {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let multi_workspace_enabled = self.multi_workspace_enabled(cx);
let sidebar: Option<AnyElement> = if multi_workspace_enabled && self.sidebar_open {
self.sidebar.as_ref().map(|sidebar_handle| {
let weak = cx.weak_entity();
let sidebar_width = sidebar_handle.width(cx);
let resize_handle = deferred(
div()
.id("sidebar-resize-handle")
.absolute()
.right(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.)
.top(px(0.))
.h_full()
.w(SIDEBAR_RESIZE_HANDLE_SIZE)
.cursor_col_resize()
.on_drag(DraggedSidebar, |dragged, _, _, cx| {
cx.stop_propagation();
cx.new(|_| dragged.clone())
})
.on_mouse_down(MouseButton::Left, |_, _, cx| {
cx.stop_propagation();
})
.on_mouse_up(MouseButton::Left, move |event, _, cx| {
if event.click_count == 2 {
weak.update(cx, |this, cx| {
if let Some(sidebar) = this.sidebar.as_mut() {
sidebar.set_width(None, cx);
}
})
.ok();
cx.stop_propagation();
}
})
.occlude(),
);
div()
.id("sidebar-container")
.relative()
.h_full()
.w(sidebar_width)
.flex_shrink_0()
.child(sidebar_handle.to_any())
.child(resize_handle)
.into_any_element()
})
} else {
None
};
let ui_font = theme::setup_ui_font(window, cx);
let text_color = cx.theme().colors().text;
@ -799,32 +557,6 @@ impl Render for MultiWorkspace {
this.activate_previous_workspace(window, cx);
},
))
.when(self.multi_workspace_enabled(cx), |this| {
this.on_action(cx.listener(
|this: &mut Self, _: &ToggleWorkspaceSidebar, window, cx| {
this.toggle_sidebar(window, cx);
},
))
.on_action(cx.listener(
|this: &mut Self, _: &FocusWorkspaceSidebar, window, cx| {
this.focus_sidebar(window, cx);
},
))
})
.when(
self.sidebar_open() && self.multi_workspace_enabled(cx),
|this| {
this.on_drag_move(cx.listener(
|this: &mut Self, e: &DragMoveEvent<DraggedSidebar>, _window, cx| {
if let Some(sidebar) = &this.sidebar {
let new_width = e.event.position.x;
sidebar.set_width(Some(new_width), cx);
}
},
))
.children(sidebar)
},
)
.child(
div()
.flex()
@ -837,98 +569,9 @@ impl Render for MultiWorkspace {
window,
cx,
Tiling {
left: multi_workspace_enabled && self.sidebar_open,
left: false,
..Tiling::default()
},
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use fs::FakeFs;
use gpui::TestAppContext;
use settings::SettingsStore;
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
theme::init(theme::LoadThemes::JustBase, cx);
DisableAiSettings::register(cx);
cx.update_flags(false, vec!["agent-v2".into()]);
});
}
#[gpui::test]
async fn test_sidebar_disabled_when_disable_ai_is_enabled(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, [], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
multi_workspace.read_with(cx, |mw, cx| {
assert!(mw.multi_workspace_enabled(cx));
});
multi_workspace.update_in(cx, |mw, _window, cx| {
mw.open_sidebar(cx);
assert!(mw.is_sidebar_open());
});
cx.update(|_window, cx| {
DisableAiSettings::override_global(DisableAiSettings { disable_ai: true }, cx);
});
cx.run_until_parked();
multi_workspace.read_with(cx, |mw, cx| {
assert!(
!mw.is_sidebar_open(),
"Sidebar should be closed when disable_ai is true"
);
assert!(
!mw.multi_workspace_enabled(cx),
"Multi-workspace should be disabled when disable_ai is true"
);
});
multi_workspace.update_in(cx, |mw, window, cx| {
mw.toggle_sidebar(window, cx);
});
multi_workspace.read_with(cx, |mw, _cx| {
assert!(
!mw.is_sidebar_open(),
"Sidebar should remain closed when toggled with disable_ai true"
);
});
cx.update(|_window, cx| {
DisableAiSettings::override_global(DisableAiSettings { disable_ai: false }, cx);
});
cx.run_until_parked();
multi_workspace.read_with(cx, |mw, cx| {
assert!(
mw.multi_workspace_enabled(cx),
"Multi-workspace should be enabled after re-enabling AI"
);
assert!(
!mw.is_sidebar_open(),
"Sidebar should still be closed after re-enabling AI (not auto-opened)"
);
});
multi_workspace.update_in(cx, |mw, window, cx| {
mw.toggle_sidebar(window, cx);
});
multi_workspace.read_with(cx, |mw, _cx| {
assert!(
mw.is_sidebar_open(),
"Sidebar should open when toggled after re-enabling AI"
);
});
}
}

View file

@ -341,6 +341,7 @@ pub fn read_serialized_multi_workspaces(
.map(read_multi_workspace_state)
.unwrap_or_default();
model::SerializedMultiWorkspace {
id: window_id.map(|id| model::MultiWorkspaceId(id.as_u64())),
workspaces: group,
state,
}
@ -3877,7 +3878,6 @@ mod tests {
window_10,
MultiWorkspaceState {
active_workspace_id: Some(WorkspaceId(2)),
sidebar_open: true,
},
)
.await;
@ -3886,7 +3886,6 @@ mod tests {
window_20,
MultiWorkspaceState {
active_workspace_id: Some(WorkspaceId(3)),
sidebar_open: false,
},
)
.await;
@ -3924,23 +3923,20 @@ mod tests {
// Should produce 3 groups: window 10, window 20, and the orphan.
assert_eq!(results.len(), 3);
// Window 10 group: 2 workspaces, active_workspace_id = 2, sidebar open.
// Window 10 group: 2 workspaces, active_workspace_id = 2.
let group_10 = &results[0];
assert_eq!(group_10.workspaces.len(), 2);
assert_eq!(group_10.state.active_workspace_id, Some(WorkspaceId(2)));
assert_eq!(group_10.state.sidebar_open, true);
// Window 20 group: 1 workspace, active_workspace_id = 3, sidebar closed.
// Window 20 group: 1 workspace, active_workspace_id = 3.
let group_20 = &results[1];
assert_eq!(group_20.workspaces.len(), 1);
assert_eq!(group_20.state.active_workspace_id, Some(WorkspaceId(3)));
assert_eq!(group_20.state.sidebar_open, false);
// Orphan group: no window_id, so state is default.
let group_none = &results[2];
assert_eq!(group_none.workspaces.len(), 1);
assert_eq!(group_none.state.active_workspace_id, None);
assert_eq!(group_none.state.sidebar_open, false);
}
#[gpui::test]

View file

@ -63,18 +63,19 @@ pub struct SessionWorkspace {
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct MultiWorkspaceState {
pub active_workspace_id: Option<WorkspaceId>,
pub sidebar_open: bool,
}
/// The serialized state of a single MultiWorkspace window from a previous session:
/// all workspaces that shared the window, which one was active, and whether the
/// sidebar was open.
/// The serialized state of a single MultiWorkspace window from a previous session.
#[derive(Debug, Clone)]
pub struct SerializedMultiWorkspace {
pub id: Option<MultiWorkspaceId>,
pub workspaces: Vec<SessionWorkspace>,
pub state: MultiWorkspaceState,
}
#[derive(Debug, Clone, Copy)]
pub struct MultiWorkspaceId(pub u64);
#[derive(Debug, PartialEq, Clone)]
pub(crate) struct SerializedWorkspace {
pub(crate) id: WorkspaceId,

View file

@ -34,7 +34,6 @@ pub struct StatusBar {
right_items: Vec<Box<dyn StatusItemViewHandle>>,
active_pane: Entity<Pane>,
_observe_active_pane: Subscription,
workspace_sidebar_open: bool,
}
impl Render for StatusBar {
@ -52,10 +51,9 @@ impl Render for StatusBar {
.when(!(tiling.bottom || tiling.right), |el| {
el.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING)
})
.when(
!(tiling.bottom || tiling.left) && !self.workspace_sidebar_open,
|el| el.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING),
)
.when(!(tiling.bottom || tiling.left), |el| {
el.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING)
})
// This border is to avoid a transparent gap in the rounded corners
.mb(px(-1.))
.border_b(px(1.0))
@ -91,17 +89,11 @@ impl StatusBar {
_observe_active_pane: cx.observe_in(active_pane, window, |this, _, window, cx| {
this.update_active_pane_item(window, cx)
}),
workspace_sidebar_open: false,
};
this.update_active_pane_item(window, cx);
this
}
pub fn set_workspace_sidebar_open(&mut self, open: bool, cx: &mut Context<Self>) {
self.workspace_sidebar_open = open;
cx.notify();
}
pub fn add_left_item<T>(&mut self, item: Entity<T>, window: &mut Window, cx: &mut Context<Self>)
where
T: 'static + StatusItemView,

View file

@ -28,8 +28,8 @@ pub use crate::notifications::NotificationFrame;
pub use dock::Panel;
pub use multi_workspace::{
DraggedSidebar, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent,
NewWorkspaceInWindow, NextWorkspaceInWindow, PreviousWorkspaceInWindow, Sidebar, SidebarEvent,
SidebarHandle, ToggleWorkspaceSidebar,
NewWorkspaceInWindow, NextWorkspaceInWindow, PreviousWorkspaceInWindow,
SIDEBAR_RESIZE_HANDLE_SIZE, ToggleWorkspaceSidebar, multi_workspace_enabled,
};
pub use path_list::{PathList, SerializedPathList};
pub use toast_layer::{ToastAction, ToastLayer, ToastView};
@ -80,8 +80,8 @@ use persistence::{DB, SerializedWindowBounds, model::SerializedWorkspace};
pub use persistence::{
DB as WORKSPACE_DB, WorkspaceDb, delete_unloaded_items,
model::{
DockStructure, ItemId, SerializedMultiWorkspace, SerializedWorkspaceLocation,
SessionWorkspace,
DockStructure, ItemId, MultiWorkspaceId, SerializedMultiWorkspace,
SerializedWorkspaceLocation, SessionWorkspace,
},
read_serialized_multi_workspaces,
};
@ -2154,12 +2154,6 @@ impl Workspace {
&self.status_bar
}
pub fn set_workspace_sidebar_open(&self, open: bool, cx: &mut App) {
self.status_bar.update(cx, |status_bar, cx| {
status_bar.set_workspace_sidebar_open(open, cx);
});
}
pub fn status_bar_visible(&self, cx: &App) -> bool {
StatusBarSettings::get_global(cx).show
}
@ -8184,7 +8178,11 @@ pub async fn restore_multiworkspace(
app_state: Arc<AppState>,
cx: &mut AsyncApp,
) -> anyhow::Result<MultiWorkspaceRestoreResult> {
let SerializedMultiWorkspace { workspaces, state } = multi_workspace;
let SerializedMultiWorkspace {
workspaces,
state,
id: window_id,
} = multi_workspace;
let mut group_iter = workspaces.into_iter();
let first = group_iter
.next()
@ -8248,6 +8246,7 @@ pub async fn restore_multiworkspace(
if let Some(target_id) = state.active_workspace_id {
window_handle
.update(cx, |multi_workspace, window, cx| {
multi_workspace.set_database_id(window_id);
let target_index = multi_workspace
.workspaces()
.iter()
@ -8269,14 +8268,6 @@ pub async fn restore_multiworkspace(
.ok();
}
if state.sidebar_open {
window_handle
.update(cx, |multi_workspace, _, cx| {
multi_workspace.open_sidebar(cx);
})
.ok();
}
window_handle
.update(cx, |_, window, _cx| {
window.activate_window();

View file

@ -182,7 +182,6 @@ settings.workspace = true
settings_profile_selector.workspace = true
settings_ui.workspace = true
shellexpand.workspace = true
sidebar.workspace = true
smol.workspace = true
snippet_provider.workspace = true
snippets_ui.workspace = true

View file

@ -103,8 +103,8 @@ use {
feature_flags::FeatureFlagAppExt as _,
git_ui::project_diff::ProjectDiff,
gpui::{
App, AppContext as _, Bounds, KeyBinding, Modifiers, SharedString, VisualTestAppContext,
WindowBounds, WindowHandle, WindowOptions, point, px, size,
Action as _, App, AppContext as _, Bounds, KeyBinding, Modifiers, SharedString,
VisualTestAppContext, WindowBounds, WindowHandle, WindowOptions, point, px, size,
},
image::RgbaImage,
project_panel::ProjectPanel,
@ -2649,22 +2649,6 @@ fn run_multi_workspace_sidebar_visual_tests(
cx.run_until_parked();
// Create the sidebar and register it on the MultiWorkspace
let sidebar = multi_workspace_window
.update(cx, |_multi_workspace, window, cx| {
let multi_workspace_handle = cx.entity();
cx.new(|cx| sidebar::Sidebar::new(multi_workspace_handle, window, cx))
})
.context("Failed to create sidebar")?;
multi_workspace_window
.update(cx, |multi_workspace, window, cx| {
multi_workspace.register_sidebar(sidebar.clone(), window, cx);
})
.context("Failed to register sidebar")?;
cx.run_until_parked();
// Save test threads to the ThreadStore for each workspace
let save_tasks = multi_workspace_window
.update(cx, |multi_workspace, _window, cx| {
@ -2742,8 +2726,8 @@ fn run_multi_workspace_sidebar_visual_tests(
// Open the sidebar
multi_workspace_window
.update(cx, |multi_workspace, window, cx| {
multi_workspace.toggle_sidebar(window, cx);
.update(cx, |_multi_workspace, window, cx| {
window.dispatch_action(workspace::ToggleWorkspaceSidebar.boxed_clone(), cx);
})
.context("Failed to toggle sidebar")?;
@ -3181,24 +3165,10 @@ edition = "2021"
cx.run_until_parked();
// Create and register the workspace sidebar
let sidebar = workspace_window
.update(cx, |_multi_workspace, window, cx| {
let multi_workspace_handle = cx.entity();
cx.new(|cx| sidebar::Sidebar::new(multi_workspace_handle, window, cx))
})
.context("Failed to create sidebar")?;
workspace_window
.update(cx, |multi_workspace, window, cx| {
multi_workspace.register_sidebar(sidebar.clone(), window, cx);
})
.context("Failed to register sidebar")?;
// Open the sidebar
workspace_window
.update(cx, |multi_workspace, window, cx| {
multi_workspace.toggle_sidebar(window, cx);
.update(cx, |_multi_workspace, window, cx| {
window.dispatch_action(workspace::ToggleWorkspaceSidebar.boxed_clone(), cx);
})
.context("Failed to toggle sidebar")?;

View file

@ -68,7 +68,6 @@ use settings::{
initial_local_debug_tasks_content, initial_project_settings_content, initial_tasks_content,
update_settings_file,
};
use sidebar::Sidebar;
use std::time::Duration;
use std::{
borrow::Cow,
@ -389,20 +388,6 @@ pub fn initialize_workspace(
})
.unwrap_or(true)
});
let window_handle = window.window_handle();
let multi_workspace_handle = cx.entity();
cx.defer(move |cx| {
window_handle
.update(cx, |_, window, cx| {
let sidebar =
cx.new(|cx| Sidebar::new(multi_workspace_handle.clone(), window, cx));
multi_workspace_handle.update(cx, |multi_workspace, cx| {
multi_workspace.register_sidebar(sidebar, window, cx);
});
})
.ok();
});
})
.detach();