Settings SSH: two-column master-detail layout

This commit is contained in:
l0ng-ai
2026-07-14 10:34:21 +08:00
parent 3d3fc15c34
commit e45bb5e824
2 changed files with 177 additions and 180 deletions
+1 -24
View File
@@ -231,9 +231,6 @@ pub struct Tty7App {
/// prompt (keyed to the pane that raised it), its input widgets, and
/// dismissable banners. Empty when no prompt is pending.
pub(crate) ssh_prompt: crate::ui::ssh_prompt::SshPromptState,
/// Cached `known_hosts` entries for the "SSH → Known hosts" settings section,
/// refreshed from the daemon when that section is opened / after a delete.
pub(crate) known_hosts: Vec<crate::daemon::protocol::KnownHostEntry>,
/// In-pane "confirm close of a live SSH session" state (PRD FR-E3): the close
/// action awaiting confirmation, or `None` when no prompt is up.
pub(crate) ssh_close_confirm: Option<SshCloseKind>,
@@ -391,7 +388,6 @@ impl Tty7App {
_sidebar_search_sub: sidebar_search_sub,
settings: None,
ssh_prompt: crate::ui::ssh_prompt::SshPromptState::new(cx),
known_hosts: Vec::new(),
ssh_close_confirm: None,
};
// Discover this machine's shells for the "+" dropdown off the UI thread
@@ -905,22 +901,6 @@ impl Tty7App {
self.update_config(cx, |cfg| cfg.ssh_warn_on_close = on);
}
/// Re-fetch the daemon's `known_hosts` entries for the settings section.
pub(crate) fn refresh_known_hosts(&mut self, cx: &mut Context<Self>) {
self.known_hosts = crate::terminal::RemoteTerminal::list_known_hosts();
cx.notify();
}
/// Delete one `known_hosts` entry and refresh the list from the daemon.
pub(crate) fn delete_known_host(
&mut self,
id: crate::daemon::protocol::KnownHostId,
cx: &mut Context<Self>,
) {
self.known_hosts = crate::terminal::RemoteTerminal::delete_known_host(id);
cx.notify();
}
pub(crate) fn refresh_loopback_forwards(&mut self, cx: &mut Context<Self>) {
self.loopback_panel.forwards = crate::terminal::RemoteTerminal::list_loopback_forwards();
cx.notify();
@@ -2084,6 +2064,7 @@ impl Tty7App {
recording: None,
rebinding_note: None,
ssh_form: None,
ssh_detail: crate::ui::settings::SshDetail::None,
_subs: subs,
});
// Land the caret in the search box so Settings opens ready to type/filter
@@ -2716,10 +2697,6 @@ impl Tty7App {
// the interceptor doesn't keep swallowing keys off-screen.
s.recording = None;
}
// Opening the SSH section pulls a fresh known_hosts list from the daemon.
if target == SettingsSection::Ssh {
self.refresh_known_hosts(cx);
}
cx.notify();
}
+176 -156
View File
@@ -209,11 +209,6 @@ fn settings_search_entries() -> &'static [SearchEntry] {
title: "Verify host keys",
keywords: "ssh security known_hosts fingerprint mitm host key verification",
},
SearchEntry {
section: Ssh,
title: "Known hosts",
keywords: "ssh known_hosts trusted fingerprint delete revoked certificate authority",
},
// Window & Tabs
SearchEntry {
section: WindowTabs,
@@ -335,9 +330,25 @@ pub(crate) struct SettingsState {
/// input set) each time, so the section never carries N profiles' worth of
/// inputs up front. See `SshProfileForm`.
pub(crate) ssh_form: Option<SshProfileForm>,
/// Which detail the SSH section's right (detail) pane is showing. The section
/// is a two-column master-detail: the left column lists profiles, and this
/// tracks the selected one. `Profile(id)` pairs with `ssh_form` (the loaded
/// edit form); `None` shows the empty state (the "pick a profile" hint plus
/// the two global security toggles).
pub(crate) ssh_detail: SshDetail,
pub(crate) _subs: Vec<Subscription>,
}
/// The SSH section's right-pane selection (see [`SettingsState::ssh_detail`]).
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum SshDetail {
/// Nothing selected — the right pane shows the empty state (the "pick a
/// profile" hint plus the two global security toggles).
None,
/// A profile's edit form (paired with `ssh_form`, keyed by the profile id).
Profile(Uuid),
}
/// The live edit-form state for one SSH profile, folded into Settings → SSH.
/// A single reusable input set, rebuilt (via `Tty7App::ssh_form_load`) each time
/// a profile is selected. Edits are committed to `Config::ssh_profiles` only on
@@ -707,20 +718,35 @@ impl Tty7App {
// One continuous, flat sheet (no cards) — one document: bold section
// headers and full-width rules carry the structure, so settings read as a
// unified document rather than a widget floating in empty space.
let content_pane = v_flex()
.id("settings-content")
.flex_1()
.h_full()
.bg(background)
.overflow_y_scroll()
.child(
div()
.px_10()
.py_8()
// Fill the pane edge-to-edge; cap only on very wide windows so
// rows never stretch to an unreadable width.
.child(div().w_full().max_w(px(860.)).child(content)),
);
//
// The SSH section is the exception: it is its own two-column master-detail
// that fills the pane height, with each column owning its scroll — so it
// bypasses the shared padded, single-scroll wrapper (which would otherwise
// give the whole section one outer scrollbar and no definite height for the
// columns to fill) and is dropped in flush instead.
let content_pane = if section == SettingsSection::Ssh {
v_flex()
.id("settings-content")
.flex_1()
.h_full()
.bg(background)
.child(content)
} else {
v_flex()
.id("settings-content")
.flex_1()
.h_full()
.bg(background)
.overflow_y_scroll()
.child(
div()
.px_10()
.py_8()
// Fill the pane edge-to-edge; cap only on very wide windows so
// rows never stretch to an unreadable width.
.child(div().w_full().max_w(px(860.)).child(content)),
)
};
let root = div()
.size_full()
@@ -1232,61 +1258,90 @@ impl Tty7App {
self.settings_row(label, "", control, cx)
}
/// SSH section: saved connection profiles (list + inline editor), the OpenSSH
/// `known_hosts` manager (PRD FR-S3/S4), and the security toggles (the global
/// host-key verification default and warn-on-close; a per-profile override
/// still wins where set). One scrollable page, Profiles → Known hosts →
/// Security, so profiles are managed here rather than as a parallel page.
/// SSH section: saved connection profiles plus the global security toggles
/// (host-key verification default and warn-on-close; a per-profile override
/// still wins where set).
///
/// A two-column master-detail (like the theme picker): the **left** column is
/// a fixed-width, self-scrolling master — Import / Add on top, then the profile
/// list; the **right** column is the flex-1, self-scrolling detail pane showing
/// the selected profile's edit form, or — with nothing selected — an empty
/// state carrying the "pick a profile" hint and the two global security
/// toggles. Selection is tracked in [`SettingsState::ssh_detail`].
fn render_settings_ssh(&self, cx: &mut Context<Self>) -> AnyElement {
v_flex()
.child(self.render_ssh_profiles_block(cx))
.child(self.section_rule(cx))
.child(self.render_ssh_known_hosts_block(cx))
.child(self.section_rule(cx))
.child(self.render_ssh_security_block(cx))
let border = cx.theme().border;
h_flex()
.size_full()
.items_start()
.child(
// LEFT (master): fixed width, its own scroll, a right divider.
v_flex()
.id("ssh-master")
.flex_shrink_0()
.w(px(280.))
.h_full()
.border_r_1()
.border_color(border)
.overflow_y_scroll()
.child(self.render_ssh_master(cx)),
)
.child(
// RIGHT (detail): flex-1, its own scroll.
v_flex()
.id("ssh-detail")
.flex_1()
.h_full()
.overflow_y_scroll()
.child(
// Clear the title-bar drag strip / close ✕ up top, and cap
// the detail width so the form stays readable on wide panes.
div()
.pt(px(crate::ui::app::TITLE_BAR_HEIGHT))
.px_8()
.pb_8()
.child(
div()
.w_full()
.max_w(px(720.))
.child(self.render_ssh_detail(cx)),
),
),
)
.into_any_element()
}
/// Profiles block: header + Import / Add controls, the saved-profile list, and
/// — when a profile is being added or edited — the inline edit form below it.
fn render_ssh_profiles_block(&self, cx: &mut Context<Self>) -> AnyElement {
/// The left (master) column: the Import / Add buttons on top, then the
/// saved-profile list (each row selects into the detail pane).
fn render_ssh_master(&self, cx: &mut Context<Self>) -> AnyElement {
let muted = cx.theme().muted_foreground;
let border = cx.theme().border;
let profiles = cx.global::<Config>().ssh_profiles.clone();
let detail = self
.active_settings()
.map(|s| s.ssh_detail)
.unwrap_or(SshDetail::None);
let header = h_flex()
.items_center()
.justify_between()
.child(self.section_header("Profiles", cx))
// Import / Add stacked full-width: the long import label doesn't fit beside
// Add in the narrow column.
let header = v_flex()
.gap_2()
.child(
h_flex()
.gap_2()
.child(
Button::new("ssh-profiles-import")
.label("Import from ~/.ssh/config")
.outline()
.small()
.on_click(
cx.listener(|this, _, _w, cx| this.import_ssh_config_profiles(cx)),
),
)
.child(
Button::new("ssh-profiles-add")
.label("Add")
.primary()
.small()
.on_click(
cx.listener(|this, _, window, cx| this.add_new_profile(window, cx)),
),
),
Button::new("ssh-profiles-add")
.label("Add profile")
.primary()
.small()
.w_full()
.on_click(cx.listener(|this, _, window, cx| this.add_new_profile(window, cx))),
)
.child(
Button::new("ssh-profiles-import")
.label("Import from ~/.ssh/config")
.outline()
.small()
.w_full()
.on_click(cx.listener(|this, _, _w, cx| this.import_ssh_config_profiles(cx))),
);
let editing_id = self
.active_settings()
.and_then(|s| s.ssh_form.as_ref())
.map(|f| f.editing);
let mut list = v_flex().gap_1().w_full();
let mut list = v_flex().gap_0p5().w_full();
if profiles.is_empty() {
list = list.child(
div()
@@ -1305,7 +1360,7 @@ impl Tty7App {
} else {
p.name.clone()
};
let selected = editing_id == Some(id);
let selected = detail == SshDetail::Profile(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}"));
@@ -1324,16 +1379,14 @@ impl Tty7App {
.py_2()
.px_2()
.rounded_md()
.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.
// select 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
// Left-click anywhere on the row selects it — its edit form
// opens in the detail pane. Clicks on the trailing ⋯ are
// swallowed by its wrapper, so they don't also start an edit.
.on_mouse_down(
MouseButton::Left,
@@ -1352,14 +1405,15 @@ impl Tty7App {
)
.child(
v_flex()
.min_w_0()
.gap_0p5()
.child(div().text_sm().child(title))
.child(div().text_xs().text_color(muted).child(subtitle)),
.child(div().text_sm().truncate().child(title))
.child(div().text_xs().text_color(muted).truncate().child(subtitle)),
)
.child(
// 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.
// also fires the row's select click.
div()
.flex_shrink_0()
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
@@ -1391,12 +1445,43 @@ impl Tty7App {
);
}
let mut block = v_flex().gap_2().child(header).child(list);
// The inline edit form, rendered below the list for the selected profile.
if self.active_settings().is_some_and(|s| s.ssh_form.is_some()) {
block = block.child(self.render_ssh_profile_form(cx));
v_flex()
.p_2()
.gap_2()
// Clear the title-bar drag strip up top so the buttons stay clickable.
.pt(px(crate::ui::app::TITLE_BAR_HEIGHT))
.child(header)
.child(list)
.into_any_element()
}
/// The right (detail) pane: a selected profile's edit form, or — with nothing
/// selected — the empty state (a "pick a profile" hint plus the two global
/// security toggles).
fn render_ssh_detail(&self, cx: &mut Context<Self>) -> AnyElement {
let detail = self
.active_settings()
.map(|s| s.ssh_detail)
.unwrap_or(SshDetail::None);
match detail {
SshDetail::Profile(_)
if self.active_settings().is_some_and(|s| s.ssh_form.is_some()) =>
{
self.render_ssh_profile_form(cx)
}
// No selection (or a stale profile whose form is gone): the empty-state
// hint, then the global security toggles below it.
_ => v_flex()
.gap_6()
.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child("Select a profile to edit, or add a new one."),
)
.child(self.render_ssh_security_block(cx))
.into_any_element(),
}
block.into_any_element()
}
/// Build the per-profile overflow menu shared by the hover ⋯ dropdown and the
@@ -1447,72 +1532,6 @@ impl Tty7App {
)
}
/// 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<Self>) -> AnyElement {
let muted_fg = cx.theme().muted_foreground;
let mut list = v_flex().gap_1().w_full();
if self.known_hosts.is_empty() {
list = list.child(
div()
.text_sm()
.text_color(muted_fg)
.child("No known hosts recorded yet."),
);
} else {
for (i, entry) in self.known_hosts.iter().enumerate() {
let id = entry.id.clone();
let marker = entry
.marker
.as_deref()
.map(|m| format!("{m} "))
.unwrap_or_default();
let label = format!("{marker}{} · {}", entry.host, entry.key_type);
let fp = entry.fingerprint_sha256.clone();
list = list.child(
h_flex()
.items_center()
.justify_between()
.w_full()
.gap_2()
.py_1()
.child(
v_flex()
.flex_1()
.min_w_0()
.child(div().text_sm().child(label))
.child(div().text_xs().text_color(muted_fg).child(fp)),
)
.child(
Button::new(("ssh-known-host-del", i))
.label("Delete")
.small()
.on_click(cx.listener(move |this, _, _w, cx| {
this.delete_known_host(id.clone(), cx)
})),
),
);
}
}
v_flex()
.child(self.section_intro(
"Known hosts",
"Trusted, revoked, and certificate-authority entries in your \
~/.ssh/known_hosts. Delete an entry to be re-prompted on the next \
connection.",
cx,
))
.child(
Button::new("ssh-known-hosts-refresh")
.label("Refresh")
.small()
.on_click(cx.listener(|this, _, _w, cx| this.refresh_known_hosts(cx))),
)
.child(list)
.into_any_element()
}
/// Security block: the global host-key verification default and warn-on-close
/// toggle (both overridable per profile).
fn render_ssh_security_block(&self, cx: &mut Context<Self>) -> AnyElement {
@@ -1676,8 +1695,12 @@ impl Tty7App {
warn_on_close: profile.warn_on_close,
_subs: subs,
};
let editing = form.editing;
if let Some(s) = self.active_settings_mut() {
s.ssh_form = Some(form);
// Loading a form selects that profile in the master-detail layout, so
// its row highlights and the detail pane shows the form.
s.ssh_detail = SshDetail::Profile(editing);
}
cx.notify();
}
@@ -1748,19 +1771,21 @@ impl Tty7App {
Some(id)
}
/// Save the form and collapse it back to the list.
/// Save the form and return the detail pane to its empty state.
pub(crate) fn save_ssh_form(&mut self, cx: &mut Context<Self>) {
self.save_editing_profile(cx);
if let Some(s) = self.active_settings_mut() {
s.ssh_form = None;
s.ssh_detail = SshDetail::None;
}
cx.notify();
}
/// Collapse the form without saving (discard unsaved edits).
/// Discard unsaved edits and return the detail pane to its empty state (Back).
pub(crate) fn close_ssh_form(&mut self, cx: &mut Context<Self>) {
if let Some(s) = self.active_settings_mut() {
s.ssh_form = None;
s.ssh_detail = SshDetail::None;
}
cx.notify();
}
@@ -1807,13 +1832,13 @@ impl Tty7App {
cfg.ssh_profiles.retain(|p| p.id != id);
cfg.ssh_profile_frecency.remove(&id);
});
let editing_deleted = self
.active_settings()
.and_then(|s| s.ssh_form.as_ref())
.map(|f| f.editing)
== Some(id);
let editing_deleted =
self.active_settings().map(|s| s.ssh_detail) == Some(SshDetail::Profile(id));
if let Some(s) = self.active_settings_mut().filter(|_| editing_deleted) {
// The deleted profile was selected: drop its form and clear the
// selection back to the empty state.
s.ssh_form = None;
s.ssh_detail = SshDetail::None;
}
cx.notify();
}
@@ -1849,7 +1874,6 @@ impl Tty7App {
let Some(form) = self.active_settings().and_then(|s| s.ssh_form.as_ref()) else {
return div().into_any_element();
};
let border = cx.theme().border;
let is_new = !cx
.global::<Config>()
.ssh_profiles
@@ -1956,11 +1980,7 @@ impl Tty7App {
));
v_flex()
.mt_2()
.pt_4()
.gap_4()
.border_t_1()
.border_color(border)
.child(header)
.child(core)
.child(self.render_ssh_profile_jump_section(form, cx))