Detailed changes
@@ -694,6 +694,7 @@
"enter": "menu::Confirm",
"space": "menu::Confirm",
"ctrl-f": "agents_sidebar::FocusSidebarFilter",
+ "ctrl-g": "agents_sidebar::ToggleArchive",
"shift-backspace": "agent::RemoveSelectedThread",
},
},
@@ -760,6 +760,7 @@
"enter": "menu::Confirm",
"space": "menu::Confirm",
"cmd-f": "agents_sidebar::FocusSidebarFilter",
+ "cmd-g": "agents_sidebar::ToggleArchive",
"shift-backspace": "agent::RemoveSelectedThread",
},
},
@@ -696,6 +696,7 @@
"enter": "menu::Confirm",
"space": "menu::Confirm",
"ctrl-f": "agents_sidebar::FocusSidebarFilter",
+ "ctrl-g": "agents_sidebar::ToggleArchive",
"shift-backspace": "agent::RemoveSelectedThread",
},
},
@@ -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<ThreadView>,
@@ -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>) {
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>) {
- 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>) {
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>) {
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<Self>) {
@@ -369,24 +403,29 @@ impl ThreadsArchiveView {
}
}
- fn select_previous(
- &mut self,
- _: &SelectPrevious,
- _window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- 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<Self>) {
+ 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);
+ })),
+ )
+ })
}
}
@@ -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<Picker<RecentProjectsDelegate>>,
rem_width: f32,
- _subscription: Subscription,
+ _subscriptions: Vec<Subscription>,
}
impl ModalView for RecentProjects {
@@ -550,6 +551,7 @@ impl RecentProjects {
window: &mut Window,
cx: &mut Context<Self>,
) -> 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<usize>,
path_start_offset: usize,
@@ -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<Picker<SidebarRecentProjectsDelegate>>,
+ _subscription: Subscription,
+}
+
+impl SidebarRecentProjects {
+ pub fn popover(
+ workspace: WeakEntity<Workspace>,
+ sibling_workspace_ids: HashSet<WorkspaceId>,
+ _focus_handle: FocusHandle,
+ window: &mut Window,
+ cx: &mut App,
+ ) -> Entity<Self> {
+ 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<Picker<SidebarRecentProjectsDelegate>> = 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<DismissEvent> 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<Self>) -> impl IntoElement {
+ v_flex()
+ .key_context("SidebarRecentProjects")
+ .w(rems(18.))
+ .child(self.picker.clone())
+ }
+}
+
+pub struct SidebarRecentProjectsDelegate {
+ workspace: WeakEntity<Workspace>,
+ sibling_workspace_ids: HashSet<WorkspaceId>,
+ workspaces: Vec<(
+ WorkspaceId,
+ SerializedWorkspaceLocation,
+ PathList,
+ DateTime<Utc>,
+ )>,
+ filtered_workspaces: Vec<StringMatch>,
+ selected_index: usize,
+ focus_handle: FocusHandle,
+}
+
+impl SidebarRecentProjectsDelegate {
+ pub fn set_workspaces(
+ &mut self,
+ workspaces: Vec<(
+ WorkspaceId,
+ SerializedWorkspaceLocation,
+ PathList,
+ DateTime<Utc>,
+ )>,
+ ) {
+ self.workspaces = workspaces;
+ }
+}
+
+impl EventEmitter<DismissEvent> for SidebarRecentProjectsDelegate {}
+
+impl PickerDelegate for SidebarRecentProjectsDelegate {
+ type ListItem = AnyElement;
+
+ fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
+ "Search recent projectsβ¦".into()
+ }
+
+ fn render_editor(
+ &self,
+ editor: &Arc<dyn ErasedEditor>,
+ window: &mut Window,
+ cx: &mut Context<Picker<Self>>,
+ ) -> 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<Picker<Self>>,
+ ) {
+ self.selected_index = ix;
+ }
+
+ fn update_matches(
+ &mut self,
+ query: String,
+ _: &mut Window,
+ cx: &mut Context<Picker<Self>>,
+ ) -> 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::<Vec<_>>()
+ .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<Picker<Self>>) {
+ 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::<MultiWorkspace>() {
+ 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::<MultiWorkspace>();
+ 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<Picker<Self>>) {}
+
+ fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
+ 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<Picker<Self>>,
+ ) -> Option<Self::ListItem> {
+ 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<Picker<Self>>) -> Option<AnyElement> {
+ 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(),
+ )
+ }
+}
@@ -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<ListEntry>,
notified_threads: HashSet<acp::SessionId>,
project_header_indices: Vec<usize>,
+ 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<acp::SessionId>,
agent_panel_visible: bool,
+ active_thread_is_draft: bool,
hovered_thread_index: Option<usize>,
collapsed_groups: HashSet<PathList>,
expanded_groups: HashMap<PathList, usize>,
view: SidebarView,
- recent_projects_popover_handle: PopoverMenuHandle<RecentProjects>,
+ recent_projects_popover_handle: PopoverMenuHandle<SidebarRecentProjects>,
+ project_header_menu_ix: Option<usize>,
_subscriptions: Vec<gpui::Subscription>,
_draft_observation: Option<gpui::Subscription>,
}
@@ -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::<AgentPanel>(cx))
+ .map_or(false, |panel| panel.read(cx).active_thread_is_draft(cx));
+
let previous = mem::take(&mut self.contents);
let old_statuses: HashMap<acp::SessionId, AgentThreadStatus> = 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>,
+ workspace_for_remove: &Entity<Workspace>,
+ cx: &mut Context<Self>,
+ ) -> 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<Self>) {
- 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>,
) {
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<Self>,
) -> 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<Self>) -> 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<Self>) -> 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<Self>,
) -> 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<Self>) {
+ 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<Self>) {
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>) {
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| <dyn fs::Fs>::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,
@@ -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<Self>,
) -> 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<WorkspaceId> = 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(
@@ -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);
@@ -49,6 +49,7 @@ pub struct ListItem {
overflow_x: bool,
focused: Option<bool>,
docked_right: bool,
+ height: Option<Pixels>,
}
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),
@@ -41,8 +41,7 @@ pub trait Sidebar: Focusable + Render + Sized {
fn width(&self, cx: &App) -> Pixels;
fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>);
fn has_notifications(&self, cx: &App) -> bool;
- 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<T: Sidebar> SidebarHandle for Entity<T> {
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()
@@ -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,