From e2610f904419f4d091ff034bc4768a2a0f514a47 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 14 Jul 2026 02:15:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(ssh):=20UX=20integration=20=E2=80=94=20nat?= =?UTF-8?q?ive=20connect,=20palette=20entry,=20profile=20editor,=20session?= =?UTF-8?q?=20UX=20(WS6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the SSH connection manager reachable and alive from the UI: - Native SSH spawn keystone: TerminalView::new_native_ssh + Tty7App connect paths. Saved profiles connect via the native russh engine; use_system_ssh profiles fall back to the frozen shell-out path (FR-C5). - Unified palette entry (FR-P3): saved profiles (frecency-ordered) + ~/.ssh/config aliases + live QuickConnect all in the root flow. Enter connects; Cmd-Enter / -> opens the profile editor. Per-profile frecency (count + last-used) persisted in config and used to rank rows. - Profile editor (FR-P1/P5): full-window page like Settings, list + edit views with progressive disclosure (4 core fields; collapsed jump host, forwards, and advanced sections incl. the use_system_ssh compat toggle with its disabled-features note). Import from ssh_config, duplicate, delete, copy user@host:port, connect. - Session UX (FR-E1..E4): in-pane phase-coloured SSH status strip with the reconnect notice; per-tab status dots in the strip and sidebar; warn-on-close confirm sheet (global toggle + per-profile override); RestartSshSession (Cmd-Shift-R) reconnecting a dead pane in place; and session-restore respawn of dead native panes (re-resolving secrets from the profile, else prompting). - Actions/keymap/palette wiring for OpenSshProfiles and RestartSshSession. --- src/core/actions.rs | 4 + src/core/config.rs | 96 +++ src/terminal/view.rs | 53 ++ src/ui/app.rs | 341 +++++++++- src/ui/forwards.rs | 155 ++++- src/ui/keymap.rs | 8 + src/ui/mod.rs | 1 + src/ui/palette.rs | 197 +++++- src/ui/pane.rs | 30 + src/ui/profile_editor.rs | 1300 ++++++++++++++++++++++++++++++++++++++ src/ui/settings.rs | 19 +- src/ui/ssh_connect.rs | 214 ++++++- src/ui/tab_sidebar.rs | 6 + src/ui/tab_strip.rs | 6 + 14 files changed, 2370 insertions(+), 60 deletions(-) create mode 100644 src/ui/profile_editor.rs diff --git a/src/core/actions.rs b/src/core/actions.rs index 5a98387d..dd338417 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -59,6 +59,10 @@ actions!( RestartDaemon, // Toggle the SFTP file panel for the focused native-SSH pane (WS5). ToggleSftp, + // Open the SSH profile manager/editor full-window page (WS6, FR-P1). + OpenSshProfiles, + // Reconnect a dead native-SSH pane in place (WS6, FR-E4). + RestartSshSession, SendTab, SendBackTab, Quit diff --git a/src/core/config.rs b/src/core/config.rs index 43be9884..d5ce8526 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -185,6 +185,53 @@ pub struct Config { /// deliberate, documented escape hatch (PRD FR-S4). #[serde(default = "default_true")] pub verify_host_keys: bool, + /// Global default for the "confirm before closing a live SSH session" + /// prompt (PRD FR-E3). Off by default (closing is unsurprising for most + /// panes). A per-profile `warn_on_close: Some(true/false)` override wins over + /// this when set; this is the fallback for profiles that leave it unset and + /// for QuickConnect panes. + #[serde(default)] + pub ssh_warn_on_close: bool, + /// Per-profile usage stats driving the palette's frecency ordering (PRD + /// FR-P3): a saved profile's id → how many times it was connected and when it + /// was last used. Bumped on every connect; read to rank the palette's profile + /// rows. Entries for deleted profiles are harmless (never surfaced). + #[serde(default)] + pub ssh_profile_frecency: HashMap, +} + +/// One saved profile's usage record for palette frecency (see +/// [`Config::ssh_profile_frecency`]). +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(default)] +pub struct ProfileUsage { + /// Times this profile has been connected. + pub count: u32, + /// Unix timestamp (seconds) of the most recent connect. + pub last_used: u64, +} + +impl ProfileUsage { + /// A frecency score combining frequency (how often) with recency (how + /// recently), so the palette floats both heavily-used and just-used profiles + /// to the top. Recency decays smoothly over days; `now` is unix seconds. + pub fn score(&self, now: u64) -> f64 { + if self.count == 0 { + return 0.0; + } + let age_days = now.saturating_sub(self.last_used) as f64 / 86_400.0; + // Frequency, discounted by how stale the last use is (halves ~weekly). + self.count as f64 / (1.0 + age_days / 7.0) + } +} + +/// The current unix time in whole seconds (0 before the epoch, which never +/// happens). Used to stamp [`ProfileUsage::last_used`]. +pub fn unix_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) } /// Policy for a shell's starting directory (see [`Config::working_directory`]). @@ -377,6 +424,8 @@ impl Default for Config { env: HashMap::new(), ssh_profiles: Vec::new(), verify_host_keys: true, + ssh_warn_on_close: false, + ssh_profile_frecency: HashMap::new(), } } } @@ -654,6 +703,53 @@ where mod tests { use super::*; + #[test] + fn profile_usage_score_ranks_frequency_and_recency() { + let now = 100_000_000u64; + let day = 86_400u64; + // Never-used scores zero. + assert_eq!(ProfileUsage::default().score(now), 0.0); + // Same recency, more uses ⇒ higher score. + let a = ProfileUsage { + count: 10, + last_used: now, + }; + let b = ProfileUsage { + count: 2, + last_used: now, + }; + assert!(a.score(now) > b.score(now)); + // Same count, more recent ⇒ higher score (recency decays with age). + let recent = ProfileUsage { + count: 3, + last_used: now, + }; + let stale = ProfileUsage { + count: 3, + last_used: now - 30 * day, + }; + assert!(recent.score(now) > stale.score(now)); + } + + #[test] + fn ssh_warn_on_close_and_frecency_round_trip() { + let mut cfg = Config::default(); + assert!(!cfg.ssh_warn_on_close); + cfg.ssh_warn_on_close = true; + let id = uuid::Uuid::new_v4(); + cfg.ssh_profile_frecency.insert( + id, + ProfileUsage { + count: 4, + last_used: 42, + }, + ); + let json = serde_json::to_string(&cfg).unwrap(); + let back: Config = serde_json::from_str(&json).unwrap(); + assert!(back.ssh_warn_on_close); + assert_eq!(back.ssh_profile_frecency.get(&id).unwrap().count, 4); + } + #[test] fn font_features_are_optional_and_parse_as_gpui_features() { let cfg: Config = diff --git a/src/terminal/view.rs b/src/terminal/view.rs index cfa86255..8f51062f 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -95,6 +95,12 @@ pub struct TerminalView { /// panes. In-memory only (not persisted) — held so splits of this pane /// inherit the same shell. shell_spec: Option, + /// The native-SSH spec this pane was spawned with, **secrets stripped** + /// ([`NativeSshSpec::without_secrets`]). `None` for local shells and + /// compat-mode (shell-out) SSH panes. Persisted into the session so a *dead* + /// native-SSH pane can be respawned/reconnected on restore (PRD FR-E4 / C2), + /// and read live to drive the in-pane reconnect (`RestartSshSession`). + ssh_spec: Option>, pub focus_handle: FocusHandle, pub font: Font, /// Optional distinct base face for bold cells (from `font_family_bold`), with @@ -605,6 +611,32 @@ impl TerminalView { Ok(view) } + /// Spawn a native (russh) SSH pane for `spec` and build the view around it + /// (PRD FR-C1/E-series). The caller (`ui::ssh_connect`) has already resolved + /// keychain secrets into `spec`; this view retains only the **secret-free** + /// copy ([`NativeSshSpec::without_secrets`]) for session-restore respawn and + /// the in-pane reconnect. Auth/host-key prompts and the connection phase ride + /// this pane's own stream and surface through the usual `AuthPromptReady` + /// path. + pub fn new_native_ssh( + spec: Box, + working_directory: Option, + window: &mut Window, + cx: &mut Context, + ) -> anyhow::Result { + let persist = Box::new(spec.without_secrets()); + let (terminal, pane_id) = RemoteTerminal::spawn_native_ssh( + TermSize::new(80, 24), + 8, + 17, + working_directory, + spec, + )?; + let mut view = Self::with_terminal(terminal, pane_id, window, cx); + view.ssh_spec = Some(persist); + Ok(view) + } + /// Build the view around an already-connected terminal. Split from [`new`] /// so tests can hand in a `RemoteTerminal` backed by a plain socketpair /// and exercise the event plumbing without a live daemon. @@ -782,6 +814,7 @@ impl TerminalView { terminal, pane_id, shell_spec: None, + ssh_spec: None, focus_handle, font, font_bold, @@ -870,6 +903,26 @@ impl TerminalView { self.shell_spec.clone() } + /// The secret-free native-SSH spec this pane ran, if it is a native-SSH pane. + /// Persisted for session restore and re-used by the in-pane reconnect + /// (`RestartSshSession`). + pub fn ssh_spec(&self) -> Option> { + self.ssh_spec.clone() + } + + /// The native-SSH connection phase for the status strip (PRD FR-E1); `None` + /// for a non-native pane. + pub fn ssh_phase(&self) -> Option { + self.terminal.ssh_phase() + } + + /// Whether this native-SSH pane's connection is dead (shell exited or the + /// connect failed) and so eligible for an in-pane reconnect. False for live + /// panes and non-native panes. + pub fn ssh_disconnected(&self) -> bool { + self.ssh_spec.is_some() && self.terminal.exited + } + fn handle_event(&mut self, ev: AlacEvent, cx: &mut Context) { // Surface a child-exit/daemon-disconnect noticed by the reader thread into // the field the view reads directly (`self.terminal.exited`). diff --git a/src/ui/app.rs b/src/ui/app.rs index 6967ab27..c232c6ed 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -235,6 +235,21 @@ pub struct Tty7App { /// 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, + /// `Some` while the SSH profile editor page is open (a full-window overlay + /// like Settings; see `ui::profile_editor`). + pub(crate) profiles_editor: Option, + /// 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, +} + +/// Which close action a live-SSH close-confirmation is gating (PRD FR-E3). +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum SshCloseKind { + /// Close the whole tab at this index. + Tab(usize), + /// Close the focused pane. + Pane, } impl Tty7App { @@ -381,6 +396,8 @@ impl Tty7App { settings: None, ssh_prompt: crate::ui::ssh_prompt::SshPromptState::new(cx), known_hosts: Vec::new(), + profiles_editor: None, + ssh_close_confirm: None, }; // Discover this machine's shells for the "+" dropdown off the UI thread // (the WSL probe on Windows spawns a process, and /etc/shells hits the @@ -862,7 +879,11 @@ impl Tty7App { /// it, and repaint so the control reflects the new value. Keeping the /// persist/notify contract here means a future change (e.g. debounced /// saves) lands in one place. - fn update_config(&mut self, cx: &mut Context, mutate: impl FnOnce(&mut Config)) { + pub(crate) fn update_config( + &mut self, + cx: &mut Context, + mutate: impl FnOnce(&mut Config), + ) { let cfg = cx.global_mut::(); mutate(cfg); cfg.save(); @@ -883,6 +904,12 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.verify_host_keys = on); } + /// Global default for confirming before closing a live SSH session (FR-E3). + /// A per-profile `warn_on_close` override still wins where set. + pub(crate) fn set_ssh_warn_on_close(&mut self, on: bool, cx: &mut Context) { + 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.known_hosts = crate::terminal::RemoteTerminal::list_known_hosts(); @@ -1125,7 +1152,12 @@ impl Tty7App { cx.notify(); } - fn open_managed_ssh_spec(&mut self, ssh: SshSpec, window: &mut Window, cx: &mut Context) { + pub(crate) fn open_managed_ssh_spec( + &mut self, + ssh: SshSpec, + window: &mut Window, + cx: &mut Context, + ) { if ssh.validate().is_err() { return; } @@ -1279,7 +1311,7 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.startup_mode = mode); } - fn focus_active(&self, window: &mut Window, cx: &mut App) { + pub(crate) fn focus_active(&self, window: &mut Window, cx: &mut App) { // While the settings overlay is open it owns focus (so Esc-to-close and // keybinding capture keep working); tab operations behind it don't steal // it. `close_settings` refocuses the active terminal on the way out. @@ -1344,6 +1376,55 @@ impl Tty7App { cx.notify(); } + /// Open a new tab running a native (russh) SSH session for the resolved + /// `spec` (PRD FR-C1). The caller (`ui::ssh_connect`) has already pulled any + /// keychain secrets into `spec`. Mirrors `new_tab_with_shell` but for the + /// native backend. + pub(crate) fn open_native_ssh_tab( + &mut self, + spec: Box, + window: &mut Window, + cx: &mut Context, + ) { + let cwd = self.tabs.get(self.active).and_then(|t| { + t.pane + .focused_or_first(window, cx) + .and_then(|leaf| leaf.read(cx).cwd()) + }); + let view = new_terminal_native(self.font_size, cwd, spec, window, cx); + self.maximized = None; + let insert_at = self.new_tab_insert_at(cx); + self.tabs.insert(insert_at, Tab::new(Pane::leaf(view))); + self.active = insert_at; + self.focus_active(window, cx); + self.save_session(cx); + cx.notify(); + } + + /// Respawn a native SSH pane **in place** (same tab / split slot), replacing a + /// dead pane's view with a fresh native connection for `spec` (PRD FR-E4). The + /// daemon re-establishes the profile's preconfigured forwards on connect. + pub(crate) fn respawn_native_ssh_in_place( + &mut self, + dead: &Entity, + spec: Box, + window: &mut Window, + cx: &mut Context, + ) { + let cwd = dead.read(cx).cwd(); + let fresh = new_terminal_native(self.font_size, cwd, spec, window, cx); + // Swap the fresh leaf into the dead one's position across every tab. + for tab in &mut self.tabs { + if tab.pane.replace_leaf(dead, fresh.clone()) { + break; + } + } + self.maximized = None; + self.focus_leaf(&fresh, window, cx); + self.save_session(cx); + cx.notify(); + } + /// Split the focused pane in the active tab, focusing the new terminal. pub(crate) fn split(&mut self, axis: Axis, window: &mut Window, cx: &mut Context) { // Capture the target leaf BEFORE creating the new terminal: constructing @@ -1374,6 +1455,14 @@ impl Tty7App { /// Close the focused pane. If it was the tab's only pane, close the tab. fn close_pane(&mut self, window: &mut Window, cx: &mut Context) { + // FR-E3: if the focused pane is a live SSH session flagged warn-on-close, + // raise the in-pane confirm sheet instead of closing outright. + if self.ssh_close_confirm.is_none() && self.focused_pane_is_warn_ssh(window, cx) { + self.ssh_close_confirm = Some(SshCloseKind::Pane); + cx.notify(); + return; + } + self.ssh_close_confirm = None; self.maximized = None; // Capture the focused leaf before closing: if a split collapses, that // leaf is destroyed with no reopen path, so we kill its daemon pane. Owned @@ -1599,6 +1688,15 @@ impl Tty7App { if index >= self.tabs.len() { return; } + // FR-E3: confirm before closing a tab that holds a live warn-on-close SSH + // session (unless this call is the confirmation itself). + let already_confirming = self.ssh_close_confirm == Some(SshCloseKind::Tab(index)); + if !already_confirming && self.tab_has_warn_ssh(index, cx) { + self.ssh_close_confirm = Some(SshCloseKind::Tab(index)); + cx.notify(); + return; + } + self.ssh_close_confirm = None; self.maximized = None; // A rename in progress stores a fixed tab index; removing a tab shifts // indices and would let the pending edit commit onto the wrong tab. Drop it. @@ -1717,13 +1815,53 @@ impl Tty7App { /// "Switch to Tab: …" entry per open tab (label matches the tab strip). fn palette_commands(&self, cx: &App) -> Vec { let mut commands = Command::base_commands(); - let profiles = ssh_config::discover_profiles(); - if !profiles.is_empty() { - commands.push(Command { - title: "SSH Profiles…".to_string(), - kind: CommandKind::OpenSshProfilePicker(profiles), - }); + + // Saved SSH profiles, ordered by frecency then name (PRD FR-P3). Each row + // connects on Enter (native or compat per its flag) and edits on ⌘⏎ / →. + let cfg = cx.global::(); + let now = crate::core::config::unix_now(); + let mut profiles: Vec<&crate::core::ssh_profile::SshProfile> = + cfg.ssh_profiles.iter().collect(); + profiles.sort_by(|a, b| { + let score = |p: &crate::core::ssh_profile::SshProfile| { + cfg.ssh_profile_frecency + .get(&p.id) + .map(|u| u.score(now)) + .unwrap_or(0.0) + }; + score(b) + .partial_cmp(&score(a)) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + for p in profiles { + let subtitle = crate::core::ssh_profile::to_connect_string(p); + let title = if p.name.is_empty() { + subtitle.clone() + } else { + p.name.clone() + }; + commands.push( + Command::new( + format!("SSH: {title}"), + CommandKind::ConnectSavedProfile(p.id), + ) + .with_subtitle(subtitle), + ); } + + // Live `~/.ssh/config` aliases, marked and connected via the (frozen) + // shell-out alias path — OpenSSH stays their source of truth (PRD §3.3). + for alias in ssh_config::discover_profiles() { + commands.push( + Command::new( + format!("SSH: {}", alias.alias), + CommandKind::OpenSshProfile(alias), + ) + .with_subtitle("~/.ssh/config"), + ); + } + for (i, tab) in self.tabs.iter().enumerate() { // Skip the active tab — "switch to the tab you're already on" is a // no-op that only pads the list. @@ -1731,10 +1869,10 @@ impl Tty7App { continue; } let label = self.tab_label(tab, i, cx); - commands.push(Command { - title: format!("Switch to Tab: {label}"), - kind: CommandKind::ActivateTab(i), - }); + commands.push(Command::new( + format!("Switch to Tab: {label}"), + CommandKind::ActivateTab(i), + )); } commands } @@ -1774,7 +1912,7 @@ impl Tty7App { } /// Close the palette and hand focus back to the active terminal. - fn close_palette(&mut self, window: &mut Window, cx: &mut Context) { + pub(crate) fn close_palette(&mut self, window: &mut Window, cx: &mut Context) { self.palette = None; self.palette_sub = None; self.focus_active(window, cx); @@ -1833,6 +1971,7 @@ impl Tty7App { OpenSettings => self.toggle_settings(window, cx), RestartDaemon => self.restart_daemon(window, cx), ToggleSftp => self.toggle_sftp(window, cx), + RestartSshSession => self.restart_ssh_session(window, cx), SetTheme(i) => { if let Some(id) = crate::ui::presets::all(cx).get(i).map(|t| t.id.clone()) { self.set_preset(&id, window, cx); @@ -1853,9 +1992,18 @@ impl Tty7App { self.open_managed_ssh_spec(ssh, window, cx); } } + ConnectSavedProfile(id) => self.connect_ssh_profile(id, window, cx), + EditSavedProfile(id) => self.open_ssh_profiles_for(Some(id), None, window, cx), + QuickConnect(target) => { + if let Some(qc) = crate::core::ssh_profile::parse_quick_connect(&target) { + self.quick_connect(qc, window, cx); + } + } + SaveQuickConnect(target) => self.open_ssh_profiles_for(None, Some(target), window, cx), + OpenSshProfiles => self.open_ssh_profiles_for(None, None, window, cx), // Handled inside `PaletteView` (opens a sub-list); these never emit a // `Confirm` for this variant, so they never reach here. - OpenThemePicker | OpenSshConnectInput | OpenSshProfilePicker(_) => {} + OpenThemePicker | OpenSshConnectInput => {} ActivateTab(i) => self.activate(i, window, cx), } } @@ -2362,6 +2510,86 @@ impl Tty7App { self.settings.as_mut() } + /// The status-dot colour for a tab whose representative pane is an SSH + /// session (PRD FR-E2): native panes are phase-coloured (connecting = warning, + /// connected = accent, failed/disconnected = red); shell-out (compat) SSH + /// panes get a plain neutral dot. `None` for non-SSH tabs (no dot). + pub(crate) fn tab_ssh_dot(&self, tab: &Tab, cx: &App) -> Option { + use crate::daemon::protocol::SshPhase; + let leaf = tab.pane.first_leaf()?; + let v = leaf.read(cx); + let theme = cx.theme(); + if let Some(phase) = v.ssh_phase() { + // Native pane. + let color = if v.ssh_disconnected() { + theme.danger + } else { + match phase { + SshPhase::Connecting | SshPhase::Authenticating => theme.warning, + SshPhase::Connected => theme.accent, + SshPhase::Failed { .. } => theme.danger, + } + }; + Some(color) + } else if v.remote_context().is_some() { + // Compat-mode / detected shell-out ssh: a plain neutral dot. + Some(theme.muted_foreground) + } else { + None + } + } + + /// Whether `leaf` is a live, connected native-SSH pane whose effective + /// warn-on-close is on (per-profile override, else the global toggle). + fn leaf_is_warn_ssh(&self, leaf: &Entity, cx: &App) -> bool { + use crate::daemon::protocol::SshPhase; + let v = leaf.read(cx); + let connected = matches!(v.ssh_phase(), Some(SshPhase::Connected)) && !v.terminal.exited; + if !connected { + return false; + } + let cfg = cx.global::(); + let per_profile = v + .ssh_spec() + .and_then(|s| s.profile_id.clone()) + .and_then(|id| uuid::Uuid::parse_str(&id).ok()) + .and_then(|id| cfg.ssh_profiles.iter().find(|p| p.id == id)) + .and_then(|p| p.warn_on_close); + per_profile.unwrap_or(cfg.ssh_warn_on_close) + } + + /// Whether the tab at `index` holds any live warn-on-close SSH pane (FR-E3). + pub(crate) fn tab_has_warn_ssh(&self, index: usize, cx: &App) -> bool { + self.tabs + .get(index) + .map(|t| t.pane.leaves().iter().any(|l| self.leaf_is_warn_ssh(l, cx))) + .unwrap_or(false) + } + + /// Whether the focused pane is a live warn-on-close SSH pane (FR-E3). + pub(crate) fn focused_pane_is_warn_ssh(&self, window: &Window, cx: &App) -> bool { + self.tabs + .get(self.active) + .and_then(|t| t.pane.focused_or_first(window, cx)) + .map(|l| self.leaf_is_warn_ssh(&l, cx)) + .unwrap_or(false) + } + + /// Proceed with a pending SSH-close after confirmation (FR-E3). + pub(crate) fn confirm_ssh_close(&mut self, window: &mut Window, cx: &mut Context) { + match self.ssh_close_confirm { + Some(SshCloseKind::Tab(i)) => self.close_tab(i, window, cx), + Some(SshCloseKind::Pane) => self.close_pane(window, cx), + None => {} + } + } + + /// Dismiss the SSH-close confirmation, leaving the session open (FR-E3). + pub(crate) fn cancel_ssh_close(&mut self, cx: &mut Context) { + self.ssh_close_confirm = None; + cx.notify(); + } + pub(crate) fn active_ssh_pane( &self, window: &Window, @@ -2676,6 +2904,12 @@ impl Render for Tty7App { let strip = self.tab_strip(!vertical, window, cx); let sidebar = vertical.then(|| self.tab_sidebar(window, cx)); let active_ssh_pane = self.active_ssh_pane(window, cx); + // Native-SSH status strip / reconnect notice for the focused pane (E1/E4). + let ssh_status = self + .tabs + .get(self.active) + .and_then(|t| t.pane.focused_or_first(window, cx)) + .and_then(|leaf| self.render_ssh_status_strip(&leaf, cx)); // Render the active tab's pane tree; show focus rings only when split. let body = match self.tabs.get(self.active) { // Zero tabs: the window's own face — the home page (see `ui::home`). @@ -2735,6 +2969,12 @@ impl Render for Tty7App { // that raised the prompt. .when_some(self.render_ssh_prompt_overlay(window, cx), |this, el| { this.child(el) + }) + // Native-SSH status strip / reconnect notice (E1/E4). + .when_some(ssh_status, |this, el| this.child(el)) + // Live-SSH close-confirmation sheet (E3). + .when_some(self.render_ssh_close_confirm_overlay(cx), |this, el| { + this.child(el) }); // The two layouts. Horizontal (default): a column of [title bar / body]. @@ -2787,6 +3027,17 @@ impl Render for Tty7App { .child(self.render_settings(cx)) }); + // SSH profile editor — a second full-window overlay (PRD §6.2 ②), + // mounted the same way as Settings. + let profiles_overlay = self.profiles_editor.is_some().then(|| { + div() + .absolute() + .inset_0() + .occlude() + .bg(cx.theme().background) + .child(self.render_profile_editor(cx)) + }); + div() .id("tty7-root") .size_full() @@ -2914,9 +3165,17 @@ impl Render for Tty7App { // than relying solely on the global handler (which the keystroke // doesn't reach while focus is deep in the terminal view). .on_action(cx.listener(|_, _: &Quit, _, cx| cx.quit())) + .on_action(cx.listener(|this, _: &OpenSshProfiles, window, cx| { + this.open_ssh_profiles_for(None, None, window, cx) + })) + .on_action(cx.listener(|this, _: &RestartSshSession, window, cx| { + this.restart_ssh_session(window, cx) + })) .child(main_layout) // Settings overlay, above the tabs/terminal when open. .when_some(settings_overlay, |this, overlay| this.child(overlay)) + // SSH profile editor overlay. + .when_some(profiles_overlay, |this, overlay| this.child(overlay)) // Command palette overlay, layered above everything when open. .when_some(self.palette.clone(), |this, palette| this.child(palette)) } @@ -2939,9 +3198,10 @@ fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane { SessionPane::Leaf { cwd: view.cwd(), pane_id: Some(view.pane_id), - // WS2 seam: WS6 populates this (via `NativeSshSpec::without_secrets`) - // so a dead native-SSH pane can be respawned on restore. - ssh_spec: None, + // Persist the secret-free native-SSH spec so a *dead* pane can be + // reconnected on restore (FR-E4/C2); `None` for local panes. A + // live pane reattaches by `pane_id` and never needs this. + ssh_spec: view.ssh_spec(), } } Pane::Split { @@ -3021,11 +3281,22 @@ fn session_to_pane( SessionPane::Leaf { cwd, pane_id, - ssh_spec: _, + ssh_spec, } => { // Only restore the pane id when the daemon confirms it's still live; // a stale id (daemon restarted, pane killed) falls back to a spawn. let restore = (*pane_id).filter(|id| alive.contains(id)); + // A *dead* native-SSH leaf (spec persisted, pane no longer alive) + // reconnects rather than dropping back to a local shell (FR-C2/E4): + // re-resolve secrets from the profile when it names one, else reuse + // the secret-free spec and let the auth sheets prompt. + if restore.is_none() { + if let Some(spec) = ssh_spec.clone() { + let resolved = crate::ui::ssh_connect::resolve_persisted_ssh_spec(spec, cx); + let view = new_terminal_native(font_size, cwd.clone(), resolved, window, cx); + return Pane::leaf(view); + } + } // A shell pick isn't persisted in the session, so a stale pane that // must respawn comes back on the default shell. let view = new_terminal(font_size, cwd.clone(), restore, None, window, cx); @@ -3081,6 +3352,38 @@ fn new_terminal( view } +/// Build a native (russh) SSH terminal view for `spec`, wiring the same +/// per-pane subscriptions (`ChildExited`, `AuthPromptReady`) as [`new_terminal`] +/// so it participates in auto-close and the in-pane auth sheets. Mirrors +/// `new_terminal` but takes the resolved connect spec instead of a shell. +pub(crate) fn new_terminal_native( + font_size: f32, + working_directory: Option, + spec: Box, + window: &mut Window, + cx: &mut Context, +) -> Entity { + let view = cx.new(|cx| { + let mut view = TerminalView::new_native_ssh(spec, working_directory, window, cx) + .expect("failed to start native SSH session"); + view.font_size = px(font_size); + view + }); + cx.subscribe_in(&view, window, |app, view, _: &ChildExited, window, cx| { + app.on_child_exited(view.clone(), window, cx); + }) + .detach(); + cx.subscribe_in( + &view, + window, + |app, view, _: &crate::terminal::view::AuthPromptReady, window, cx| { + app.on_auth_prompt_ready(view.clone(), window, cx); + }, + ) + .detach(); + view +} + pub(crate) fn parse_ssh_option_words(input: &str) -> Result, ()> { let mut words = Vec::new(); let mut current = String::new(); diff --git a/src/ui/forwards.rs b/src/ui/forwards.rs index 30a538e0..8636245d 100644 --- a/src/ui/forwards.rs +++ b/src/ui/forwards.rs @@ -3,7 +3,7 @@ //! Settings owns persistent preferences; this module owns the live forwarding //! dashboard that only makes sense beside a concrete SSH pane. -use gpui::{AnyElement, Context, Div, FontWeight, SharedString, div, prelude::*, px}; +use gpui::{AnyElement, Context, Div, Entity, FontWeight, SharedString, div, prelude::*, px}; use gpui_component::Selectable as _; use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::input::Input; @@ -11,10 +11,163 @@ use gpui_component::{ActiveTheme as _, Sizable as _, h_flex, v_flex}; use crate::daemon::protocol::{ ForwardStatus, LoopbackForwardInfo, ManagedForward, RemoteContext, RemoteKind, SshForwardKind, + SshPhase, }; +use crate::terminal::view::TerminalView; use crate::ui::app::Tty7App; impl Tty7App { + /// The in-pane native-SSH status strip (PRD FR-E1): a subtle ` SSH ` chip + /// coloured by the connection phase, with the hostname, pinned top-left of the + /// terminal body. A dead pane also shows the "connection lost — ⌘⇧R to + /// reconnect" notice (FR-E4). Returns `None` for a non-native pane. + pub(crate) fn render_ssh_status_strip( + &self, + leaf: &Entity, + cx: &mut Context, + ) -> Option { + let view = leaf.read(cx); + let phase = view.ssh_phase()?; + let disconnected = view.ssh_disconnected(); + let host = view + .terminal + .ssh_endpoint() + .map(|(h, _)| h) + .or_else(|| view.remote_context().map(|c| c.target)) + .unwrap_or_default(); + + let theme = cx.theme(); + // Phase → accent. Connecting/authenticating are cautionary (yellow), + // connected reads calm (accent), failed/disconnected are red. + let (color, label) = if disconnected { + (theme.danger, "SSH ✕") + } else { + match &phase { + SshPhase::Connecting => (theme.warning, "SSH …"), + SshPhase::Authenticating => (theme.warning, "SSH ⚿"), + SshPhase::Connected => (theme.accent, "SSH"), + SshPhase::Failed { .. } => (theme.danger, "SSH ✕"), + } + }; + + let chip = h_flex() + .items_center() + .gap_1p5() + .px_2() + .py_0p5() + .rounded_md() + .bg(color.opacity(0.15)) + .border_1() + .border_color(color.opacity(0.5)) + .text_xs() + .text_color(color) + .child(div().font_weight(FontWeight::SEMIBOLD).child(label)) + .when(!host.is_empty(), |d| { + d.child(div().text_color(theme.muted_foreground).child(host)) + }); + + let mut col = div().flex().flex_col().items_start().gap_1().child(chip); + + if disconnected { + col = col.child( + h_flex() + .items_center() + .gap_2() + .px_2() + .py_1() + .rounded_md() + .bg(theme.danger.opacity(0.12)) + .border_1() + .border_color(theme.danger.opacity(0.4)) + .text_xs() + .text_color(theme.foreground) + .child("Connection lost — press ⌘⇧R to reconnect") + .child( + Button::new("ssh-reconnect") + .label("Reconnect") + .primary() + .small() + .on_click(cx.listener(|this, _, window, cx| { + this.restart_ssh_session(window, cx) + })), + ), + ); + } + + Some( + div() + .absolute() + .top_2() + .left_4() + .child(col) + .into_any_element(), + ) + } + + /// The in-pane "confirm close of a live SSH session" sheet (PRD FR-E3), + /// centered over the terminal. Enter/Close closes; Esc/Keep cancels. Returns + /// `None` when no confirmation is pending. + pub(crate) fn render_ssh_close_confirm_overlay( + &self, + cx: &mut Context, + ) -> Option { + self.ssh_close_confirm?; + let theme = cx.theme(); + let card = v_flex() + .w(px(360.)) + .gap_3() + .p_4() + .bg(theme.popover) + .border_1() + .border_color(theme.border) + .rounded_lg() + .shadow_lg() + .occlude() + .child( + div() + .font_weight(FontWeight::SEMIBOLD) + .child("Close this SSH session?"), + ) + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child("The connection is live. Closing will end it."), + ) + .child( + h_flex() + .justify_end() + .gap_2() + .child( + Button::new("ssh-close-cancel") + .label("Keep") + .small() + .on_click( + cx.listener(|this, _, _window, cx| this.cancel_ssh_close(cx)), + ), + ) + .child( + Button::new("ssh-close-confirm") + .label("Close") + .primary() + .small() + .on_click(cx.listener(|this, _, window, cx| { + this.confirm_ssh_close(window, cx) + })), + ), + ); + Some( + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .child(card) + .into_any_element(), + ) + } + pub(crate) fn render_loopback_forward_overlay( &self, pane_id: u64, diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index b6a0407b..e7cb4e9b 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -165,6 +165,12 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> { // No default chord — reachable from the command palette ("SFTP Panel") and // bindable in Settings like any other action. ("ToggleSftp", ""), + // No default chord — reachable from the command palette ("SSH: Manage + // Profiles…") and bindable in Settings. + ("OpenSshProfiles", ""), + // Reconnect a dropped native-SSH pane (PRD FR-E4). ⌘⇧R is free (no + // existing binding uses it). + ("RestartSshSession", "secondary-shift-r"), ("Quit", "secondary-q"), ] } @@ -443,6 +449,8 @@ fn make_binding(action: &str, keystroke: &str) -> Option { "ClearScrollback" => KeyBinding::new(keystroke, ClearScrollback, Some("Terminal")), "OpenSettings" => KeyBinding::new(keystroke, OpenSettings, None), "ToggleSftp" => KeyBinding::new(keystroke, ToggleSftp, None), + "OpenSshProfiles" => KeyBinding::new(keystroke, OpenSshProfiles, None), + "RestartSshSession" => KeyBinding::new(keystroke, RestartSshSession, None), "Quit" => KeyBinding::new(keystroke, Quit, None), _ => return None, }) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 2f994636..1a5fa552 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -14,6 +14,7 @@ pub mod palette; pub mod pane; pub mod perf; pub mod presets; +pub mod profile_editor; pub mod settings; pub mod sftp; pub mod ssh_connect; diff --git a/src/ui/palette.rs b/src/ui/palette.rs index 512aa001..e63c3646 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -19,8 +19,11 @@ use gpui_component::{ v_flex, }; +use uuid::Uuid; + use crate::core::config::Config; use crate::core::ssh_config::SshProfile; +use crate::core::ssh_profile::parse_quick_connect; /// What a command actually does. Most variants map to an existing `Tty7App` /// operation dispatched in `app.rs` (so it can touch tabs/panes); submenu @@ -56,12 +59,12 @@ pub enum CommandKind { RestartDaemon, /// Toggle the SFTP file panel for the focused native-SSH pane (WS5). ToggleSftp, + /// Reconnect a dead native-SSH pane in place (WS6, FR-E4). + RestartSshSession, /// Opens the theme sub-list (a nested palette). Handled in `PaletteView`. OpenThemePicker, /// Opens a typed SSH connection sub-list. Handled in `PaletteView`. OpenSshConnectInput, - /// Opens the SSH profile sub-list. Handled in `PaletteView`. - OpenSshProfilePicker(Vec), /// Open a tty7-managed SSH tab from a typed target/options line. OpenSshConnect(String), /// Apply the preset at this index in `presets::all()`. Emitted from the @@ -71,6 +74,29 @@ pub enum CommandKind { OpenSshProfile(SshProfile), /// Switch to the tab at this index in `Tty7App::tabs`. ActivateTab(usize), + /// Connect a saved SSH profile by id (native or compat, per its flag). + ConnectSavedProfile(Uuid), + /// Open the profile editor focused on this saved profile (⌘⏎ / → on a row). + EditSavedProfile(Uuid), + /// QuickConnect to a typed `user@host[:port]` target via the native path. + QuickConnect(String), + /// Open the profile editor pre-filled from a typed QuickConnect target + /// ("save as profile" from a quick connect). + SaveQuickConnect(String), + /// Open the full-window SSH profile manager/editor page. + OpenSshProfiles, +} + +impl CommandKind { + /// The "edit" counterpart of a connect-style command, for the ⌘⏎ / → gesture + /// (PRD §6.2 ①). `None` for commands that have no editor. + pub fn edit_variant(&self) -> Option { + match self { + CommandKind::ConnectSavedProfile(id) => Some(CommandKind::EditSavedProfile(*id)), + CommandKind::QuickConnect(s) => Some(CommandKind::SaveQuickConnect(s.clone())), + _ => None, + } + } } impl CommandKind { @@ -107,14 +133,19 @@ impl CommandKind { OpenSettings => "OpenSettings", RestartDaemon => "RestartDaemon", ToggleSftp => "ToggleSftp", + RestartSshSession => "RestartSshSession", FindInTerminal | OpenThemePicker | OpenSshConnectInput - | OpenSshProfilePicker(_) | OpenSshConnect(_) | SetTheme(_) | OpenSshProfile(_) - | ActivateTab(_) => return None, + | ActivateTab(_) + | ConnectSavedProfile(_) + | EditSavedProfile(_) + | QuickConnect(_) + | SaveQuickConnect(_) + | OpenSshProfiles => return None, }) } } @@ -123,17 +154,27 @@ impl CommandKind { #[derive(Clone)] pub struct Command { pub title: String, + /// Optional dimmed secondary text on the right of the title (e.g. a saved + /// profile's `user@host`, or `(~/.ssh/config)` for an alias). + pub subtitle: Option, pub kind: CommandKind, } impl Command { - fn new(title: impl Into, kind: CommandKind) -> Self { + pub fn new(title: impl Into, kind: CommandKind) -> Self { Self { title: title.into(), + subtitle: None, kind, } } + /// Attach a dimmed subtitle rendered to the right of the title. + pub fn with_subtitle(mut self, subtitle: impl Into) -> Self { + self.subtitle = Some(subtitle.into()); + self + } + /// The static commands available regardless of how many tabs exist. The /// caller appends the dynamic "Switch to Tab: …" entries (one per tab). /// @@ -170,6 +211,8 @@ impl Command { Command::new("Find in Terminal…", FindInTerminal), Command::new("Reopen Closed Tab", ReopenClosedTab), Command::new("SSH: Add Connection…", OpenSshConnectInput), + Command::new("SSH: Manage Profiles…", OpenSshProfiles), + Command::new("Reconnect SSH Session", RestartSshSession), Command::new("SFTP Panel", ToggleSftp), Command::new("Change Theme…", OpenThemePicker), Command::new("Open Settings", OpenSettings), @@ -198,18 +241,6 @@ impl Command { .collect() } - pub fn ssh_profile_commands(profiles: Vec) -> Vec { - profiles - .into_iter() - .map(|profile| { - Command::new( - format!("SSH: {}", profile.alias), - CommandKind::OpenSshProfile(profile), - ) - }) - .collect() - } - fn ssh_connect_command(input: &str) -> Command { let title = if input.trim().is_empty() { "SSH: Add Connection…".to_string() @@ -234,6 +265,15 @@ pub fn fuzzy_match(query: &str, title: &str) -> bool { needle.peek().is_none() } +/// True when `query` fuzzy-matches a command's subtitle (e.g. typing a hostname +/// matches a profile row whose subtitle is `user@host`). A command with no +/// subtitle never matches this way. +fn fuzzy_match_subtitle(query: &str, cmd: &Command) -> bool { + cmd.subtitle + .as_deref() + .is_some_and(|s| fuzzy_match(query, s)) +} + /// Feeds the command catalog to gpui-component's `ListState`. It keeps the full /// catalog plus the subset matching the current query (`matched`), re-filtering /// in `perform_search` whenever the search input changes. @@ -244,6 +284,10 @@ pub struct PaletteDelegate { matched: Vec, input: Option, query: String, + /// Whether this is the root catalog, where a query that parses as + /// `user@host[:port]` injects live "Connect to …" / "Save … as profile" + /// rows so QuickConnect shares the one entry box (PRD §6.2 ①). + quick_connect_root: bool, /// Index of the highlighted row, mirrored from the list's own selection so /// `render_item` can mark it. `None` when nothing matches. selected: Option, @@ -261,10 +305,40 @@ impl PaletteDelegate { commands, input: None, query: String::new(), + quick_connect_root: false, selected: Some(IndexPath::default()), } } + /// The root delegate: like [`new`], but a query that parses as a QuickConnect + /// target injects live connect/save rows. + pub fn root(commands: Vec) -> Self { + Self { + quick_connect_root: true, + ..Self::new(commands) + } + } + + /// The QuickConnect rows for a query at the root, if it parses as a target. + fn quick_connect_commands(query: &str) -> Vec { + match parse_quick_connect(query) { + Some(_) => { + let target = query.trim().to_string(); + vec![ + Command::new( + format!("Connect to \"{target}\""), + CommandKind::QuickConnect(target.clone()), + ), + Command::new( + format!("Save \"{target}\" as profile…"), + CommandKind::SaveQuickConnect(target), + ), + ] + } + None => Vec::new(), + } + } + fn ssh_connect() -> Self { let matched = vec![Command::ssh_connect_command("")]; Self { @@ -272,6 +346,7 @@ impl PaletteDelegate { matched, input: Some(PaletteInput::SshConnect), query: String::new(), + quick_connect_root: false, selected: Some(IndexPath::default()), } } @@ -281,6 +356,11 @@ impl PaletteDelegate { pub fn command_at(&self, ix: IndexPath) -> Option { self.matched.get(ix.row).map(|c| c.kind.clone()) } + + /// The currently highlighted command, if any (for the ⌘⏎ / → edit gesture). + pub fn selected_command(&self) -> Option { + self.selected.and_then(|ix| self.command_at(ix)) + } } impl ListDelegate for PaletteDelegate { @@ -302,12 +382,19 @@ impl ListDelegate for PaletteDelegate { self.query = query.to_string(); self.matched = vec![Command::ssh_connect_command(query)]; } else { - self.matched = self - .commands - .iter() - .filter(|c| fuzzy_match(query, &c.title)) - .cloned() - .collect(); + let mut matched: Vec = Vec::new(); + // At the root, a query that parses as a connect target leads with + // QuickConnect rows (PRD §6.2 ①), above the fuzzy-matched catalog. + if self.quick_connect_root { + matched.extend(Self::quick_connect_commands(query)); + } + matched.extend( + self.commands + .iter() + .filter(|c| fuzzy_match(query, &c.title) || fuzzy_match_subtitle(query, c)) + .cloned(), + ); + self.matched = matched; } self.selected = (!self.matched.is_empty()).then(IndexPath::default); Task::ready(()) @@ -337,11 +424,23 @@ impl ListDelegate for PaletteDelegate { .and_then(|action| crate::ui::keymap::effective_key(action, cx)) .map(|spec| crate::ui::keymap::key_tokens(&spec)); + // Title, with an optional dimmed subtitle to its right (a profile's + // `user@host`, or `(~/.ssh/config)` for an alias). + let mut left = h_flex().items_center().gap_2().child(cmd.title.clone()); + if let Some(subtitle) = cmd.subtitle.clone() { + left = left.child(div().text_xs().text_color(muted).child(subtitle)); + } + let mut row = h_flex() .w_full() .items_center() .justify_between() - .child(cmd.title.clone()); + .child(left); + // Editable rows (saved profiles, quick-connect) advertise the ⌘⏎ / → + // edit gesture with a subtle trailing hint (PRD §6.2 ①). + if cmd.kind.edit_variant().is_some() { + row = row.child(div().text_xs().text_color(muted).child("→ edit")); + } if let Some(tokens) = keys { row = row.child(h_flex().gap_1().children(tokens.into_iter().map(move |t| { div() @@ -396,7 +495,6 @@ enum PaletteMenu { Root, Theme, SshConnect, - SshProfiles, } /// The command palette as a self-contained view. It owns the `ListState` @@ -421,7 +519,7 @@ pub struct PaletteView { impl PaletteView { pub fn new(commands: Vec, window: &mut Window, cx: &mut Context) -> Self { - let list = Self::build_list(commands.clone(), window, cx); + let list = Self::build_root_list(commands.clone(), window, cx); let _sub = cx.subscribe_in(&list, window, Self::on_list_event); Self { list, @@ -443,6 +541,15 @@ impl PaletteView { Self::build_list_with_delegate(PaletteDelegate::new(commands), window, cx) } + /// The root list, whose delegate injects live QuickConnect rows. + fn build_root_list( + commands: Vec, + window: &mut Window, + cx: &mut Context, + ) -> Entity> { + Self::build_list_with_delegate(PaletteDelegate::root(commands), window, cx) + } + fn build_list_with_delegate( delegate: PaletteDelegate, window: &mut Window, @@ -474,10 +581,21 @@ impl PaletteView { fn search_placeholder(&self) -> &'static str { match self.menu { PaletteMenu::SshConnect => "user@host [-p 2222 -J jump]", - PaletteMenu::Root | PaletteMenu::Theme | PaletteMenu::SshProfiles => "Search…", + PaletteMenu::Root => "Search or type user@host to connect…", + PaletteMenu::Theme => "Search…", } } + /// Read the currently highlighted command's "edit" variant, if any — the + /// target of the ⌘⏎ / → gesture on a profile / quick-connect row. + fn selected_edit_command(&self, cx: &App) -> Option { + self.list + .read(cx) + .delegate() + .selected_command() + .and_then(|k| k.edit_variant()) + } + /// Translate the current list's confirm/cancel into either a host-facing /// event or an in-place transition into/out of a sub-list. fn on_list_event( @@ -502,11 +620,6 @@ impl PaletteView { self.menu = PaletteMenu::SshConnect; self.show_ssh_connect(window, cx); } - Some(CommandKind::OpenSshProfilePicker(profiles)) => { - self.menu = PaletteMenu::SshProfiles; - let profiles = Command::ssh_profile_commands(profiles); - self.show(profiles, window, cx); - } Some(CommandKind::OpenSshConnect(input)) if input.trim().is_empty() => {} Some(kind) => cx.emit(PaletteEvent::Confirm(kind)), None => cx.emit(PaletteEvent::Dismiss), @@ -518,7 +631,10 @@ impl PaletteView { if self.menu != PaletteMenu::Root { self.menu = PaletteMenu::Root; let root = self.root.clone(); - self.show(root, window, cx); + let list = Self::build_root_list(root, window, cx); + self._sub = cx.subscribe_in(&list, window, Self::on_list_event); + self.list = list; + cx.notify(); } else { cx.emit(PaletteEvent::Dismiss); } @@ -569,6 +685,21 @@ impl Render for PaletteView { .justify_center() .pt(px(120.)) .bg(background.opacity(0.45)) + // ⌘⏎ or → on a highlighted profile / quick-connect row opens its + // editor instead of connecting (PRD §6.2 ①). Captured on the scrim + // (an ancestor of the focused search box) so it fires before the list + // acts on a bare Enter. Plain Enter / navigation keys fall through. + .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, _window, cx| { + let ks = &ev.keystroke; + let is_edit_gesture = (ks.key == "enter" && ks.modifiers.platform) + || (ks.key == "right" && !ks.modifiers.platform); + if is_edit_gesture { + if let Some(edit) = this.selected_edit_command(cx) { + cx.stop_propagation(); + cx.emit(PaletteEvent::Confirm(edit)); + } + } + })) .on_mouse_down( MouseButton::Left, cx.listener(|_this, _: &MouseDownEvent, _window, cx| { diff --git a/src/ui/pane.rs b/src/ui/pane.rs index 6c31a26d..c342eac0 100644 --- a/src/ui/pane.rs +++ b/src/ui/pane.rs @@ -162,6 +162,26 @@ impl Pane { } } + /// Replace the first leaf matching `is_target` with `new`, keeping the tree + /// shape (used for in-place SSH reconnect: the dead pane's slot gets a fresh + /// connection). Returns whether a match was found. + fn replace_leaf_where(&mut self, is_target: &impl Fn(&L) -> bool, new: L) -> bool { + match self { + Pane::Leaf(v) => { + if is_target(v) { + *v = new; + true + } else { + false + } + } + Pane::Split { a, b, .. } => { + a.replace_leaf_where(is_target, new.clone()) || b.replace_leaf_where(is_target, new) + } + Pane::Empty => false, + } + } + /// Remove the first leaf matching `is_target` (depth-first, `a` before /// `b`), collapsing its parent split into the sibling. fn close_leaf_where(&mut self, is_target: &impl Fn(&L) -> bool) -> CloseOutcome { @@ -470,6 +490,16 @@ impl Pane> { self.split_leaf_where(&|v| v.entity_id() == target.entity_id(), axis, new) } + /// Replace `target` (matched by entity identity) with `new`, preserving the + /// tree shape. Used by the in-place SSH reconnect (PRD FR-E4). + pub fn replace_leaf( + &mut self, + target: &Entity, + new: Entity, + ) -> bool { + self.replace_leaf_where(&|v| v.entity_id() == target.entity_id(), new) + } + /// Remove the focused leaf, collapsing its parent split into the sibling. pub fn close_focused(&mut self, window: &Window, cx: &App) -> CloseOutcome { self.close_leaf_where(&|v| v.read(cx).focus_handle.contains_focused(window, cx)) diff --git a/src/ui/profile_editor.rs b/src/ui/profile_editor.rs new file mode 100644 index 00000000..6c73f84b --- /dev/null +++ b/src/ui/profile_editor.rs @@ -0,0 +1,1300 @@ +//! The SSH profile editor: a full-window page (cloning the Settings overlay +//! pattern) for managing saved [`SshProfile`]s (PRD §6.2 ②, FR-P1/P5). +//! +//! Two views share one overlay: a **list** (profiles grouped, add / duplicate / +//! delete, import from `~/.ssh/config`) and an **edit** form with progressive +//! disclosure — four fields up front (name, host+port, user, auth mode) and +//! collapsed sections for jump host, port forwards, and advanced options +//! (identity files, proxies, algorithms, keepalive/timeouts, X11, login scripts, +//! banner, host-key verification, warn-on-close, and the `use_system_ssh` +//! compat-mode escape hatch). +//! +//! Edits are committed to `Config::ssh_profiles` (via `update_config`) only on +//! **Save**, so the form can be abandoned freely. Connect and "copy +//! `user@host:port`" act on the saved profile. + +use std::cell::Cell; +use std::rc::Rc; + +use gpui::{ + AnyElement, App, Context, Entity, FocusHandle, KeyDownEvent, MouseButton, ParentElement as _, + Styled as _, Subscription, Window, WindowControlArea, div, prelude::*, px, +}; +use gpui_component::button::{Button, ButtonVariants as _}; +use gpui_component::input::{Input, InputState}; +use gpui_component::switch::Switch; +use gpui_component::{ + ActiveTheme as _, IconName, InteractiveElementExt as _, Sizable as _, h_flex, v_flex, +}; +use uuid::Uuid; + +use crate::core::config::Config; +use crate::core::ssh_profile::{ + Algorithms, AuthMode, ForwardKind, ForwardRule, HostPort, SshProfile, parse_quick_connect, + to_connect_string, +}; + +use super::app::Tty7App; + +/// Live state of the open profile-editor page. `None` on `Tty7App` when closed. +pub(crate) struct ProfileEditorState { + pub(crate) focus_handle: FocusHandle, + /// The profile being edited (its id). `None` shows the list view. A *new* + /// (unsaved) profile carries a freshly minted id here and is only written to + /// config on Save. + editing: Option, + /// The group/credential_ref carried over from the profile being edited, so a + /// Save round-trips fields the form doesn't expose. + carry_group: Option, + carry_credential_ref: Option, + + // Section expansion (progressive disclosure). + show_jump: bool, + show_forwards: bool, + show_advanced: bool, + + // Core fields. + name: Entity, + host: Entity, + port: Entity, + user: Entity, + auth: AuthMode, + + // Jump host (a profile name; empty = none). + jump: Entity, + + // Forwards, one rule per line: `L bind_host:bind_port target_host:target_port [desc]`. + forwards: Entity, + + // Advanced text inputs. + identity_files: Entity, + proxy_command: Entity, + socks: Entity, + http: Entity, + kex: Entity, + cipher: Entity, + mac: Entity, + hostkey: Entity, + compression: Entity, + keepalive_interval: Entity, + keepalive_count: Entity, + connect_timeout: Entity, + login_scripts: Entity, + + // Advanced booleans / tri-states. + agent_forward: bool, + x11: bool, + skip_banner: bool, + use_system_ssh: bool, + verify_host_keys: Option, + warn_on_close: Option, + + _subs: Vec, +} + +/// Parse a `host:port` fragment into a [`HostPort`], or `None` when empty/blank. +fn parse_host_port(s: &str) -> Option { + let s = s.trim(); + if s.is_empty() { + return None; + } + match s.rsplit_once(':') { + Some((h, p)) => Some(HostPort::new(h.trim(), p.trim().parse().unwrap_or(0))), + None => Some(HostPort::new(s, 0)), + } +} + +/// Render a `HostPort` back to `host:port` for the form (empty string for `None`). +fn host_port_text(hp: &Option) -> String { + hp.as_ref() + .map(|h| format!("{}:{}", h.host, h.port)) + .unwrap_or_default() +} + +/// Split a comma/whitespace list into non-empty items (algorithms, etc.). +fn split_list(s: &str) -> Vec { + s.split([',', ' ', '\n']) + .map(str::trim) + .filter(|t| !t.is_empty()) + .map(str::to_string) + .collect() +} + +/// Split a multiline input into non-empty trimmed lines. +fn split_lines(s: &str) -> Vec { + s.lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .map(str::to_string) + .collect() +} + +/// Parse the forwards text area (one rule per line) into [`ForwardRule`]s. +/// Lines that don't parse are skipped rather than failing the whole save. +fn parse_forwards(s: &str) -> Vec { + let mut out = Vec::new(); + for line in s.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let mut parts = line.splitn(4, char::is_whitespace); + let kind = match parts.next().map(|k| k.to_ascii_uppercase()) { + Some(k) if k == "L" || k == "LOCAL" => ForwardKind::Local, + Some(k) if k == "R" || k == "REMOTE" => ForwardKind::Remote, + Some(k) if k == "D" || k == "DYNAMIC" => ForwardKind::Dynamic, + _ => continue, + }; + let Some(bind) = parts.next().and_then(parse_host_port) else { + continue; + }; + // Dynamic ignores the target; Local/Remote need it. + let target = if kind == ForwardKind::Dynamic { + HostPort::default() + } else { + match parts.next().and_then(parse_host_port) { + Some(t) => t, + None => continue, + } + }; + let description = parts.next().unwrap_or("").trim().to_string(); + out.push(ForwardRule { + kind, + bind, + target, + description, + }); + } + out +} + +/// Render `ForwardRule`s back into the text-area format. +fn forwards_text(rules: &[ForwardRule]) -> String { + rules + .iter() + .map(|r| { + let kind = match r.kind { + ForwardKind::Local => "L", + ForwardKind::Remote => "R", + ForwardKind::Dynamic => "D", + }; + let bind = format!("{}:{}", r.bind.host, r.bind.port); + if r.kind == ForwardKind::Dynamic { + format!("{kind} {bind} {}", r.description) + .trim() + .to_string() + } else { + let target = format!("{}:{}", r.target.host, r.target.port); + format!("{kind} {bind} {target} {}", r.description) + .trim() + .to_string() + } + }) + .collect::>() + .join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn forwards_round_trip_through_text() { + let rules = vec![ + ForwardRule { + kind: ForwardKind::Local, + bind: HostPort::new("127.0.0.1", 8080), + target: HostPort::new("10.0.0.1", 80), + description: "web".to_string(), + }, + ForwardRule { + kind: ForwardKind::Dynamic, + bind: HostPort::new("127.0.0.1", 1080), + target: HostPort::default(), + description: String::new(), + }, + ]; + let text = forwards_text(&rules); + let parsed = parse_forwards(&text); + assert_eq!(parsed.len(), 2); + assert_eq!(parsed[0].kind, ForwardKind::Local); + assert_eq!(parsed[0].bind.port, 8080); + assert_eq!(parsed[0].target.host, "10.0.0.1"); + assert_eq!(parsed[0].description, "web"); + assert_eq!(parsed[1].kind, ForwardKind::Dynamic); + assert_eq!(parsed[1].bind.port, 1080); + } + + #[test] + fn parse_forwards_skips_malformed_lines() { + // Bad kind, and a Local rule missing its target — both skipped. + let parsed = parse_forwards("X 1:2 3:4\nL 127.0.0.1:9000\nR 0.0.0.0:80 10.0.0.2:8080"); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].kind, ForwardKind::Remote); + } + + #[test] + fn parse_host_port_handles_blank_and_ports() { + assert!(parse_host_port(" ").is_none()); + let hp = parse_host_port("example.com:2222").unwrap(); + assert_eq!(hp.host, "example.com"); + assert_eq!(hp.port, 2222); + // No colon → host only, port 0. + assert_eq!(parse_host_port("host").unwrap().port, 0); + } +} + +/// Build an `InputState` seeded with `value` (single- or multi-line). A free +/// function so `window` auto-reborrows cleanly at each call site. +fn seed_input( + window: &mut Window, + cx: &mut Context, + value: &str, + multi_line: bool, +) -> Entity { + let value = value.to_string(); + cx.new(|cx| { + InputState::new(window, cx) + .multi_line(multi_line) + .default_value(value) + }) +} + +impl Tty7App { + /// Open (or refocus) the profile editor page. `edit` jumps straight into the + /// edit form for that profile id; `prefill_target` opens a *new* profile + /// seeded from a QuickConnect string ("save as profile"). Passing both `None` + /// shows the list. + pub(crate) fn open_ssh_profiles_for( + &mut self, + edit: Option, + prefill_target: Option, + window: &mut Window, + cx: &mut Context, + ) { + // Close any competing overlay so only one page shows at a time. + if self.active_settings().is_some() { + self.close_settings(window, cx); + } + self.close_palette(window, cx); + + let focus_handle = cx.focus_handle(); + // Seed with placeholder inputs; the real ones are rebuilt when entering + // the edit view. The list view uses none of them. + let state = ProfileEditorState { + focus_handle: focus_handle.clone(), + editing: None, + carry_group: None, + carry_credential_ref: None, + show_jump: false, + show_forwards: false, + show_advanced: false, + name: seed_input(window, cx, "", false), + host: seed_input(window, cx, "", false), + port: seed_input(window, cx, "", false), + user: seed_input(window, cx, "", false), + auth: AuthMode::Auto, + jump: seed_input(window, cx, "", false), + forwards: seed_input(window, cx, "", false), + identity_files: seed_input(window, cx, "", false), + proxy_command: seed_input(window, cx, "", false), + socks: seed_input(window, cx, "", false), + http: seed_input(window, cx, "", false), + kex: seed_input(window, cx, "", false), + cipher: seed_input(window, cx, "", false), + mac: seed_input(window, cx, "", false), + hostkey: seed_input(window, cx, "", false), + compression: seed_input(window, cx, "", false), + keepalive_interval: seed_input(window, cx, "", false), + keepalive_count: seed_input(window, cx, "", false), + connect_timeout: seed_input(window, cx, "", false), + login_scripts: seed_input(window, cx, "", false), + agent_forward: false, + x11: false, + skip_banner: false, + use_system_ssh: false, + verify_host_keys: None, + warn_on_close: None, + _subs: Vec::new(), + }; + self.profiles_editor = Some(state); + + // Decide the initial view. + if let Some(id) = edit { + if let Some(profile) = cx + .global::() + .ssh_profiles + .iter() + .find(|p| p.id == id) + .cloned() + { + self.profile_editor_load(&profile, window, cx); + } + } else if let Some(target) = prefill_target { + let mut profile = SshProfile::new(String::new()); + if let Some(qc) = parse_quick_connect(&target) { + profile.port = qc.port_or_default(); + profile.host = qc.host; + if let Some(user) = qc.user { + profile.user = user; + } + if profile.name.is_empty() { + profile.name = profile.host.clone(); + } + } + // A brand-new id so a Save inserts rather than overwrites. + self.profile_editor_load(&profile, window, cx); + } + + window.focus(&focus_handle, cx); + cx.notify(); + } + + pub(crate) fn close_ssh_profiles(&mut self, window: &mut Window, cx: &mut Context) { + self.profiles_editor = None; + self.focus_active(window, cx); + cx.notify(); + } + + /// Build the edit-view inputs seeded from `profile` and switch to it. + fn profile_editor_load( + &mut self, + profile: &SshProfile, + window: &mut Window, + cx: &mut Context, + ) { + let jump_name = profile + .jump_host + .and_then(|id| { + cx.global::() + .ssh_profiles + .iter() + .find(|p| p.id == id) + .map(|p| p.name.clone()) + }) + .unwrap_or_default(); + + let Some(state) = self.profiles_editor.as_mut() else { + return; + }; + state.editing = Some(profile.id); + state.carry_group = profile.group.clone(); + state.carry_credential_ref = profile.credential_ref.clone(); + state.auth = profile.auth; + state.agent_forward = profile.agent_forward; + state.x11 = profile.x11; + state.skip_banner = profile.skip_banner; + state.use_system_ssh = profile.use_system_ssh; + state.verify_host_keys = profile.verify_host_keys; + state.warn_on_close = profile.warn_on_close; + state.show_jump = profile.jump_host.is_some(); + state.show_forwards = !profile.forwards.is_empty(); + state.show_advanced = false; + + state.name = seed_input(window, cx, &profile.name, false); + state.host = seed_input(window, cx, &profile.host, false); + state.port = seed_input(window, cx, &profile.port.to_string(), false); + state.user = seed_input(window, cx, &profile.user, false); + state.jump = seed_input(window, cx, &jump_name, false); + state.forwards = seed_input(window, cx, &forwards_text(&profile.forwards), true); + state.identity_files = seed_input(window, cx, &profile.identity_files.join("\n"), true); + state.proxy_command = seed_input( + window, + cx, + profile.proxy_command.as_deref().unwrap_or(""), + false, + ); + state.socks = seed_input(window, cx, &host_port_text(&profile.socks_proxy), false); + state.http = seed_input(window, cx, &host_port_text(&profile.http_proxy), false); + state.kex = seed_input(window, cx, &profile.algorithms.kex.join(", "), false); + state.cipher = seed_input(window, cx, &profile.algorithms.cipher.join(", "), false); + state.mac = seed_input(window, cx, &profile.algorithms.mac.join(", "), false); + state.hostkey = seed_input(window, cx, &profile.algorithms.hostkey.join(", "), false); + state.compression = seed_input( + window, + cx, + &profile.algorithms.compression.join(", "), + false, + ); + state.keepalive_interval = seed_input( + window, + cx, + &profile + .keepalive_interval_s + .map(|n| n.to_string()) + .unwrap_or_default(), + false, + ); + state.keepalive_count = seed_input( + window, + cx, + &profile + .keepalive_count_max + .map(|n| n.to_string()) + .unwrap_or_default(), + false, + ); + state.connect_timeout = seed_input( + window, + cx, + &profile + .connect_timeout_s + .map(|n| n.to_string()) + .unwrap_or_default(), + false, + ); + state.login_scripts = seed_input(window, cx, &profile.login_scripts.join("\n"), true); + cx.notify(); + } + + /// Read the edit form back into an [`SshProfile`], preserving the id and the + /// carried-over group / credential_ref. + fn profile_editor_collect(&self, cx: &App) -> Option { + let state = self.profiles_editor.as_ref()?; + let id = state.editing?; + let val = |e: &Entity| e.read(cx).value().trim().to_string(); + + let jump_name = val(&state.jump); + let jump_host = if jump_name.is_empty() { + None + } else { + cx.global::() + .ssh_profiles + .iter() + .find(|p| p.name == jump_name && p.id != id) + .map(|p| p.id) + }; + + Some(SshProfile { + id, + name: val(&state.name), + group: state.carry_group.clone(), + host: val(&state.host), + port: val(&state.port).parse().unwrap_or(22), + user: val(&state.user), + jump_host, + proxy_command: (!val(&state.proxy_command).is_empty()) + .then(|| val(&state.proxy_command)), + socks_proxy: parse_host_port(&val(&state.socks)), + http_proxy: parse_host_port(&val(&state.http)), + auth: state.auth, + identity_files: split_lines(&state.identity_files.read(cx).value()), + agent_forward: state.agent_forward, + credential_ref: state.carry_credential_ref.clone(), + forwards: parse_forwards(&state.forwards.read(cx).value()), + keepalive_interval_s: val(&state.keepalive_interval).parse().ok(), + keepalive_count_max: val(&state.keepalive_count).parse().ok(), + connect_timeout_s: val(&state.connect_timeout).parse().ok(), + warn_on_close: state.warn_on_close, + skip_banner: state.skip_banner, + login_scripts: split_lines(&state.login_scripts.read(cx).value()), + x11: state.x11, + algorithms: Algorithms { + kex: split_list(&state.kex.read(cx).value()), + cipher: split_list(&state.cipher.read(cx).value()), + mac: split_list(&state.mac.read(cx).value()), + hostkey: split_list(&state.hostkey.read(cx).value()), + compression: split_list(&state.compression.read(cx).value()), + }, + verify_host_keys: state.verify_host_keys, + use_system_ssh: state.use_system_ssh, + }) + } + + /// Save the edit form into `Config::ssh_profiles` (upsert by id). + pub(crate) fn save_editing_profile(&mut self, cx: &mut Context) -> Option { + let profile = self.profile_editor_collect(cx)?; + let id = profile.id; + self.update_config(cx, |cfg| { + if let Some(slot) = cfg.ssh_profiles.iter_mut().find(|p| p.id == id) { + *slot = profile; + } else { + cfg.ssh_profiles.push(profile); + } + }); + Some(id) + } + + /// Save and return to the list view. + pub(crate) fn save_and_back(&mut self, cx: &mut Context) { + self.save_editing_profile(cx); + if let Some(state) = self.profiles_editor.as_mut() { + state.editing = None; + } + cx.notify(); + } + + /// Save the current form, then connect the saved profile in a new tab. + pub(crate) fn save_and_connect_profile(&mut self, window: &mut Window, cx: &mut Context) { + if let Some(id) = self.save_editing_profile(cx) { + self.close_ssh_profiles(window, cx); + self.connect_ssh_profile(id, window, cx); + } + } + + /// Add a fresh blank profile and open it in the edit view. + pub(crate) fn add_new_profile(&mut self, window: &mut Window, cx: &mut Context) { + let profile = SshProfile::new(String::new()); + self.profile_editor_load(&profile, window, cx); + } + + /// Duplicate a saved profile (new id, "… (copy)" name) and edit the copy. + pub(crate) fn duplicate_profile( + &mut self, + id: Uuid, + window: &mut Window, + cx: &mut Context, + ) { + let Some(mut profile) = cx + .global::() + .ssh_profiles + .iter() + .find(|p| p.id == id) + .cloned() + else { + return; + }; + profile.id = Uuid::new_v4(); + profile.name = format!("{} (copy)", profile.name); + self.update_config(cx, |cfg| cfg.ssh_profiles.push(profile.clone())); + self.profile_editor_load(&profile, window, cx); + } + + /// Delete a saved profile and its frecency entry. + pub(crate) fn delete_profile(&mut self, id: Uuid, cx: &mut Context) { + self.update_config(cx, |cfg| { + cfg.ssh_profiles.retain(|p| p.id != id); + cfg.ssh_profile_frecency.remove(&id); + }); + if let Some(state) = self.profiles_editor.as_mut() { + if state.editing == Some(id) { + state.editing = None; + } + } + cx.notify(); + } + + /// Import `~/.ssh/config` aliases as profiles (idempotent upsert by name). + pub(crate) fn import_ssh_config_profiles(&mut self, cx: &mut Context) { + let imported = crate::core::ssh_config::import_profiles(); + if imported.is_empty() { + return; + } + self.update_config(cx, |cfg| { + crate::core::ssh_config::merge_imported(&mut cfg.ssh_profiles, imported); + }); + cx.notify(); + } + + /// Copy a saved profile's `user@host:port` to the clipboard (FR-P5). + pub(crate) fn copy_profile_connect_string(&mut self, id: Uuid, cx: &mut Context) { + if let Some(profile) = cx + .global::() + .ssh_profiles + .iter() + .find(|p| p.id == id) + { + let s = to_connect_string(profile); + cx.write_to_clipboard(gpui::ClipboardItem::new_string(s)); + } + } + + // ── Rendering ──────────────────────────────────────────────────────────── + + pub(crate) fn render_profile_editor(&self, cx: &mut Context) -> AnyElement { + let theme = cx.theme(); + let background = theme.background; + let foreground = theme.foreground; + let Some(state) = self.profiles_editor.as_ref() else { + return div().into_any_element(); + }; + + let content = match state.editing { + None => self.render_profile_list(cx), + Some(_) => self.render_profile_form(state, cx), + }; + + let content_pane = v_flex() + .id("profiles-content") + .flex_1() + .h_full() + .bg(background) + .overflow_y_scroll() + .child( + div() + .px_10() + .py_8() + .child(div().w_full().max_w(px(860.)).child(content)), + ); + + div() + .size_full() + .relative() + .flex() + .flex_col() + .bg(background) + .text_color(foreground) + .track_focus(&state.focus_handle) + .on_key_down(cx.listener(|this, ev: &KeyDownEvent, window, cx| { + if ev.keystroke.key.as_str() == "escape" { + // From the edit view, Esc steps back to the list; from the + // list, it closes the page. + let in_edit = this + .profiles_editor + .as_ref() + .is_some_and(|s| s.editing.is_some()); + if in_edit { + if let Some(s) = this.profiles_editor.as_mut() { + s.editing = None; + } + cx.notify(); + } else { + this.close_ssh_profiles(window, cx); + } + } + })) + .child( + div() + .pt(px(crate::ui::app::TITLE_BAR_HEIGHT)) + .child(content_pane), + ) + // Restore the window drag region the overlay covers. + .child({ + let should_move = Rc::new(Cell::new(false)); + div() + .id("profiles-titlebar-drag") + .absolute() + .top_0() + .left_0() + .right_0() + .h(px(crate::ui::app::TITLE_BAR_HEIGHT)) + .window_control_area(WindowControlArea::Drag) + .on_mouse_down(MouseButton::Left, { + let should_move = should_move.clone(); + move |_, _, _| should_move.set(true) + }) + .on_mouse_up(MouseButton::Left, { + let should_move = should_move.clone(); + move |_, _, _| should_move.set(false) + }) + .on_mouse_move(move |_, window, _| { + if should_move.replace(false) { + window.start_window_move(); + } + }) + .on_double_click(|_, window, _| window.titlebar_double_click()) + }) + .child( + div().absolute().top(px(6.)).right(px(10.)).occlude().child( + Button::new("profiles-close") + .icon(IconName::Close) + .ghost() + .small() + .on_click( + cx.listener(|this, _, window, cx| this.close_ssh_profiles(window, cx)), + ), + ), + ) + .into_any_element() + } + + /// The list view: header + import/add controls + one row per saved profile. + fn render_profile_list(&self, cx: &mut Context) -> AnyElement { + let theme = cx.theme(); + let muted = theme.muted_foreground; + let border = theme.border; + let profiles = cx.global::().ssh_profiles.clone(); + + let header = h_flex() + .items_center() + .justify_between() + .child(self.section_header("SSH Profiles", cx)) + .child( + h_flex() + .gap_2() + .child( + Button::new("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("profiles-add") + .label("Add Profile") + .primary() + .small() + .on_click( + cx.listener(|this, _, window, cx| this.add_new_profile(window, cx)), + ), + ), + ); + + let mut list = v_flex().gap_1().w_full(); + if profiles.is_empty() { + list = list.child( + div() + .py_8() + .text_color(muted) + .child("No saved profiles yet. Add one, or import from ~/.ssh/config."), + ); + } + for p in &profiles { + let id = p.id; + let subtitle = to_connect_string(p); + let title = if p.name.is_empty() { + subtitle.clone() + } else { + p.name.clone() + }; + list = list.child( + h_flex() + .id(("profile-row", id.as_u128() as usize)) + .items_center() + .justify_between() + .w_full() + .py_2() + .px_2() + .rounded_md() + .border_b_1() + .border_color(border) + .child( + v_flex() + .gap_0p5() + .child(div().child(title)) + .child(div().text_xs().text_color(muted).child(subtitle)), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new(("prof-connect", id.as_u128() as usize)) + .label("Connect") + .primary() + .small() + .on_click(cx.listener(move |this, _, window, cx| { + this.close_ssh_profiles(window, cx); + this.connect_ssh_profile(id, window, cx); + })), + ) + .child( + Button::new(("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.profile_editor_load(&profile, window, cx); + } + })), + ) + .child( + Button::new(("prof-copy", id.as_u128() as usize)) + .label("Copy") + .ghost() + .small() + .on_click(cx.listener(move |this, _, _w, cx| { + this.copy_profile_connect_string(id, cx) + })), + ) + .child( + Button::new(("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(("prof-del", id.as_u128() as usize)) + .label("Delete") + .ghost() + .small() + .on_click(cx.listener(move |this, _, _w, cx| { + this.delete_profile(id, cx) + })), + ), + ), + ); + } + + v_flex() + .gap_4() + .child(header) + .child(self.section_rule(cx)) + .child(list) + .into_any_element() + } + + /// The edit view: four core fields + collapsible jump/forwards/advanced. + fn render_profile_form( + &self, + state: &ProfileEditorState, + cx: &mut Context, + ) -> AnyElement { + let auth_idx = match state.auth { + AuthMode::Auto => 0, + AuthMode::Password => 1, + AuthMode::PublicKey => 2, + AuthMode::Agent => 3, + AuthMode::KeyboardInteractive => 4, + }; + let header = h_flex() + .items_center() + .justify_between() + .child( + Button::new("prof-back") + .label("‹ Back") + .ghost() + .small() + .on_click(cx.listener(|this, _, _w, cx| { + if let Some(s) = this.profiles_editor.as_mut() { + s.editing = None; + } + cx.notify(); + })), + ) + .child( + h_flex() + .gap_2() + .child( + Button::new("prof-form-connect") + .label("Connect") + .outline() + .small() + .on_click(cx.listener(|this, _, window, cx| { + this.save_and_connect_profile(window, cx) + })), + ) + .child( + Button::new("prof-form-save") + .label("Save") + .primary() + .small() + .on_click(cx.listener(|this, _, _w, cx| this.save_and_back(cx))), + ), + ); + + // Core fields. + let core = v_flex() + .gap_3() + .child(self.settings_row( + "Name", + "A label for this connection.", + Input::new(&state.name).small().into_any_element(), + cx, + )) + .child( + self.settings_row( + "Host", + "Hostname or IP address.", + h_flex() + .gap_2() + .child(Input::new(&state.host).small()) + .child(div().w(px(80.)).child(Input::new(&state.port).small())) + .into_any_element(), + cx, + ), + ) + .child(self.settings_row( + "User", + "Login user (blank = resolve at connect).", + Input::new(&state.user).small().into_any_element(), + cx, + )) + .child(self.settings_row( + "Auth", + "Authentication method. Auto tries every applicable method.", + self.segmented( + "prof-auth", + &["Auto", "Password", "Key", "Agent", "2FA"], + auth_idx, + cx, + |this, ix, _w, cx| { + if let Some(s) = this.profiles_editor.as_mut() { + s.auth = match ix { + 0 => AuthMode::Auto, + 1 => AuthMode::Password, + 2 => AuthMode::PublicKey, + 3 => AuthMode::Agent, + _ => AuthMode::KeyboardInteractive, + }; + cx.notify(); + } + }, + ), + cx, + )); + + v_flex() + .gap_4() + .child(header) + .child(self.section_rule(cx)) + .child(core) + .child(self.render_profile_jump_section(state, cx)) + .child(self.render_profile_forwards_section(state, cx)) + .child(self.render_profile_advanced_section(state, cx)) + .into_any_element() + } + + /// A collapsible section header (▸/▾ label + summary), toggling `open`. + fn disclosure_header( + &self, + id: &'static str, + label: &str, + summary: &str, + open: bool, + cx: &mut Context, + on_toggle: impl Fn(&mut Self, &mut Context) + 'static, + ) -> AnyElement { + let muted = cx.theme().muted_foreground; + let caret = if open { "▾" } else { "▸" }; + h_flex() + .id(id) + .items_center() + .gap_2() + .py_2() + .cursor_pointer() + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, _w, cx| on_toggle(this, cx)), + ) + .child(div().text_color(muted).child(caret.to_string())) + .child( + div() + .font_weight(gpui::FontWeight::MEDIUM) + .child(label.to_string()), + ) + .child(div().text_xs().text_color(muted).child(summary.to_string())) + .into_any_element() + } + + fn render_profile_jump_section( + &self, + state: &ProfileEditorState, + cx: &mut Context, + ) -> AnyElement { + let summary = { + let name = state.jump.read(cx).value().trim().to_string(); + if name.is_empty() { + "(none)".to_string() + } else { + name + } + }; + let mut section = v_flex().child(self.disclosure_header( + "prof-sec-jump", + "Jump host", + &summary, + state.show_jump, + cx, + |this, cx| { + if let Some(s) = this.profiles_editor.as_mut() { + s.show_jump = !s.show_jump; + cx.notify(); + } + }, + )); + if state.show_jump { + section = section.child(self.settings_row( + "Jump host", + "Name of another profile to tunnel through (blank = direct).", + Input::new(&state.jump).small().into_any_element(), + cx, + )); + } + section.into_any_element() + } + + fn render_profile_forwards_section( + &self, + state: &ProfileEditorState, + cx: &mut Context, + ) -> AnyElement { + let count = parse_forwards(&state.forwards.read(cx).value()).len(); + let mut section = v_flex().child(self.disclosure_header( + "prof-sec-fwd", + "Port forwards", + &format!("({count})"), + state.show_forwards, + cx, + |this, cx| { + if let Some(s) = this.profiles_editor.as_mut() { + s.show_forwards = !s.show_forwards; + cx.notify(); + } + }, + )); + if state.show_forwards { + section = section + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child( + "One rule per line: L|R|D bind_host:port target_host:port [description]. Dynamic (D) omits the target.", + ), + ) + .child(div().w_full().child(Input::new(&state.forwards).small())); + } + section.into_any_element() + } + + fn render_profile_advanced_section( + &self, + state: &ProfileEditorState, + cx: &mut Context, + ) -> AnyElement { + let mut section = v_flex().child(self.disclosure_header( + "prof-sec-adv", + "Advanced", + "algorithms / keepalive / proxies / X11 / login scripts / compat mode", + state.show_advanced, + cx, + |this, cx| { + if let Some(s) = this.profiles_editor.as_mut() { + s.show_advanced = !s.show_advanced; + cx.notify(); + } + }, + )); + if !state.show_advanced { + return section.into_any_element(); + } + + let text_row = |this: &Self, + label: &str, + desc: &str, + input: &Entity, + cx: &mut Context| { + this.settings_row( + label.to_string(), + desc.to_string(), + Input::new(input).small().into_any_element(), + cx, + ) + }; + + // Verify host keys tri-state (Default / On / Off). + let vhk_idx = match state.verify_host_keys { + None => 0, + Some(true) => 1, + Some(false) => 2, + }; + let woc_idx = match state.warn_on_close { + None => 0, + Some(true) => 1, + Some(false) => 2, + }; + + section = section + .child(text_row( + self, + "Identity files", + "Private-key paths, one per line (%h/%r expand).", + &state.identity_files, + cx, + )) + .child( + self.settings_row( + "Agent forwarding", + "Forward the local ssh-agent to the session.", + Switch::new("prof-agent") + .checked(state.agent_forward) + .on_click(cx.listener(|this, on: &bool, _w, cx| { + if let Some(s) = this.profiles_editor.as_mut() { + s.agent_forward = *on; + cx.notify(); + } + })) + .into_any_element(), + cx, + ), + ) + .child(text_row( + self, + "ProxyCommand", + "Transport command (%h/%p/%r substituted).", + &state.proxy_command, + cx, + )) + .child(text_row( + self, + "SOCKS5 proxy", + "host:port (blank = none).", + &state.socks, + cx, + )) + .child(text_row( + self, + "HTTP proxy", + "host:port (blank = none).", + &state.http, + cx, + )) + .child(text_row( + self, + "KEX algorithms", + "Comma-separated (blank = library default).", + &state.kex, + cx, + )) + .child(text_row( + self, + "Ciphers", + "Comma-separated (blank = default).", + &state.cipher, + cx, + )) + .child(text_row( + self, + "MACs", + "Comma-separated (blank = default).", + &state.mac, + cx, + )) + .child(text_row( + self, + "Host-key algorithms", + "Comma-separated (blank = default).", + &state.hostkey, + cx, + )) + .child(text_row( + self, + "Compression", + "Comma-separated (blank = default).", + &state.compression, + cx, + )) + .child(text_row( + self, + "Keepalive interval (s)", + "Blank = library default.", + &state.keepalive_interval, + cx, + )) + .child(text_row( + self, + "Keepalive count max", + "Missed keepalives before dead.", + &state.keepalive_count, + cx, + )) + .child(text_row( + self, + "Connect timeout (s)", + "Blank = library default.", + &state.connect_timeout, + cx, + )) + .child( + self.settings_row( + "X11 forwarding", + "Request X11 forwarding (needs XQuartz on macOS).", + Switch::new("prof-x11") + .checked(state.x11) + .on_click(cx.listener(|this, on: &bool, _w, cx| { + if let Some(s) = this.profiles_editor.as_mut() { + s.x11 = *on; + cx.notify(); + } + })) + .into_any_element(), + cx, + ), + ) + .child(text_row( + self, + "Login scripts", + "Commands sent after the shell opens, one per line.", + &state.login_scripts, + cx, + )) + .child( + self.settings_row( + "Skip banner", + "Suppress the server login banner.", + Switch::new("prof-banner") + .checked(state.skip_banner) + .on_click(cx.listener(|this, on: &bool, _w, cx| { + if let Some(s) = this.profiles_editor.as_mut() { + s.skip_banner = *on; + cx.notify(); + } + })) + .into_any_element(), + cx, + ), + ) + .child(self.settings_row( + "Verify host keys", + "Override the global known_hosts check for this profile.", + self.segmented( + "prof-vhk", + &["Default", "On", "Off"], + vhk_idx, + cx, + |this, ix, _w, cx| { + if let Some(s) = this.profiles_editor.as_mut() { + s.verify_host_keys = match ix { + 1 => Some(true), + 2 => Some(false), + _ => None, + }; + cx.notify(); + } + }, + ), + cx, + )) + .child(self.settings_row( + "Warn on close", + "Override the global confirm-before-closing for this profile.", + self.segmented( + "prof-woc", + &["Default", "On", "Off"], + woc_idx, + cx, + |this, ix, _w, cx| { + if let Some(s) = this.profiles_editor.as_mut() { + s.warn_on_close = match ix { + 1 => Some(true), + 2 => Some(false), + _ => None, + }; + cx.notify(); + } + }, + ), + cx, + )) + .child( + self.settings_row( + "System ssh compat mode", + "Connect via the system `ssh` binary instead of the native engine. \ + SFTP, GUI auth, and the credential vault are disabled for this profile.", + Switch::new("prof-compat") + .checked(state.use_system_ssh) + .on_click(cx.listener(|this, on: &bool, _w, cx| { + if let Some(s) = this.profiles_editor.as_mut() { + s.use_system_ssh = *on; + cx.notify(); + } + })) + .into_any_element(), + cx, + ), + ); + section.into_any_element() + } +} diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 15b3fc9b..1904df33 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -618,7 +618,7 @@ impl Tty7App { /// A bold section header that introduces a group of settings. /// With no cards, the header *is* the unit of grouping — it tells the eye /// where one set of related controls begins. - fn section_header(&self, title: &str, cx: &Context) -> Div { + pub(crate) fn section_header(&self, title: &str, cx: &Context) -> Div { self.header_text(title, cx).mb_4() } @@ -643,7 +643,7 @@ impl Tty7App { /// A full-width hairline between sections, so the page reads as one /// continuous sheet rather than stacked boxes. - fn section_rule(&self, cx: &Context) -> Div { + pub(crate) fn section_rule(&self, cx: &Context) -> Div { div().h(px(1.)).my_7().bg(cx.theme().border) } @@ -652,7 +652,7 @@ impl Tty7App { /// column (not space-between) keeps label and control visually paired /// regardless of window width — space-between on a wide pane stretched the /// two apart into a dead gap. - fn settings_row( + pub(crate) fn settings_row( &self, label: impl Into, desc: impl Into, @@ -1051,6 +1051,12 @@ impl Tty7App { .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_verify_host_keys(*on, cx))) .into_any_element(); + let warn_on_close = cx.global::().ssh_warn_on_close; + let warn_switch = Switch::new("ssh-warn-on-close") + .checked(warn_on_close) + .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_ssh_warn_on_close(*on, cx))) + .into_any_element(); + let mut list = v_flex().gap_1().w_full(); if self.known_hosts.is_empty() { list = list.child( @@ -1105,6 +1111,13 @@ impl Tty7App { verify_switch, cx, )) + .child(self.settings_row( + "Warn before closing", + "Ask for confirmation before closing a tab or pane with a live SSH \ + session. A profile can override this.", + warn_switch, + cx, + )) .child(self.section_rule(cx)) .child(self.section_intro( "Known hosts", diff --git a/src/ui/ssh_connect.rs b/src/ui/ssh_connect.rs index 71e94208..6d21dd7e 100644 --- a/src/ui/ssh_connect.rs +++ b/src/ui/ssh_connect.rs @@ -10,10 +10,10 @@ //! profile store — everything it needs rides this spec once, over the local socket //! (secrets redacted in `Debug`; see `NativeSshSpec`). //! -//! WS6 wires the UI entry points (palette connect, profile editor) that call -//! [`Tty7App::native_ssh_spec_for_profile`]; until then this is exercised by the -//! unit tests and reachable internally. -#![allow(dead_code)] // the spec-builder is consumed by WS6's connect UI; tests cover it now +//! WS6 wires the UI entry points to this module: the palette connect flow, the +//! profile editor, QuickConnect, and the reconnect/restore paths all resolve +//! their specs through here (see [`Tty7App::connect_ssh_profile`], +//! [`Tty7App::quick_connect`], and [`resolve_persisted_ssh_spec`]). use std::collections::{HashMap, HashSet}; @@ -48,6 +48,184 @@ impl Tty7App { cfg.verify_host_keys, ) } + + /// Connect a saved profile (PRD FR-P3). Honors the per-profile + /// `use_system_ssh` compat-mode flag (FR-C5): flagged profiles go through the + /// frozen shell-out path (no SFTP / GUI auth / vault); everything else takes + /// the native russh path. Bumps the profile's frecency either way. + pub(crate) fn connect_ssh_profile( + &mut self, + profile_id: uuid::Uuid, + window: &mut gpui::Window, + cx: &mut gpui::Context, + ) { + let Some(profile) = cx + .global::() + .ssh_profiles + .iter() + .find(|p| p.id == profile_id) + .cloned() + else { + return; + }; + self.bump_ssh_frecency(profile_id, cx); + if profile.use_system_ssh { + let spec = compat_ssh_spec(&profile, &cx.global::().ssh_profiles); + self.open_managed_ssh_spec(spec, window, cx); + } else { + let spec = Box::new(self.native_ssh_spec_for_profile(&profile, cx)); + self.open_native_ssh_tab(spec, window, cx); + } + } + + /// QuickConnect to a typed `user@host[:port]` target (PRD FR-P4), always via + /// the native path. Builds a transient profile so keychain lookup by endpoint + /// still applies (a QuickConnect can reuse a remembered password). + pub(crate) fn quick_connect( + &mut self, + qc: crate::core::ssh_profile::QuickConnect, + window: &mut gpui::Window, + cx: &mut gpui::Context, + ) { + let port = qc.port_or_default(); + let mut profile = SshProfile::new(qc.host.clone()); + profile.host = qc.host; + profile.port = port; + if let Some(user) = qc.user { + profile.user = user; + } + let spec = Box::new(self.native_ssh_spec_for_profile(&profile, cx)); + self.open_native_ssh_tab(spec, window, cx); + } + + /// Reconnect the focused native-SSH pane after it dropped (PRD FR-E4). A + /// no-op unless the focused pane is a *dead* native-SSH pane. Re-resolves + /// credentials from the saved profile when the pane's persisted spec names one + /// (`profile_id`), otherwise reuses the secret-free spec and lets the auth + /// sheets fill in. Respawns in the same tab/split slot; the daemon rebuilds + /// the profile's preconfigured forwards on connect. + pub(crate) fn restart_ssh_session( + &mut self, + window: &mut gpui::Window, + cx: &mut gpui::Context, + ) { + let Some(view) = self.focused_pane_view(window, cx) else { + return; + }; + let dead_spec = { + let v = view.read(cx); + if !v.ssh_disconnected() { + return; + } + v.ssh_spec() + }; + let Some(spec) = dead_spec else { + return; + }; + let resolved = self.resolve_restart_spec(spec, cx); + self.respawn_native_ssh_in_place(&view, resolved, window, cx); + } + + /// If the persisted (secret-free) spec names a saved profile that still + /// exists, rebuild it from the profile so keychain secrets are re-applied; + /// otherwise return the spec unchanged (the auth sheets will prompt). + fn resolve_restart_spec( + &self, + spec: Box, + cx: &gpui::App, + ) -> Box { + resolve_persisted_ssh_spec(spec, cx) + } + + /// The focused pane's terminal view, if any. + fn focused_pane_view( + &self, + window: &gpui::Window, + cx: &gpui::App, + ) -> Option> { + self.tabs + .get(self.active)? + .pane + .focused_or_first(window, cx) + } + + /// Record a connect against a profile's frecency stats (FR-P3). + fn bump_ssh_frecency(&mut self, profile_id: uuid::Uuid, cx: &mut gpui::Context) { + self.update_config(cx, |cfg| { + let entry = cfg.ssh_profile_frecency.entry(profile_id).or_default(); + entry.count = entry.count.saturating_add(1); + entry.last_used = crate::core::config::unix_now(); + }); + } +} + +/// Re-resolve a persisted (secret-free) [`NativeSshSpec`] for reconnection +/// (FR-E4/C2). When the spec names a saved profile that still exists, rebuild it +/// from that profile so keychain secrets are re-applied; otherwise return the +/// spec unchanged and let the in-pane auth sheets prompt. A free function so both +/// the in-place reconnect and session-restore (which has no `Tty7App` yet) share +/// it. +pub(crate) fn resolve_persisted_ssh_spec( + spec: Box, + cx: &gpui::App, +) -> Box { + let cfg = cx.global::(); + let profile = spec + .profile_id + .as_deref() + .and_then(|s| uuid::Uuid::parse_str(s).ok()) + .and_then(|id| cfg.ssh_profiles.iter().find(|p| p.id == id).cloned()); + match profile { + Some(p) => Box::new(build_native_ssh_spec( + &p, + &cfg.ssh_profiles, + &OsCredentialStore, + cfg.verify_host_keys, + )), + None => spec, + } +} + +/// Build a compat-mode [`SshSpec`] (system `ssh` shell-out) from a profile +/// (PRD FR-C5). A best-effort mapping of the common fields — the compat path is +/// frozen (PRD §3.1), so exotic options aren't threaded through here; users who +/// need them keep them in `~/.ssh/config`. +fn compat_ssh_spec( + profile: &SshProfile, + profiles: &[SshProfile], +) -> crate::daemon::protocol::SshSpec { + let target = if profile.user.is_empty() { + profile.host.clone() + } else { + format!("{}@{}", profile.user, profile.host) + }; + let mut args: Vec = Vec::new(); + if profile.port != 22 { + args.push("-p".to_string()); + args.push(profile.port.to_string()); + } + for id in profile.expanded_identity_files() { + args.push("-i".to_string()); + args.push(id); + } + // A jump host resolves to a `-J user@host` hop (single level; deeper chains + // are rare in compat mode and left to ssh_config). + if let Some(jump) = profile + .jump_host + .and_then(|id| profiles.iter().find(|p| p.id == id)) + { + let hop = if jump.user.is_empty() { + jump.host.clone() + } else { + format!("{}@{}", jump.user, jump.host) + }; + args.push("-J".to_string()); + args.push(hop); + } + if profile.agent_forward { + args.push("-A".to_string()); + } + crate::daemon::protocol::SshSpec { target, args } } /// Build a [`NativeSshSpec`] from `profile`, resolving keychain secrets via @@ -296,6 +474,34 @@ mod tests { assert!(!build_native_ssh_spec(&p, &[], &store, true).verify_host_keys); } + #[test] + fn compat_ssh_spec_maps_common_fields() { + let bastion = profile("bastion", "bastion.example.com", "jump"); + let mut p = profile("web", "10.0.0.5", "deploy"); + p.port = 2222; + p.identity_files = vec!["~/.ssh/id_ed25519".to_string()]; + p.agent_forward = true; + p.jump_host = Some(bastion.id); + let profiles = vec![bastion.clone(), p.clone()]; + + let spec = compat_ssh_spec(&p, &profiles); + assert_eq!(spec.target, "deploy@10.0.0.5"); + assert!(spec.args.windows(2).any(|w| w == ["-p", "2222"])); + assert!(spec.args.iter().any(|a| a == "-i")); + assert!( + spec.args + .windows(2) + .any(|w| w == ["-J", "jump@bastion.example.com"]) + ); + assert!(spec.args.iter().any(|a| a == "-A")); + // A default-port, user-less profile omits `-p` and `user@`. + let mut bare = profile("bare", "host", ""); + bare.port = 22; + let spec = compat_ssh_spec(&bare, &[]); + assert_eq!(spec.target, "host"); + assert!(!spec.args.iter().any(|a| a == "-p")); + } + #[test] fn maps_proxy_precedence_command_over_socks_over_http() { let store = InMemoryCredentialStore::new(); diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index 1456d01b..8e356887 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -76,6 +76,8 @@ impl Tty7App { for (i, tab) in self.tabs.iter().enumerate() { let is_active = i == active; let label = self.tab_label(tab, i, cx); + // SSH status dot (PRD FR-E2). + let ssh_dot = self.tab_ssh_dot(tab, cx); // Filter by the search box; matching is on the visible label. The row // keeps its real index `i`, so activate/close/move still hit the right // tab even when the list is narrowed. @@ -180,6 +182,10 @@ impl Tty7App { this.activate(i, window, cx); }), ) + // Leading SSH status dot when this tab hosts an SSH session. + .when_some(ssh_dot, |c, color| { + c.child(div().flex_shrink_0().size(px(6.)).rounded_full().bg(color)) + }) .child(label_region) // Trailing slot: while the shortcut hints are armed it shows the // row's ⌘N switch digit; otherwise the close affordance — always diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index 43d98a9d..1b6bff5c 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -299,6 +299,8 @@ impl Tty7App { } let is_active = i == active; let label = self.tab_label(tab, i, cx); + // SSH status dot (PRD FR-E2): coloured by the pane's connection phase. + let ssh_dot = self.tab_ssh_dot(tab, cx); // Inline rename input for this tab, if it's the one being renamed. let rename_input = self @@ -423,6 +425,10 @@ impl Tty7App { this.activate(i, window, cx); }), ) + // Leading SSH status dot when this tab hosts an SSH session. + .when_some(ssh_dot, |c, color| { + c.child(div().flex_shrink_0().size(px(6.)).rounded_full().bg(color)) + }) // Clickable / editable label region. No leading context glyph — // the label carries the whole chip, so a row of tabs reads as // plain text rather than icon-per-chip busy.