From 95842c798708057a92e7a72221583c8aeeeb6994 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Fri, 1 Nov 2024 14:47:46 +0100 Subject: [PATCH] gpui: Add scroll anchors (#19894) ## Problem statement I want to add keyboard navigation support to SSH modal. Doing so is possible in current landscape, but not particularly ergonomic; `gpui::ScrollHandle` has `scroll_to_item` API that takes an index of the item you want to scroll to. The problem is, however, that it only works with it's immediate children - thus in order to support scrolling via keyboard you have to bend your UI to have a particular layout. Even when your list of items is perfectly flat, having decorations inbetween items is problematic as they are also children of the list, which means that you either have to maintain the mapping to devise a correct index of an item that you want to scroll to, or you have to make the decoration a part of the list item itself, which might render the scrolling imprecise (you might e.g. not want to scroll to a header, but to a button beneath it). ## The solution This PR adds `ScrollAnchor`, a new kind of handle to the gpui. It has a similar role to that of a ScrollHandle, but instead of tracking how far along an item has been scrolled, it tracks position of an element relative to the parent to which a given scroll handle belongs. In short, it allows us to persist the position of an element in a list of items and scroll to it even if it's not an immediate children of a container whose scroll position is tracked via an associated scroll handle. Additionally this PR adds a new kind of the container to the UI crate that serves as a convenience wrapper for using ScrollAnchors. This container provides handlers for `menu::SelectNext` and `menu::SelectPrev` and figures out which item should be focused next. Release Notes: - Improve keyboard navigation in ssh modal --- crates/gpui/src/elements/div.rs | 39 +- crates/recent_projects/src/remote_servers.rs | 900 ++++++++++-------- crates/recent_projects/src/ssh_connections.rs | 4 +- crates/ui/src/components.rs | 2 + crates/ui/src/components/navigable.rs | 98 ++ 5 files changed, 631 insertions(+), 412 deletions(-) create mode 100644 crates/ui/src/components/navigable.rs diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 6c3b577e4c..e2015197e0 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -915,6 +915,12 @@ pub trait StatefulInteractiveElement: InteractiveElement { self } + /// Track the scroll state of this element with the given handle. + fn anchor_scroll(mut self, scroll_anchor: Option) -> Self { + self.interactivity().scroll_anchor = scroll_anchor; + self + } + /// Set the given styles to be applied when this element is active. fn active(mut self, f: impl FnOnce(StyleRefinement) -> StyleRefinement) -> Self where @@ -1156,6 +1162,9 @@ impl Element for Div { ) -> Option { let mut child_min = point(Pixels::MAX, Pixels::MAX); let mut child_max = Point::default(); + if let Some(handle) = self.interactivity.scroll_anchor.as_ref() { + *handle.last_origin.borrow_mut() = bounds.origin - cx.element_offset(); + } let content_size = if request_layout.child_layout_ids.is_empty() { bounds.size } else if let Some(scroll_handle) = self.interactivity.tracked_scroll_handle.as_ref() { @@ -1245,6 +1254,7 @@ pub struct Interactivity { pub(crate) focusable: bool, pub(crate) tracked_focus_handle: Option, pub(crate) tracked_scroll_handle: Option, + pub(crate) scroll_anchor: Option, pub(crate) scroll_offset: Option>>>, pub(crate) group: Option, /// The base style of the element, before any modifications are applied @@ -2091,7 +2101,6 @@ impl Interactivity { } scroll_offset.y += delta_y; scroll_offset.x += delta_x; - cx.stop_propagation(); if *scroll_offset != old_scroll_offset { cx.refresh(); @@ -2454,6 +2463,34 @@ where } } +/// Represents an element that can be scrolled *to* in its parent element. +/// +/// Contrary to [ScrollHandle::scroll_to_item], an anchored element does not have to be an immediate child of the parent. +#[derive(Clone)] +pub struct ScrollAnchor { + handle: ScrollHandle, + last_origin: Rc>>, +} + +impl ScrollAnchor { + /// Creates a [ScrollAnchor] associated with a given [ScrollHandle]. + pub fn for_handle(handle: ScrollHandle) -> Self { + Self { + handle, + last_origin: Default::default(), + } + } + /// Request scroll to this item on the next frame. + pub fn scroll_to(&self, cx: &mut WindowContext<'_>) { + let this = self.clone(); + + cx.on_next_frame(move |_| { + let viewport_bounds = this.handle.bounds(); + let self_bounds = *this.last_origin.borrow(); + this.handle.set_offset(viewport_bounds.origin - self_bounds); + }); + } +} #[derive(Default, Debug)] struct ScrollHandleState { offset: Rc>>, diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index 1b83120eb3..7806459ed1 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -20,6 +20,8 @@ use remote::SshConnectionOptions; use remote::SshRemoteClient; use settings::update_settings_file; use settings::Settings; +use ui::Navigable; +use ui::NavigableEntry; use ui::{ prelude::*, IconButtonShape, List, ListItem, ListSeparator, Modal, ModalHeader, Scrollbar, ScrollbarState, Section, Tooltip, @@ -41,12 +43,11 @@ use crate::ssh_connections::SshPrompt; use crate::ssh_connections::SshSettings; use crate::OpenRemote; +mod navigation_base {} pub struct RemoteServerProjects { mode: Mode, focus_handle: FocusHandle, - scroll_handle: ScrollHandle, workspace: WeakView, - selectable_items: SelectableItemList, retained_connections: Vec>, } @@ -79,16 +80,6 @@ struct ProjectPicker { _path_task: Shared>>, } -type SelectedItemCallback = - Box) + 'static>; - -/// Used to implement keyboard navigation for SSH modal. -#[derive(Default)] -struct SelectableItemList { - items: Vec, - active_item: Option, -} - struct EditNicknameState { index: usize, editor: View, @@ -116,60 +107,6 @@ impl EditNicknameState { } } -impl SelectableItemList { - fn reset(&mut self) { - self.items.clear(); - } - - fn reset_selection(&mut self) { - self.active_item.take(); - } - - fn prev(&mut self, _: &mut WindowContext<'_>) { - match self.active_item.as_mut() { - Some(active_index) => { - *active_index = active_index.checked_sub(1).unwrap_or(self.items.len() - 1) - } - None => { - self.active_item = Some(self.items.len() - 1); - } - } - } - - fn next(&mut self, _: &mut WindowContext<'_>) { - match self.active_item.as_mut() { - Some(active_index) => { - if *active_index + 1 < self.items.len() { - *active_index += 1; - } else { - *active_index = 0; - } - } - None => { - self.active_item = Some(0); - } - } - } - - fn add_item(&mut self, callback: SelectedItemCallback) { - self.items.push(callback) - } - - fn is_selected(&self) -> bool { - self.active_item == self.items.len().checked_sub(1) - } - - fn confirm( - &self, - remote_modal: &mut RemoteServerProjects, - cx: &mut ViewContext, - ) { - if let Some(active_item) = self.active_item.and_then(|ix| self.items.get(ix)) { - active_item(remote_modal, cx); - } - } -} - impl FocusableView for ProjectPicker { fn focus_handle(&self, cx: &AppContext) -> FocusHandle { self.picker.focus_handle(cx) @@ -309,18 +246,69 @@ impl gpui::Render for ProjectPicker { ) } } + +#[derive(Clone)] +struct ProjectEntry { + open_folder: NavigableEntry, + projects: Vec<(NavigableEntry, SshProject)>, + configure: NavigableEntry, + connection: SshConnection, +} + +#[derive(Clone)] +struct DefaultState { + scrollbar: ScrollbarState, + add_new_server: NavigableEntry, + servers: Vec, +} +impl DefaultState { + fn new(cx: &WindowContext<'_>) -> Self { + let handle = ScrollHandle::new(); + let scrollbar = ScrollbarState::new(handle.clone()); + let add_new_server = NavigableEntry::new(&handle, cx); + let servers = SshSettings::get_global(cx) + .ssh_connections() + .map(|connection| { + let open_folder = NavigableEntry::new(&handle, cx); + let configure = NavigableEntry::new(&handle, cx); + let projects = connection + .projects + .iter() + .map(|project| (NavigableEntry::new(&handle, cx), project.clone())) + .collect(); + ProjectEntry { + open_folder, + configure, + projects, + connection, + } + }) + .collect(); + Self { + scrollbar, + add_new_server, + servers, + } + } +} + +#[derive(Clone)] +struct ViewServerOptionsState { + server_index: usize, + connection: SshConnection, + entries: [NavigableEntry; 4], +} enum Mode { - Default(ScrollbarState), - ViewServerOptions(usize, SshConnection), + Default(DefaultState), + ViewServerOptions(ViewServerOptionsState), EditNickname(EditNicknameState), ProjectPicker(View), CreateRemoteServer(CreateRemoteServer), } impl Mode { - fn default_mode() -> Self { - let handle = ScrollHandle::new(); - Self::Default(ScrollbarState::new(handle)) + fn default_mode(cx: &WindowContext<'_>) -> Self { + Self::Default(DefaultState::new(cx)) } } impl RemoteServerProjects { @@ -348,30 +336,13 @@ impl RemoteServerProjects { }); Self { - mode: Mode::default_mode(), + mode: Mode::default_mode(cx), focus_handle, - scroll_handle: ScrollHandle::new(), workspace, - selectable_items: Default::default(), retained_connections: Vec::new(), } } - fn next_item(&mut self, _: &menu::SelectNext, cx: &mut ViewContext) { - if !matches!(self.mode, Mode::Default(_) | Mode::ViewServerOptions(_, _)) { - return; - } - - self.selectable_items.next(cx); - } - - fn prev_item(&mut self, _: &menu::SelectPrev, cx: &mut ViewContext) { - if !matches!(self.mode, Mode::Default(_) | Mode::ViewServerOptions(_, _)) { - return; - } - self.selectable_items.prev(cx); - } - pub fn project_picker( ix: usize, connection_options: remote::SshConnectionOptions, @@ -433,8 +404,7 @@ impl RemoteServerProjects { }); this.retained_connections.push(client); this.add_ssh_server(connection_options, cx); - this.mode = Mode::default_mode(); - this.selectable_items.reset_selection(); + this.mode = Mode::default_mode(cx); cx.notify() }) .log_err(), @@ -469,11 +439,15 @@ impl RemoteServerProjects { fn view_server_options( &mut self, - (index, connection): (usize, SshConnection), + (server_index, connection): (usize, SshConnection), cx: &mut ViewContext, ) { - self.selectable_items.reset_selection(); - self.mode = Mode::ViewServerOptions(index, connection); + self.mode = Mode::ViewServerOptions(ViewServerOptionsState { + server_index, + connection, + entries: std::array::from_fn(|_| NavigableEntry::focusable(cx)), + }); + self.focus_handle(cx).focus(cx); cx.notify(); } @@ -562,11 +536,7 @@ impl RemoteServerProjects { fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext) { match &self.mode { - Mode::Default(_) | Mode::ViewServerOptions(_, _) => { - let items = std::mem::take(&mut self.selectable_items); - items.confirm(self, cx); - self.selectable_items = items; - } + Mode::Default(_) | Mode::ViewServerOptions(_) => {} Mode::ProjectPicker(_) => {} Mode::CreateRemoteServer(state) => { if let Some(prompt) = state.ssh_prompt.as_ref() { @@ -588,8 +558,7 @@ impl RemoteServerProjects { } } }); - self.mode = Mode::default_mode(); - self.selectable_items.reset_selection(); + self.mode = Mode::default_mode(cx); self.focus_handle.focus(cx); } } @@ -606,12 +575,10 @@ impl RemoteServerProjects { }); self.mode = Mode::CreateRemoteServer(new_state); - self.selectable_items.reset_selection(); cx.notify(); } _ => { - self.mode = Mode::default_mode(); - self.selectable_items.reset_selection(); + self.mode = Mode::default_mode(cx); self.focus_handle(cx).focus(cx); cx.notify(); } @@ -621,14 +588,15 @@ impl RemoteServerProjects { fn render_ssh_connection( &mut self, ix: usize, - ssh_connection: SshConnection, + ssh_server: ProjectEntry, cx: &mut ViewContext, ) -> impl IntoElement { - let (main_label, aux_label) = if let Some(nickname) = ssh_connection.nickname.clone() { - let aux_label = SharedString::from(format!("({})", ssh_connection.host)); + let (main_label, aux_label) = if let Some(nickname) = ssh_server.connection.nickname.clone() + { + let aux_label = SharedString::from(format!("({})", ssh_server.connection.host)); (nickname.into(), Some(aux_label)) } else { - (ssh_connection.host.clone(), None) + (ssh_server.connection.host.clone(), None) }; v_flex() .w_full() @@ -657,75 +625,101 @@ impl RemoteServerProjects { .child( List::new() .empty_message("No projects.") - .children(ssh_connection.projects.iter().enumerate().map(|(pix, p)| { + .children(ssh_server.projects.iter().enumerate().map(|(pix, p)| { v_flex().gap_0p5().child(self.render_ssh_project( ix, - &ssh_connection, + &ssh_server, pix, p, cx, )) })) - .child(h_flex().map(|this| { - self.selectable_items.add_item(Box::new({ - let ssh_connection = ssh_connection.clone(); - move |this, cx| { - this.create_ssh_project(ix, ssh_connection.clone(), cx); - } - })); - let is_selected = self.selectable_items.is_selected(); - this.child( - ListItem::new(("new-remote-project", ix)) - .selected(is_selected) - .inset(true) - .spacing(ui::ListItemSpacing::Sparse) - .start_slot(Icon::new(IconName::Plus).color(Color::Muted)) - .child(Label::new("Open Folder")) - .on_click(cx.listener({ - let ssh_connection = ssh_connection.clone(); - move |this, _, cx| { - this.create_ssh_project(ix, ssh_connection.clone(), cx); - } - })), - ) - })) - .child(h_flex().map(|this| { - self.selectable_items.add_item(Box::new({ - let ssh_connection = ssh_connection.clone(); - move |this, cx| { - this.view_server_options((ix, ssh_connection.clone()), cx); - } - })); - let is_selected = self.selectable_items.is_selected(); - this.child( - ListItem::new(("server-options", ix)) - .selected(is_selected) - .inset(true) - .spacing(ui::ListItemSpacing::Sparse) - .start_slot(Icon::new(IconName::Settings).color(Color::Muted)) - .child(Label::new("View Server Options")) - .on_click(cx.listener({ - let ssh_connection = ssh_connection.clone(); - move |this, _, cx| { - this.view_server_options((ix, ssh_connection.clone()), cx); - } - })), - ) - })), + .child( + h_flex() + .id(("new-remote-project-container", ix)) + .track_focus(&ssh_server.open_folder.focus_handle) + .anchor_scroll(ssh_server.open_folder.scroll_anchor.clone()) + .on_action(cx.listener({ + let ssh_connection = ssh_server.clone(); + move |this, _: &menu::Confirm, cx| { + this.create_ssh_project( + ix, + ssh_connection.connection.clone(), + cx, + ); + } + })) + .child( + ListItem::new(("new-remote-project", ix)) + .selected( + ssh_server.open_folder.focus_handle.contains_focused(cx), + ) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot(Icon::new(IconName::Plus).color(Color::Muted)) + .child(Label::new("Open Folder")) + .on_click(cx.listener({ + let ssh_connection = ssh_server.clone(); + move |this, _, cx| { + this.create_ssh_project( + ix, + ssh_connection.connection.clone(), + cx, + ); + } + })), + ), + ) + .child( + h_flex() + .id(("server-options-container", ix)) + .track_focus(&ssh_server.configure.focus_handle) + .anchor_scroll(ssh_server.configure.scroll_anchor.clone()) + .on_action(cx.listener({ + let ssh_connection = ssh_server.clone(); + move |this, _: &menu::Confirm, cx| { + this.view_server_options( + (ix, ssh_connection.connection.clone()), + cx, + ); + } + })) + .child( + ListItem::new(("server-options", ix)) + .selected( + ssh_server.configure.focus_handle.contains_focused(cx), + ) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot(Icon::new(IconName::Settings).color(Color::Muted)) + .child(Label::new("View Server Options")) + .on_click(cx.listener({ + let ssh_connection = ssh_server.clone(); + move |this, _, cx| { + this.view_server_options( + (ix, ssh_connection.connection.clone()), + cx, + ); + } + })), + ), + ), ) } fn render_ssh_project( &mut self, server_ix: usize, - server: &SshConnection, + server: &ProjectEntry, ix: usize, - project: &SshProject, + (navigation, project): &(NavigableEntry, SshProject), cx: &ViewContext, ) -> impl IntoElement { let server = server.clone(); - let element_id_base = SharedString::from(format!("remote-project-{server_ix}")); + let container_element_id_base = + SharedString::from(format!("remote-project-container-{element_id_base}")); + let callback = Arc::new({ let project = project.clone(); move |this: &mut Self, cx: &mut ViewContext| { @@ -737,7 +731,7 @@ impl RemoteServerProjects { return; }; let project = project.clone(); - let server = server.clone(); + let server = server.connection.clone(); cx.emit(DismissEvent); cx.spawn(|_, mut cx| async move { let result = open_ssh_project( @@ -763,39 +757,46 @@ impl RemoteServerProjects { .detach(); } }); - self.selectable_items.add_item(Box::new({ - let callback = callback.clone(); - move |this, cx| callback(this, cx) - })); - let is_selected = self.selectable_items.is_selected(); - ListItem::new((element_id_base, ix)) - .inset(true) - .selected(is_selected) - .spacing(ui::ListItemSpacing::Sparse) - .start_slot( - Icon::new(IconName::Folder) - .color(Color::Muted) - .size(IconSize::Small), - ) - .child(Label::new(project.paths.join(", "))) - .on_click(cx.listener(move |this, _, cx| callback(this, cx))) - .end_hover_slot::(Some( - div() - .mr_2() - .child( - // Right-margin to offset it from the Scrollbar - IconButton::new("remove-remote-project", IconName::TrashAlt) - .icon_size(IconSize::Small) - .shape(IconButtonShape::Square) - .size(ButtonSize::Large) - .tooltip(|cx| Tooltip::text("Delete Remote Project", cx)) - .on_click(cx.listener(move |this, _, cx| { - this.delete_ssh_project(server_ix, ix, cx) - })), + div() + .id((container_element_id_base, ix)) + .track_focus(&navigation.focus_handle) + .anchor_scroll(navigation.scroll_anchor.clone()) + .on_action(cx.listener({ + let callback = callback.clone(); + move |this, _: &menu::Confirm, cx| { + callback(this, cx); + } + })) + .child( + ListItem::new((element_id_base, ix)) + .selected(navigation.focus_handle.contains_focused(cx)) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot( + Icon::new(IconName::Folder) + .color(Color::Muted) + .size(IconSize::Small), ) - .into_any_element(), - )) + .child(Label::new(project.paths.join(", "))) + .on_click(cx.listener(move |this, _, cx| callback(this, cx))) + .end_hover_slot::(Some( + div() + .mr_2() + .child( + // Right-margin to offset it from the Scrollbar + IconButton::new("remove-remote-project", IconName::TrashAlt) + .icon_size(IconSize::Small) + .shape(IconButtonShape::Square) + .size(ButtonSize::Large) + .tooltip(|cx| Tooltip::text("Delete Remote Project", cx)) + .on_click(cx.listener(move |this, _, cx| { + this.delete_ssh_project(server_ix, ix, cx) + })), + ) + .into_any_element(), + )), + ) } fn update_settings_file( @@ -870,6 +871,7 @@ impl RemoteServerProjects { let theme = cx.theme(); v_flex() + .track_focus(&self.focus_handle(cx)) .id("create-remote-server") .overflow_hidden() .size_full() @@ -930,185 +932,231 @@ impl RemoteServerProjects { fn render_view_options( &mut self, - index: usize, - connection: SshConnection, + ViewServerOptionsState { + server_index, + connection, + entries, + }: ViewServerOptionsState, cx: &mut ViewContext, ) -> impl IntoElement { let connection_string = connection.host.clone(); - div() - .size_full() - .child( - SshConnectionHeader { - connection_string: connection_string.clone(), - paths: Default::default(), - nickname: connection.nickname.clone().map(|s| s.into()), - } - .render(cx), - ) - .child( - v_flex() - .pb_1() - .child(ListSeparator) - .child({ - self.selectable_items.add_item(Box::new({ - move |this, cx| { - this.mode = Mode::EditNickname(EditNicknameState::new(index, cx)); - cx.notify(); - } - })); - let is_selected = self.selectable_items.is_selected(); - let label = if connection.nickname.is_some() { - "Edit Nickname" - } else { - "Add Nickname to Server" - }; - ListItem::new("add-nickname") - .selected(is_selected) - .inset(true) - .spacing(ui::ListItemSpacing::Sparse) - .start_slot(Icon::new(IconName::Pencil).color(Color::Muted)) - .child(Label::new(label)) - .on_click(cx.listener(move |this, _, cx| { - this.mode = Mode::EditNickname(EditNicknameState::new(index, cx)); - cx.notify(); - })) - }) - .child({ - let workspace = self.workspace.clone(); - fn callback( - workspace: WeakView, - connection_string: SharedString, - cx: &mut WindowContext<'_>, - ) { - cx.write_to_clipboard(ClipboardItem::new_string( - connection_string.to_string(), - )); - workspace - .update(cx, |this, cx| { - struct SshServerAddressCopiedToClipboard; - let notification = format!( - "Copied server address ({}) to clipboard", - connection_string - ); - - this.show_toast( - Toast::new( - NotificationId::composite::< - SshServerAddressCopiedToClipboard, - >( - connection_string.clone() - ), - notification, - ) - .autohide(), + let mut view = Navigable::new( + div() + .track_focus(&self.focus_handle(cx)) + .size_full() + .child( + SshConnectionHeader { + connection_string: connection_string.clone(), + paths: Default::default(), + nickname: connection.nickname.clone().map(|s| s.into()), + } + .render(cx), + ) + .child( + v_flex() + .pb_1() + .child(ListSeparator) + .child({ + let label = if connection.nickname.is_some() { + "Edit Nickname" + } else { + "Add Nickname to Server" + }; + div() + .id("ssh-options-add-nickname") + .track_focus(&entries[0].focus_handle) + .on_action(cx.listener(move |this, _: &menu::Confirm, cx| { + this.mode = Mode::EditNickname(EditNicknameState::new( + server_index, cx, - ); - }) - .ok(); - } - self.selectable_items.add_item(Box::new({ - let workspace = workspace.clone(); - let connection_string = connection_string.clone(); - move |_, cx| { - callback(workspace.clone(), connection_string.clone(), cx); - } - })); - let is_selected = self.selectable_items.is_selected(); - ListItem::new("copy-server-address") - .selected(is_selected) - .inset(true) - .spacing(ui::ListItemSpacing::Sparse) - .start_slot(Icon::new(IconName::Copy).color(Color::Muted)) - .child(Label::new("Copy Server Address")) - .end_hover_slot( - Label::new(connection_string.clone()).color(Color::Muted), - ) - .on_click({ - let connection_string = connection_string.clone(); - move |_, cx| { - callback(workspace.clone(), connection_string.clone(), cx); - } - }) - }) - .child({ - fn remove_ssh_server( - remote_servers: View, - index: usize, - connection_string: SharedString, - cx: &mut WindowContext<'_>, - ) { - let prompt_message = format!("Remove server `{}`?", connection_string); - - let confirmation = cx.prompt( - PromptLevel::Warning, - &prompt_message, - None, - &["Yes, remove it", "No, keep it"], - ); - - cx.spawn(|mut cx| async move { - if confirmation.await.ok() == Some(0) { - remote_servers - .update(&mut cx, |this, cx| { - this.delete_ssh_server(index, cx); - this.mode = Mode::default_mode(); + )); + cx.notify(); + })) + .child( + ListItem::new("add-nickname") + .selected(entries[0].focus_handle.contains_focused(cx)) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot(Icon::new(IconName::Pencil).color(Color::Muted)) + .child(Label::new(label)) + .on_click(cx.listener(move |this, _, cx| { + this.mode = Mode::EditNickname(EditNicknameState::new( + server_index, + cx, + )); cx.notify(); - }) - .ok(); - } - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } - self.selectable_items.add_item(Box::new({ - let connection_string = connection_string.clone(); - move |_, cx| { - remove_ssh_server( - cx.view().clone(), - index, - connection_string.clone(), - cx, - ); + })), + ) + }) + .child({ + let workspace = self.workspace.clone(); + fn callback( + workspace: WeakView, + connection_string: SharedString, + cx: &mut WindowContext<'_>, + ) { + cx.write_to_clipboard(ClipboardItem::new_string( + connection_string.to_string(), + )); + workspace + .update(cx, |this, cx| { + struct SshServerAddressCopiedToClipboard; + let notification = format!( + "Copied server address ({}) to clipboard", + connection_string + ); + + this.show_toast( + Toast::new( + NotificationId::composite::< + SshServerAddressCopiedToClipboard, + >( + connection_string.clone() + ), + notification, + ) + .autohide(), + cx, + ); + }) + .ok(); } - })); - let is_selected = self.selectable_items.is_selected(); - ListItem::new("remove-server") - .selected(is_selected) - .inset(true) - .spacing(ui::ListItemSpacing::Sparse) - .start_slot(Icon::new(IconName::Trash).color(Color::Error)) - .child(Label::new("Remove Server").color(Color::Error)) - .on_click(cx.listener(move |_, _, cx| { - remove_ssh_server( - cx.view().clone(), - index, - connection_string.clone(), - cx, + div() + .id("ssh-options-copy-server-address") + .track_focus(&entries[1].focus_handle) + .on_action({ + let connection_string = connection_string.clone(); + let workspace = self.workspace.clone(); + move |_: &menu::Confirm, cx| { + callback(workspace.clone(), connection_string.clone(), cx); + } + }) + .child( + ListItem::new("copy-server-address") + .selected(entries[1].focus_handle.contains_focused(cx)) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot(Icon::new(IconName::Copy).color(Color::Muted)) + .child(Label::new("Copy Server Address")) + .end_hover_slot( + Label::new(connection_string.clone()) + .color(Color::Muted), + ) + .on_click({ + let connection_string = connection_string.clone(); + move |_, cx| { + callback( + workspace.clone(), + connection_string.clone(), + cx, + ); + } + }), + ) + }) + .child({ + fn remove_ssh_server( + remote_servers: View, + index: usize, + connection_string: SharedString, + cx: &mut WindowContext<'_>, + ) { + let prompt_message = + format!("Remove server `{}`?", connection_string); + + let confirmation = cx.prompt( + PromptLevel::Warning, + &prompt_message, + None, + &["Yes, remove it", "No, keep it"], ); - })) - }) - .child(ListSeparator) - .child({ - self.selectable_items.add_item(Box::new({ - move |this, cx| { - this.mode = Mode::default_mode(); - cx.notify(); + + cx.spawn(|mut cx| async move { + if confirmation.await.ok() == Some(0) { + remote_servers + .update(&mut cx, |this, cx| { + this.delete_ssh_server(index, cx); + }) + .ok(); + remote_servers + .update(&mut cx, |this, cx| { + this.mode = Mode::default_mode(cx); + cx.notify(); + }) + .ok(); + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); } - })); - let is_selected = self.selectable_items.is_selected(); - ListItem::new("go-back") - .selected(is_selected) - .inset(true) - .spacing(ui::ListItemSpacing::Sparse) - .start_slot(Icon::new(IconName::ArrowLeft).color(Color::Muted)) - .child(Label::new("Go Back")) - .on_click(cx.listener(|this, _, cx| { - this.mode = Mode::default_mode(); - cx.notify() - })) - }), - ) + div() + .id("ssh-options-copy-server-address") + .track_focus(&entries[2].focus_handle) + .on_action(cx.listener({ + let connection_string = connection_string.clone(); + move |_, _: &menu::Confirm, cx| { + remove_ssh_server( + cx.view().clone(), + server_index, + connection_string.clone(), + cx, + ); + cx.focus_self(); + } + })) + .child( + ListItem::new("remove-server") + .selected(entries[2].focus_handle.contains_focused(cx)) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot(Icon::new(IconName::Trash).color(Color::Error)) + .child(Label::new("Remove Server").color(Color::Error)) + .on_click(cx.listener(move |_, _, cx| { + remove_ssh_server( + cx.view().clone(), + server_index, + connection_string.clone(), + cx, + ); + cx.focus_self(); + })), + ) + }) + .child(ListSeparator) + .child({ + div() + .id("ssh-options-copy-server-address") + .track_focus(&entries[3].focus_handle) + .on_action(cx.listener(|this, _: &menu::Confirm, cx| { + this.mode = Mode::default_mode(cx); + cx.focus_self(); + cx.notify(); + })) + .child( + ListItem::new("go-back") + .selected(entries[3].focus_handle.contains_focused(cx)) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot( + Icon::new(IconName::ArrowLeft).color(Color::Muted), + ) + .child(Label::new("Go Back")) + .on_click(cx.listener(|this, _, cx| { + this.mode = Mode::default_mode(cx); + cx.focus_self(); + cx.notify() + })), + ) + }), + ) + .into_any_element(), + ); + for entry in entries { + view = view.entry(entry); + } + + view.render(cx).into_any_element() } fn render_edit_nickname( @@ -1120,13 +1168,17 @@ impl RemoteServerProjects { .ssh_connections() .nth(state.index) else { - return v_flex(); + return v_flex() + .id("ssh-edit-nickname") + .track_focus(&self.focus_handle(cx)); }; let connection_string = connection.host.clone(); let nickname = connection.nickname.clone().map(|s| s.into()); v_flex() + .id("ssh-edit-nickname") + .track_focus(&self.focus_handle(cx)) .child( SshConnectionHeader { connection_string, @@ -1146,27 +1198,45 @@ impl RemoteServerProjects { fn render_default( &mut self, - scroll_state: ScrollbarState, + mut state: DefaultState, cx: &mut ViewContext, ) -> impl IntoElement { - let scroll_state = scroll_state.parent_view(cx.view()); - let ssh_connections = SshSettings::get_global(cx) - .ssh_connections() - .collect::>(); - self.selectable_items.add_item(Box::new(|this, cx| { - this.mode = Mode::CreateRemoteServer(CreateRemoteServer::new(cx)); - cx.notify(); - })); + if SshSettings::get_global(cx) + .ssh_connections + .as_ref() + .map_or(false, |connections| { + state + .servers + .iter() + .map(|server| &server.connection) + .ne(connections.iter()) + }) + { + self.mode = Mode::default_mode(cx); + if let Mode::Default(new_state) = &self.mode { + state = new_state.clone(); + } + } + let scroll_state = state.scrollbar.parent_view(cx.view()); + let connect_button = div() + .id("ssh-connect-new-server-container") + .track_focus(&state.add_new_server.focus_handle) + .anchor_scroll(state.add_new_server.scroll_anchor.clone()) + .child( + ListItem::new("register-remove-server-button") + .selected(state.add_new_server.focus_handle.contains_focused(cx)) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot(Icon::new(IconName::Plus).color(Color::Muted)) + .child(Label::new("Connect New Server")) + .on_click(cx.listener(|this, _, cx| { + let state = CreateRemoteServer::new(cx); + this.mode = Mode::CreateRemoteServer(state); - let is_selected = self.selectable_items.is_selected(); - - let connect_button = ListItem::new("register-remove-server-button") - .selected(is_selected) - .inset(true) - .spacing(ui::ListItemSpacing::Sparse) - .start_slot(Icon::new(IconName::Plus).color(Color::Muted)) - .child(Label::new("Connect New Server")) - .on_click(cx.listener(|this, _, cx| { + cx.notify(); + })), + ) + .on_action(cx.listener(|this, _: &menu::Confirm, cx| { let state = CreateRemoteServer::new(cx); this.mode = Mode::CreateRemoteServer(state); @@ -1177,31 +1247,46 @@ impl RemoteServerProjects { unreachable!() }; - let mut modal_section = v_flex() - .id("ssh-server-list") - .overflow_y_scroll() - .track_scroll(&scroll_handle) - .size_full() - .child(connect_button) - .child( - List::new() - .empty_message( - v_flex() - .child(div().px_3().child( - Label::new("No remote servers registered yet.").color(Color::Muted), - )) - .into_any_element(), - ) - .children(ssh_connections.iter().cloned().enumerate().map( - |(ix, connection)| { - self.render_ssh_connection(ix, connection, cx) + let mut modal_section = Navigable::new( + v_flex() + .track_focus(&self.focus_handle(cx)) + .id("ssh-server-list") + .overflow_y_scroll() + .track_scroll(&scroll_handle) + .size_full() + .child(connect_button) + .child( + List::new() + .empty_message( + v_flex() + .child( + div().px_3().child( + Label::new("No remote servers registered yet.") + .color(Color::Muted), + ), + ) + .into_any_element(), + ) + .children(state.servers.iter().enumerate().map(|(ix, connection)| { + self.render_ssh_connection(ix, connection.clone(), cx) .into_any_element() - }, - )), - ) - .into_any_element(); + })), + ) + .into_any_element(), + ) + .entry(state.add_new_server.clone()); - Modal::new("remote-projects", Some(self.scroll_handle.clone())) + for server in &state.servers { + for (navigation_state, _) in &server.projects { + modal_section = modal_section.entry(navigation_state.clone()); + } + modal_section = modal_section + .entry(server.open_folder.clone()) + .entry(server.configure.clone()); + } + let mut modal_section = modal_section.render(cx).into_any_element(); + + Modal::new("remote-projects", None) .header( ModalHeader::new() .child(Headline::new("Remote Projects (beta)").size(HeadlineSize::XSmall)), @@ -1242,6 +1327,7 @@ impl RemoteServerProjects { ), ), ) + .into_any_element() } } @@ -1264,16 +1350,12 @@ impl EventEmitter for RemoteServerProjects {} impl Render for RemoteServerProjects { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - self.selectable_items.reset(); div() - .track_focus(&self.focus_handle(cx)) .elevation_3(cx) .w(rems(34.)) .key_context("RemoteServerModal") .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::confirm)) - .on_action(cx.listener(Self::prev_item)) - .on_action(cx.listener(Self::next_item)) .capture_any_mouse_down(cx.listener(|this, _, cx| { this.focus_handle(cx).focus(cx); })) @@ -1284,8 +1366,8 @@ impl Render for RemoteServerProjects { })) .child(match &self.mode { Mode::Default(state) => self.render_default(state.clone(), cx).into_any_element(), - Mode::ViewServerOptions(index, connection) => self - .render_view_options(*index, connection.clone(), cx) + Mode::ViewServerOptions(state) => self + .render_view_options(state.clone(), cx) .into_any_element(), Mode::ProjectPicker(element) => element.clone().into_any_element(), Mode::CreateRemoteServer(state) => self diff --git a/crates/recent_projects/src/ssh_connections.rs b/crates/recent_projects/src/ssh_connections.rs index 0d40da375b..f84576a1d9 100644 --- a/crates/recent_projects/src/ssh_connections.rs +++ b/crates/recent_projects/src/ssh_connections.rs @@ -63,7 +63,7 @@ impl SshSettings { } } -#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)] +#[derive(Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema)] pub struct SshConnection { pub host: SharedString, #[serde(skip_serializing_if = "Option::is_none")] @@ -100,7 +100,7 @@ impl From for SshConnectionOptions { } } -#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)] +#[derive(Clone, Default, Serialize, PartialEq, Deserialize, JsonSchema)] pub struct SshProject { pub paths: Vec, } diff --git a/crates/ui/src/components.rs b/crates/ui/src/components.rs index 7a13ff6917..08ebe1a771 100644 --- a/crates/ui/src/components.rs +++ b/crates/ui/src/components.rs @@ -14,6 +14,7 @@ mod keybinding; mod label; mod list; mod modal; +mod navigable; mod numeric_stepper; mod popover; mod popover_menu; @@ -47,6 +48,7 @@ pub use keybinding::*; pub use label::*; pub use list::*; pub use modal::*; +pub use navigable::*; pub use numeric_stepper::*; pub use popover::*; pub use popover_menu::*; diff --git a/crates/ui/src/components/navigable.rs b/crates/ui/src/components/navigable.rs new file mode 100644 index 0000000000..fadd6d597e --- /dev/null +++ b/crates/ui/src/components/navigable.rs @@ -0,0 +1,98 @@ +use crate::prelude::*; +use gpui::{AnyElement, FocusHandle, ScrollAnchor, ScrollHandle}; + +/// An element that can be navigated through via keyboard. Intended for use with scrollable views that want to use +pub struct Navigable { + child: AnyElement, + selectable_children: Vec, +} + +/// An entry of [Navigable] that can be navigated to. +#[derive(Clone)] +pub struct NavigableEntry { + #[allow(missing_docs)] + pub focus_handle: FocusHandle, + #[allow(missing_docs)] + pub scroll_anchor: Option, +} + +impl NavigableEntry { + /// Creates a new [NavigableEntry] for a given scroll handle. + pub fn new(scroll_handle: &ScrollHandle, cx: &WindowContext<'_>) -> Self { + Self { + focus_handle: cx.focus_handle(), + scroll_anchor: Some(ScrollAnchor::for_handle(scroll_handle.clone())), + } + } + /// Create a new [NavigableEntry] that cannot be scrolled to. + pub fn focusable(cx: &WindowContext<'_>) -> Self { + Self { + focus_handle: cx.focus_handle(), + scroll_anchor: None, + } + } +} +impl Navigable { + /// Creates new empty [Navigable] wrapper. + pub fn new(child: AnyElement) -> Self { + Self { + child, + selectable_children: vec![], + } + } + + /// Add a new entry that can be navigated to via keyboard. + /// The order of calls to [Navigable::entry] determines the order of traversal of elements via successive + /// uses of [menu:::SelectNext]/[menu::SelectPrev] + pub fn entry(mut self, child: NavigableEntry) -> Self { + self.selectable_children.push(child); + self + } + + fn find_focused( + selectable_children: &[NavigableEntry], + cx: &mut WindowContext<'_>, + ) -> Option { + selectable_children + .iter() + .position(|entry| entry.focus_handle.contains_focused(cx)) + } +} +impl RenderOnce for Navigable { + fn render(self, _: &mut WindowContext<'_>) -> impl crate::IntoElement { + div() + .on_action({ + let children = self.selectable_children.clone(); + + move |_: &menu::SelectNext, cx| { + let target = Self::find_focused(&children, cx) + .and_then(|index| { + index.checked_add(1).filter(|index| *index < children.len()) + }) + .unwrap_or(0); + if let Some(entry) = children.get(target) { + entry.focus_handle.focus(cx); + if let Some(anchor) = &entry.scroll_anchor { + anchor.scroll_to(cx); + } + } + } + }) + .on_action({ + let children = self.selectable_children; + move |_: &menu::SelectPrev, cx| { + let target = Self::find_focused(&children, cx) + .and_then(|index| index.checked_sub(1)) + .or(children.len().checked_sub(1)); + if let Some(entry) = target.and_then(|target| children.get(target)) { + entry.focus_handle.focus(cx); + if let Some(anchor) = &entry.scroll_anchor { + anchor.scroll_to(cx); + } + } + } + }) + .size_full() + .child(self.child) + } +}