diff --git a/Cargo.lock b/Cargo.lock index 007fcac22c93a5e86a034041603faf3c24dea4c1..d7638023beda2f92983e8a486bd7e4de132393c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 4ce16aa36a52428fe81bf31391a30d16cecb9824..75addb8f8835be32e0e82d84253650d2a1a2d21c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 } diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 7a0910726e03221dc0a105d69c4852e7515e0c35..8b06417d2f5812ef2e0fb265e6afa4cfeb26eb3f 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -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 diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 4a40f3303b8aab6f7ad5794e564d79e2c4943d98..232786f9e543b8ed7903a3a4d5d70ea630bd836e 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -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>, -); - -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::() - .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> { - let window_id = window.window_handle().window_id(); - let multi_workspace = window.root::().flatten()?; - - if !cx.has_global::() { - cx.set_global(SidebarsByWindow::default()); - } - - cx.global_mut::() - .0 - .retain(|_, weak| weak.upgrade().is_some()); - - let existing = cx - .global::() - .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::() - .0 - .insert(window_id, sidebar.downgrade()); - Some(sidebar) -} - fn read_serialized_panel(workspace_id: workspace::WorkspaceId) -> Option { 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::(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::(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, on_boarding_upsell_dismissed: AtomicBool, _active_view_observation: Option, - pub(crate) sidebar: Option>, } 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) -> Option { - 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) -> Option { - 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) -> 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, _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(), } } } diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 2488bc1687cc01af47e1939a5feff8b49cce64dd..b2fb8d67f7c9d224fe7f58a94396bc1f4c182f7e 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -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; diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index c9a6c7334d22cc3159c4fc6dde02fe57c8676ae9..277b3412e9862e6458d1e6999a6fdede7bd1ec37 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -2130,7 +2130,7 @@ impl ConversationView { acp_thread.connection().clone().downcast() } - pub(crate) fn as_native_thread(&self, cx: &App) -> Option> { + pub fn as_native_thread(&self, cx: &App) -> Option> { 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::().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 diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 3bee1499d983da06e345cae23c3deba5973b22e6..63442e7aa9c526172966b5d6a340205a9cfc7780 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -532,7 +532,7 @@ impl ThreadView { acp_thread.connection().clone().downcast() } - pub(crate) fn as_native_thread(&self, cx: &App) -> Option> { + pub fn as_native_thread(&self, cx: &App) -> Option> { let acp_thread = self.thread.read(cx); self.as_native_connection(cx)? .thread(acp_thread.session_id(), cx) diff --git a/crates/debugger_ui/src/tests/stack_frame_list.rs b/crates/debugger_ui/src/tests/stack_frame_list.rs index 9a9a9316fb09def438f78734831c5e560c838fba..1f5ac5dea4a19af338feceaa2ee51fd9322fa9a5 100644 --- a/crates/debugger_ui/src/tests/stack_frame_list.rs +++ b/crates/debugger_ui/src/tests/stack_frame_list.rs @@ -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"); diff --git a/crates/platform_title_bar/src/platform_title_bar.rs b/crates/platform_title_bar/src/platform_title_bar.rs index 1db29b0f53d9e7b185e6c3cd3029ed2e6077753e..7053fe89e7fdc6ece9ad50fdd8facaf31dba3086 100644 --- a/crates/platform_title_bar/src/platform_title_bar.rs +++ b/crates/platform_title_bar/src/platform_title_bar.rs @@ -31,6 +31,8 @@ pub struct PlatformTitleBar { children: SmallVec<[AnyElement; 2]>, should_move: bool, system_window_tabs: Entity, + 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.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.sidebar_has_notifications = has_notifications; + cx.notify(); + } + pub fn is_multi_workspace_enabled(cx: &App) -> bool { cx.has_flag::() && !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.)) diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..0539450e1444535349a9ee53c195deb1ad830e10 --- /dev/null +++ b/crates/sidebar/Cargo.toml @@ -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"] } diff --git a/crates/sidebar/LICENSE-GPL b/crates/sidebar/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/sidebar/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/agent_ui/src/sidebar.rs b/crates/sidebar/src/sidebar.rs similarity index 94% rename from crates/agent_ui/src/sidebar.rs rename to crates/sidebar/src/sidebar.rs index 87f4efeb0cf145f635b9af9d6923ec53a8e38ff5..f79cc99c651f2a80815629d5c56a6e4e95f90a5d 100644 --- a/crates/agent_ui/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -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::(&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, - persistence_key: Option, - is_open: bool, width: Pixels, focus_handle: FocusHandle, filter_editor: Entity, @@ -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::(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> = 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, ) -> 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, ) -> Option { @@ -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, ) -> 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, ) -> 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, - ) -> impl IntoElement { - let icon = if docked_right { - IconName::ThreadsSidebarRightOpen - } else { - IconName::ThreadsSidebarLeftOpen - }; + fn render_sidebar_toggle_button(&self, _cx: &mut Context) -> 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) { 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) { - 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) { - 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, - ) { - 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, cx: &mut Context) { + fn set_width(&mut self, width: Option, cx: &mut Context) { 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) -> 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, cx: &mut gpui::VisualTestContext, ) -> Entity { - let (sidebar, _panel) = setup_sidebar_with_agent_panel(multi_workspace, cx); - sidebar - } - - fn setup_sidebar_with_agent_panel( - multi_workspace: &Entity, - cx: &mut gpui::VisualTestContext, - ) -> (Entity, Entity) { - 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::() { - 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, 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 { + 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| ::set_global(fs.clone(), cx)); + project::Project::test(fs, [worktree_path.as_ref()], cx).await + } + fn add_agent_panel( workspace: &Entity, project: &Entity, @@ -3257,12 +3180,23 @@ mod tests { }) } + fn setup_sidebar_with_agent_panel( + multi_workspace: &Entity, + project: &Entity, + cx: &mut gpui::VisualTestContext, + ) -> (Entity, Entity) { + 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" ); }); } diff --git a/crates/title_bar/Cargo.toml b/crates/title_bar/Cargo.toml index f6483d1d70d4017edf8ab8b188d67ecf85e19aef..b5c10835c6bf85ea24db1ff9bad5abbbf3b517ee 100644 --- a/crates/title_bar/Cargo.toml +++ b/crates/title_bar/Cargo.toml @@ -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 diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 7fc86706a3eb0971b1f8539d76b8daf3b709537e..4e20c607c297c5461582a32f07352b993dcf4aa7 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -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, cx| { let Some(multi_workspace_handle) = window_handle.downcast::() @@ -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, + ) -> Option { + if !cx.has_flag::() || 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) -> impl IntoElement { let workspace = self.workspace.clone(); diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index cb60978d85220baa8519a7a1816434b4c06eb0c3..7dda99774c3602809d4ca6a9fe6b92cbb0cc69ff 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -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, cx: &mut Context); + 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, 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 SidebarHandle for Entity { + fn width(&self, cx: &App) -> Pixels { + self.read(cx).width(cx) + } + + fn set_width(&self, width: Option, 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>, - database_id: Option, active_workspace_index: usize, + sidebar: Option>, + sidebar_open: bool, pending_removal_tasks: Vec>, _serialize_task: Option>, _create_task: Option>, @@ -63,10 +111,6 @@ pub struct MultiWorkspace { impl EventEmitter for MultiWorkspace {} -pub fn multi_workspace_enabled(cx: &App) -> bool { - cx.has_flag::() && !DisableAiSettings::get_global(cx).disable_ai -} - impl MultiWorkspace { pub fn new(workspace: Entity, window: &mut Window, cx: &mut Context) -> 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::(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(&mut self, sidebar: Entity) { + 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::() && !DisableAiSettings::get_global(cx).disable_ai + } + + pub fn toggle_sidebar(&mut self, window: &mut Window, cx: &mut Context) { + 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) { + 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.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.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) { 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, cx: &mut Context) { - 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 { - self.database_id - } - - pub fn set_database_id(&mut self, id: Option) { - self.database_id = id; - } - pub fn activate_index(&mut self, index: usize, window: &mut Window, cx: &mut Context) { 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::(cx) } - pub fn active_workspace_database_id(&self, cx: &App) -> Option { + pub fn database_id(&self, cx: &App) -> Option { self.workspace().read(cx).database_id() } @@ -392,7 +533,7 @@ impl MultiWorkspace { } pub fn create_workspace(&mut self, window: &mut Window, cx: &mut Context) { - 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>> { 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) -> impl IntoElement { + let multi_workspace_enabled = self.multi_workspace_enabled(cx); + + let sidebar: Option = 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, _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" + ); + }); + } +} diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 89ce7dade6e17d5b422dceb46cd9b0a6107eaa46..2e581fb89302910629ac785a0a1a703d9a0e69d4 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -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] diff --git a/crates/workspace/src/persistence/model.rs b/crates/workspace/src/persistence/model.rs index c5251f20be9313a50f2256c54823d8839bdfe7fd..0971ebd0ddc9265ccf9ea10da7745ba59914db30 100644 --- a/crates/workspace/src/persistence/model.rs +++ b/crates/workspace/src/persistence/model.rs @@ -63,19 +63,18 @@ pub struct SessionWorkspace { #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] pub struct MultiWorkspaceState { pub active_workspace_id: Option, + 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, pub workspaces: Vec, pub state: MultiWorkspaceState, } -#[derive(Debug, Clone, Copy)] -pub struct MultiWorkspaceId(pub u64); - #[derive(Debug, PartialEq, Clone)] pub(crate) struct SerializedWorkspace { pub(crate) id: WorkspaceId, diff --git a/crates/workspace/src/status_bar.rs b/crates/workspace/src/status_bar.rs index 6164ff3f7f1ba3ee2b578beb6aa0c3ccced50884..cd492f5b92be74ada112ac85dcbacab4f215a874 100644 --- a/crates/workspace/src/status_bar.rs +++ b/crates/workspace/src/status_bar.rs @@ -34,6 +34,7 @@ pub struct StatusBar { right_items: Vec>, active_pane: Entity, _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.workspace_sidebar_open = open; + cx.notify(); + } + pub fn add_left_item(&mut self, item: Entity, window: &mut Window, cx: &mut Context) where T: 'static + StatusItemView, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 38271ac77cf05d9545f22084696837121b13f93d..27493941b206c871ed1bbbab0a4d51e6b11791d8 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -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, cx: &mut AsyncApp, ) -> anyhow::Result { - 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(); diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index b38e5a774d7efe6e46642ed226515d7dff7275d3..45ebd78755b34392db0715896a072faf945d11bc 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -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 diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index 197a7c6003737c486bc6adfb7190a1f23dbcf94b..701413281a0156e9e4015dbedba690257df2fb04 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -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")?; diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index ebc09a7a6b38a02e8b30e20482e4e8656e146933..13b8b7aa158af929445fa3f8a2b2b1b68990b8e1 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -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();