mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 16:02:24 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
@@ -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| {
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user