From 3da868a4bf2395337b0993b72f3adca13cd8b30f Mon Sep 17 00:00:00 2001 From: "zed-zippy[bot]" <234243425+zed-zippy[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 11:08:56 +0000 Subject: [PATCH] agent_ui: Require an open project for agent panel (#56577) (cherry-pick to preview) (#56716) Cherry-pick of #56577 to preview ---- A bit brute force, but it works. image Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --------- Co-authored-by: Danilo Leal Co-authored-by: Ben Brandt Co-authored-by: Danilo Leal --- crates/agent_ui/src/agent_panel.rs | 338 ++++++++++++++---- crates/git_ui/src/git_panel.rs | 22 +- crates/project_panel/src/project_panel.rs | 75 ++-- crates/sidebar/src/sidebar.rs | 81 ++--- crates/sidebar/src/sidebar_tests.rs | 37 ++ crates/ui/src/components.rs | 2 + .../ui/src/components/project_empty_state.rs | 94 +++++ crates/zed/src/main.rs | 39 +- 8 files changed, 513 insertions(+), 175 deletions(-) create mode 100644 crates/ui/src/components/project_empty_state.rs diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 77d50667efb..6717695dd5d 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -78,8 +78,8 @@ use terminal::{Event as TerminalEvent, terminal_settings::TerminalSettings}; use terminal_view::{TerminalView, terminal_panel::TerminalPanel}; use theme_settings::ThemeSettings; use ui::{ - Button, ContextMenu, ContextMenuEntry, GradientFade, IconButton, PopoverMenu, - PopoverMenuHandle, Tab, Tooltip, prelude::*, utils::WithRemSize, + Button, ContextMenu, ContextMenuEntry, GradientFade, IconButton, KeyBinding, PopoverMenu, + PopoverMenuHandle, ProjectEmptyState, Tab, Tooltip, prelude::*, utils::WithRemSize, }; use util::ResultExt as _; use workspace::{ @@ -936,36 +936,43 @@ impl AgentPanel { }) .await; - let thread_to_restore = serialized_panel - .as_ref() - .and_then(|panel| panel.last_active_thread.as_ref()) - .and_then(|info| { - let lookup = cx.update(|_window, cx| { - let store = ThreadMetadataStore::global(cx); - let store = store.read(cx); - let primary = info.thread_id.and_then(|tid| store.entry(tid)); - let fallback = info.session_id.as_ref().and_then(|sid| { - store.entry_by_session(&acp::SessionId::new(sid.clone())) + let has_open_project = workspace + .read_with(cx, |workspace, cx| !workspace.root_paths(cx).is_empty()) + .unwrap_or(false); + let thread_to_restore = if has_open_project { + serialized_panel + .as_ref() + .and_then(|panel| panel.last_active_thread.as_ref()) + .and_then(|info| { + let lookup = cx.update(|_window, cx| { + let store = ThreadMetadataStore::global(cx); + let store = store.read(cx); + let primary = info.thread_id.and_then(|tid| store.entry(tid)); + let fallback = info.session_id.as_ref().and_then(|sid| { + store.entry_by_session(&acp::SessionId::new(sid.clone())) + }); + primary + .or(fallback) + .filter(|entry| !entry.archived) + .map(|entry| entry.thread_id) }); - primary - .or(fallback) - .filter(|entry| !entry.archived) - .map(|entry| entry.thread_id) - }); - match lookup { - Ok(Some(thread_id)) => Some((info, thread_id)), - Ok(None) => { - log::info!( - "last active thread is archived or missing, skipping restoration" - ); - None + match lookup { + Ok(Some(thread_id)) => Some((info, thread_id)), + Ok(None) => { + log::info!( + "last active thread is archived or missing, skipping restoration" + ); + None + } + Err(err) => { + log::warn!("failed to look up last active thread metadata: {err}"); + None + } } - Err(err) => { - log::warn!("failed to look up last active thread metadata: {err}"); - None - } - } - }); + }) + } else { + None + }; let panel = workspace.update_in(cx, |workspace, window, cx| { let panel = cx.new(|cx| Self::new(workspace, prompt_store, window, cx)); @@ -1091,23 +1098,15 @@ impl AgentPanel { None }; - let connection_store = cx.new(|cx| { - let mut store = AgentConnectionStore::new(project.clone(), cx); - // Register the native agent right away, so that it is available for - // the inline assistant etc. - store.request_connection( - Agent::NativeAgent, - Agent::NativeAgent.server(fs.clone(), thread_store.clone()), - cx, - ); - store - }); + let connection_store = cx.new(|cx| AgentConnectionStore::new(project.clone(), cx)); let _project_subscription = cx.subscribe(&project, |this, _project, event, cx| match event { project::Event::WorktreeAdded(_) | project::Event::WorktreeRemoved(_) | project::Event::WorktreeOrderChanged => { + this.ensure_native_agent_connection(cx); this.update_thread_work_dirs(cx); + cx.notify(); } _ => {} }); @@ -1164,6 +1163,7 @@ impl AgentPanel { // Initial sync of agent servers from extensions panel.sync_agent_servers_from_extensions(cx); + panel.ensure_native_agent_connection(cx); panel } @@ -1323,6 +1323,10 @@ impl AgentPanel { } pub fn new_thread(&mut self, _action: &NewThread, window: &mut Window, cx: &mut Context) { + if !self.has_open_project(cx) { + return; + } + self.new_thread_with_workspace(None, window, cx); } @@ -1346,6 +1350,10 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { + if !self.has_open_project(cx) { + return; + } + self.set_last_created_entry_kind(AgentPanelEntryKind::Thread, cx); // If the user is viewing a *parked* draft and the ephemeral @@ -1424,6 +1432,10 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { + if !self.has_open_project(cx) { + return; + } + let active_matching = match &self.base_view { BaseView::AgentThread { conversation_view } if conversation_view.read(cx).thread_id == thread_id => @@ -1477,6 +1489,10 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { + if !self.has_open_project(cx) { + return; + } + self.selected_agent = action.agent.clone().into(); self.activate_new_thread(true, "agent_panel", window, cx); } @@ -1487,6 +1503,9 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { + if !self.supports_terminal(cx) { + return; + } let working_directory = self.terminal_working_directory(workspace, cx); self.spawn_terminal(TerminalId::new(), working_directory, true, window, cx); } @@ -1501,6 +1520,10 @@ impl AgentPanel { .unwrap_or_else(|| self.default_terminal_working_directory(cx)) } + pub fn supports_terminal(&self, cx: &App) -> bool { + self.has_open_project(cx) && self.project.read(cx).supports_terminal(cx) + } + pub fn should_create_terminal_for_new_entry(&self, cx: &App) -> bool { self.last_created_entry_kind == AgentPanelEntryKind::Terminal && self.project.read(cx).supports_terminal(cx) @@ -2015,6 +2038,26 @@ impl AgentPanel { .and_then(|workspace| terminal_view::default_working_directory(workspace.read(cx), cx)) } + fn has_open_project(&self, cx: &App) -> bool { + self.project.read(cx).visible_worktrees(cx).next().is_some() + } + + fn ensure_native_agent_connection(&self, cx: &mut Context) { + if !self.has_open_project(cx) { + return; + } + + let fs = self.fs.clone(); + let thread_store = self.thread_store.clone(); + self.connection_store.update(cx, |store, cx| { + store.request_connection( + Agent::NativeAgent, + Agent::NativeAgent.server(fs, thread_store), + cx, + ); + }); + } + pub fn activate_draft( &mut self, focus: bool, @@ -2022,6 +2065,10 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { + if !self.has_open_project(cx) { + return; + } + let draft = self.ensure_draft(source, window, cx); if let BaseView::AgentThread { conversation_view } = &self.base_view { if conversation_view.entity_id() == draft.entity_id() { @@ -2445,6 +2492,10 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { + if resume_thread_id.is_none() && !self.has_open_project(cx) { + return; + } + let agent = agent_choice.unwrap_or_else(|| self.selected_agent(cx)); let thread = self.create_agent_thread_with_server( agent, @@ -2515,6 +2566,10 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { + if !self.has_open_project(cx) { + return; + } + self.new_thread_menu_handle.toggle(window, cx); } @@ -2729,6 +2784,11 @@ impl AgentPanel { } fn load_thread_from_clipboard(&mut self, window: &mut Window, cx: &mut Context) { + if !self.has_open_project(cx) { + Self::show_deferred_toast(&self.workspace, "Open a project to load a thread", cx); + return; + } + let Some(clipboard) = cx.read_from_clipboard() else { Self::show_deferred_toast(&self.workspace, "No clipboard content available", cx); return; @@ -3815,6 +3875,10 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) -> bool { + if !self.has_open_project(cx) { + return false; + } + if self.destination_has_meaningful_state(cx) { return false; } @@ -4000,21 +4064,24 @@ impl AgentPanel { .max_w_full() .overflow_x_hidden() .child(content) - .when(!self.is_title_editor_focused(window, cx), |this| { - this.child(gradient_overlay).child( - h_flex() - .visible_on_hover("title_editor") - .absolute() - .right_0() - .h_full() - .bg(cx.theme().colors().tab_bar_background) - .child( - IconButton::new("edit_tile", IconName::Pencil) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Edit Thread Title")), - ), - ) - }) + .when( + self.has_open_project(cx) && !self.is_title_editor_focused(window, cx), + |this| { + this.child(gradient_overlay).child( + h_flex() + .visible_on_hover("title_editor") + .absolute() + .right_0() + .h_full() + .bg(cx.theme().colors().tab_bar_background) + .child( + IconButton::new("edit_tile", IconName::Pencil) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Edit Thread Title")), + ), + ) + }, + ) .into_any() } @@ -4147,12 +4214,31 @@ impl AgentPanel { }) } + fn render_no_project_state(&self, cx: &mut Context) -> impl IntoElement { + let focus_handle = self.focus_handle(cx); + + ProjectEmptyState::new( + "Agent Panel", + focus_handle.clone(), + KeyBinding::for_action_in(&workspace::Open::default(), &focus_handle, cx), + ) + .on_open_project(|_, window, cx| { + telemetry::event!("Agent Panel Add Project Clicked"); + window.dispatch_action(workspace::Open::default().boxed_clone(), cx); + }) + .on_clone_repo(|_, window, cx| { + telemetry::event!("Agent Panel Clone Repo Clicked"); + window.dispatch_action(git::Clone.boxed_clone(), cx); + }) + } + 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 supports_terminal = self.project.read(cx).supports_terminal(cx); + let can_create_entries = self.has_open_project(cx); + let supports_terminal = self.supports_terminal(cx); let showing_terminal = matches!(self.visible_surface(), VisibleSurface::Terminal(_)); let (selected_agent_custom_icon, selected_agent_label) = if showing_terminal { @@ -4483,7 +4569,7 @@ impl AgentPanel { .flex_none() .justify_between(); - let toolbar_content = if matches!(mode, ToolbarMode::EmptyThread) { + let toolbar_content = if can_create_entries && matches!(mode, ToolbarMode::EmptyThread) { let (chevron_icon, icon_color, label_color) = if self.new_thread_menu_handle.is_deployed() { (IconName::ChevronUp, Color::Accent, Color::Accent) @@ -4594,7 +4680,7 @@ impl AgentPanel { .gap_1() .pl_1() .pr_1() - .child(new_thread_menu) + .when(can_create_entries, |this| this.child(new_thread_menu)) .child(full_screen_button) .child(self.render_panel_options_menu(window, cx)), ) @@ -4833,10 +4919,11 @@ impl Render for AgentPanel { // - Scrolling in all views works as expected // - Files can be dropped into the panel let content = v_flex() + .key_context(self.key_context()) .relative() .size_full() .justify_between() - .key_context(self.key_context()) + .bg(cx.theme().colors().panel_background) .on_action(cx.listener(|this, action: &NewThread, window, cx| { this.new_thread(action, window, cx); })) @@ -4861,6 +4948,9 @@ impl Render for AgentPanel { .child(self.render_toolbar(window, cx)) .children(self.render_new_user_onboarding(window, cx)) .map(|parent| match self.visible_surface() { + VisibleSurface::Uninitialized if !self.has_open_project(cx) => { + parent.child(self.render_no_project_state(cx)) + } VisibleSurface::Uninitialized => parent, VisibleSurface::AgentThread(conversation_view) => parent .child(conversation_view.clone()) @@ -6408,6 +6498,94 @@ mod tests { }); } + #[gpui::test] + async fn test_empty_workspace_does_not_create_agent_entries(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + }); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs.clone(), [], cx).await; + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }); + + panel.read_with(cx, |panel, cx| { + assert_eq!( + panel + .connection_store() + .read(cx) + .connection_status(&Agent::NativeAgent, cx), + crate::agent_connection_store::AgentConnectionStatus::Disconnected, + "empty workspaces should not start the native agent connection" + ); + }); + + panel.update_in(cx, |panel, window, cx| { + panel.new_thread(&NewThread, window, cx); + panel.activate_draft(true, "agent_panel", window, cx); + panel.new_external_agent_thread( + &NewExternalAgentThread { + agent: AgentId::new("external-agent"), + }, + window, + cx, + ); + }); + cx.run_until_parked(); + + panel.read_with(cx, |panel, cx| { + assert!( + panel.active_conversation_view().is_none(), + "empty workspaces should not create agent threads" + ); + assert!( + panel.draft_thread.is_none(), + "empty workspaces should not create draft threads" + ); + assert!( + panel.terminals(cx).is_empty(), + "empty workspaces should not create agent panel terminals" + ); + }); + + cx.update(|_, cx| { + cx.update_flags(true, vec!["agent-panel-terminal".to_string()]); + }); + panel.update_in(cx, |panel, window, cx| { + panel.new_terminal(None, window, cx); + }); + cx.run_until_parked(); + + panel.read_with(cx, |panel, cx| { + assert!( + panel.terminals(cx).is_empty(), + "empty workspaces should not create terminals after the terminal feature is enabled" + ); + assert_eq!( + panel + .connection_store() + .read(cx) + .connection_status(&Agent::NativeAgent, cx), + crate::agent_connection_store::AgentConnectionStatus::Disconnected, + "empty workspace actions should not start the native agent connection" + ); + }); + } + async fn setup_panel(cx: &mut TestAppContext) -> (Entity, VisualTestContext) { init_test(cx); cx.update(|cx| { @@ -6417,7 +6595,8 @@ mod tests { let fs = FakeFs::new(cx.executor()); cx.update(|cx| ::set_global(fs.clone(), cx)); - let project = Project::test(fs.clone(), [], cx).await; + fs.insert_tree("/project", json!({ "file.txt": "" })).await; + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; let multi_workspace = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); @@ -7612,7 +7791,8 @@ mod tests { }); let fs = FakeFs::new(cx.executor()); - let project = Project::test(fs.clone(), [], cx).await; + fs.insert_tree("/project", json!({ "file.txt": "" })).await; + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; let multi_workspace = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); @@ -7672,7 +7852,8 @@ mod tests { ::set_global(fs.clone(), cx); }); - let project = Project::test(fs.clone(), [], cx).await; + fs.insert_tree("/project", json!({ "file.txt": "" })).await; + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; let multi_workspace = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); @@ -7761,7 +7942,8 @@ mod tests { ::set_global(fs.clone(), cx); }); - let project = Project::test(fs.clone(), [], cx).await; + fs.insert_tree("/project", json!({ "file.txt": "" })).await; + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; let multi_workspace = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); @@ -7855,7 +8037,8 @@ mod tests { ::set_global(fs.clone(), cx); }); - let project = Project::test(fs.clone(), [], cx).await; + fs.insert_tree("/project", json!({ "file.txt": "" })).await; + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; let multi_workspace = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = multi_workspace @@ -7964,7 +8147,8 @@ mod tests { ::set_global(fs.clone(), cx); }); - let project = Project::test(fs.clone(), [], cx).await; + fs.insert_tree("/project", json!({ "file.txt": "" })).await; + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; let multi_workspace = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = multi_workspace @@ -8062,7 +8246,8 @@ mod tests { ::set_global(fs.clone(), cx); }); - let project = Project::test(fs.clone(), [], cx).await; + fs.insert_tree("/project", json!({ "file.txt": "" })).await; + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; let multi_workspace = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); @@ -8876,8 +9061,9 @@ mod tests { }); let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project", json!({ "file.txt": "" })).await; cx.update(|cx| ::set_global(fs.clone(), cx)); - let project = Project::test(fs.clone(), [], cx).await; + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; let multi_workspace = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); @@ -9102,8 +9288,12 @@ mod tests { }); let fs = FakeFs::new(cx.executor()); - let project_a = Project::test(fs.clone(), [], cx).await; - let project_b = Project::test(fs.clone(), [], cx).await; + fs.insert_tree("/project_a", json!({ "file.txt": "" })) + .await; + fs.insert_tree("/project_b", json!({ "file.txt": "" })) + .await; + let project_a = Project::test(fs.clone(), [Path::new("/project_a")], cx).await; + let project_b = Project::test(fs.clone(), [Path::new("/project_b")], cx).await; let multi_workspace = cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); @@ -9192,8 +9382,12 @@ mod tests { }); let fs = FakeFs::new(cx.executor()); - let project_a = Project::test(fs.clone(), [], cx).await; - let project_b = Project::test(fs.clone(), [], cx).await; + fs.insert_tree("/project_a", json!({ "file.txt": "" })) + .await; + fs.insert_tree("/project_b", json!({ "file.txt": "" })) + .await; + let project_a = Project::test(fs.clone(), [Path::new("/project_a")], cx).await; + let project_b = Project::test(fs.clone(), [Path::new("/project_b")], cx).await; let multi_workspace = cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index bf5a20ff150..18a16bbb67a 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -75,9 +75,9 @@ use strum::{IntoEnumIterator, VariantNames}; use theme_settings::ThemeSettings; use time::OffsetDateTime; use ui::{ - ButtonLike, Checkbox, ContextMenu, Divider, ElevationIndex, IndentGuideColors, PopoverMenu, - RenderedIndentGuide, ScrollAxes, Scrollbars, SplitButton, Tab, TintColor, Tooltip, - WithScrollbar, prelude::*, + ButtonLike, Checkbox, ContextMenu, Divider, ElevationIndex, IndentGuideColors, KeyBinding, + PopoverMenu, ProjectEmptyState, RenderedIndentGuide, ScrollAxes, Scrollbars, SplitButton, Tab, + TintColor, Tooltip, WithScrollbar, prelude::*, }; use util::paths::PathStyle; use util::{ResultExt, TryFutureExt, markdown::MarkdownInlineCode, maybe, rel_path::RelPath}; @@ -5453,6 +5453,22 @@ impl GitPanel { }), ) .into_any_element() + } else if worktree_count == 0 { + let focus_handle = self.focus_handle.clone(); + ProjectEmptyState::new( + "Git Panel", + focus_handle.clone(), + KeyBinding::for_action_in(&workspace::Open::default(), &focus_handle, cx), + ) + .on_open_project(|_, window, cx| { + telemetry::event!("Git Panel Add Project Clicked"); + window.dispatch_action(workspace::Open::default().boxed_clone(), cx); + }) + .on_clone_repo(|_, window, cx| { + telemetry::event!("Git Panel Clone Repo Clicked"); + window.dispatch_action(git::Clone.boxed_clone(), cx); + }) + .into_any_element() } else { Empty.into_any_element() } diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 780d8c9274e..2cd835c26c5 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -60,10 +60,10 @@ use std::{ }; use theme_settings::ThemeSettings; use ui::{ - Color, ContextMenu, ContextMenuEntry, DecoratedIcon, Divider, Icon, IconDecoration, - IconDecorationKind, IndentGuideColors, IndentGuideLayout, Indicator, KeyBinding, Label, - LabelSize, ListItem, ListItemSpacing, ScrollAxes, ScrollableHandle, Scrollbars, - StickyCandidate, Tooltip, WithScrollbar, prelude::*, v_flex, + Color, ContextMenu, ContextMenuEntry, DecoratedIcon, Icon, IconDecoration, IconDecorationKind, + IndentGuideColors, IndentGuideLayout, Indicator, KeyBinding, Label, LabelSize, ListItem, + ListItemSpacing, ProjectEmptyState, ScrollAxes, ScrollableHandle, Scrollbars, StickyCandidate, + Tooltip, WithScrollbar, prelude::*, v_flex, }; use util::{ ResultExt, TakeUntilExt, TryFutureExt, @@ -7112,52 +7112,35 @@ impl Render for ProjectPanel { })) } else { let focus_handle = self.focus_handle(cx); + let workspace = self.workspace.clone(); + let workspace_clone = self.workspace.clone(); v_flex() - .id("empty-project_panel") - .p_4() + .id("empty-project_panel-wrapper") .size_full() - .items_center() - .justify_center() - .gap_1() - .track_focus(&self.focus_handle(cx)) .child( - Button::new("open_project", "Open Project") - .full_width() - .key_binding(KeyBinding::for_action_in( - &workspace::Open::default(), - &focus_handle, - cx, - )) - .on_click(cx.listener(|this, _, window, cx| { - this.workspace - .update(cx, |_, cx| { - window.dispatch_action( - workspace::Open::default().boxed_clone(), - cx, - ); - }) - .log_err(); - })), - ) - .child( - h_flex() - .w_1_2() - .gap_2() - .child(Divider::horizontal()) - .child(Label::new("or").size(LabelSize::XSmall).color(Color::Muted)) - .child(Divider::horizontal()), - ) - .child( - Button::new("clone_repo", "Clone Repository") - .full_width() - .on_click(cx.listener(|this, _, window, cx| { - this.workspace - .update(cx, |_, cx| { - window.dispatch_action(git::Clone.boxed_clone(), cx); - }) - .log_err(); - })), + ProjectEmptyState::new( + "Project Panel", + focus_handle.clone(), + KeyBinding::for_action_in(&workspace::Open::default(), &focus_handle, cx), + ) + .on_open_project(move |_, window, cx| { + telemetry::event!("Project Panel Add Project Clicked"); + workspace + .update(cx, |_, cx| { + window + .dispatch_action(workspace::Open::default().boxed_clone(), cx); + }) + .log_err(); + }) + .on_clone_repo(move |_, window, cx| { + telemetry::event!("Project Panel Clone Repo Clicked"); + workspace_clone + .update(cx, |_, cx| { + window.dispatch_action(git::Clone.boxed_clone(), cx); + }) + .log_err(); + }), ) .when(is_local, |div| { div.when(panel_settings.drag_and_drop, |div| { diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index b37583eb61d..13dc930debf 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -48,8 +48,9 @@ use std::sync::Arc; use theme::ActiveTheme; use ui::{ AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, GradientFade, HighlightedLabel, - KeyBinding, PopoverMenu, PopoverMenuHandle, ScrollAxes, Scrollbars, Tab, ThreadItem, - ThreadItemWorktreeInfo, TintColor, Tooltip, WithScrollbar, prelude::*, render_modifiers, + KeyBinding, PopoverMenu, PopoverMenuHandle, ProjectEmptyState, ScrollAxes, Scrollbars, Tab, + ThreadItem, ThreadItemWorktreeInfo, TintColor, Tooltip, WithScrollbar, prelude::*, + render_modifiers, }; use util::ResultExt as _; use util::path_list::PathList; @@ -4719,6 +4720,10 @@ impl Sidebar { window: &mut Window, cx: &mut Context, ) { + if workspace_path_list(workspace, cx).paths().is_empty() { + return; + } + if self.should_create_terminal_for_workspace(workspace, cx) { self.create_new_terminal(workspace, window, cx); } else { @@ -4743,6 +4748,10 @@ impl Sidebar { window: &mut Window, cx: &mut Context, ) { + if workspace_path_list(workspace, cx).paths().is_empty() { + return; + } + let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; @@ -4776,6 +4785,10 @@ impl Sidebar { window: &mut Window, cx: &mut Context, ) { + if workspace_path_list(workspace, cx).paths().is_empty() { + return; + } + let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; @@ -5025,48 +5038,28 @@ impl Sidebar { } fn render_empty_state(&self, cx: &mut Context) -> impl IntoElement { - v_flex() - .id("sidebar-empty-state") - .p_4() - .size_full() - .items_center() - .justify_center() - .gap_1() - .track_focus(&self.focus_handle(cx)) - .child( - Button::new("open_project", "Open Project") - .full_width() - .key_binding(KeyBinding::for_action(&workspace::Open::default(), cx)) - .on_click(|_, window, cx| { - let side = match AgentSettings::get_global(cx).sidebar_side() { - SidebarSide::Left => "left", - SidebarSide::Right => "right", - }; - telemetry::event!("Sidebar Add Project Clicked", side = side); - window.dispatch_action( - Open { - create_new_window: false, - } - .boxed_clone(), - cx, - ); - }), - ) - .child( - h_flex() - .w_1_2() - .gap_2() - .child(Divider::horizontal().color(ui::DividerColor::Border)) - .child(Label::new("or").size(LabelSize::XSmall).color(Color::Muted)) - .child(Divider::horizontal().color(ui::DividerColor::Border)), - ) - .child( - Button::new("clone_repo", "Clone Repository") - .full_width() - .on_click(|_, window, cx| { - window.dispatch_action(git::Clone.boxed_clone(), cx); - }), - ) + ProjectEmptyState::new( + "Threads Sidebar", + self.focus_handle(cx), + KeyBinding::for_action(&workspace::Open::default(), cx), + ) + .on_open_project(|_, window, cx| { + let side = match AgentSettings::get_global(cx).sidebar_side() { + SidebarSide::Left => "left", + SidebarSide::Right => "right", + }; + telemetry::event!("Sidebar Add Project Clicked", side = side); + window.dispatch_action( + Open { + create_new_window: false, + } + .boxed_clone(), + cx, + ); + }) + .on_clone_repo(|_, window, cx| { + window.dispatch_action(git::Clone.boxed_clone(), cx); + }) } fn render_sidebar_header( diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index 956c30e412d..3421704e4b8 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -1366,6 +1366,43 @@ async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) { assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); } +#[gpui::test] +async fn test_new_entry_noops_without_open_project(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + cx.update(|cx| ::set_global(fs.clone(), cx)); + let project = project::Project::test(fs, [], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + let workspace = multi_workspace.read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }); + + assert!( + !sidebar.read_with(cx, |sidebar, _cx| sidebar.contents.has_open_projects), + "empty workspaces should be treated as having no open projects" + ); + + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.create_new_entry(&workspace, window, cx); + }); + cx.run_until_parked(); + + panel.read_with(cx, |panel, _cx| { + assert!( + panel.active_conversation_view().is_none(), + "sidebar should not create an agent thread without an open project" + ); + }); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + Vec::::new() + ); +} + #[gpui::test] async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) { let project = init_test_project("/my-project", cx).await; diff --git a/crates/ui/src/components.rs b/crates/ui/src/components.rs index 6c0242a7913..12d5946627e 100644 --- a/crates/ui/src/components.rs +++ b/crates/ui/src/components.rs @@ -29,6 +29,7 @@ mod notification; mod popover; mod popover_menu; mod progress; +mod project_empty_state; mod redistributable_columns; mod right_click_menu; mod scrollbar; @@ -71,6 +72,7 @@ pub use notification::*; pub use popover::*; pub use popover_menu::*; pub use progress::*; +pub use project_empty_state::*; pub use redistributable_columns::*; pub use right_click_menu::*; pub use scrollbar::*; diff --git a/crates/ui/src/components/project_empty_state.rs b/crates/ui/src/components/project_empty_state.rs new file mode 100644 index 00000000000..f5ef58da3d3 --- /dev/null +++ b/crates/ui/src/components/project_empty_state.rs @@ -0,0 +1,94 @@ +use crate::{Divider, DividerColor, KeyBinding, prelude::*}; +use gpui::{ClickEvent, FocusHandle}; + +type ClickHandler = Box; + +#[derive(IntoElement)] +pub struct ProjectEmptyState { + label: SharedString, + focus_handle: FocusHandle, + open_project_key_binding: KeyBinding, + on_open_project: Option, + on_clone_repo: Option, +} + +impl ProjectEmptyState { + pub fn new( + label: impl Into, + focus_handle: FocusHandle, + open_project_key_binding: KeyBinding, + ) -> Self { + Self { + label: label.into(), + focus_handle, + open_project_key_binding, + on_open_project: None, + on_clone_repo: None, + } + } + + pub fn on_open_project( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_open_project = Some(Box::new(handler)); + self + } + + pub fn on_clone_repo( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_clone_repo = Some(Box::new(handler)); + self + } +} + +impl RenderOnce for ProjectEmptyState { + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + let id = format!("empty-state-{}", self.label); + let label = format!("Choose one of the options below to use the {}", self.label); + + v_flex() + .id(id) + .p_4() + .size_full() + .items_center() + .justify_center() + .track_focus(&self.focus_handle) + .child( + v_flex() + .w_48() + .max_w_full() + .gap_1() + .child( + div() + .text_center() + .mb_2() + .child(Label::new(label).size(LabelSize::Small).color(Color::Muted)), + ) + .child( + Button::new("open_project", "Open Project") + .full_width() + .key_binding(self.open_project_key_binding) + .when_some(self.on_open_project, |button, handler| { + button.on_click(handler) + }), + ) + .child( + h_flex() + .gap_2() + .child(Divider::horizontal().color(DividerColor::Border)) + .child(Label::new("or").size(LabelSize::XSmall).color(Color::Muted)) + .child(Divider::horizontal().color(DividerColor::Border)), + ) + .child( + Button::new("clone_repo", "Clone Repository") + .full_width() + .when_some(self.on_clone_repo, |button, handler| { + button.on_click(handler) + }), + ), + ) + } +} diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 5a6d2c878e1..7adbbc9bb82 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -1025,16 +1025,35 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone())?; - let (client, thread_store) = - multi_workspace.update(cx, |_, _window, cx| { - workspace.update(cx, |workspace, cx| { - let client = workspace.project().read(cx).client(); - let thread_store: Option> = workspace - .panel::(cx) - .map(|panel| panel.read(cx).thread_store().clone()); - anyhow::Ok((client, thread_store)) - }) - })??; + let import_state = multi_workspace.update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + if workspace.root_paths(cx).is_empty() { + workspace.focus_panel::(window, cx); + + struct OpenProjectForSharedThreadToast; + workspace.show_toast( + Toast::new( + NotificationId::unique::(), + "Open a project to import shared threads", + ) + .autohide(), + cx, + ); + + return anyhow::Ok(None); + } + + let client = workspace.project().read(cx).client(); + let thread_store: Option> = workspace + .panel::(cx) + .map(|panel| panel.read(cx).thread_store().clone()); + anyhow::Ok(Some((client, thread_store))) + }) + })??; + + let Some((client, thread_store)) = import_state else { + return Ok(()); + }; let Some(thread_store): Option> = thread_store else { anyhow::bail!("Agent panel not available");