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) + } +}