feat(ssh): UX integration — native connect, palette entry, profile editor, session UX (WS6)

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.
This commit is contained in:
l0ng-ai
2026-07-14 02:15:04 +08:00
parent a0ef990949
commit e2610f9044
14 changed files with 2370 additions and 60 deletions
+4
View File
@@ -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
+96
View File
@@ -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<uuid::Uuid, ProfileUsage>,
}
/// 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 =
+53
View File
@@ -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<ShellSpec>,
/// 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<Box<crate::daemon::protocol::NativeSshSpec>>,
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<crate::daemon::protocol::NativeSshSpec>,
working_directory: Option<std::path::PathBuf>,
window: &mut Window,
cx: &mut Context<Self>,
) -> anyhow::Result<Self> {
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<Box<crate::daemon::protocol::NativeSshSpec>> {
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<crate::daemon::protocol::SshPhase> {
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<Self>) {
// Surface a child-exit/daemon-disconnect noticed by the reader thread into
// the field the view reads directly (`self.terminal.exited`).
+322 -19
View File
@@ -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<crate::daemon::protocol::KnownHostEntry>,
/// `Some` while the SSH profile editor page is open (a full-window overlay
/// like Settings; see `ui::profile_editor`).
pub(crate) profiles_editor: Option<crate::ui::profile_editor::ProfileEditorState>,
/// In-pane "confirm close of a live SSH session" state (PRD FR-E3): the close
/// action awaiting confirmation, or `None` when no prompt is up.
pub(crate) ssh_close_confirm: Option<SshCloseKind>,
}
/// 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<Self>, mutate: impl FnOnce(&mut Config)) {
pub(crate) fn update_config(
&mut self,
cx: &mut Context<Self>,
mutate: impl FnOnce(&mut Config),
) {
let cfg = cx.global_mut::<Config>();
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>) {
self.update_config(cx, |cfg| cfg.ssh_warn_on_close = on);
}
/// Re-fetch the daemon's `known_hosts` entries for the settings section.
pub(crate) fn refresh_known_hosts(&mut self, cx: &mut Context<Self>) {
self.known_hosts = crate::terminal::RemoteTerminal::list_known_hosts();
@@ -1125,7 +1152,12 @@ impl Tty7App {
cx.notify();
}
fn open_managed_ssh_spec(&mut self, ssh: SshSpec, window: &mut Window, cx: &mut Context<Self>) {
pub(crate) fn open_managed_ssh_spec(
&mut self,
ssh: SshSpec,
window: &mut Window,
cx: &mut Context<Self>,
) {
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<crate::daemon::protocol::NativeSshSpec>,
window: &mut Window,
cx: &mut Context<Self>,
) {
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<TerminalView>,
spec: Box<crate::daemon::protocol::NativeSshSpec>,
window: &mut Window,
cx: &mut Context<Self>,
) {
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<Self>) {
// 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<Self>) {
// 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<Command> {
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::<Config>();
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<Self>) {
pub(crate) fn close_palette(&mut self, window: &mut Window, cx: &mut Context<Self>) {
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<gpui::Hsla> {
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<TerminalView>, 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::<Config>();
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<Self>) {
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>) {
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<std::path::PathBuf>,
spec: Box<crate::daemon::protocol::NativeSshSpec>,
window: &mut Window,
cx: &mut Context<Tty7App>,
) -> Entity<TerminalView> {
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<Vec<String>, ()> {
let mut words = Vec::new();
let mut current = String::new();
+154 -1
View File
@@ -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<TerminalView>,
cx: &mut Context<Self>,
) -> Option<AnyElement> {
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<Self>,
) -> Option<AnyElement> {
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,
+8
View File
@@ -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<KeyBinding> {
"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,
})
+1
View File
@@ -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;
+164 -33
View File
@@ -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<SshProfile>),
/// 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<CommandKind> {
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<String>,
pub kind: CommandKind,
}
impl Command {
fn new(title: impl Into<String>, kind: CommandKind) -> Self {
pub fn new(title: impl Into<String>, 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<String>) -> 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<SshProfile>) -> Vec<Command> {
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<Command>,
input: Option<PaletteInput>,
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<IndexPath>,
@@ -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<Command>) -> 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<Command> {
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<CommandKind> {
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<CommandKind> {
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<Command> = 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<Command>, window: &mut Window, cx: &mut Context<Self>) -> 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<Command>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Entity<ListState<PaletteDelegate>> {
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<CommandKind> {
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| {
+30
View File
@@ -162,6 +162,26 @@ impl<L: Clone> Pane<L> {
}
}
/// 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<Entity<TerminalView>> {
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<TerminalView>,
new: Entity<TerminalView>,
) -> 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))
File diff suppressed because it is too large Load Diff
+16 -3
View File
@@ -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<Self>) -> Div {
pub(crate) fn section_header(&self, title: &str, cx: &Context<Self>) -> 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<Self>) -> Div {
pub(crate) fn section_rule(&self, cx: &Context<Self>) -> 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<String>,
desc: impl Into<String>,
@@ -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::<Config>().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",
+210 -4
View File
@@ -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<Self>,
) {
let Some(profile) = cx
.global::<Config>()
.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::<Config>().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<Self>,
) {
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<Self>,
) {
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<crate::daemon::protocol::NativeSshSpec>,
cx: &gpui::App,
) -> Box<crate::daemon::protocol::NativeSshSpec> {
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<gpui::Entity<crate::terminal::view::TerminalView>> {
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>) {
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<crate::daemon::protocol::NativeSshSpec>,
cx: &gpui::App,
) -> Box<crate::daemon::protocol::NativeSshSpec> {
let cfg = cx.global::<Config>();
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<String> = 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();
+6
View File
@@ -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
+6
View File
@@ -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.