diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 56b983f7e763ed5a3a7d275bae1d9f53f1715db1..26144db389ef553c73e099926bcf7ff0868ffc52 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -694,6 +694,7 @@ "enter": "menu::Confirm", "space": "menu::Confirm", "ctrl-f": "agents_sidebar::FocusSidebarFilter", + "ctrl-g": "agents_sidebar::ToggleArchive", "shift-backspace": "agent::RemoveSelectedThread", }, }, diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 9bd8856672d1f2aff1ae55301a8d5f095b9e1ca2..aa455cfb70ca1c6bc627cdc587d8d4980bd71397 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -760,6 +760,7 @@ "enter": "menu::Confirm", "space": "menu::Confirm", "cmd-f": "agents_sidebar::FocusSidebarFilter", + "cmd-g": "agents_sidebar::ToggleArchive", "shift-backspace": "agent::RemoveSelectedThread", }, }, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index b698736fb459ce874da9a3d965b33993f68431ae..0316bf08ffdf9df659707845038caf74072b75c4 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -696,6 +696,7 @@ "enter": "menu::Confirm", "space": "menu::Confirm", "ctrl-f": "agents_sidebar::FocusSidebarFilter", + "ctrl-g": "agents_sidebar::ToggleArchive", "shift-backspace": "agent::RemoveSelectedThread", }, }, diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 4765263a6cf7fa5a90298282b49f58724d03fa0f..d8d6f273d0d785fc77df390c98e1e0f2886bb8f5 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -2511,6 +2511,10 @@ impl AgentPanel { .is_some_and(|thread| !thread.read(cx).entries().is_empty()) } + pub fn active_thread_is_draft(&self, cx: &App) -> bool { + self.active_conversation().is_some() && !self.active_thread_has_messages(cx) + } + fn handle_first_send_requested( &mut self, thread_view: Entity, diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index 06146a32f6b57de3c47bff7fae4b4b0f793d7b9f..e95607a5966d072d085e91247dc1c3a9fd580628 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -19,11 +19,12 @@ use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use project::{AgentId, AgentServerStore}; use theme::ActiveTheme; use ui::{ - ButtonLike, CommonAnimationExt, ContextMenu, ContextMenuEntry, HighlightedLabel, KeyBinding, - ListItem, PopoverMenu, PopoverMenuHandle, Tab, TintColor, Tooltip, WithScrollbar, prelude::*, + ButtonLike, CommonAnimationExt, ContextMenu, ContextMenuEntry, Divider, HighlightedLabel, + KeyBinding, PopoverMenu, PopoverMenuHandle, TintColor, Tooltip, WithScrollbar, prelude::*, utils::platform_title_bar_height, }; use util::ResultExt as _; +use zed_actions::agents_sidebar::FocusSidebarFilter; use zed_actions::editor::{MoveDown, MoveUp}; #[derive(Clone)] @@ -162,6 +163,25 @@ impl ThreadsArchiveView { } }); + let filter_focus_handle = filter_editor.read(cx).focus_handle(cx); + cx.on_focus_in( + &filter_focus_handle, + window, + |this: &mut Self, _window, cx| { + if this.selection.is_some() { + this.selection = None; + cx.notify(); + } + }, + ) + .detach(); + + cx.on_focus_out(&focus_handle, window, |this: &mut Self, _, _window, cx| { + this.selection = None; + cx.notify(); + }) + .detach(); + let mut this = Self { agent_connection_store, agent_server_store, @@ -185,6 +205,19 @@ impl ThreadsArchiveView { this } + pub fn has_selection(&self) -> bool { + self.selection.is_some() + } + + pub fn clear_selection(&mut self) { + self.selection = None; + } + + pub fn focus_filter_editor(&self, window: &mut Window, cx: &mut App) { + let handle = self.filter_editor.read(cx).focus_handle(cx); + handle.focus(window, cx); + } + fn set_selected_agent(&mut self, agent: Agent, window: &mut Window, cx: &mut Context) { self.selected_agent = agent.clone(); self.is_loading = true; @@ -287,11 +320,6 @@ impl ThreadsArchiveView { }); } - fn go_back(&mut self, window: &mut Window, cx: &mut Context) { - self.reset_filter_editor_text(window, cx); - cx.emit(ThreadsArchiveViewEvent::Close); - } - fn unarchive_thread( &mut self, session_info: AgentSessionInfo, @@ -351,10 +379,16 @@ impl ThreadsArchiveView { fn editor_move_down(&mut self, _: &MoveDown, window: &mut Window, cx: &mut Context) { self.select_next(&SelectNext, window, cx); + if self.selection.is_some() { + self.focus_handle.focus(window, cx); + } } fn editor_move_up(&mut self, _: &MoveUp, window: &mut Window, cx: &mut Context) { self.select_previous(&SelectPrevious, window, cx); + if self.selection.is_some() { + self.focus_handle.focus(window, cx); + } } fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context) { @@ -369,24 +403,29 @@ impl ThreadsArchiveView { } } - fn select_previous( - &mut self, - _: &SelectPrevious, - _window: &mut Window, - cx: &mut Context, - ) { - let prev = match self.selection { - Some(ix) if ix > 0 => self.find_previous_selectable(ix - 1), + fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context) { + match self.selection { + Some(ix) => { + if let Some(prev) = (ix > 0) + .then(|| self.find_previous_selectable(ix - 1)) + .flatten() + { + self.selection = Some(prev); + self.list_state.scroll_to_reveal_item(prev); + } else { + self.selection = None; + self.focus_filter_editor(window, cx); + } + cx.notify(); + } None => { let last = self.items.len().saturating_sub(1); - self.find_previous_selectable(last) + if let Some(prev) = self.find_previous_selectable(last) { + self.selection = Some(prev); + self.list_state.scroll_to_reveal_item(prev); + cx.notify(); + } } - _ => return, - }; - if let Some(prev) = prev { - self.selection = Some(prev); - self.list_state.scroll_to_reveal_item(prev); - cx.notify(); } } @@ -488,15 +527,27 @@ impl ThreadsArchiveView { let highlight_positions = highlight_positions.clone(); let title_label = if highlight_positions.is_empty() { - Label::new(title).truncate().into_any_element() + Label::new(title).truncate().flex_1().into_any_element() } else { HighlightedLabel::new(title, highlight_positions) .truncate() + .flex_1() .into_any_element() }; - ListItem::new(id) - .focused(is_focused) + h_flex() + .id(id) + .min_w_0() + .w_full() + .px(DynamicSpacing::Base06.rems(cx)) + .border_1() + .map(|this| { + if is_focused { + this.border_color(cx.theme().colors().border_focused) + } else { + this.border_color(gpui::transparent_black()) + } + }) .on_hover(cx.listener(move |this, is_hovered, _window, cx| { if *is_hovered { this.hovered_index = Some(ix); @@ -509,9 +560,78 @@ impl ThreadsArchiveView { v_flex() .min_w_0() .w_full() - .py_1() - .pl_1() - .child(title_label) + .p_1() + .child( + h_flex() + .min_w_0() + .w_full() + .gap_1() + .justify_between() + .child(title_label) + .when(hovered || is_focused, |this| { + this.child( + h_flex() + .gap_0p5() + .when(can_unarchive, |this| { + this.child( + Button::new("unarchive-thread", "Restore") + .style(ButtonStyle::OutlinedGhost) + .label_size(LabelSize::Small) + .when(is_focused, |this| { + this.key_binding( + KeyBinding::for_action_in( + &menu::Confirm, + &focus_handle, + cx, + ) + .map(|kb| { + kb.size(rems_from_px(12.)) + }), + ) + }) + .on_click(cx.listener( + move |this, _, window, cx| { + this.unarchive_thread( + session_info.clone(), + window, + cx, + ); + }, + )), + ) + }) + .when(supports_delete, |this| { + this.child( + IconButton::new( + "delete-thread", + IconName::Trash, + ) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip({ + move |_window, cx| { + Tooltip::for_action_in( + "Delete Thread", + &RemoveSelectedThread, + &focus_handle, + cx, + ) + } + }) + .on_click(cx.listener( + move |this, _, _, cx| { + this.delete_thread( + &session_id_for_delete, + cx, + ); + cx.stop_propagation(); + }, + )), + ) + }), + ) + }), + ) .child( h_flex() .gap_1() @@ -537,58 +657,6 @@ impl ThreadsArchiveView { }), ), ) - .when(hovered || is_focused, |this| { - this.end_slot( - h_flex() - .pr_2p5() - .gap_0p5() - .when(can_unarchive, |this| { - this.child( - Button::new("unarchive-thread", "Unarchive") - .style(ButtonStyle::OutlinedGhost) - .label_size(LabelSize::Small) - .when(is_focused, |this| { - this.key_binding( - KeyBinding::for_action_in( - &menu::Confirm, - &focus_handle, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), - ) - }) - .on_click(cx.listener(move |this, _, window, cx| { - this.unarchive_thread( - session_info.clone(), - window, - cx, - ); - })), - ) - }) - .when(supports_delete, |this| { - this.child( - IconButton::new("delete-thread", IconName::Trash) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip({ - move |_window, cx| { - Tooltip::for_action_in( - "Delete Thread", - &RemoveSelectedThread, - &focus_handle, - cx, - ) - } - }) - .on_click(cx.listener(move |this, _, _, cx| { - this.delete_thread(&session_id_for_delete, cx); - cx.stop_propagation(); - })), - ) - }), - ) - }) .into_any_element() } } @@ -728,62 +796,52 @@ impl ThreadsArchiveView { let has_query = !self.filter_editor.read(cx).text(cx).is_empty(); let traffic_lights = cfg!(target_os = "macos") && !window.is_fullscreen(); let header_height = platform_title_bar_height(window); - - v_flex() - .child( - h_flex() - .h(header_height) - .mt_px() - .pb_px() - .when(traffic_lights, |this| { - this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING)) - }) - .pr_1p5() - .border_b_1() - .border_color(cx.theme().colors().border) - .justify_between() - .child( - h_flex() - .gap_1p5() - .child( - IconButton::new("back", IconName::ArrowLeft) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Back to Sidebar")) - .on_click(cx.listener(|this, _, window, cx| { - this.go_back(window, cx); - })), - ) - .child(Label::new("Threads Archive").size(LabelSize::Small).mb_px()), - ) - .child(self.render_agent_picker(cx)), - ) + let show_focus_keybinding = + self.selection.is_some() && !self.filter_editor.focus_handle(cx).is_focused(window); + + h_flex() + .h(header_height) + .mt_px() + .pb_px() + .when(traffic_lights, |this| { + this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING)) + }) + .pr_1p5() + .gap_1() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border) + .child(Divider::vertical().color(ui::DividerColor::Border)) .child( h_flex() - .h(Tab::container_height(cx)) - .px_1p5() - .gap_1p5() - .border_b_1() - .border_color(cx.theme().colors().border) + .ml_1() + .min_w_0() + .w_full() + .gap_1() .child( - h_flex().size_4().flex_none().justify_center().child( - Icon::new(IconName::MagnifyingGlass) - .size(IconSize::Small) - .color(Color::Muted), - ), + Icon::new(IconName::MagnifyingGlass) + .size(IconSize::Small) + .color(Color::Muted), ) - .child(self.filter_editor.clone()) - .when(has_query, |this| { - this.child( - IconButton::new("clear_filter", IconName::Close) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Clear Search")) - .on_click(cx.listener(|this, _, window, cx| { - this.reset_filter_editor_text(window, cx); - this.update_items(cx); - })), - ) - }), + .child(self.filter_editor.clone()), ) + .when(show_focus_keybinding, |this| { + this.child(KeyBinding::for_action(&FocusSidebarFilter, cx)) + }) + .when(!has_query && !show_focus_keybinding, |this| { + this.child(self.render_agent_picker(cx)) + }) + .when(has_query, |this| { + this.child( + IconButton::new("clear_filter", IconName::Close) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Clear Search")) + .on_click(cx.listener(|this, _, window, cx| { + this.reset_filter_editor_text(window, cx); + this.update_items(cx); + })), + ) + }) } } diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 9b0dc2abfe7c3aa3039f6f89a403c71b2efe5dc8..cd01af2ce9778af441c31d17e4424627997b2495 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -2,6 +2,7 @@ mod dev_container_suggest; pub mod disconnected_overlay; mod remote_connections; mod remote_servers; +pub mod sidebar_recent_projects; mod ssh_config; use std::{ @@ -526,7 +527,7 @@ pub fn add_wsl_distro( pub struct RecentProjects { pub picker: Entity>, rem_width: f32, - _subscription: Subscription, + _subscriptions: Vec, } impl ModalView for RecentProjects { @@ -550,6 +551,7 @@ impl RecentProjects { window: &mut Window, cx: &mut Context, ) -> Self { + let style = delegate.style; let picker = cx.new(|cx| { Picker::list(delegate, window, cx) .list_measure_all() @@ -561,7 +563,21 @@ impl RecentProjects { picker.delegate.focus_handle = picker_focus_handle; }); - let _subscription = cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent)); + let mut subscriptions = vec![cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent))]; + + if style == ProjectPickerStyle::Popover { + let picker_focus = picker.focus_handle(cx); + subscriptions.push( + cx.on_focus_out(&picker_focus, window, |this, _, window, cx| { + let submenu_focused = this.picker.update(cx, |picker, cx| { + picker.delegate.actions_menu_handle.is_focused(window, cx) + }); + if !submenu_focused { + cx.emit(DismissEvent); + } + }), + ); + } // We do not want to block the UI on a potentially lengthy call to DB, so we're gonna swap // out workspace locations once the future runs to completion. let db = WorkspaceDb::global(cx); @@ -585,7 +601,7 @@ impl RecentProjects { Self { picker, rem_width, - _subscription, + _subscriptions: subscriptions, } } @@ -1635,7 +1651,7 @@ impl PickerDelegate for RecentProjectsDelegate { } } -fn icon_for_remote_connection(options: Option<&RemoteConnectionOptions>) -> IconName { +pub(crate) fn icon_for_remote_connection(options: Option<&RemoteConnectionOptions>) -> IconName { match options { None => IconName::Screen, Some(options) => match options { @@ -1649,7 +1665,7 @@ fn icon_for_remote_connection(options: Option<&RemoteConnectionOptions>) -> Icon } // Compute the highlighted text for the name and path -fn highlights_for_path( +pub(crate) fn highlights_for_path( path: &Path, match_positions: &Vec, path_start_offset: usize, diff --git a/crates/recent_projects/src/sidebar_recent_projects.rs b/crates/recent_projects/src/sidebar_recent_projects.rs new file mode 100644 index 0000000000000000000000000000000000000000..5ae8ee8bbf48c50c105251ea2ca08b3a88b05ec4 --- /dev/null +++ b/crates/recent_projects/src/sidebar_recent_projects.rs @@ -0,0 +1,417 @@ +use std::collections::HashSet; +use std::sync::Arc; + +use chrono::{DateTime, Utc}; +use fuzzy::{StringMatch, StringMatchCandidate}; +use gpui::{ + Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, + Subscription, Task, WeakEntity, Window, +}; +use picker::{ + Picker, PickerDelegate, + highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths}, +}; +use remote::RemoteConnectionOptions; +use settings::Settings; +use ui::{KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*}; +use ui_input::ErasedEditor; +use util::{ResultExt, paths::PathExt}; +use workspace::{ + MultiWorkspace, OpenOptions, PathList, SerializedWorkspaceLocation, Workspace, WorkspaceDb, + WorkspaceId, notifications::DetachAndPromptErr, +}; + +use crate::{highlights_for_path, icon_for_remote_connection, open_remote_project}; + +pub struct SidebarRecentProjects { + pub picker: Entity>, + _subscription: Subscription, +} + +impl SidebarRecentProjects { + pub fn popover( + workspace: WeakEntity, + sibling_workspace_ids: HashSet, + _focus_handle: FocusHandle, + window: &mut Window, + cx: &mut App, + ) -> Entity { + let fs = workspace + .upgrade() + .map(|ws| ws.read(cx).app_state().fs.clone()); + + cx.new(|cx| { + let delegate = SidebarRecentProjectsDelegate { + workspace, + sibling_workspace_ids, + workspaces: Vec::new(), + filtered_workspaces: Vec::new(), + selected_index: 0, + focus_handle: cx.focus_handle(), + }; + + let picker: Entity> = cx.new(|cx| { + Picker::list(delegate, window, cx) + .list_measure_all() + .show_scrollbar(true) + }); + + let picker_focus_handle = picker.focus_handle(cx); + picker.update(cx, |picker, _| { + picker.delegate.focus_handle = picker_focus_handle; + }); + + let _subscription = + cx.subscribe(&picker, |_this: &mut Self, _, _, cx| cx.emit(DismissEvent)); + + let db = WorkspaceDb::global(cx); + cx.spawn_in(window, async move |this, cx| { + let Some(fs) = fs else { return }; + let workspaces = db + .recent_workspaces_on_disk(fs.as_ref()) + .await + .log_err() + .unwrap_or_default(); + let workspaces = + workspace::resolve_worktree_workspaces(workspaces, fs.as_ref()).await; + this.update_in(cx, move |this, window, cx| { + this.picker.update(cx, move |picker, cx| { + picker.delegate.set_workspaces(workspaces); + picker.update_matches(picker.query(cx), window, cx) + }) + }) + .ok(); + }) + .detach(); + + picker.focus_handle(cx).focus(window, cx); + + Self { + picker, + _subscription, + } + }) + } +} + +impl EventEmitter for SidebarRecentProjects {} + +impl Focusable for SidebarRecentProjects { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.picker.focus_handle(cx) + } +} + +impl Render for SidebarRecentProjects { + fn render(&mut self, _: &mut Window, _cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("SidebarRecentProjects") + .w(rems(18.)) + .child(self.picker.clone()) + } +} + +pub struct SidebarRecentProjectsDelegate { + workspace: WeakEntity, + sibling_workspace_ids: HashSet, + workspaces: Vec<( + WorkspaceId, + SerializedWorkspaceLocation, + PathList, + DateTime, + )>, + filtered_workspaces: Vec, + selected_index: usize, + focus_handle: FocusHandle, +} + +impl SidebarRecentProjectsDelegate { + pub fn set_workspaces( + &mut self, + workspaces: Vec<( + WorkspaceId, + SerializedWorkspaceLocation, + PathList, + DateTime, + )>, + ) { + self.workspaces = workspaces; + } +} + +impl EventEmitter for SidebarRecentProjectsDelegate {} + +impl PickerDelegate for SidebarRecentProjectsDelegate { + type ListItem = AnyElement; + + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { + "Search recent projects…".into() + } + + fn render_editor( + &self, + editor: &Arc, + window: &mut Window, + cx: &mut Context>, + ) -> Div { + h_flex() + .flex_none() + .h_9() + .px_2p5() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .child(editor.render(window, cx)) + } + + fn match_count(&self) -> usize { + self.filtered_workspaces.len() + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index( + &mut self, + ix: usize, + _window: &mut Window, + _cx: &mut Context>, + ) { + self.selected_index = ix; + } + + fn update_matches( + &mut self, + query: String, + _: &mut Window, + cx: &mut Context>, + ) -> Task<()> { + let query = query.trim_start(); + let smart_case = query.chars().any(|c| c.is_uppercase()); + let is_empty_query = query.is_empty(); + + let current_workspace_id = self + .workspace + .upgrade() + .and_then(|ws| ws.read(cx).database_id()); + + let candidates: Vec<_> = self + .workspaces + .iter() + .enumerate() + .filter(|(_, (id, _, _, _))| { + Some(*id) != current_workspace_id && !self.sibling_workspace_ids.contains(id) + }) + .map(|(id, (_, _, paths, _))| { + let combined_string = paths + .ordered_paths() + .map(|path| path.compact().to_string_lossy().into_owned()) + .collect::>() + .join(""); + StringMatchCandidate::new(id, &combined_string) + }) + .collect(); + + if is_empty_query { + self.filtered_workspaces = candidates + .into_iter() + .map(|candidate| StringMatch { + candidate_id: candidate.id, + score: 0.0, + positions: Vec::new(), + string: candidate.string, + }) + .collect(); + } else { + let mut matches = smol::block_on(fuzzy::match_strings( + &candidates, + query, + smart_case, + true, + 100, + &Default::default(), + cx.background_executor().clone(), + )); + matches.sort_unstable_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.candidate_id.cmp(&b.candidate_id)) + }); + self.filtered_workspaces = matches; + } + + self.selected_index = 0; + Task::ready(()) + } + + fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) { + let Some(hit) = self.filtered_workspaces.get(self.selected_index) else { + return; + }; + let Some((_, location, candidate_workspace_paths, _)) = + self.workspaces.get(hit.candidate_id) + else { + return; + }; + + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + + match location { + SerializedWorkspaceLocation::Local => { + if let Some(handle) = window.window_handle().downcast::() { + let paths = candidate_workspace_paths.paths().to_vec(); + cx.defer(move |cx| { + if let Some(task) = handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.open_project(paths, window, cx) + }) + .log_err() + { + task.detach_and_log_err(cx); + } + }); + } + } + SerializedWorkspaceLocation::Remote(connection) => { + let mut connection = connection.clone(); + workspace.update(cx, |workspace, cx| { + let app_state = workspace.app_state().clone(); + let replace_window = window.window_handle().downcast::(); + let open_options = OpenOptions { + replace_window, + ..Default::default() + }; + if let RemoteConnectionOptions::Ssh(connection) = &mut connection { + crate::RemoteSettings::get_global(cx) + .fill_connection_options_from_settings(connection); + }; + let paths = candidate_workspace_paths.paths().to_vec(); + cx.spawn_in(window, async move |_, cx| { + open_remote_project(connection.clone(), paths, app_state, open_options, cx) + .await + }) + .detach_and_prompt_err( + "Failed to open project", + window, + cx, + |_, _, _| None, + ); + }); + } + } + cx.emit(DismissEvent); + } + + fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context>) {} + + fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { + let text = if self.workspaces.is_empty() { + "Recently opened projects will show up here" + } else { + "No matches" + }; + Some(text.into()) + } + + fn render_match( + &self, + ix: usize, + selected: bool, + window: &mut Window, + cx: &mut Context>, + ) -> Option { + let hit = self.filtered_workspaces.get(ix)?; + let (_, location, paths, _) = self.workspaces.get(hit.candidate_id)?; + + let ordered_paths: Vec<_> = paths + .ordered_paths() + .map(|p| p.compact().to_string_lossy().to_string()) + .collect(); + + let tooltip_path: SharedString = match &location { + SerializedWorkspaceLocation::Remote(options) => { + let host = options.display_name(); + if ordered_paths.len() == 1 { + format!("{} ({})", ordered_paths[0], host).into() + } else { + format!("{}\n({})", ordered_paths.join("\n"), host).into() + } + } + _ => ordered_paths.join("\n").into(), + }; + + let mut path_start_offset = 0; + let match_labels: Vec<_> = paths + .ordered_paths() + .map(|p| p.compact()) + .map(|path| { + let (label, path_match) = + highlights_for_path(path.as_ref(), &hit.positions, path_start_offset); + path_start_offset += path_match.text.len(); + label + }) + .collect(); + + let prefix = match &location { + SerializedWorkspaceLocation::Remote(options) => { + Some(SharedString::from(options.display_name())) + } + _ => None, + }; + + let highlighted_match = HighlightedMatchWithPaths { + prefix, + match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "), + paths: Vec::new(), + }; + + let icon = icon_for_remote_connection(match location { + SerializedWorkspaceLocation::Local => None, + SerializedWorkspaceLocation::Remote(options) => Some(options), + }); + + Some( + ListItem::new(ix) + .toggle_state(selected) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .child( + h_flex() + .gap_3() + .flex_grow() + .child(Icon::new(icon).color(Color::Muted)) + .child(highlighted_match.render(window, cx)), + ) + .tooltip(Tooltip::text(tooltip_path)) + .into_any_element(), + ) + } + + fn render_footer(&self, _: &mut Window, cx: &mut Context>) -> Option { + let focus_handle = self.focus_handle.clone(); + + Some( + v_flex() + .flex_1() + .p_1p5() + .gap_1() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child({ + let open_action = workspace::Open { + create_new_window: false, + }; + Button::new("open_local_folder", "Add Local Project") + .key_binding(KeyBinding::for_action_in(&open_action, &focus_handle, cx)) + .on_click(move |_, window, cx| { + window.dispatch_action(open_action.boxed_clone(), cx) + }) + }) + .into_any(), + ) + } +} diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index dc687b01bdf298835497a18f09a9946769a0c193..6c4142df5c3f65919a701a29e9a2f7dbd3dd2216 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -17,24 +17,26 @@ use menu::{ Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious, }; use project::{AgentId, Event as ProjectEvent, linked_worktree_short_name}; -use recent_projects::RecentProjects; +use recent_projects::sidebar_recent_projects::SidebarRecentProjects; use ui::utils::platform_title_bar_height; use settings::Settings as _; use std::collections::{HashMap, HashSet}; use std::mem; use std::path::Path; +use std::rc::Rc; use std::sync::Arc; use theme::ActiveTheme; use ui::{ - AgentThreadStatus, CommonAnimationExt, Divider, HighlightedLabel, KeyBinding, ListItem, - PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, TintColor, Tooltip, WithScrollbar, prelude::*, + AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, HighlightedLabel, KeyBinding, + ListItem, PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, TintColor, Tooltip, WithScrollbar, + prelude::*, }; use util::ResultExt as _; use util::path_list::PathList; use workspace::{ - FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, Open, Sidebar as WorkspaceSidebar, - ToggleWorkspaceSidebar, Workspace, WorkspaceId, + AddFolderToProject, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, Open, + Sidebar as WorkspaceSidebar, ToggleWorkspaceSidebar, Workspace, WorkspaceId, }; use zed_actions::OpenRecent; @@ -47,6 +49,8 @@ gpui::actions!( [ /// Creates a new thread in the currently selected or active project group. NewThreadInGroup, + /// Toggles between the thread list and the archive view. + ToggleArchive, ] ); @@ -143,6 +147,7 @@ struct SidebarContents { entries: Vec, notified_threads: HashSet, project_header_indices: Vec, + has_open_projects: bool, } impl SidebarContents { @@ -239,11 +244,13 @@ pub struct Sidebar { /// loading. User actions may write directly for immediate feedback. focused_thread: Option, agent_panel_visible: bool, + active_thread_is_draft: bool, hovered_thread_index: Option, collapsed_groups: HashSet, expanded_groups: HashMap, view: SidebarView, - recent_projects_popover_handle: PopoverMenuHandle, + recent_projects_popover_handle: PopoverMenuHandle, + project_header_menu_ix: Option, _subscriptions: Vec, _draft_observation: Option, } @@ -329,11 +336,13 @@ impl Sidebar { selection: None, focused_thread: None, agent_panel_visible: false, + active_thread_is_draft: false, hovered_thread_index: None, collapsed_groups: HashSet::new(), expanded_groups: HashMap::new(), view: SidebarView::default(), recent_projects_popover_handle: PopoverMenuHandle::default(), + project_header_menu_ix: None, _subscriptions: Vec::new(), _draft_observation: None, } @@ -613,6 +622,11 @@ impl Sidebar { .as_ref() .map_or(false, |ws| AgentPanel::is_visible(ws, cx)); + self.active_thread_is_draft = active_workspace + .as_ref() + .and_then(|ws| ws.read(cx).panel::(cx)) + .map_or(false, |panel| panel.read(cx).active_thread_is_draft(cx)); + let previous = mem::take(&mut self.contents); let old_statuses: HashMap = previous @@ -676,6 +690,10 @@ impl Sidebar { } } + let has_open_projects = workspaces + .iter() + .any(|ws| !workspace_path_list(ws, cx).paths().is_empty()); + for (ws_index, workspace) in workspaces.iter().enumerate() { if absorbed.contains_key(&ws_index) { continue; @@ -1024,6 +1042,7 @@ impl Sidebar { entries, notified_threads, project_header_indices, + has_open_projects, }; } @@ -1158,7 +1177,9 @@ impl Sidebar { } else { IconName::ChevronDown }; + let workspace_for_remove = workspace.clone(); + let workspace_for_menu = workspace.clone(); let path_list_for_toggle = path_list.clone(); let path_list_for_collapse = path_list.clone(); @@ -1180,6 +1201,7 @@ impl Sidebar { }; ListItem::new(id) + .height(Tab::content_height(cx)) .group_name(group_name) .focused(is_selected) .child( @@ -1187,7 +1209,6 @@ impl Sidebar { .relative() .min_w_0() .w_full() - .py_1() .gap_1p5() .child( h_flex().size_4().flex_none().justify_center().child( @@ -1224,27 +1245,21 @@ impl Sidebar { }), ) .end_hover_gradient_overlay(true) - .end_hover_slot( + .end_slot({ h_flex() - .gap_1() - .when(workspace_count > 1, |this| { - this.child( - IconButton::new( - SharedString::from(format!( - "{id_prefix}project-header-remove-{ix}", - )), - IconName::Close, - ) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip(Tooltip::text("Remove Project")) - .on_click(cx.listener( - move |this, _, window, cx| { - this.remove_workspace(&workspace_for_remove, window, cx); - }, - )), - ) + .when(self.project_header_menu_ix != Some(ix), |this| { + this.visible_on_hover("list_item") + }) + .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); }) + .child(self.render_project_header_menu( + ix, + id_prefix, + &workspace_for_menu, + &workspace_for_remove, + cx, + )) .when(view_more_expanded && !is_collapsed, |this| { this.child( IconButton::new( @@ -1265,8 +1280,27 @@ impl Sidebar { } })), ) - }), - ) + }) + .when(workspace_count > 1, |this| { + let workspace_for_remove_btn = workspace_for_remove.clone(); + this.child( + IconButton::new( + SharedString::from(format!( + "{id_prefix}project-header-remove-{ix}", + )), + IconName::Close, + ) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Remove Project")) + .on_click(cx.listener( + move |this, _, window, cx| { + this.remove_workspace(&workspace_for_remove_btn, window, cx); + }, + )), + ) + }) + }) .on_click(cx.listener(move |this, _, window, cx| { this.selection = None; this.toggle_collapse(&path_list_for_toggle, window, cx); @@ -1279,6 +1313,140 @@ impl Sidebar { .into_any_element() } + fn render_project_header_menu( + &self, + ix: usize, + id_prefix: &str, + workspace: &Entity, + workspace_for_remove: &Entity, + cx: &mut Context, + ) -> impl IntoElement { + let workspace_for_menu = workspace.clone(); + let workspace_for_remove = workspace_for_remove.clone(); + let multi_workspace = self.multi_workspace.clone(); + let this = cx.weak_entity(); + + PopoverMenu::new(format!("{id_prefix}project-header-menu-{ix}")) + .on_open(Rc::new({ + let this = this.clone(); + move |_window, cx| { + this.update(cx, |sidebar, cx| { + sidebar.project_header_menu_ix = Some(ix); + cx.notify(); + }) + .ok(); + } + })) + .menu(move |window, cx| { + let workspace = workspace_for_menu.clone(); + let workspace_for_remove = workspace_for_remove.clone(); + let multi_workspace = multi_workspace.clone(); + + let menu = ContextMenu::build_persistent(window, cx, move |menu, _window, cx| { + let worktrees: Vec<_> = workspace + .read(cx) + .visible_worktrees(cx) + .map(|worktree| { + let worktree_read = worktree.read(cx); + let id = worktree_read.id(); + let name: SharedString = + worktree_read.root_name().as_unix_str().to_string().into(); + (id, name) + }) + .collect(); + + let worktree_count = worktrees.len(); + + let mut menu = menu + .header("Project Folders") + .end_slot_action(Box::new(menu::EndSlot)); + + for (worktree_id, name) in &worktrees { + let worktree_id = *worktree_id; + let workspace_for_worktree = workspace.clone(); + let workspace_for_remove_worktree = workspace_for_remove.clone(); + let multi_workspace_for_worktree = multi_workspace.clone(); + + let remove_handler = move |window: &mut Window, cx: &mut App| { + if worktree_count <= 1 { + if let Some(mw) = multi_workspace_for_worktree.upgrade() { + let ws = workspace_for_remove_worktree.clone(); + mw.update(cx, |multi_workspace, cx| { + if let Some(index) = multi_workspace + .workspaces() + .iter() + .position(|w| *w == ws) + { + multi_workspace.remove_workspace(index, window, cx); + } + }); + } + } else { + workspace_for_worktree.update(cx, |workspace, cx| { + workspace.project().update(cx, |project, cx| { + project.remove_worktree(worktree_id, cx); + }); + }); + } + }; + + menu = menu.entry_with_end_slot_on_hover( + name.clone(), + None, + |_, _| {}, + IconName::Close, + "Remove Folder".into(), + remove_handler, + ); + } + + let workspace_for_add = workspace.clone(); + let multi_workspace_for_add = multi_workspace.clone(); + menu.separator().entry( + "Add Folder to Project", + Some(Box::new(AddFolderToProject)), + move |window, cx| { + if let Some(mw) = multi_workspace_for_add.upgrade() { + mw.update(cx, |mw, cx| { + mw.activate(workspace_for_add.clone(), cx); + }); + } + workspace_for_add.update(cx, |workspace, cx| { + workspace.add_folder_to_project(&AddFolderToProject, window, cx); + }); + }, + ) + }); + + let this = this.clone(); + window + .subscribe(&menu, cx, move |_, _: &gpui::DismissEvent, _window, cx| { + this.update(cx, |sidebar, cx| { + sidebar.project_header_menu_ix = None; + cx.notify(); + }) + .ok(); + }) + .detach(); + + Some(menu) + }) + .trigger( + IconButton::new( + SharedString::from(format!("{id_prefix}-ellipsis-menu-{ix}")), + IconName::Ellipsis, + ) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .icon_size(IconSize::Small) + .icon_color(Color::Muted), + ) + .anchor(gpui::Corner::TopRight) + .offset(gpui::Point { + x: px(0.), + y: px(1.), + }) + } + fn render_sticky_header( &self, window: &mut Window, @@ -1445,11 +1613,16 @@ impl Sidebar { } fn focus_in(&mut self, window: &mut Window, cx: &mut Context) { - if matches!(self.view, SidebarView::Archive(_)) { + if !self.focus_handle.is_focused(window) { return; } - if self.selection.is_none() { + if let SidebarView::Archive(archive) = &self.view { + let has_selection = archive.read(cx).has_selection(); + if !has_selection { + archive.update(cx, |view, cx| view.focus_filter_editor(window, cx)); + } + } else if self.selection.is_none() { self.filter_editor.focus_handle(cx).focus(window, cx); } } @@ -1471,7 +1644,14 @@ impl Sidebar { cx: &mut Context, ) { self.selection = None; - self.filter_editor.focus_handle(cx).focus(window, cx); + if let SidebarView::Archive(archive) = &self.view { + archive.update(cx, |view, cx| { + view.clear_selection(); + view.focus_filter_editor(window, cx); + }); + } else { + self.filter_editor.focus_handle(cx).focus(window, cx); + } // When vim mode is active, the editor defaults to normal mode which // blocks text input. Switch to insert mode so the user can type @@ -2307,10 +2487,9 @@ impl Sidebar { .with_handle(popover_handle) .menu(move |window, cx| { workspace.as_ref().map(|ws| { - RecentProjects::popover( + SidebarRecentProjects::popover( ws.clone(), sibling_workspace_ids.clone(), - false, focus_handle.clone(), window, cx, @@ -2323,7 +2502,7 @@ impl Sidebar { .selected_style(ButtonStyle::Tinted(TintColor::Accent)), |_window, cx| { Tooltip::for_action( - "Recent Projects", + "Add Project", &OpenRecent { create_new_window: false, }, @@ -2331,7 +2510,11 @@ impl Sidebar { ) }, ) - .anchor(gpui::Corner::TopLeft) + .offset(gpui::Point { + x: px(-2.0), + y: px(-2.0), + }) + .anchor(gpui::Corner::BottomRight) } fn render_view_more( @@ -2448,14 +2631,8 @@ impl Sidebar { is_selected: bool, cx: &mut Context, ) -> AnyElement { - let focused_thread_in_list = self.focused_thread.as_ref().is_some_and(|focused_id| { - self.contents.entries.iter().any(|entry| { - matches!(entry, ListEntry::Thread(t) if &t.session_info.session_id == focused_id) - }) - }); - let is_active = self.agent_panel_visible - && !focused_thread_in_list + && self.active_thread_is_draft && self .multi_workspace .upgrade() @@ -2471,7 +2648,7 @@ impl Sidebar { let workspace = workspace.clone(); let id = SharedString::from(format!("new-thread-btn-{}", ix)); - ThreadItem::new(id, label) + let thread_item = ThreadItem::new(id, label) .icon(IconName::Plus) .selected(is_active) .focused(is_selected) @@ -2481,8 +2658,39 @@ impl Sidebar { this.selection = None; this.create_new_thread(&workspace, window, cx); })) - }) - .into_any_element() + }); + + if is_active { + div() + .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + .child(thread_item) + .into_any_element() + } else { + thread_item.into_any_element() + } + } + + fn render_no_results(&self, cx: &mut Context) -> impl IntoElement { + let has_query = self.has_filter_query(cx); + let message = if has_query { + "No threads match your search." + } else { + "No threads yet" + }; + + v_flex() + .id("sidebar-no-results") + .p_4() + .size_full() + .items_center() + .justify_center() + .child( + Label::new(message) + .size(LabelSize::Small) + .color(Color::Muted), + ) } fn render_empty_state(&self, cx: &mut Context) -> impl IntoElement { @@ -2527,7 +2735,7 @@ impl Sidebar { fn render_sidebar_header( &self, - empty_state: bool, + no_open_projects: bool, window: &Window, cx: &mut Context, ) -> impl IntoElement { @@ -2535,69 +2743,47 @@ impl Sidebar { let traffic_lights = cfg!(target_os = "macos") && !window.is_fullscreen(); let header_height = platform_title_bar_height(window); - v_flex() - .child( - h_flex() - .h(header_height) - .mt_px() - .pb_px() - .when(traffic_lights, |this| { - this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING)) - }) - .pr_1p5() - .gap_1() - .border_b_1() + h_flex() + .h(header_height) + .mt_px() + .pb_px() + .when(traffic_lights, |this| { + this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING)) + }) + .pr_1p5() + .gap_1() + .when(!no_open_projects, |this| { + this.border_b_1() .border_color(cx.theme().colors().border) - .justify_end() + .child(Divider::vertical().color(ui::DividerColor::Border)) .child( - IconButton::new("archive", IconName::Archive) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("View Archived Threads")) - .on_click(cx.listener(|this, _, window, cx| { - this.show_archive(window, cx); - })), + div().ml_1().child( + Icon::new(IconName::MagnifyingGlass) + .size(IconSize::Small) + .color(Color::Muted), + ), ) - .child(self.render_recent_projects_button(cx)), - ) - .when(!empty_state, |this| { - this.child( - h_flex() - .h(Tab::container_height(cx)) - .px_1p5() - .gap_1p5() - .border_b_1() - .border_color(cx.theme().colors().border) - .child( - h_flex().size_4().flex_none().justify_center().child( - Icon::new(IconName::MagnifyingGlass) - .size(IconSize::Small) - .color(Color::Muted), - ), - ) - .child(self.render_filter_input(cx)) - .child( - h_flex() - .gap_1() - .when( - self.selection.is_some() - && !self.filter_editor.focus_handle(cx).is_focused(window), - |this| { - this.child(KeyBinding::for_action(&FocusSidebarFilter, cx)) - }, + .child(self.render_filter_input(cx)) + .child( + h_flex() + .gap_1() + .when( + self.selection.is_some() + && !self.filter_editor.focus_handle(cx).is_focused(window), + |this| this.child(KeyBinding::for_action(&FocusSidebarFilter, cx)), + ) + .when(has_query, |this| { + this.child( + IconButton::new("clear_filter", IconName::Close) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Clear Search")) + .on_click(cx.listener(|this, _, window, cx| { + this.reset_filter_editor_text(window, cx); + this.update_entries(cx); + })), ) - .when(has_query, |this| { - this.child( - IconButton::new("clear_filter", IconName::Close) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Clear Search")) - .on_click(cx.listener(|this, _, window, cx| { - this.reset_filter_editor_text(window, cx); - this.update_entries(cx); - })), - ) - }), - ), - ) + }), + ) }) } @@ -2633,6 +2819,13 @@ impl Sidebar { } impl Sidebar { + fn toggle_archive(&mut self, _: &ToggleArchive, window: &mut Window, cx: &mut Context) { + match &self.view { + SidebarView::ThreadList => self.show_archive(window, cx), + SidebarView::Archive(_) => self.show_thread_list(window, cx), + } + } + 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) @@ -2685,14 +2878,16 @@ impl Sidebar { ); self._subscriptions.push(subscription); - self.view = SidebarView::Archive(archive_view); + self.view = SidebarView::Archive(archive_view.clone()); + archive_view.update(cx, |view, cx| view.focus_filter_editor(window, cx)); cx.notify(); } fn show_thread_list(&mut self, window: &mut Window, cx: &mut Context) { self.view = SidebarView::ThreadList; self._subscriptions.clear(); - window.focus(&self.focus_handle, cx); + let handle = self.filter_editor.read(cx).focus_handle(cx); + handle.focus(window, cx); cx.notify(); } } @@ -2711,14 +2906,6 @@ impl WorkspaceSidebar for Sidebar { !self.contents.notified_threads.is_empty() } - fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App) { - self.recent_projects_popover_handle.toggle(window, cx); - } - - fn is_recent_projects_popover_deployed(&self) -> bool { - self.recent_projects_popover_handle.is_deployed() - } - fn is_threads_list_view_active(&self) -> bool { matches!(self.view, SidebarView::ThreadList) } @@ -2746,7 +2933,8 @@ impl Render for Sidebar { .title_bar_background .blend(cx.theme().colors().panel_background.opacity(0.8)); - let empty_state = self.contents.entries.is_empty(); + let no_open_projects = !self.contents.has_open_projects; + let no_search_results = self.contents.entries.is_empty(); v_flex() .id("workspace-sidebar") @@ -2767,7 +2955,11 @@ impl Render for Sidebar { .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::remove_selected_thread)) .on_action(cx.listener(Self::new_thread_in_group)) + .on_action(cx.listener(Self::toggle_archive)) .on_action(cx.listener(Self::focus_sidebar_filter)) + .on_action(cx.listener(|this, _: &OpenRecent, window, cx| { + this.recent_projects_popover_handle.toggle(window, cx); + })) .font(ui_font) .h_full() .w(self.width) @@ -2776,9 +2968,9 @@ impl Render for Sidebar { .border_color(cx.theme().colors().border) .map(|this| match &self.view { SidebarView::ThreadList => this - .child(self.render_sidebar_header(empty_state, window, cx)) + .child(self.render_sidebar_header(no_open_projects, window, cx)) .map(|this| { - if empty_state { + if no_open_projects { this.child(self.render_empty_state(cx)) } else { this.child( @@ -2794,6 +2986,9 @@ impl Render for Sidebar { .flex_1() .size_full(), ) + .when(no_search_results, |this| { + this.child(self.render_no_results(cx)) + }) .when_some(sticky_header, |this, header| this.child(header)) .vertical_scrollbar_for(&self.list_state, window, cx), ) @@ -2804,9 +2999,31 @@ impl Render for Sidebar { .child( h_flex() .p_1() + .gap_1() + .justify_between() .border_t_1() .border_color(cx.theme().colors().border) - .child(self.render_sidebar_toggle_button(cx)), + .child(self.render_sidebar_toggle_button(cx)) + .child( + h_flex() + .gap_1() + .child(self.render_recent_projects_button(cx)) + .child( + IconButton::new("archive", IconName::Archive) + .icon_size(IconSize::Small) + .toggle_state(matches!(self.view, SidebarView::Archive(..))) + .tooltip(move |_, cx| { + Tooltip::for_action( + "Toggle Archived Threads", + &ToggleArchive, + cx, + ) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.toggle_archive(&ToggleArchive, window, cx); + })), + ), + ), ) } } @@ -4938,6 +5155,93 @@ mod tests { }); } + #[gpui::test] + async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/project-a", cx).await; + let fs = cx.update(|cx| ::global(cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); + + let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); + + // Start a thread and send a message so it has history. + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&panel, connection, cx); + send_message(&panel, cx); + let session_id = active_session_id(&panel, cx); + save_test_thread_metadata(&session_id, path_list_a.clone(), cx).await; + cx.run_until_parked(); + + // Verify the thread appears in the sidebar. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " [+ New Thread]", " Hello *",] + ); + + // The "New Thread" button should NOT be in "active/draft" state + // because the panel has a thread with messages. + sidebar.read_with(cx, |sidebar, _cx| { + assert!( + !sidebar.active_thread_is_draft, + "Panel has a thread with messages, so it should not be a draft" + ); + }); + + // Now add a second folder to the workspace, changing the path_list. + fs.as_fake() + .insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + project + .update(cx, |project, cx| { + project.find_or_create_worktree("/project-b", true, cx) + }) + .await + .expect("should add worktree"); + cx.run_until_parked(); + + // The workspace path_list is now [project-a, project-b]. The old + // thread was stored under [project-a], so it no longer appears in + // the sidebar list for this workspace. + let entries = visible_entries_as_strings(&sidebar, cx); + assert!( + !entries.iter().any(|e| e.contains("Hello")), + "Thread stored under the old path_list should not appear: {:?}", + entries + ); + + // The "New Thread" button must still be clickable (not stuck in + // "active/draft" state). Verify that `active_thread_is_draft` is + // false — the panel still has the old thread with messages. + sidebar.read_with(cx, |sidebar, _cx| { + assert!( + !sidebar.active_thread_is_draft, + "After adding a folder the panel still has a thread with messages, \ + so active_thread_is_draft should be false" + ); + }); + + // Actually click "New Thread" by calling create_new_thread and + // verify a new draft is created. + let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.create_new_thread(&workspace, window, cx); + }); + cx.run_until_parked(); + + // After creating a new thread, the panel should now be in draft + // state (no messages on the new thread). + sidebar.read_with(cx, |sidebar, _cx| { + assert!( + sidebar.active_thread_is_draft, + "After creating a new thread the panel should be in draft state" + ); + }); + } + async fn init_test_project_with_git( worktree_path: &str, cx: &mut TestAppContext, diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 50d3db65b94040c494b369932a1ac05afc57314a..b5fdece055d2c7f80421d361a27a5a93d62e3420 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -743,7 +743,7 @@ impl TitleBar { if is_sidebar_open && is_threads_list_view_active { return self - .render_project_name_with_sidebar_popover(display_name, is_project_selected, cx) + .render_recent_projects_popover(display_name, is_project_selected, cx) .into_any_element(); } @@ -802,51 +802,66 @@ impl TitleBar { .into_any_element() } - /// When the sidebar is open, the title bar's project name button becomes a - /// plain button that toggles the sidebar's popover (so the popover is always - /// anchored to the sidebar). Both buttons show their selected state together. - fn render_project_name_with_sidebar_popover( + fn render_recent_projects_popover( &self, display_name: String, is_project_selected: bool, cx: &mut Context, ) -> impl IntoElement { - let multi_workspace = self.multi_workspace.clone(); + let workspace = self.workspace.clone(); + + let focus_handle = workspace + .upgrade() + .map(|w| w.read(cx).focus_handle(cx)) + .unwrap_or_else(|| cx.focus_handle()); - let is_popover_deployed = multi_workspace + let sibling_workspace_ids: HashSet = self + .multi_workspace .as_ref() .and_then(|mw| mw.upgrade()) - .map(|mw| mw.read(cx).is_recent_projects_popover_deployed(cx)) - .unwrap_or(false); - - Button::new("project_name_trigger", display_name) - .label_size(LabelSize::Small) - .when(self.worktree_count(cx) > 1, |this| { - this.end_icon( - Icon::new(IconName::ChevronDown) - .size(IconSize::XSmall) - .color(Color::Muted), - ) + .map(|mw| { + mw.read(cx) + .workspaces() + .iter() + .filter_map(|ws| ws.read(cx).database_id()) + .collect() }) - .toggle_state(is_popover_deployed) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .when(!is_project_selected, |s| s.color(Color::Muted)) - .tooltip(move |_window, cx| { - Tooltip::for_action( - "Recent Projects", - &zed_actions::OpenRecent { - create_new_window: false, - }, + .unwrap_or_default(); + + PopoverMenu::new("sidebar-title-recent-projects-menu") + .menu(move |window, cx| { + Some(recent_projects::RecentProjects::popover( + workspace.clone(), + sibling_workspace_ids.clone(), + false, + focus_handle.clone(), + window, cx, - ) - }) - .on_click(move |_, window, cx| { - if let Some(mw) = multi_workspace.as_ref().and_then(|mw| mw.upgrade()) { - mw.update(cx, |mw, cx| { - mw.toggle_recent_projects_popover(window, cx); - }); - } + )) }) + .trigger_with_tooltip( + Button::new("project_name_trigger", display_name) + .label_size(LabelSize::Small) + .when(self.worktree_count(cx) > 1, |this| { + this.end_icon( + Icon::new(IconName::ChevronDown) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + }) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .when(!is_project_selected, |s| s.color(Color::Muted)), + move |_window, cx| { + Tooltip::for_action( + "Recent Projects", + &zed_actions::OpenRecent { + create_new_window: false, + }, + cx, + ) + }, + ) + .anchor(gpui::Corner::TopLeft) } fn render_project_branch( diff --git a/crates/ui/src/components/label/highlighted_label.rs b/crates/ui/src/components/label/highlighted_label.rs index 840bba7b173fe31a3472d758c64b0b1ef984da2c..1b10d910dd0ed1501188781622851e720c0ca102 100644 --- a/crates/ui/src/components/label/highlighted_label.rs +++ b/crates/ui/src/components/label/highlighted_label.rs @@ -1,6 +1,6 @@ use std::ops::Range; -use gpui::{FontWeight, HighlightStyle, StyledText}; +use gpui::{FontWeight, HighlightStyle, StyleRefinement, StyledText}; use crate::{LabelCommon, LabelLike, LabelSize, LineHeightStyle, prelude::*}; @@ -38,6 +38,40 @@ impl HighlightedLabel { } } +impl HighlightedLabel { + fn style(&mut self) -> &mut StyleRefinement { + self.base.base.style() + } + + pub fn flex_1(mut self) -> Self { + self.style().flex_grow = Some(1.); + self.style().flex_shrink = Some(1.); + self.style().flex_basis = Some(gpui::relative(0.).into()); + self + } + + pub fn flex_none(mut self) -> Self { + self.style().flex_grow = Some(0.); + self.style().flex_shrink = Some(0.); + self + } + + pub fn flex_grow(mut self) -> Self { + self.style().flex_grow = Some(1.); + self + } + + pub fn flex_shrink(mut self) -> Self { + self.style().flex_shrink = Some(1.); + self + } + + pub fn flex_shrink_0(mut self) -> Self { + self.style().flex_shrink = Some(0.); + self + } +} + impl LabelCommon for HighlightedLabel { fn size(mut self, size: LabelSize) -> Self { self.base = self.base.size(size); diff --git a/crates/ui/src/components/list/list_item.rs b/crates/ui/src/components/list/list_item.rs index d707df82f4d19b0a3f519e9d6ac9ccdb22965e27..3eb21c3429d428675774d96a9969542536c31a26 100644 --- a/crates/ui/src/components/list/list_item.rs +++ b/crates/ui/src/components/list/list_item.rs @@ -49,6 +49,7 @@ pub struct ListItem { overflow_x: bool, focused: Option, docked_right: bool, + height: Option, } impl ListItem { @@ -80,6 +81,7 @@ impl ListItem { overflow_x: false, focused: None, docked_right: false, + height: None, } } @@ -201,6 +203,11 @@ impl ListItem { self.docked_right = docked_right; self } + + pub fn height(mut self, height: Pixels) -> Self { + self.height = Some(height); + self + } } impl Disableable for ListItem { @@ -244,6 +251,7 @@ impl RenderOnce for ListItem { .id(self.id) .when_some(self.group_name, |this, group| this.group(group)) .w_full() + .when_some(self.height, |this, height| this.h(height)) .relative() // When an item is inset draw the indent spacing outside of the item .when(self.inset, |this| { @@ -285,26 +293,21 @@ impl RenderOnce for ListItem { ListItemSpacing::Sparse => this.py_1(), }) .when(self.inset && !self.disabled, |this| { - this - // TODO: Add focus state - //.when(self.state == InteractionState::Focused, |this| { - .when_some(self.focused, |this, focused| { - if focused { - this.border_1() - .border_color(cx.theme().colors().border_focused) - } else { - this.border_1() - } - }) - .when(self.selectable, |this| { - this.hover(|style| { - style.bg(cx.theme().colors().ghost_element_hover) - }) + this.when_some(self.focused, |this, focused| { + if focused { + this.border_1() + .border_color(cx.theme().colors().border_focused) + } else { + this.border_1() + } + }) + .when(self.selectable, |this| { + this.hover(|style| style.bg(cx.theme().colors().ghost_element_hover)) .active(|style| style.bg(cx.theme().colors().ghost_element_active)) .when(self.selected, |this| { this.bg(cx.theme().colors().ghost_element_selected) }) - }) + }) }) .when_some( self.on_click.filter(|_| !self.disabled), diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index a7a7d50dca59727ebe81b7f55c10adc8ef8638f9..0205ed8a0429fe69577f4e9afe9db46407703db8 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -41,8 +41,7 @@ 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; - fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App); - fn is_recent_projects_popover_deployed(&self) -> bool; + fn is_threads_list_view_active(&self) -> bool { true } @@ -59,8 +58,7 @@ pub trait SidebarHandle: 'static + Send + Sync { fn has_notifications(&self, cx: &App) -> bool; fn to_any(&self) -> AnyView; fn entity_id(&self) -> EntityId; - fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App); - fn is_recent_projects_popover_deployed(&self, cx: &App) -> bool; + fn is_threads_list_view_active(&self, cx: &App) -> bool; } @@ -107,16 +105,6 @@ impl SidebarHandle for Entity { Entity::entity_id(self) } - fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App) { - self.update(cx, |this, cx| { - this.toggle_recent_projects_popover(window, cx); - }); - } - - fn is_recent_projects_popover_deployed(&self, cx: &App) -> bool { - self.read(cx).is_recent_projects_popover_deployed() - } - fn is_threads_list_view_active(&self, cx: &App) -> bool { self.read(cx).is_threads_list_view_active() } @@ -203,18 +191,6 @@ impl MultiWorkspace { .map_or(false, |s| s.has_notifications(cx)) } - pub fn toggle_recent_projects_popover(&self, window: &mut Window, cx: &mut App) { - if let Some(sidebar) = &self.sidebar { - sidebar.toggle_recent_projects_popover(window, cx); - } - } - - pub fn is_recent_projects_popover_deployed(&self, cx: &App) -> bool { - self.sidebar - .as_ref() - .map_or(false, |s| s.is_recent_projects_popover_deployed(cx)) - } - pub fn is_threads_list_view_active(&self, cx: &App) -> bool { self.sidebar .as_ref() diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 5ecd607b785419ac44af2553ad15d9ce8c91ff48..da65d8d3cf8df55e1a3db42e4ebadf43cb47a3f4 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1419,7 +1419,13 @@ impl Workspace { this.collaborator_left(*peer_id, window, cx); } - &project::Event::WorktreeRemoved(id) | &project::Event::WorktreeAdded(id) => { + &project::Event::WorktreeRemoved(_) => { + this.update_window_title(window, cx); + this.serialize_workspace(window, cx); + this.update_history(cx); + } + + &project::Event::WorktreeAdded(id) => { this.update_window_title(window, cx); if this .project() @@ -3366,7 +3372,7 @@ impl Workspace { .map(|wt| wt.read(cx).abs_path().as_ref().to_path_buf()) } - fn add_folder_to_project( + pub fn add_folder_to_project( &mut self, _: &AddFolderToProject, window: &mut Window,