Detailed changes
@@ -15848,6 +15848,37 @@ 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",
+ "action_log",
+ "agent",
+ "agent-client-protocol",
+ "agent_ui",
+ "anyhow",
+ "assistant_text_thread",
+ "chrono",
+ "editor",
+ "feature_flags",
+ "fs",
+ "git",
+ "gpui",
+ "language_model",
+ "menu",
+ "pretty_assertions",
+ "project",
+ "prompt_store",
+ "serde_json",
+ "settings",
+ "theme",
+ "ui",
+ "util",
+ "workspace",
+ "zed_actions",
+]
+
[[package]]
name = "signal-hook"
version = "0.3.18"
@@ -17695,6 +17726,7 @@ dependencies = [
"client",
"cloud_api_types",
"db",
+ "feature_flags",
"git_ui",
"gpui",
"notifications",
@@ -21921,6 +21953,7 @@ dependencies = [
"settings_profile_selector",
"settings_ui",
"shellexpand 2.1.2",
+ "sidebar",
"smol",
"snippet_provider",
"snippets_ui",
@@ -173,6 +173,7 @@ members = [
"crates/settings_profile_selector",
"crates/settings_ui",
"crates/shell_command_parser",
+ "crates/sidebar",
"crates/snippet",
"crates/snippet_provider",
"crates/snippets_ui",
@@ -411,6 +412,7 @@ 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" }
@@ -902,6 +904,7 @@ 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 }
@@ -132,6 +132,7 @@ 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
@@ -62,10 +62,9 @@ use extension_host::ExtensionStore;
use fs::Fs;
use git::repository::validate_worktree_directory;
use gpui::{
- 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,
+ Action, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem, Corner,
+ DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels,
+ Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between,
};
use language::LanguageRegistry;
use language_model::{ConfigurationError, LanguageModelRegistry};
@@ -77,16 +76,14 @@ use search::{BufferSearchBar, buffer_search};
use settings::{Settings, update_settings_file};
use theme::ThemeSettings;
use ui::{
- Button, Callout, ContextMenu, ContextMenuEntry, DocumentationSide, Indicator, KeyBinding,
- PopoverMenu, PopoverMenuHandle, SpinnerLabel, Tab, Tooltip, prelude::*, utils::WithRemSize,
+ Button, Callout, ContextMenu, ContextMenuEntry, DocumentationSide, KeyBinding, PopoverMenu,
+ PopoverMenuHandle, SpinnerLabel, Tab, Tooltip, prelude::*, utils::WithRemSize,
};
use util::{ResultExt as _, debug_panic};
use workspace::{
- CollaboratorId, DraggedSelection, DraggedSidebar, DraggedTab, FocusWorkspaceSidebar,
- MultiWorkspace, OpenResult, PathList, SIDEBAR_RESIZE_HANDLE_SIZE, SerializedPathList,
- ToggleWorkspaceSidebar, ToggleZoom, ToolbarItemView, Workspace, WorkspaceId,
+ CollaboratorId, DraggedSelection, DraggedTab, OpenResult, PathList, SerializedPathList,
+ ToggleZoom, ToolbarItemView, Workspace, WorkspaceId,
dock::{DockPosition, Panel, PanelEvent},
- multi_workspace_enabled,
};
use zed_actions::{
DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize,
@@ -98,55 +95,6 @@ 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();
@@ -467,38 +415,6 @@ pub fn init(cx: &mut App) {
panel.cycle_start_thread_in(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() {
- let was_open = sidebar.read(cx).is_open();
- sidebar.update(cx, |sidebar, cx| {
- sidebar.toggle(window, cx);
- });
- // When closing the sidebar, restore focus to the active pane
- // to avoid "zombie focus" on the now-hidden sidebar elements
- if was_open {
- let active_pane = workspace.active_pane().clone();
- let pane_focus = active_pane.read(cx).focus_handle(cx);
- window.focus(&pane_focus, 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);
- });
- }
- }
});
},
)
@@ -838,7 +754,6 @@ 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 {
@@ -1021,6 +936,7 @@ 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));
@@ -1175,17 +1091,10 @@ 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
}
@@ -3727,130 +3636,9 @@ 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, docked_right: bool, 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);
-
- let icon = if docked_right {
- IconName::ThreadsSidebarRightClosed
- } else {
- IconName::ThreadsSidebarLeftClosed
- };
-
- Some(
- h_flex()
- .h_full()
- .px_1()
- .map(|this| {
- if docked_right {
- this.border_l_1()
- } else {
- this.border_r_1()
- }
- })
- .border_color(cx.theme().colors().border_variant)
- .child(
- IconButton::new("toggle-workspace-sidebar", icon)
- .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 { id, .. } = &self.selected_agent_type {
@@ -4144,12 +3932,6 @@ impl AgentPanel {
let use_v2_empty_toolbar =
has_v2_flag && is_empty_state && !is_in_history_or_config && !is_text_thread;
- let is_sidebar_open = self
- .sidebar
- .as_ref()
- .map(|s| s.read(cx).is_open())
- .unwrap_or(false);
-
let base_container = h_flex()
.id("agent-panel-toolbar")
.h(Tab::container_height(cx))
@@ -4213,11 +3995,8 @@ impl AgentPanel {
.child(
h_flex()
.size_full()
- .gap_1()
- .when(is_sidebar_open || docked_right, |this| this.pl_1())
- .when(!docked_right, |this| {
- this.children(self.render_sidebar_toggle(false, cx))
- })
+ .gap(DynamicSpacing::Base04.rems(cx))
+ .pl(DynamicSpacing::Base04.rems(cx))
.child(agent_selector_menu)
.child(self.render_start_thread_in_selector(cx)),
)
@@ -4235,10 +4014,7 @@ impl AgentPanel {
cx,
))
})
- .child(self.render_panel_options_menu(window, cx))
- .when(docked_right, |this| {
- this.children(self.render_sidebar_toggle(true, cx))
- }),
+ .child(self.render_panel_options_menu(window, cx)),
)
.into_any_element()
} else {
@@ -4265,16 +4041,8 @@ impl AgentPanel {
.child(
h_flex()
.size_full()
- .map(|this| {
- if is_sidebar_open || docked_right {
- this.pl_1().gap_1()
- } else {
- this.pl_0().gap_0p5()
- }
- })
- .when(!docked_right, |this| {
- this.children(self.render_sidebar_toggle(false, cx))
- })
+ .gap(DynamicSpacing::Base04.rems(cx))
+ .pl(DynamicSpacing::Base04.rems(cx))
.child(match &self.active_view {
ActiveView::History { .. } | ActiveView::Configuration => {
self.render_toolbar_back_button(cx).into_any_element()
@@ -4298,10 +4066,7 @@ impl AgentPanel {
cx,
))
})
- .child(self.render_panel_options_menu(window, cx))
- .when(docked_right, |this| {
- this.children(self.render_sidebar_toggle(true, cx))
- }),
+ .child(self.render_panel_options_menu(window, cx)),
)
.into_any_element()
}
@@ -4848,44 +4613,14 @@ 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(panel)
+ .child(content)
.into_any()
}
- _ => panel.into_any(),
+ _ => content.into_any(),
}
}
}
@@ -23,7 +23,6 @@ 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;
@@ -34,8 +33,8 @@ mod text_thread_editor;
mod text_thread_history;
mod thread_history;
mod thread_history_view;
-mod thread_metadata_store;
-mod threads_archive_view;
+pub mod thread_metadata_store;
+pub mod threads_archive_view;
mod ui;
use std::rc::Rc;
@@ -2130,7 +2130,7 @@ impl ConversationView {
acp_thread.connection().clone().downcast()
}
- pub(crate) fn as_native_thread(&self, cx: &App) -> Option<Entity<agent::Thread>> {
+ pub fn as_native_thread(&self, cx: &App) -> Option<Entity<agent::Thread>> {
let acp_thread = self.active_thread()?.read(cx).thread.read(cx);
self.as_native_connection(cx)?
.thread(acp_thread.session_id(), cx)
@@ -2342,7 +2342,7 @@ impl ConversationView {
}
if let Some(multi_workspace) = window.root::<MultiWorkspace>().flatten() {
- crate::agent_panel::sidebar_is_open(window, cx)
+ multi_workspace.read(cx).sidebar_open()
|| self.agent_panel_visible(&multi_workspace, cx)
} else {
self.workspace
@@ -532,7 +532,7 @@ impl ThreadView {
acp_thread.connection().clone().downcast()
}
- pub(crate) fn as_native_thread(&self, cx: &App) -> Option<Entity<agent::Thread>> {
+ pub fn as_native_thread(&self, cx: &App) -> Option<Entity<agent::Thread>> {
let acp_thread = self.thread.read(cx);
self.as_native_connection(cx)?
.thread(acp_thread.session_id(), cx)
@@ -1211,9 +1211,7 @@ async fn test_stack_frame_filter_persistence(
cx.run_until_parked();
let workspace_id = workspace
- .update(cx, |workspace, _window, cx| {
- workspace.active_workspace_database_id(cx)
- })
+ .update(cx, |workspace, _window, cx| workspace.database_id(cx))
.ok()
.flatten()
.expect("workspace id has to be some for this test to work properly");
@@ -31,6 +31,8 @@ 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 {
@@ -44,6 +46,8 @@ impl PlatformTitleBar {
children: SmallVec::new(),
should_move: false,
system_window_tabs,
+ workspace_sidebar_open: false,
+ sidebar_has_notifications: false,
}
}
@@ -70,6 +74,28 @@ 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
}
@@ -84,6 +110,9 @@ 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()
@@ -132,7 +161,9 @@ impl Render for PlatformTitleBar {
.map(|this| {
if window.is_fullscreen() {
this.pl_2()
- } else if self.platform_style == PlatformStyle::Mac {
+ } else if self.platform_style == PlatformStyle::Mac
+ && !is_multiworkspace_sidebar_open
+ {
this.pl(px(TRAFFIC_LIGHT_PADDING))
} else {
this.pl_2()
@@ -144,9 +175,10 @@ impl Render for PlatformTitleBar {
.when(!(tiling.top || tiling.right), |el| {
el.rounded_tr(theme::CLIENT_SIDE_DECORATION_ROUNDING)
})
- .when(!(tiling.top || tiling.left), |el| {
- el.rounded_tl(theme::CLIENT_SIDE_DECORATION_ROUNDING)
- })
+ .when(
+ !(tiling.top || tiling.left) && !is_multiworkspace_sidebar_open,
+ |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.))
@@ -0,0 +1,54 @@
+[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
+action_log.workspace = true
+agent.workspace = true
+agent-client-protocol.workspace = true
+agent_ui.workspace = true
+anyhow.workspace = true
+chrono.workspace = true
+editor.workspace = true
+feature_flags.workspace = true
+fs.workspace = true
+gpui.workspace = true
+menu.workspace = true
+project.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"] }
+pretty_assertions.workspace = true
+prompt_store.workspace = true
+serde_json.workspace = true
+feature_flags.workspace = true
+fs = { workspace = true, features = ["test-support"] }
+git.workspace = true
+gpui = { workspace = true, features = ["test-support"] }
+project = { workspace = true, features = ["test-support"] }
+settings = { workspace = true, features = ["test-support"] }
+workspace = { workspace = true, features = ["test-support"] }
@@ -0,0 +1 @@
+../../LICENSE-GPL
@@ -1,13 +1,11 @@
-use crate::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore};
-use crate::threads_archive_view::{ThreadsArchiveView, ThreadsArchiveViewEvent};
-use crate::{Agent, AgentPanel, AgentPanelEvent, NewThread, RemoveSelectedThread};
use acp_thread::ThreadStatus;
use action_log::DiffStats;
use agent::ThreadStore;
use agent_client_protocol::{self as acp};
-use agent_settings::AgentSettings;
+use agent_ui::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore};
+use agent_ui::threads_archive_view::{ThreadsArchiveView, ThreadsArchiveViewEvent};
+use agent_ui::{Agent, AgentPanel, AgentPanelEvent, NewThread, RemoveSelectedThread};
use chrono::Utc;
-use db::kvp::KEY_VALUE_STORE;
use editor::Editor;
use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _};
use gpui::{
@@ -16,7 +14,7 @@ use gpui::{
};
use menu::{Cancel, Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
use project::{AgentId, Event as ProjectEvent};
-use settings::Settings;
+
use std::collections::{HashMap, HashSet};
use std::mem;
use std::path::Path;
@@ -29,8 +27,10 @@ use ui::{
use util::ResultExt as _;
use util::path_list::PathList;
use workspace::{
- MultiWorkspace, MultiWorkspaceEvent, ToggleWorkspaceSidebar, Workspace, multi_workspace_enabled,
+ MultiWorkspace, MultiWorkspaceEvent, Sidebar as WorkspaceSidebar, ToggleWorkspaceSidebar,
+ Workspace,
};
+
use zed_actions::editor::{MoveDown, MoveUp};
actions!(
@@ -47,7 +47,6 @@ 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";
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum SidebarView {
@@ -56,26 +55,6 @@ enum SidebarView {
Archive,
}
-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 {
session_id: acp::SessionId,
@@ -233,8 +212,6 @@ fn workspace_label_from_path_list(path_list: &PathList) -> SharedString {
pub struct Sidebar {
multi_workspace: WeakEntity<MultiWorkspace>,
- persistence_key: Option<u64>,
- is_open: bool,
width: Pixels,
focus_handle: FocusHandle,
filter_editor: Entity<Editor>,
@@ -276,6 +253,7 @@ impl Sidebar {
window,
|this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event {
MultiWorkspaceEvent::ActiveWorkspaceChanged => {
+ this.focused_thread = None;
this.update_entries(false, cx);
}
MultiWorkspaceEvent::WorkspaceAdded(workspace) => {
@@ -318,16 +296,9 @@ impl Sidebar {
this.update_entries(false, 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 {
_update_entries_task: None,
multi_workspace: multi_workspace.downgrade(),
- persistence_key,
- is_open,
width: DEFAULT_WIDTH,
focus_handle,
filter_editor,
@@ -413,10 +384,25 @@ impl Sidebar {
cx.subscribe_in(
agent_panel,
window,
- |this, _agent_panel, event: &AgentPanelEvent, _window, cx| match event {
- AgentPanelEvent::ActiveViewChanged
- | AgentPanelEvent::ThreadFocused
- | AgentPanelEvent::BackgroundThreadChanged => {
+ |this, agent_panel, event: &AgentPanelEvent, _window, cx| match event {
+ AgentPanelEvent::ActiveViewChanged => {
+ this.focused_thread = agent_panel
+ .read(cx)
+ .active_conversation()
+ .and_then(|cv| cv.read(cx).parent_id(cx));
+ this.update_entries(false, cx);
+ }
+ AgentPanelEvent::ThreadFocused => {
+ let new_focused = agent_panel
+ .read(cx)
+ .active_conversation()
+ .and_then(|cv| cv.read(cx).parent_id(cx));
+ if new_focused.is_some() && new_focused != this.focused_thread {
+ this.focused_thread = new_focused;
+ this.update_entries(false, cx);
+ }
+ }
+ AgentPanelEvent::BackgroundThreadChanged => {
this.update_entries(false, cx);
}
},
@@ -483,12 +469,6 @@ 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_conversation().cloned())
- .and_then(|cv| cv.read(cx).parent_id(cx));
-
let mut threads_by_paths: HashMap<PathList, Vec<ThreadMetadata>> = HashMap::new();
for row in thread_entries {
threads_by_paths
@@ -918,7 +898,7 @@ impl Sidebar {
let Some(multi_workspace) = self.multi_workspace.upgrade() else {
return;
};
- if !multi_workspace_enabled(cx) {
+ if !multi_workspace.read(cx).multi_workspace_enabled(cx) {
return;
}
@@ -983,8 +963,6 @@ impl Sidebar {
let is_group_header_after_first =
ix > 0 && matches!(entry, ListEntry::ProjectHeader { .. });
- let docked_right = AgentSettings::get_global(cx).dock == settings::DockPosition::Right;
-
let rendered = match entry {
ListEntry::ProjectHeader {
path_list,
@@ -1001,12 +979,9 @@ impl Sidebar {
highlight_positions,
*has_threads,
is_selected,
- docked_right,
cx,
),
- ListEntry::Thread(thread) => {
- self.render_thread(ix, thread, is_selected, docked_right, cx)
- }
+ ListEntry::Thread(thread) => self.render_thread(ix, thread, is_selected, cx),
ListEntry::ViewMore {
path_list,
remaining_count,
@@ -1047,7 +1022,6 @@ impl Sidebar {
highlight_positions: &[usize],
has_threads: bool,
is_selected: bool,
- docked_right: bool,
cx: &mut Context<Self>,
) -> AnyElement {
let id_prefix = if is_sticky { "sticky-" } else { "" };
@@ -1063,7 +1037,6 @@ impl Sidebar {
};
let workspace_for_new_thread = workspace.clone();
let workspace_for_remove = workspace.clone();
- // let workspace_for_activate = workspace.clone();
let path_list_for_toggle = path_list.clone();
let path_list_for_collapse = path_list.clone();
@@ -1094,7 +1067,6 @@ impl Sidebar {
.group_name(group_name)
.toggle_state(is_active_workspace)
.focused(is_selected)
- .docked_right(docked_right)
.child(
h_flex()
.relative()
@@ -1179,7 +1151,6 @@ impl Sidebar {
fn render_sticky_header(
&self,
- docked_right: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<AnyElement> {
@@ -1223,7 +1194,6 @@ impl Sidebar {
&highlight_positions,
*has_threads,
is_selected,
- docked_right,
cx,
);
@@ -1265,6 +1235,8 @@ impl Sidebar {
return;
};
+ self.focused_thread = None;
+
multi_workspace.update(cx, |multi_workspace, cx| {
multi_workspace.activate(workspace.clone(), cx);
});
@@ -1693,7 +1665,6 @@ impl Sidebar {
ix: usize,
thread: &ThreadEntry,
is_focused: bool,
- docked_right: bool,
cx: &mut Context<Self>,
) -> AnyElement {
let has_notification = self
@@ -1763,7 +1734,6 @@ impl Sidebar {
})
.selected(is_selected)
.focused(is_focused)
- .docked_right(docked_right)
.hovered(is_hovered)
.on_hover(cx.listener(move |this, is_hovered: &bool, _window, cx| {
if *is_hovered {
@@ -1938,92 +1908,75 @@ impl Sidebar {
fn render_thread_list_header(
&self,
- docked_right: bool,
+ window: &Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
let has_query = self.has_filter_query(cx);
+ let needs_traffic_light_padding = cfg!(target_os = "macos") && !window.is_fullscreen();
- h_flex()
- .h(Tab::container_height(cx))
+ v_flex()
.flex_none()
- .gap_1p5()
- .border_b_1()
- .border_color(cx.theme().colors().border)
- .when(!docked_right, |this| {
- this.child(self.render_sidebar_toggle_button(false, cx))
- })
- .child(self.render_filter_input())
.child(
h_flex()
- .gap_0p5()
- .when(!docked_right, |this| this.pr_1p5())
- .when(has_query, |this| {
- this.child(
- IconButton::new("clear_filter", IconName::Close)
- .shape(IconButtonShape::Square)
- .tooltip(Tooltip::text("Clear Search"))
- .on_click(cx.listener(|this, _, window, cx| {
- this.reset_filter_editor_text(window, cx);
- this.update_entries(false, cx);
- })),
- )
+ .h(Tab::container_height(cx) - px(1.))
+ .border_b_1()
+ .border_color(cx.theme().colors().border)
+ .when(needs_traffic_light_padding, |this| {
+ this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
})
+ .child(self.render_sidebar_toggle_button(cx)),
+ )
+ .child(
+ h_flex()
+ .h(Tab::container_height(cx))
+ .gap_1p5()
+ .px_1p5()
+ .border_b_1()
+ .border_color(cx.theme().colors().border)
+ .child(self.render_filter_input())
.child(
- IconButton::new("archive", IconName::Archive)
- .icon_size(IconSize::Small)
- .tooltip(Tooltip::text("Archive"))
- .on_click(cx.listener(|this, _, window, cx| {
- this.show_archive(window, cx);
- })),
+ h_flex()
+ .gap_0p5()
+ .when(has_query, |this| {
+ this.child(
+ IconButton::new("clear_filter", IconName::Close)
+ .shape(IconButtonShape::Square)
+ .tooltip(Tooltip::text("Clear Search"))
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.reset_filter_editor_text(window, cx);
+ this.update_entries(false, cx);
+ })),
+ )
+ })
+ .child(
+ IconButton::new("archive", IconName::Archive)
+ .icon_size(IconSize::Small)
+ .tooltip(Tooltip::text("Archive"))
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.show_archive(window, cx);
+ })),
+ ),
),
)
- .when(docked_right, |this| {
- this.pl_2()
- .pr_0p5()
- .child(self.render_sidebar_toggle_button(true, cx))
- })
}
- fn render_sidebar_toggle_button(
- &self,
- docked_right: bool,
- cx: &mut Context<Self>,
- ) -> impl IntoElement {
- let icon = if docked_right {
- IconName::ThreadsSidebarRightOpen
- } else {
- IconName::ThreadsSidebarLeftOpen
- };
+ fn render_sidebar_toggle_button(&self, _cx: &mut Context<Self>) -> impl IntoElement {
+ let icon = IconName::ThreadsSidebarLeftOpen;
- h_flex()
- .h_full()
- .px_1()
- .map(|this| {
- if docked_right {
- this.pr_1p5().border_l_1()
- } else {
- this.border_r_1()
- }
- })
- .border_color(cx.theme().colors().border_variant)
- .child(
- IconButton::new("sidebar-close-toggle", icon)
- .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().h_full().child(
+ IconButton::new("sidebar-close-toggle", icon)
+ .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);
+ }),
+ )
}
}
impl Sidebar {
- pub fn is_open(&self) -> bool {
- self.is_open
- }
-
fn show_archive(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(active_workspace) = self.multi_workspace.upgrade().and_then(|w| {
w.read(cx)
@@ -2088,61 +2041,19 @@ impl Sidebar {
window.focus(&self.focus_handle, cx);
cx.notify();
}
+}
- 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 {
+impl WorkspaceSidebar for Sidebar {
+ fn width(&self, _cx: &App) -> Pixels {
self.width
}
- pub fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
+ 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();
}
- pub fn has_notifications(&self, _cx: &App) -> bool {
+ fn has_notifications(&self, _cx: &App) -> bool {
!self.contents.notified_threads.is_empty()
}
}
@@ -2155,9 +2066,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 docked_right = AgentSettings::get_global(cx).dock == settings::DockPosition::Right;
- let sticky_header = self.render_sticky_header(docked_right, window, cx);
+ let sticky_header = self.render_sticky_header(window, cx);
v_flex()
.id("workspace-sidebar")
@@ -2175,11 +2086,14 @@ impl Render for Sidebar {
.on_action(cx.listener(Self::cancel))
.on_action(cx.listener(Self::remove_selected_thread))
.font(ui_font)
- .size_full()
+ .h_full()
+ .w(self.width)
.bg(cx.theme().colors().surface_background)
+ .border_r_1()
+ .border_color(cx.theme().colors().border)
.map(|this| match self.view {
SidebarView::ThreadList => this
- .child(self.render_thread_list_header(docked_right, cx))
+ .child(self.render_thread_list_header(window, cx))
.child(
v_flex()
.relative()
@@ -2210,21 +2124,25 @@ 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 pretty_assertions::assert_eq;
+ use settings::SettingsStore;
use std::{path::PathBuf, 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);
ThreadMetadataStore::init_global(cx);
@@ -2249,33 +2167,14 @@ mod tests {
multi_workspace: &Entity<MultiWorkspace>,
cx: &mut gpui::VisualTestContext,
) -> Entity<Sidebar> {
- 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);
- });
+ 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(cx, |mw, _cx| {
+ mw.register_sidebar(sidebar.clone());
});
cx.run_until_parked();
- let sidebar = panel.read_with(cx, |panel, _cx| {
- panel
- .sidebar
- .clone()
- .expect("AgentPanel should have created a sidebar")
- });
- (sidebar, panel)
+ sidebar
}
async fn save_n_test_threads(
@@ -2350,9 +2249,16 @@ mod tests {
}
fn open_and_focus_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
+ let multi_workspace = sidebar.read_with(cx, |s, _| s.multi_workspace.upgrade());
+ if let Some(multi_workspace) = multi_workspace {
+ multi_workspace.update_in(cx, |mw, window, cx| {
+ if !mw.sidebar_open() {
+ mw.toggle_sidebar(window, cx);
+ }
+ });
+ }
cx.run_until_parked();
- sidebar.update_in(cx, |sidebar, window, cx| {
- sidebar.set_open(true, cx);
+ sidebar.update_in(cx, |_, window, cx| {
cx.focus_self(window);
});
cx.run_until_parked();
@@ -3009,9 +2915,6 @@ 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());
@@ -3244,6 +3147,26 @@ 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);
+ ThreadMetadataStore::init_global(cx);
+ language_model::LanguageModelRegistry::test(cx);
+ prompt_store::init(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>,
@@ -3257,12 +3180,23 @@ 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("/my-project", cx).await;
+ let project = init_test_project_with_agent_panel("/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, cx);
+ let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
@@ -3305,10 +3239,10 @@ mod tests {
#[gpui::test]
async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) {
- let project_a = init_test_project("/project-a", cx).await;
+ let project_a = init_test_project_with_agent_panel("/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, cx);
+ let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx);
let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
@@ -4002,10 +3936,10 @@ mod tests {
#[gpui::test]
async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) {
- let project = init_test_project("/my-project", cx).await;
+ let project = init_test_project_with_agent_panel("/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, cx);
+ let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
@@ -4050,10 +3984,10 @@ mod tests {
#[gpui::test]
async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
- let project_a = init_test_project("/project-a", cx).await;
+ let project_a = init_test_project_with_agent_panel("/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, cx);
+ let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx);
let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
@@ -4082,8 +4016,7 @@ 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) and has no thread, so its header
- // is the active entry.
+ // Workspace B is active (just added), so its header is the active entry.
sidebar.read_with(cx, |sidebar, _cx| {
assert_eq!(
sidebar.focused_thread, None,
@@ -4098,7 +4031,6 @@ mod tests {
);
});
- // ── 2. Click thread in workspace A via sidebar ───────────────────────
sidebar.update_in(cx, |sidebar, window, cx| {
sidebar.activate_thread(
Agent::NativeAgent,
@@ -4143,7 +4075,6 @@ 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()),
@@ -4155,16 +4086,6 @@ mod tests {
save_test_thread_metadata(&session_id_b, path_list_b.clone(), 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| {
@@ -4200,30 +4121,25 @@ 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.as_ref(),
- Some(&session_id_a),
- "Switching workspaces should derive focused_thread from the new active workspace"
+ sidebar.focused_thread, None,
+ "External workspace switch 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::Thread(thread)) if thread.session_info.session_id == session_id_a),
- "Active entry should be workspace_a's active thread"
+ matches!(active_entry, Some(ListEntry::ProjectHeader { .. })),
+ "Active entry should be the workspace header after external switch"
);
});
- // ── 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()),
@@ -4234,48 +4150,69 @@ mod tests {
save_test_thread_metadata(&session_id_b2, path_list_b.clone(), 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_a),
- "Opening a thread in a non-active workspace should not affect focused_thread"
+ Some(&session_id_b2),
+ "Opening a thread externally should set focused_thread"
);
});
- // ── 6. Activating workspace B shows its active thread ────────────────
- sidebar.update_in(cx, |sidebar, window, cx| {
- sidebar.activate_workspace(&workspace_b, window, cx);
+ workspace_b.update_in(cx, |workspace, window, cx| {
+ workspace.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),
- "Activating workspace_b should show workspace_b's active thread"
+ "Defocusing the sidebar should not clear focused_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::Thread(thread)) if thread.session_info.session_id == session_id_b2),
- "Active entry should be workspace_b's active thread"
+ matches!(active_entry, Some(ListEntry::ProjectHeader { .. })),
+ "Active entry should be the workspace header"
);
});
- // ── 7. Switching back to workspace A reflects its thread ─────────────
- multi_workspace.update_in(cx, |mw, window, cx| {
- mw.activate_next_workspace(window, cx);
+ // ── 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_conversation_view() {
+ thread_view.read(cx).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_a),
- "Switching back to workspace_a should show its active thread"
+ Some(&session_id_b2),
+ "Focusing the agent panel thread should set focused_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"
);
});
}
@@ -38,6 +38,7 @@ 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
@@ -24,13 +24,16 @@ 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::{Project, git_store::GitStoreEvent, trusted_worktrees::TrustedWorktrees};
+use project::{
+ DisableAiSettings, Project, git_store::GitStoreEvent, trusted_worktrees::TrustedWorktrees,
+};
use remote::RemoteConnectionOptions;
use settings::Settings;
use settings::WorktreeId;
@@ -44,7 +47,8 @@ use ui::{
use update_version::UpdateVersion;
use util::ResultExt;
use workspace::{
- MultiWorkspace, ToggleWorktreeSecurity, Workspace, notifications::NotifyResultExt,
+ MultiWorkspace, ToggleWorkspaceSidebar, ToggleWorktreeSecurity, Workspace,
+ notifications::NotifyResultExt,
};
use zed_actions::OpenRemote;
@@ -170,6 +174,7 @@ 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| {
@@ -352,6 +357,7 @@ 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>()
@@ -364,8 +370,26 @@ impl TitleBar {
return;
};
+ let is_open = multi_workspace.read(cx).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).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());
});
}
@@ -663,6 +687,44 @@ 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::ThreadsSidebarLeftClosed,
+ )
+ .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, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let workspace = self.workspace.clone();
@@ -1,8 +1,9 @@
use anyhow::Result;
use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt};
use gpui::{
- App, Context, Entity, EntityId, EventEmitter, Focusable, ManagedView, Pixels, Render,
- Subscription, Task, Tiling, Window, WindowId, actions, px,
+ AnyView, App, Context, DragMoveEvent, Entity, EntityId, EventEmitter, FocusHandle, Focusable,
+ ManagedView, MouseButton, Pixels, Render, Subscription, Task, Tiling, Window, WindowId,
+ actions, deferred, px,
};
use project::{DisableAiSettings, Project};
use settings::Settings;
@@ -11,12 +12,11 @@ use std::path::PathBuf;
use ui::prelude::*;
use util::ResultExt;
-pub const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0);
+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,6 +41,22 @@ pub enum MultiWorkspaceEvent {
WorkspaceRemoved(EntityId),
}
+pub trait Sidebar: 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;
+}
+
+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;
+}
+
#[derive(Clone)]
pub struct DraggedSidebar;
@@ -50,11 +66,43 @@ 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)
+ }
+}
+
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,
pending_removal_tasks: Vec<Task<()>>,
_serialize_task: Option<Task<()>>,
_create_task: Option<Task<()>>,
@@ -63,10 +111,6 @@ 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| {
@@ -81,19 +125,118 @@ 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,
pending_removal_tasks: Vec::new(),
_serialize_task: None,
_create_task: None,
- _subscriptions: vec![release_subscription, quit_subscription],
+ _subscriptions: vec![
+ release_subscription,
+ quit_subscription,
+ settings_subscription,
+ ],
}
}
+ pub fn register_sidebar<T: Sidebar>(&mut self, sidebar: Entity<T>) {
+ self.sidebar = Some(Box::new(sidebar));
+ }
+
+ pub fn sidebar(&self) -> Option<&dyn SidebarHandle> {
+ self.sidebar.as_deref()
+ }
+
+ pub fn sidebar_open(&self) -> bool {
+ self.sidebar_open
+ }
+
+ pub fn sidebar_has_notifications(&self, cx: &App) -> bool {
+ self.sidebar
+ .as_ref()
+ .map_or(false, |s| s.has_notifications(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| {
@@ -142,7 +285,7 @@ impl MultiWorkspace {
}
pub fn activate(&mut self, workspace: Entity<Workspace>, cx: &mut Context<Self>) {
- if !multi_workspace_enabled(cx) {
+ if !self.multi_workspace_enabled(cx) {
self.workspaces[0] = workspace;
self.active_workspace_index = 0;
cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged);
@@ -178,6 +321,11 @@ 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));
@@ -186,14 +334,6 @@ 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(),
@@ -231,6 +371,7 @@ 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;
@@ -349,7 +490,7 @@ impl MultiWorkspace {
self.workspace().read(cx).items_of_type::<T>(cx)
}
- pub fn active_workspace_database_id(&self, cx: &App) -> Option<WorkspaceId> {
+ pub fn database_id(&self, cx: &App) -> Option<WorkspaceId> {
self.workspace().read(cx).database_id()
}
@@ -392,7 +533,7 @@ impl MultiWorkspace {
}
pub fn create_workspace(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- if !multi_workspace_enabled(cx) {
+ if !self.multi_workspace_enabled(cx) {
return;
}
let app_state = self.workspace().read(cx).app_state().clone();
@@ -501,7 +642,7 @@ impl MultiWorkspace {
) -> Task<Result<Entity<Workspace>>> {
let workspace = self.workspace().clone();
- if multi_workspace_enabled(cx) {
+ if self.multi_workspace_enabled(cx) {
workspace.update(cx, |workspace, cx| {
workspace.open_workspace_for_paths(true, paths, window, cx)
})
@@ -528,6 +669,57 @@ 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;
@@ -557,6 +749,32 @@ 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()
@@ -569,9 +787,98 @@ impl Render for MultiWorkspace {
window,
cx,
Tiling {
- left: false,
+ left: multi_workspace_enabled && self.sidebar_open(),
..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.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.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.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.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.sidebar_open(),
+ "Sidebar should open when toggled after re-enabling AI"
+ );
+ });
+ }
+}
@@ -341,7 +341,6 @@ 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,
}
@@ -3886,6 +3885,7 @@ mod tests {
window_10,
MultiWorkspaceState {
active_workspace_id: Some(WorkspaceId(2)),
+ sidebar_open: true,
},
)
.await;
@@ -3894,6 +3894,7 @@ mod tests {
window_20,
MultiWorkspaceState {
active_workspace_id: Some(WorkspaceId(3)),
+ sidebar_open: false,
},
)
.await;
@@ -3931,20 +3932,23 @@ 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.
+ // Window 10 group: 2 workspaces, active_workspace_id = 2, sidebar open.
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.
+ // Window 20 group: 1 workspace, active_workspace_id = 3, sidebar closed.
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]
@@ -63,19 +63,18 @@ 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.
+/// 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.
#[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,
@@ -34,6 +34,7 @@ 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 {
@@ -51,9 +52,10 @@ impl Render for StatusBar {
.when(!(tiling.bottom || tiling.right), |el| {
el.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING)
})
- .when(!(tiling.bottom || tiling.left), |el| {
- el.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING)
- })
+ .when(
+ !(tiling.bottom || tiling.left) && !self.workspace_sidebar_open,
+ |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,11 +93,17 @@ 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,
@@ -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_RESIZE_HANDLE_SIZE, ToggleWorkspaceSidebar, multi_workspace_enabled,
+ NewWorkspaceInWindow, NextWorkspaceInWindow, PreviousWorkspaceInWindow, Sidebar, SidebarHandle,
+ ToggleWorkspaceSidebar,
};
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, MultiWorkspaceId, SerializedMultiWorkspace,
- SerializedWorkspaceLocation, SessionWorkspace,
+ DockStructure, ItemId, SerializedMultiWorkspace, SerializedWorkspaceLocation,
+ SessionWorkspace,
},
read_serialized_multi_workspaces,
};
@@ -2153,6 +2153,12 @@ 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
}
@@ -8206,11 +8212,7 @@ pub async fn restore_multiworkspace(
app_state: Arc<AppState>,
cx: &mut AsyncApp,
) -> anyhow::Result<MultiWorkspaceRestoreResult> {
- let SerializedMultiWorkspace {
- workspaces,
- state,
- id: window_id,
- } = multi_workspace;
+ let SerializedMultiWorkspace { workspaces, state } = multi_workspace;
let mut group_iter = workspaces.into_iter();
let first = group_iter
.next()
@@ -8274,7 +8276,6 @@ 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()
@@ -8296,6 +8297,14 @@ 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();
@@ -182,6 +182,7 @@ 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
@@ -103,8 +103,8 @@ use {
feature_flags::FeatureFlagAppExt as _,
git_ui::project_diff::ProjectDiff,
gpui::{
- Action as _, App, AppContext as _, Bounds, KeyBinding, Modifiers, VisualTestAppContext,
- WindowBounds, WindowHandle, WindowOptions, point, px, size,
+ App, AppContext as _, Bounds, KeyBinding, Modifiers, VisualTestAppContext, WindowBounds,
+ WindowHandle, WindowOptions, point, px, size,
},
image::RgbaImage,
project::AgentId,
@@ -2650,6 +2650,22 @@ 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());
+ })
+ .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| {
@@ -2727,8 +2743,8 @@ fn run_multi_workspace_sidebar_visual_tests(
// Open the sidebar
multi_workspace_window
- .update(cx, |_multi_workspace, window, cx| {
- window.dispatch_action(workspace::ToggleWorkspaceSidebar.boxed_clone(), cx);
+ .update(cx, |multi_workspace, window, cx| {
+ multi_workspace.toggle_sidebar(window, cx);
})
.context("Failed to toggle sidebar")?;
@@ -3166,10 +3182,24 @@ 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());
+ })
+ .context("Failed to register sidebar")?;
+
// Open the sidebar
workspace_window
- .update(cx, |_multi_workspace, window, cx| {
- window.dispatch_action(workspace::ToggleWorkspaceSidebar.boxed_clone(), cx);
+ .update(cx, |multi_workspace, window, cx| {
+ multi_workspace.toggle_sidebar(window, cx);
})
.context("Failed to toggle sidebar")?;
@@ -68,6 +68,7 @@ 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,
@@ -388,6 +389,20 @@ 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);
+ });
+ })
+ .ok();
+ });
})
.detach();