From 3d3fc15c3407dd2ee39788d9854a7dd5fcbc2f33 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:55:19 +0800 Subject: [PATCH] =?UTF-8?q?Settings=20SSH=20profiles:=20clean=20rows=20wit?= =?UTF-8?q?h=20hover=20=E2=8B=AF=20/=20right-click=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ui/settings.rs | 164 ++++++++++++++++++++++++++++++--------------- 1 file changed, 110 insertions(+), 54 deletions(-) diff --git a/src/ui/settings.rs b/src/ui/settings.rs index a730a263..30a7f506 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -15,6 +15,7 @@ use gpui_component::Selectable as _; use gpui_component::button::{Button, ButtonGroup, ButtonVariants as _}; use gpui_component::color_picker::{ColorPicker, ColorPickerState}; use gpui_component::input::{Input, InputEvent, InputState}; +use gpui_component::menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenu, PopupMenuItem}; use gpui_component::select::{SearchableVec, Select, SelectState}; use gpui_component::sidebar::{Sidebar, SidebarCollapsible, SidebarMenu, SidebarMenuItem}; use gpui_component::slider::{Slider, SliderState}; @@ -1297,6 +1298,7 @@ impl Tty7App { } for p in &profiles { let id = p.id; + let row_idx = id.as_u128() as usize; let subtitle = to_connect_string(p); let title = if p.name.is_empty() { subtitle.clone() @@ -1304,9 +1306,18 @@ impl Tty7App { p.name.clone() }; let selected = editing_id == Some(id); + // A group so this row's ⋯ affordance can reveal on hover + // (progressive disclosure) without touching its neighbours. + let group_name = SharedString::from(format!("ssh-profile-row-{row_idx}")); + let hover_group = group_name.clone(); + // Weak handles so the hover ⋯ dropdown and the right-click context + // menu can drive the same `Tty7App` handlers the inline buttons used. + let menu_app = cx.entity().downgrade(); + let ctx_app = cx.entity().downgrade(); list = list.child( h_flex() - .id(("ssh-profile-row", id.as_u128() as usize)) + .id(("ssh-profile-row", row_idx)) + .group(group_name.clone()) .items_center() .justify_between() .w_full() @@ -1316,6 +1327,29 @@ impl Tty7App { .border_b_1() .border_color(border) .when(selected, |r| r.bg(cx.theme().secondary.opacity(0.4))) + // A subtle hover fill so the whole row reads as the (clickable) + // edit affordance; the selected row keeps its own highlight. + .when(!selected, |r| { + r.hover(|s| s.bg(cx.theme().secondary.opacity(0.2))) + }) + // Left-click anywhere on the row opens the inline edit form — + // the row *is* the edit target. Clicks on the trailing ⋯ are + // swallowed by its wrapper, so they don't also start an edit. + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); + if let Some(profile) = cx + .global::() + .ssh_profiles + .iter() + .find(|p| p.id == id) + .cloned() + { + this.ssh_form_load(&profile, window, cx); + } + }), + ) .child( v_flex() .gap_0p5() @@ -1323,63 +1357,37 @@ impl Tty7App { .child(div().text_xs().text_color(muted).child(subtitle)), ) .child( - h_flex() - .gap_1() + // Trailing ⋯ overflow menu, revealed on row hover. Its + // wrapper swallows the mouse-down so opening the menu never + // also fires the row's edit click. + div() + .flex_shrink_0() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .when(!selected, move |s| { + s.opacity(0.).group_hover(hover_group, |s| s.opacity(1.)) + }) .child( - Button::new(("ssh-prof-connect", id.as_u128() as usize)) - .label("Connect") - .primary() - .small() - .on_click(cx.listener(move |this, _, window, cx| { - this.close_settings(window, cx); - this.connect_ssh_profile(id, window, cx); - })), - ) - .child( - Button::new(("ssh-prof-edit", id.as_u128() as usize)) - .label("Edit") - .outline() - .small() - .on_click(cx.listener(move |this, _, window, cx| { - if let Some(profile) = cx - .global::() - .ssh_profiles - .iter() - .find(|p| p.id == id) - .cloned() - { - this.ssh_form_load(&profile, window, cx); - } - })), - ) - .child( - Button::new(("ssh-prof-copy", id.as_u128() as usize)) - .label("Copy") + Button::new(("ssh-prof-menu", row_idx)) + .icon(IconName::Ellipsis) .ghost() .small() - .on_click(cx.listener(move |this, _, _w, cx| { - this.copy_profile_connect_string(id, cx) - })), - ) - .child( - Button::new(("ssh-prof-dup", id.as_u128() as usize)) - .label("Duplicate") - .ghost() - .small() - .on_click(cx.listener(move |this, _, window, cx| { - this.duplicate_profile(id, window, cx) - })), - ) - .child( - Button::new(("ssh-prof-del", id.as_u128() as usize)) - .label("Delete") - .ghost() - .small() - .on_click(cx.listener(move |this, _, _w, cx| { - this.delete_profile(id, cx) - })), + .dropdown_menu_with_anchor( + gpui::Anchor::TopRight, + move |menu, _window, cx| { + Self::ssh_profile_row_menu( + menu, + id, + cx.theme().danger, + &menu_app, + ) + }, + ), ), - ), + ) + // Right-click anywhere on the row opens the same menu. + .context_menu(move |menu, _window, cx| { + Self::ssh_profile_row_menu(menu, id, cx.theme().danger, &ctx_app) + }), ); } @@ -1391,6 +1399,54 @@ impl Tty7App { block.into_any_element() } + /// Build the per-profile overflow menu shared by the hover ⋯ dropdown and the + /// row's right-click context menu: Connect, Copy, Duplicate, then the + /// destructive Delete — rendered last, set apart by a separator and drawn in + /// danger red. Each item drives the same `Tty7App` handler the old inline + /// buttons did, via the weak `app` handle. + fn ssh_profile_row_menu( + menu: PopupMenu, + id: Uuid, + danger: gpui::Hsla, + app: &gpui::WeakEntity, + ) -> PopupMenu { + let menu = menu + .min_w(px(180.)) + .item(PopupMenuItem::new("Connect").on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| { + this.close_settings(window, cx); + this.connect_ssh_profile(id, window, cx); + }); + } + })) + .item(PopupMenuItem::new("Copy").on_click({ + let app = app.clone(); + move |_, _window, cx| { + let _ = app.update(cx, |this, cx| this.copy_profile_connect_string(id, cx)); + } + })) + .item(PopupMenuItem::new("Duplicate").on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| this.duplicate_profile(id, window, cx)); + } + })) + .separator(); + + // Destructive, last, in danger red and set apart by the separator above. + menu.item( + PopupMenuItem::element(move |_window, _cx| div().text_color(danger).child("Delete")) + .on_click({ + let app = app.clone(); + move |_, _window, cx| { + let _ = app.update(cx, |this, cx| this.delete_profile(id, cx)); + } + }), + ) + } + /// Known-hosts block (unchanged behaviour, moved below Profiles): the trusted / /// revoked / CA entries in `~/.ssh/known_hosts`, each deletable to re-prompt. fn render_ssh_known_hosts_block(&self, cx: &mut Context) -> AnyElement {