mirror of
https://github.com/zed-industries/zed.git
synced 2026-05-31 19:05:00 +07:00
Cherry-pick of #56577 to preview ---- A bit brute force, but it works. <img width="1106" height="988" alt="image" src="https://github.com/user-attachments/assets/d23f9a80-01c5-4ad3-a280-faf8b8bc9dbe" /> 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 <daniloleal09@gmail.com> Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com> Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
This commit is contained in:
parent
fac7b9245f
commit
3da868a4bf
8 changed files with 513 additions and 175 deletions
|
|
@ -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<Self>) {
|
||||
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<Self>,
|
||||
) {
|
||||
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<Self>,
|
||||
) {
|
||||
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<Self>,
|
||||
) {
|
||||
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<Self>,
|
||||
) {
|
||||
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<Self>) {
|
||||
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<Self>,
|
||||
) {
|
||||
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<Self>,
|
||||
) {
|
||||
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<Self>,
|
||||
) {
|
||||
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<Self>) {
|
||||
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<Self>,
|
||||
) -> 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<Self>) -> 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<Self>) -> 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<AgentPanel>, VisualTestContext) {
|
||||
init_test(cx);
|
||||
cx.update(|cx| {
|
||||
|
|
@ -6417,7 +6595,8 @@ mod tests {
|
|||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
cx.update(|cx| <dyn fs::Fs>::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 {
|
|||
<dyn fs::Fs>::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 {
|
|||
<dyn fs::Fs>::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 {
|
|||
<dyn fs::Fs>::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 {
|
|||
<dyn fs::Fs>::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 {
|
|||
<dyn fs::Fs>::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| <dyn fs::Fs>::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));
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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<Self>,
|
||||
) {
|
||||
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<Self>,
|
||||
) {
|
||||
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<Self>,
|
||||
) {
|
||||
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<Self>) -> 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(
|
||||
|
|
|
|||
|
|
@ -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| <dyn Fs>::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::<String>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_selection_clamps_after_entry_removal(cx: &mut TestAppContext) {
|
||||
let project = init_test_project("/my-project", cx).await;
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
94
crates/ui/src/components/project_empty_state.rs
Normal file
94
crates/ui/src/components/project_empty_state.rs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
use crate::{Divider, DividerColor, KeyBinding, prelude::*};
|
||||
use gpui::{ClickEvent, FocusHandle};
|
||||
|
||||
type ClickHandler = Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct ProjectEmptyState {
|
||||
label: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
open_project_key_binding: KeyBinding,
|
||||
on_open_project: Option<ClickHandler>,
|
||||
on_clone_repo: Option<ClickHandler>,
|
||||
}
|
||||
|
||||
impl ProjectEmptyState {
|
||||
pub fn new(
|
||||
label: impl Into<SharedString>,
|
||||
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)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1025,16 +1025,35 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, 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<gpui::Entity<ThreadStore>> = workspace
|
||||
.panel::<AgentPanel>(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::<AgentPanel>(window, cx);
|
||||
|
||||
struct OpenProjectForSharedThreadToast;
|
||||
workspace.show_toast(
|
||||
Toast::new(
|
||||
NotificationId::unique::<OpenProjectForSharedThreadToast>(),
|
||||
"Open a project to import shared threads",
|
||||
)
|
||||
.autohide(),
|
||||
cx,
|
||||
);
|
||||
|
||||
return anyhow::Ok(None);
|
||||
}
|
||||
|
||||
let client = workspace.project().read(cx).client();
|
||||
let thread_store: Option<gpui::Entity<ThreadStore>> = workspace
|
||||
.panel::<AgentPanel>(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<gpui::Entity<ThreadStore>> = thread_store else {
|
||||
anyhow::bail!("Agent panel not available");
|
||||
|
|
|
|||
Loading…
Reference in a new issue