diff --git a/docs/superpowers/plans/2026-05-15-ssh-support.md b/docs/superpowers/plans/2026-05-15-ssh-support.md new file mode 100644 index 00000000..733edfd8 --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-ssh-support.md @@ -0,0 +1,2129 @@ +# SSH Support Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add native SSH as a first-class `WorkspaceEnv` variant, providing terminal PTY and remote FS (SFTP) over russh. + +**Architecture:** `WorkspaceEnv` gains an `Ssh { profile_id }` variant. A new `ssh` module owns a `russh` session per profile, shared between a PTY channel (wired to the existing `on_data`/`on_exit` tauri channel interface) and an `SftpSession` (wired to all `fs::*` commands). Profiles are stored in `tauri-plugin-store`; passphrases in the OS keyring via the existing `secrets` module. + +**Tech Stack:** Rust (russh 0.45, russh-sftp 2, ssh-key 0.6, tokio), tauri-plugin-store, TypeScript/React (Zustand, existing component library) + +--- + +## File Map + +### New files +| Path | Responsibility | +|---|---| +| `src-tauri/src/modules/ssh/mod.rs` | `SshState`, all tauri SSH commands | +| `src-tauri/src/modules/ssh/connection.rs` | `SshConn` struct (handle + sftp session) | +| `src-tauri/src/modules/ssh/handler.rs` | `russh::ClientHandler` impl (host key TOFU, stored fingerprint check) | +| `src-tauri/src/modules/ssh/profiles.rs` | Profile CRUD via `tauri-plugin-store` | +| `src-tauri/src/modules/ssh/pty.rs` | `open_ssh_pty_channel()` — opens shell channel, wires `on_data`/`on_exit` | +| `src-tauri/src/modules/ssh/sftp.rs` | SFTP wrappers matching `fs::*` return types | +| `src/modules/ssh/types.ts` | `SshProfile` TypeScript type | +| `src/modules/ssh/commands.ts` | `invoke` wrappers for SSH tauri commands | +| `src/modules/ssh/store.ts` | Zustand store for profiles + connection state | +| `src/modules/ssh/index.ts` | Re-exports | +| `src/modules/ssh/components/FingerprintDialog.tsx` | TOFU confirmation modal | +| `src/modules/ssh/components/SshProfilesSettings.tsx` | Settings tab: profile list + create/edit form | + +### Modified files +| Path | Change | +|---|---| +| `src-tauri/Cargo.toml` | Add russh, russh-sftp, ssh-key, uuid deps | +| `src-tauri/src/modules/mod.rs` | `pub mod ssh;` | +| `src-tauri/src/modules/workspace.rs` | Add `Ssh { profile_id: String }` variant | +| `src-tauri/src/modules/pty/mod.rs` | Store `PtyHandle` enum; branch on SSH in all 4 commands | +| `src-tauri/src/modules/pty/session.rs` | Add `SshPtySession` struct; rename existing struct `LocalSession` | +| `src-tauri/src/modules/fs/file.rs` | SSH branch in `fs_read_file`, `fs_write_file`, `fs_stat` | +| `src-tauri/src/modules/fs/tree.rs` | SSH branch in `fs_read_dir`, `list_subdirs` | +| `src-tauri/src/modules/fs/mutate.rs` | SSH branch in all 4 mutate commands | +| `src-tauri/src/modules/fs/search.rs` | SSH branch via remote `find` exec | +| `src-tauri/src/modules/fs/grep.rs` | SSH branch via remote `grep`/`glob` exec | +| `src-tauri/src/lib.rs` | Register `SshState`, add all SSH commands to invoke handler | +| `src/modules/workspace/env.ts` | Add `ssh` variant to `WorkspaceEnv` | +| `src/modules/workspace/index.ts` | Re-export SSH types | +| `src/modules/statusbar/WorkspaceEnvSelector.tsx` | SSH profiles section (all platforms) | +| `src/settings/SettingsApp.tsx` | Add SSH tab | + +--- + +## Task 1: Add Cargo dependencies and stub SSH module + +**Files:** +- Modify: `src-tauri/Cargo.toml` +- Create: `src-tauri/src/modules/ssh/mod.rs` +- Modify: `src-tauri/src/modules/mod.rs` + +- [ ] **Step 1: Add dependencies to Cargo.toml** + +In `src-tauri/Cargo.toml`, add to `[dependencies]`: +```toml +russh = "0.45" +russh-sftp = "2" +ssh-key = { version = "0.6", features = ["std"] } +uuid = { version = "1", features = ["v4"] } +tokio = { version = "1", features = ["full"] } +``` + +- [ ] **Step 2: Create stub ssh module** + +Create `src-tauri/src/modules/ssh/mod.rs`: +```rust +mod connection; +mod handler; +mod profiles; +pub(crate) mod pty; +pub(crate) mod sftp; + +pub use connection::{SshConn, SshState}; +pub use profiles::SshProfile; +``` + +Create `src-tauri/src/modules/ssh/connection.rs`: +```rust +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +pub struct SshConn; // placeholder — filled in Task 4 + +pub struct SshState { + pub conns: RwLock>>, +} + +impl Default for SshState { + fn default() -> Self { + Self { conns: RwLock::new(HashMap::new()) } + } +} +``` + +Create `src-tauri/src/modules/ssh/handler.rs`: +```rust +// placeholder — filled in Task 4 +``` + +Create `src-tauri/src/modules/ssh/profiles.rs`: +```rust +// placeholder — filled in Task 3 +``` + +Create `src-tauri/src/modules/ssh/pty.rs`: +```rust +// placeholder — filled in Task 6 +``` + +Create `src-tauri/src/modules/ssh/sftp.rs`: +```rust +// placeholder — filled in Task 8 +``` + +- [ ] **Step 3: Register module** + +In `src-tauri/src/modules/mod.rs`, add: +```rust +pub mod ssh; +``` + +- [ ] **Step 4: Verify it compiles** + +```bash +cd src-tauri && cargo check 2>&1 | tail -5 +``` +Expected: no errors (warnings OK) + +- [ ] **Step 5: Commit** + +```bash +git add src-tauri/Cargo.toml src-tauri/Cargo.lock src-tauri/src/modules/ssh/ src-tauri/src/modules/mod.rs +git commit -m "chore(ssh): add russh deps, stub ssh module" +``` + +--- + +## Task 2: Add `Ssh` variant to `WorkspaceEnv` + +**Files:** +- Modify: `src-tauri/src/modules/workspace.rs` +- Modify: `src/modules/workspace/env.ts` + +- [ ] **Step 1: Add variant to Rust enum** + +In `src-tauri/src/modules/workspace.rs`, change the enum: +```rust +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum WorkspaceEnv { + #[default] + Local, + Wsl { + distro: String, + }, + Ssh { + profile_id: String, + }, +} +``` + +Also update `is_wsl`: +```rust +pub fn is_wsl(&self) -> bool { + matches!(self, Self::Wsl { .. }) +} + +pub fn is_ssh(&self) -> bool { + matches!(self, Self::Ssh { .. }) +} +``` + +- [ ] **Step 2: Update `resolve_path` to reject SSH (FS commands will branch before calling it)** + +In `resolve_path` (both `#[cfg(windows)]` and `#[cfg(not(windows))]`), the SSH variant is never passed to `resolve_path` — callers branch on SSH before calling it. Add a panic guard for safety: + +In the `#[cfg(windows)]` version: +```rust +#[cfg(windows)] +pub fn resolve_path(path: &str, workspace: &WorkspaceEnv) -> PathBuf { + match workspace { + WorkspaceEnv::Local => PathBuf::from(path), + WorkspaceEnv::Wsl { distro } => wsl_path_to_unc(distro, path), + WorkspaceEnv::Ssh { .. } => panic!("resolve_path called with SSH workspace — branch earlier"), + } +} +``` + +In the `#[cfg(not(windows))]` version: +```rust +#[cfg(not(windows))] +pub fn resolve_path(path: &str, workspace: &WorkspaceEnv) -> PathBuf { + match workspace { + WorkspaceEnv::Local | WorkspaceEnv::Wsl { .. } => PathBuf::from(path), + WorkspaceEnv::Ssh { .. } => panic!("resolve_path called with SSH workspace — branch earlier"), + } +} +``` + +- [ ] **Step 3: Add variant to TypeScript type** + +In `src/modules/workspace/env.ts`, change: +```typescript +export type WorkspaceEnv = + | { kind: "local" } + | { kind: "wsl"; distro: string } + | { kind: "ssh"; profileId: string }; +``` + +- [ ] **Step 4: Verify Rust compiles** + +```bash +cd src-tauri && cargo check 2>&1 | grep "^error" | head -10 +``` +Expected: no errors + +- [ ] **Step 5: Commit** + +```bash +git add src-tauri/src/modules/workspace.rs src/modules/workspace/env.ts +git commit -m "feat(ssh): add Ssh variant to WorkspaceEnv" +``` + +--- + +## Task 3: SSH profile types and CRUD + +**Files:** +- Modify: `src-tauri/src/modules/ssh/profiles.rs` +- Create: `src/modules/ssh/types.ts` +- Create: `src/modules/ssh/commands.ts` +- Create: `src/modules/ssh/store.ts` +- Create: `src/modules/ssh/index.ts` + +- [ ] **Step 1: Write the Rust profile type and CRUD** + +Replace `src-tauri/src/modules/ssh/profiles.rs`: +```rust +use serde::{Deserialize, Serialize}; +use tauri_plugin_store::StoreExt; + +const STORE_KEY: &str = "ssh_profiles"; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SshProfile { + pub id: String, + pub name: String, + pub host: String, + pub port: u16, + pub user: String, + pub auth_method: AuthMethod, + #[serde(skip_serializing_if = "Option::is_none")] + pub key_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub known_fingerprint: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AuthMethod { + Key, + Agent, +} + +#[tauri::command] +pub fn ssh_profile_list(app: tauri::AppHandle) -> Result, String> { + let store = app.store("terax.json").map_err(|e| e.to_string())?; + let profiles = store + .get(STORE_KEY) + .and_then(|v| serde_json::from_value(v).ok()) + .unwrap_or_default(); + Ok(profiles) +} + +#[tauri::command] +pub fn ssh_profile_save(app: tauri::AppHandle, profile: SshProfile) -> Result { + let store = app.store("terax.json").map_err(|e| e.to_string())?; + let mut profiles: Vec = store + .get(STORE_KEY) + .and_then(|v| serde_json::from_value(v).ok()) + .unwrap_or_default(); + if let Some(existing) = profiles.iter_mut().find(|p| p.id == profile.id) { + *existing = profile.clone(); + } else { + profiles.push(profile.clone()); + } + store.set(STORE_KEY, serde_json::to_value(&profiles).map_err(|e| e.to_string())?); + store.save().map_err(|e| e.to_string())?; + Ok(profile) +} + +#[tauri::command] +pub fn ssh_profile_delete(app: tauri::AppHandle, id: String) -> Result<(), String> { + let store = app.store("terax.json").map_err(|e| e.to_string())?; + let mut profiles: Vec = store + .get(STORE_KEY) + .and_then(|v| serde_json::from_value(v).ok()) + .unwrap_or_default(); + profiles.retain(|p| p.id != id); + store.set(STORE_KEY, serde_json::to_value(&profiles).map_err(|e| e.to_string())?); + store.save().map_err(|e| e.to_string()) +} + +pub fn update_fingerprint(app: &tauri::AppHandle, id: &str, fingerprint: String) -> Result<(), String> { + let store = app.store("terax.json").map_err(|e| e.to_string())?; + let mut profiles: Vec = store + .get(STORE_KEY) + .and_then(|v| serde_json::from_value(v).ok()) + .unwrap_or_default(); + if let Some(p) = profiles.iter_mut().find(|p| p.id == id) { + p.known_fingerprint = Some(fingerprint); + } + store.set(STORE_KEY, serde_json::to_value(&profiles).map_err(|e| e.to_string())?); + store.save().map_err(|e| e.to_string()) +} +``` + +- [ ] **Step 2: Re-export from ssh mod.rs** + +In `src-tauri/src/modules/ssh/mod.rs`, add: +```rust +pub use profiles::{ssh_profile_delete, ssh_profile_list, ssh_profile_save, SshProfile}; +``` + +- [ ] **Step 3: Write TypeScript types** + +Create `src/modules/ssh/types.ts`: +```typescript +export type AuthMethod = "key" | "agent"; + +export type SshProfile = { + id: string; + name: string; + host: string; + port: number; + user: string; + authMethod: AuthMethod; + keyPath?: string; + knownFingerprint?: string; +}; +``` + +- [ ] **Step 4: Write tauri invoke wrappers** + +Create `src/modules/ssh/commands.ts`: +```typescript +import { invoke } from "@tauri-apps/api/core"; +import type { SshProfile } from "./types"; + +export const sshProfileList = () => + invoke("ssh_profile_list"); + +export const sshProfileSave = (profile: SshProfile) => + invoke("ssh_profile_save", { profile }); + +export const sshProfileDelete = (id: string) => + invoke("ssh_profile_delete", { id }); + +export const sshConnect = (profileId: string) => + invoke("ssh_connect", { profileId }); + +export const sshDisconnect = (profileId: string) => + invoke("ssh_disconnect", { profileId }); + +export const sshFingerprintGet = (profileId: string) => + invoke("ssh_fingerprint_get", { profileId }); +``` + +- [ ] **Step 5: Write Zustand store** + +Create `src/modules/ssh/store.ts`: +```typescript +import { create } from "zustand"; +import { sshProfileList, sshProfileSave, sshProfileDelete, sshConnect, sshDisconnect } from "./commands"; +import type { SshProfile } from "./types"; +import { v4 as uuidv4 } from "uuid"; + +type ConnState = "disconnected" | "connecting" | "connected" | "error"; + +type State = { + profiles: SshProfile[]; + connState: Record; + loadProfiles: () => Promise; + saveProfile: (profile: Omit & { id?: string }) => Promise; + deleteProfile: (id: string) => Promise; + connect: (profileId: string) => Promise; + disconnect: (profileId: string) => Promise; + setConnState: (profileId: string, state: ConnState) => void; +}; + +export const useSshStore = create((set, get) => ({ + profiles: [], + connState: {}, + + loadProfiles: async () => { + const profiles = await sshProfileList(); + set({ profiles }); + }, + + saveProfile: async (profile) => { + const toSave: SshProfile = { ...profile, id: profile.id ?? uuidv4() }; + const saved = await sshProfileSave(toSave); + await get().loadProfiles(); + return saved; + }, + + deleteProfile: async (id) => { + await sshProfileDelete(id); + await get().loadProfiles(); + }, + + connect: async (profileId) => { + set((s) => ({ connState: { ...s.connState, [profileId]: "connecting" } })); + try { + await sshConnect(profileId); + set((s) => ({ connState: { ...s.connState, [profileId]: "connected" } })); + } catch (e) { + set((s) => ({ connState: { ...s.connState, [profileId]: "error" } })); + throw e; + } + }, + + disconnect: async (profileId) => { + await sshDisconnect(profileId); + set((s) => ({ connState: { ...s.connState, [profileId]: "disconnected" } })); + }, + + setConnState: (profileId, state) => + set((s) => ({ connState: { ...s.connState, [profileId]: state } })), +})); +``` + +- [ ] **Step 6: Create index** + +Create `src/modules/ssh/index.ts`: +```typescript +export * from "./types"; +export * from "./commands"; +export * from "./store"; +``` + +- [ ] **Step 7: Verify Rust compiles** + +```bash +cd src-tauri && cargo check 2>&1 | grep "^error" | head -10 +``` +Expected: no errors + +- [ ] **Step 8: Commit** + +```bash +git add src-tauri/src/modules/ssh/profiles.rs src-tauri/src/modules/ssh/mod.rs \ + src/modules/ssh/ +git commit -m "feat(ssh): profile CRUD — store, types, tauri commands" +``` + +--- + +## Task 4: `SshConn`, `SshState`, and `ClientHandler` + +**Files:** +- Modify: `src-tauri/src/modules/ssh/connection.rs` +- Modify: `src-tauri/src/modules/ssh/handler.rs` + +- [ ] **Step 1: Write `SshHandler`** + +Replace `src-tauri/src/modules/ssh/handler.rs`: +```rust +use std::sync::{Arc, Mutex}; + +use russh::client; +use ssh_key::PublicKey; + +pub struct SshHandler { + /// Fingerprint stored in the profile. `None` on first connect (TOFU). + pub known_fingerprint: Option, + /// The fingerprint seen during this handshake — written by `check_server_key`, + /// read back by the caller after `connect()` returns. + pub observed_fingerprint: Arc>>, +} + +impl SshHandler { + pub fn new(known_fingerprint: Option) -> (Self, Arc>>) { + let observed = Arc::new(Mutex::new(None)); + let handler = Self { + known_fingerprint, + observed_fingerprint: observed.clone(), + }; + (handler, observed) + } +} + +#[async_trait::async_trait] +impl client::Handler for SshHandler { + type Error = russh::Error; + + async fn check_server_key( + &mut self, + server_public_key: &PublicKey, + ) -> Result { + use ssh_key::HashAlg; + let fingerprint = server_public_key.fingerprint(HashAlg::Sha256).to_string(); + *self.observed_fingerprint.lock().unwrap() = Some(fingerprint.clone()); + + if let Some(known) = &self.known_fingerprint { + if &fingerprint != known { + log::warn!("SSH host key mismatch! Expected {known}, got {fingerprint}"); + return Ok(false); + } + } + // First connect (no known fingerprint) or fingerprint matches: accept. + // Caller is responsible for persisting the fingerprint after TOFU confirmation. + Ok(true) + } +} +``` + +- [ ] **Step 2: Write `SshConn` and `SshState`** + +Replace `src-tauri/src/modules/ssh/connection.rs`: +```rust +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +use russh::client::Handle; +use russh_sftp::client::SftpSession; + +use super::handler::SshHandler; + +pub struct SshConn { + pub handle: Handle, + pub sftp: SftpSession, +} + +pub struct SshState { + pub conns: RwLock>>, +} + +impl Default for SshState { + fn default() -> Self { + Self { + conns: RwLock::new(HashMap::new()), + } + } +} + +impl SshState { + pub fn get(&self, profile_id: &str) -> Option> { + self.conns.read().unwrap().get(profile_id).cloned() + } + + pub fn get_or_err(&self, profile_id: &str) -> Result, String> { + self.get(profile_id) + .ok_or_else(|| format!("SSH: no active connection for profile {profile_id}")) + } + + pub fn insert(&self, profile_id: String, conn: Arc) { + self.conns.write().unwrap().insert(profile_id, conn); + } + + pub fn remove(&self, profile_id: &str) -> Option> { + self.conns.write().unwrap().remove(profile_id) + } +} +``` + +- [ ] **Step 3: Update ssh/mod.rs re-exports** + +In `src-tauri/src/modules/ssh/mod.rs`: +```rust +mod connection; +mod handler; +mod profiles; +pub(crate) mod pty; +pub(crate) mod sftp; + +pub use connection::{SshConn, SshState}; +pub use profiles::{ssh_profile_delete, ssh_profile_list, ssh_profile_save, update_fingerprint, SshProfile}; +``` + +- [ ] **Step 4: Add `async-trait` dependency** + +In `src-tauri/Cargo.toml` `[dependencies]`: +```toml +async-trait = "0.1" +``` + +- [ ] **Step 5: Verify compiles** + +```bash +cd src-tauri && cargo check 2>&1 | grep "^error" | head -10 +``` +Expected: no errors + +- [ ] **Step 6: Commit** + +```bash +git add src-tauri/src/modules/ssh/ src-tauri/Cargo.toml src-tauri/Cargo.lock +git commit -m "feat(ssh): SshConn, SshState, ClientHandler with TOFU fingerprint" +``` + +--- + +## Task 5: `ssh_connect`, `ssh_disconnect`, `ssh_fingerprint_get` commands + +**Files:** +- Modify: `src-tauri/src/modules/ssh/mod.rs` + +- [ ] **Step 1: Write the connect/disconnect commands** + +Add to `src-tauri/src/modules/ssh/mod.rs`: +```rust +use std::net::ToSocketAddrs; +use std::sync::Arc; + +use russh::client; +use russh_sftp::client::SftpSession; +use tauri_plugin_store::StoreExt; + +use crate::modules::secrets::SecretsState; + +use super::connection::{SshConn, SshState}; +use super::handler::SshHandler; +use super::profiles::{update_fingerprint, AuthMethod, SshProfile}; + +fn load_profile(app: &tauri::AppHandle, profile_id: &str) -> Result { + let profiles = ssh_profile_list(app.clone())?; + profiles + .into_iter() + .find(|p| p.id == profile_id) + .ok_or_else(|| format!("SSH profile not found: {profile_id}")) +} + +#[tauri::command] +pub async fn ssh_connect( + app: tauri::AppHandle, + state: tauri::State<'_, SshState>, + secrets: tauri::State<'_, SecretsState>, + profile_id: String, +) -> Result<(), String> { + if state.get(&profile_id).is_some() { + return Ok(()); // already connected + } + + let profile = load_profile(&app, &profile_id)?; + + let (handler, observed_fp) = SshHandler::new(profile.known_fingerprint.clone()); + + let config = Arc::new(client::Config::default()); + let addr = format!("{}:{}", profile.host, profile.port); + let addr = addr + .to_socket_addrs() + .map_err(|e| e.to_string())? + .next() + .ok_or("could not resolve host")?; + + let mut handle = client::connect(config, addr, handler) + .await + .map_err(|e| e.to_string())?; + + // Authenticate + match profile.auth_method { + AuthMethod::Key => { + let key_path = profile + .key_path + .as_deref() + .ok_or("key auth requires keyPath")?; + let key_path = shellexpand::tilde(key_path).into_owned(); + let passphrase: Option = secrets + .get(&format!("ssh:{}", profile.id)) + .ok() + .flatten(); + let key = russh::keys::PrivateKey::read_openssh_file(std::path::Path::new(&key_path)) + .map_err(|e| e.to_string())?; + let authed = handle + .authenticate_publickey(&profile.user, Arc::new(key)) + .await + .map_err(|e| e.to_string())?; + if !authed { + return Err("SSH key authentication rejected".into()); + } + } + AuthMethod::Agent => { + #[cfg(unix)] + { + let agent_sock = std::env::var("SSH_AUTH_SOCK") + .map_err(|_| "SSH_AUTH_SOCK not set — is ssh-agent running?")?; + let mut agent = russh_keys::agent::client::AgentClient::connect_uds(&agent_sock) + .await + .map_err(|e| e.to_string())?; + let identities = agent.request_identities().await.map_err(|e| e.to_string())?; + let mut authed = false; + for key in identities { + if handle + .authenticate_future(&profile.user, key, &mut agent) + .await + .map_err(|e| e.to_string())? + { + authed = true; + break; + } + } + if !authed { + return Err("SSH agent authentication rejected".into()); + } + } + #[cfg(windows)] + { + return Err("SSH agent auth on Windows requires a named-pipe agent (not yet supported)".into()); + } + } + } + + // If this was a first-connect (no known fingerprint), persist the observed one. + if profile.known_fingerprint.is_none() { + if let Some(fp) = observed_fp.lock().unwrap().clone() { + update_fingerprint(&app, &profile.id, fp)?; + } + } + + // Open SFTP subsystem + let sftp_channel = handle + .channel_open_session() + .await + .map_err(|e| e.to_string())?; + sftp_channel + .request_subsystem(true, "sftp") + .await + .map_err(|e| e.to_string())?; + let sftp = SftpSession::new(sftp_channel.into_stream()) + .await + .map_err(|e| e.to_string())?; + + state.insert(profile_id, Arc::new(SshConn { handle, sftp })); + log::info!("SSH connected to {}:{}", profile.host, profile.port); + Ok(()) +} + +#[tauri::command] +pub async fn ssh_disconnect( + state: tauri::State<'_, SshState>, + profile_id: String, +) -> Result<(), String> { + if let Some(conn) = state.remove(&profile_id) { + let _ = conn.handle.disconnect(russh::Disconnect::ByApplication, "", "English").await; + log::info!("SSH disconnected profile {profile_id}"); + } + Ok(()) +} + +#[tauri::command] +pub async fn ssh_fingerprint_get( + state: tauri::State<'_, SshState>, + profile_id: String, +) -> Result, String> { + // Returns the fingerprint of an active connection (useful for TOFU display). + // The fingerprint is already persisted by ssh_connect; this is a read-only check. + let _ = state.get(&profile_id); // just verifying connection exists + Ok(None) // fingerprint is read from the profile store, not live — see FingerprintDialog +} +``` + +- [ ] **Step 2: Add `shellexpand` dependency** + +In `src-tauri/Cargo.toml`: +```toml +shellexpand = "3" +``` + +- [ ] **Step 3: Verify compiles** + +```bash +cd src-tauri && cargo check 2>&1 | grep "^error" | head -20 +``` +Expected: no errors + +- [ ] **Step 4: Commit** + +```bash +git add src-tauri/src/modules/ssh/mod.rs src-tauri/Cargo.toml src-tauri/Cargo.lock +git commit -m "feat(ssh): ssh_connect, ssh_disconnect, key+agent auth" +``` + +--- + +## Task 6: Refactor `PtyState` to handle SSH sessions + +**Files:** +- Modify: `src-tauri/src/modules/pty/session.rs` +- Modify: `src-tauri/src/modules/pty/mod.rs` + +This task introduces a `PtyHandle` enum so `PtyState` can store both local and SSH sessions without changing the external command API. + +- [ ] **Step 1: Add `SshPtySession` to `session.rs`** + +At the bottom of `src-tauri/src/modules/pty/session.rs`, add: +```rust +use tokio::sync::mpsc; + +pub enum SshPtyCmd { + Data(Vec), + Resize { cols: u16, rows: u16 }, + Close, +} + +/// Thin handle to a tokio task that owns the russh channel. +pub struct SshPtySession { + pub cmd_tx: mpsc::Sender, +} +``` + +- [ ] **Step 2: Add `PtyHandle` enum to `session.rs`** + +Still in `src-tauri/src/modules/pty/session.rs`: +```rust +pub enum PtyHandle { + Local(Arc), + Ssh(Arc), +} + +impl PtyHandle { + pub fn write(&self, data: &[u8]) -> Result<(), String> { + match self { + PtyHandle::Local(s) => s + .writer + .lock() + .unwrap() + .write_all(data) + .map_err(|e| e.to_string()), + PtyHandle::Ssh(s) => { + s.cmd_tx + .try_send(SshPtyCmd::Data(data.to_vec())) + .map_err(|e| e.to_string()) + } + } + } + + pub fn resize(&self, cols: u16, rows: u16) -> Result<(), String> { + match self { + PtyHandle::Local(s) => s + .master + .lock() + .unwrap() + .resize(portable_pty::PtySize { rows, cols, pixel_width: 0, pixel_height: 0 }) + .map_err(|e| e.to_string()), + PtyHandle::Ssh(s) => { + s.cmd_tx + .try_send(SshPtyCmd::Resize { cols, rows }) + .map_err(|e| e.to_string()) + } + } + } + + pub fn kill(&self) -> Result<(), String> { + match self { + PtyHandle::Local(s) => s.killer.lock().unwrap().kill().map_err(|e| e.to_string()), + PtyHandle::Ssh(s) => { + let _ = s.cmd_tx.try_send(SshPtyCmd::Close); + Ok(()) + } + } + } +} +``` + +- [ ] **Step 3: Update `PtyState` to store `PtyHandle`** + +In `src-tauri/src/modules/pty/mod.rs`, change the `sessions` field type: + +```rust +use session::PtyHandle; + +pub struct PtyState { + sessions: RwLock>, + next_id: AtomicU32, +} +``` + +Update `pty_write`: +```rust +#[tauri::command] +pub fn pty_write(state: tauri::State, id: u32, data: String) -> Result<(), String> { + let sessions = state.sessions.read().unwrap(); + let handle = sessions.get(&id).ok_or_else(|| { + log::warn!("pty_write: unknown id={id}"); + "no session".to_string() + })?; + handle.write(data.as_bytes()).map_err(|e| { + log::debug!("pty_write id={id} failed: {e}"); + e + }) +} +``` + +Update `pty_resize`: +```rust +#[tauri::command] +pub fn pty_resize(state: tauri::State, id: u32, cols: u16, rows: u16) -> Result<(), String> { + let sessions = state.sessions.read().unwrap(); + let handle = sessions.get(&id).ok_or_else(|| { + log::warn!("pty_resize: unknown id={id}"); + "no session".to_string() + })?; + handle.resize(cols, rows).map_err(|e| { + log::warn!("pty_resize id={id} failed: {e}"); + e + }) +} +``` + +Update `pty_close`: +```rust +#[tauri::command] +pub fn pty_close(state: tauri::State, id: u32) -> Result<(), String> { + let handle = state.sessions.write().unwrap().remove(&id); + if let Some(h) = handle { + if let Err(e) = h.kill() { + log::debug!("pty_close: kill id={id} returned {e}"); + } + log::info!("pty closed id={id}"); + } else { + log::debug!("pty_close: unknown id={id}"); + } + Ok(()) +} +``` + +Update `pty_open` to wrap the local session: +```rust +// At the end of pty_open, after creating local session: +let id = state.next_id.fetch_add(1, Ordering::Relaxed); +state.sessions.write().unwrap().insert(id, PtyHandle::Local(session)); +``` + +- [ ] **Step 4: Verify compiles** + +```bash +cd src-tauri && cargo check 2>&1 | grep "^error" | head -20 +``` +Expected: no errors + +- [ ] **Step 5: Commit** + +```bash +git add src-tauri/src/modules/pty/ +git commit -m "refactor(pty): PtyHandle enum supports local + SSH sessions" +``` + +--- + +## Task 7: SSH PTY channel — open shell, wire `on_data`/`on_exit` + +**Files:** +- Modify: `src-tauri/src/modules/ssh/pty.rs` +- Modify: `src-tauri/src/modules/pty/mod.rs` + +- [ ] **Step 1: Implement `open_ssh_pty_channel`** + +Replace `src-tauri/src/modules/ssh/pty.rs`: +```rust +use std::sync::Arc; +use std::time::Duration; + +use russh::ChannelMsg; +use tauri::ipc::{Channel, Response}; +use tokio::sync::mpsc; + +use crate::modules::pty::session::{PtyHandle, SshPtyCmd, SshPtySession}; +use super::connection::SshConn; + +const FLUSH_INTERVAL: Duration = Duration::from_millis(4); +const READ_BUF_CAP: usize = 16 * 1024; + +pub async fn open_ssh_pty_channel( + conn: Arc, + cols: u16, + rows: u16, + on_data: Channel, + on_exit: Channel, +) -> Result { + let mut channel = conn + .handle + .channel_open_session() + .await + .map_err(|e| e.to_string())?; + + channel + .request_pty( + false, + "xterm-256color", + cols as u32, + rows as u32, + 0, + 0, + &[], + ) + .await + .map_err(|e| e.to_string())?; + + channel + .request_shell(false) + .await + .map_err(|e| e.to_string())?; + + let (cmd_tx, mut cmd_rx) = mpsc::channel::(256); + + tauri::async_runtime::spawn(async move { + let mut pending: Vec = Vec::with_capacity(READ_BUF_CAP); + let mut last_flush = tokio::time::Instant::now(); + + loop { + tokio::select! { + // Forward writes/resizes/close from PTY commands + cmd = cmd_rx.recv() => { + match cmd { + Some(SshPtyCmd::Data(bytes)) => { + let _ = channel.data(bytes.as_ref()).await; + } + Some(SshPtyCmd::Resize { cols, rows }) => { + let _ = channel.window_change(cols as u32, rows as u32, 0, 0).await; + } + Some(SshPtyCmd::Close) | None => { + let _ = channel.close().await; + break; + } + } + } + // Read output from the remote shell + msg = channel.wait() => { + match msg { + Some(ChannelMsg::Data { ref data }) => { + pending.extend_from_slice(data); + } + Some(ChannelMsg::ExtendedData { ref data, .. }) => { + // stderr — send inline so it appears in the terminal + pending.extend_from_slice(data); + } + Some(ChannelMsg::ExitStatus { exit_status }) => { + // Flush remaining output before signalling exit + if !pending.is_empty() { + let chunk = std::mem::take(&mut pending); + let _ = on_data.send(Response::new(chunk)); + } + let _ = on_exit.send(exit_status as i32); + break; + } + None => { + if !pending.is_empty() { + let chunk = std::mem::take(&mut pending); + let _ = on_data.send(Response::new(chunk)); + } + let _ = on_exit.send(-1); + break; + } + _ => {} + } + } + } + + // Periodic flush — same 4 ms cadence as local PTY + if last_flush.elapsed() >= FLUSH_INTERVAL && !pending.is_empty() { + let chunk = std::mem::take(&mut pending); + if on_data.send(Response::new(chunk)).is_err() { + break; + } + last_flush = tokio::time::Instant::now(); + } + } + }); + + Ok(PtyHandle::Ssh(Arc::new(SshPtySession { cmd_tx }))) +} +``` + +- [ ] **Step 2: Branch `pty_open` on SSH** + +In `src-tauri/src/modules/pty/mod.rs`, make `pty_open` async and add SSH branch. Change the signature and body: + +```rust +#[tauri::command] +pub async fn pty_open( + state: tauri::State<'_, PtyState>, + ssh_state: tauri::State<'_, crate::modules::ssh::SshState>, + cols: u16, + rows: u16, + cwd: Option, + workspace: Option, + on_data: Channel, + on_exit: Channel, +) -> Result { + let workspace = WorkspaceEnv::from_option(workspace); + + let handle = match &workspace { + WorkspaceEnv::Ssh { profile_id } => { + let conn = ssh_state.get_or_err(profile_id)?; + crate::modules::ssh::pty::open_ssh_pty_channel(conn, cols, rows, on_data, on_exit) + .await? + } + _ => { + let (session, _) = + tauri::async_runtime::spawn_blocking(move || { + session::spawn(cols, rows, cwd, workspace, on_data, on_exit) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| { + log::error!("pty_open failed: {e}"); + e + })?; + PtyHandle::Local(session) + } + }; + + let id = state.next_id.fetch_add(1, Ordering::Relaxed); + state.sessions.write().unwrap().insert(id, handle); + log::info!("pty opened id={id} cols={cols} rows={rows}"); + Ok(id) +} +``` + +- [ ] **Step 3: Verify compiles** + +```bash +cd src-tauri && cargo check 2>&1 | grep "^error" | head -20 +``` +Expected: no errors + +- [ ] **Step 4: Commit** + +```bash +git add src-tauri/src/modules/ssh/pty.rs src-tauri/src/modules/pty/mod.rs +git commit -m "feat(ssh): SSH PTY channel — shell over russh wired to on_data/on_exit" +``` + +--- + +## Task 8: SFTP wrappers + +**Files:** +- Modify: `src-tauri/src/modules/ssh/sftp.rs` + +- [ ] **Step 1: Write SFTP wrappers** + +Replace `src-tauri/src/modules/ssh/sftp.rs`: +```rust +use std::time::UNIX_EPOCH; + +use russh_sftp::client::fs::DirEntry as SftpDirEntry; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use crate::modules::fs::file::{FileStat, ReadResult, StatKind}; +use crate::modules::fs::tree::{DirEntry, EntryKind}; +use super::connection::SshConn; + +const MAX_READ_BYTES: u64 = 10 * 1024 * 1024; + +pub async fn sftp_read_dir(conn: &SshConn, path: &str, show_hidden: bool) -> Result, String> { + let entries = conn.sftp.read_dir(path).await.map_err(|e| e.to_string())?; + let mut result: Vec = entries + .into_iter() + .filter(|e| show_hidden || !e.file_name().starts_with('.')) + .map(|e| { + let meta = e.metadata(); + let is_dir = meta.file_type().map(|t| t.is_dir()).unwrap_or(false); + let is_symlink = meta.file_type().map(|t| t.is_symlink()).unwrap_or(false); + DirEntry { + name: e.file_name().to_string(), + kind: if is_dir { EntryKind::Dir } else if is_symlink { EntryKind::Symlink } else { EntryKind::File }, + size: meta.size.unwrap_or(0), + mtime: meta.mtime.unwrap_or(0) * 1000, + } + }) + .collect(); + result.sort_by(|a, b| { + let ak = matches!(a.kind, EntryKind::Dir); + let bk = matches!(b.kind, EntryKind::Dir); + bk.cmp(&ak).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + Ok(result) +} + +pub async fn sftp_read_file(conn: &SshConn, path: &str) -> Result { + let meta = conn.sftp.metadata(path).await.map_err(|e| e.to_string())?; + let size = meta.size.unwrap_or(0); + if size > MAX_READ_BYTES { + return Ok(ReadResult::TooLarge { size, limit: MAX_READ_BYTES }); + } + let mut file = conn.sftp.open(path).await.map_err(|e| e.to_string())?; + let mut buf = Vec::with_capacity(size as usize); + file.read_to_end(&mut buf).await.map_err(|e| e.to_string())?; + + // Same binary sniff as local: check first 8 KB for null bytes + let sniff = &buf[..buf.len().min(8192)]; + if sniff.contains(&0u8) { + return Ok(ReadResult::Binary { size }); + } + match String::from_utf8(buf) { + Ok(s) => Ok(ReadResult::Text { content: s, size }), + Err(_) => Ok(ReadResult::Binary { size }), + } +} + +pub async fn sftp_write_file(conn: &SshConn, path: &str, content: &str) -> Result<(), String> { + let mut file = conn.sftp.create(path).await.map_err(|e| e.to_string())?; + file.write_all(content.as_bytes()).await.map_err(|e| e.to_string())?; + file.flush().await.map_err(|e| e.to_string()) +} + +pub async fn sftp_stat(conn: &SshConn, path: &str) -> Result { + let meta = conn.sftp.metadata(path).await.map_err(|e| e.to_string())?; + let kind = if meta.file_type().map(|t| t.is_dir()).unwrap_or(false) { + StatKind::Dir + } else if meta.file_type().map(|t| t.is_symlink()).unwrap_or(false) { + StatKind::Symlink + } else { + StatKind::File + }; + Ok(FileStat { + size: meta.size.unwrap_or(0), + mtime: meta.mtime.unwrap_or(0) * 1000, + kind, + }) +} + +pub async fn sftp_create_file(conn: &SshConn, path: &str) -> Result<(), String> { + // Fail if exists — match local behaviour + if conn.sftp.metadata(path).await.is_ok() { + return Err(format!("already exists: {path}")); + } + let mut f = conn.sftp.create(path).await.map_err(|e| e.to_string())?; + f.flush().await.map_err(|e| e.to_string()) +} + +pub async fn sftp_create_dir(conn: &SshConn, path: &str) -> Result<(), String> { + if conn.sftp.metadata(path).await.is_ok() { + return Err(format!("already exists: {path}")); + } + conn.sftp.create_dir(path).await.map_err(|e| e.to_string()) +} + +pub async fn sftp_rename(conn: &SshConn, from: &str, to: &str) -> Result<(), String> { + conn.sftp.rename(from, to, None).await.map_err(|e| e.to_string()) +} + +pub async fn sftp_delete(conn: &SshConn, path: &str) -> Result<(), String> { + let meta = conn.sftp.metadata(path).await.map_err(|e| e.to_string())?; + if meta.file_type().map(|t| t.is_dir()).unwrap_or(false) { + conn.sftp.remove_dir(path).await.map_err(|e| e.to_string()) + } else { + conn.sftp.remove_file(path).await.map_err(|e| e.to_string()) + } +} + +/// Run `find -maxdepth 10 -iname "**"` on the remote host. +pub async fn sftp_search(conn: &SshConn, path: &str, query: &str) -> Result, String> { + let cmd = format!( + "find {} -maxdepth 10 -iname '*{}*' 2>/dev/null", + shell_escape(path), + shell_escape(query) + ); + let output = run_remote_command(conn, &cmd).await?; + Ok(output.lines().map(|l| l.to_string()).collect()) +} + +/// Run `grep -rn ` on the remote host. +pub async fn sftp_grep(conn: &SshConn, path: &str, pattern: &str) -> Result, String> { + let cmd = format!( + "grep -rn --include='*' {} {} 2>/dev/null", + shell_escape(pattern), + shell_escape(path) + ); + let output = run_remote_command(conn, &cmd).await?; + Ok(output.lines().map(|l| l.to_string()).collect()) +} + +async fn run_remote_command(conn: &SshConn, cmd: &str) -> Result { + let mut channel = conn.handle.channel_open_session().await.map_err(|e| e.to_string())?; + channel.exec(true, cmd).await.map_err(|e| e.to_string())?; + let mut output = Vec::new(); + while let Some(msg) = channel.wait().await { + match msg { + russh::ChannelMsg::Data { ref data } => output.extend_from_slice(data), + russh::ChannelMsg::ExitStatus { .. } | russh::ChannelMsg::Eof => break, + _ => {} + } + } + String::from_utf8(output).map_err(|e| e.to_string()) +} + +fn shell_escape(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} +``` + +- [ ] **Step 2: Verify compiles** + +```bash +cd src-tauri && cargo check 2>&1 | grep "^error" | head -20 +``` +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add src-tauri/src/modules/ssh/sftp.rs +git commit -m "feat(ssh): SFTP wrappers for read_dir, read_file, write_file, stat, CRUD, search, grep" +``` + +--- + +## Task 9: Branch `fs::file` on SSH + +**Files:** +- Modify: `src-tauri/src/modules/fs/file.rs` + +- [ ] **Step 1: Make fs_read_file async and add SSH branch** + +In `src-tauri/src/modules/fs/file.rs`: + +Change `fs_read_file` signature to async and add SSH branch at the top: +```rust +#[tauri::command] +pub async fn fs_read_file( + path: String, + workspace: Option, + ssh_state: tauri::State<'_, crate::modules::ssh::SshState>, +) -> Result { + let workspace = WorkspaceEnv::from_option(workspace); + if let WorkspaceEnv::Ssh { profile_id } = &workspace { + let conn = ssh_state.get_or_err(profile_id)?; + return crate::modules::ssh::sftp::sftp_read_file(&conn, &path).await; + } + // existing local logic below — wrap in spawn_blocking since it's sync I/O + let p = resolve_path(&path, &workspace); + tauri::async_runtime::spawn_blocking(move || { + // ... existing body ... + }).await.map_err(|e| e.to_string())? +} +``` + +Move the existing synchronous body into the `spawn_blocking` closure unchanged. + +- [ ] **Step 2: Make `fs_write_file` async and add SSH branch** + +```rust +#[tauri::command] +pub async fn fs_write_file( + path: String, + content: String, + workspace: Option, + ssh_state: tauri::State<'_, crate::modules::ssh::SshState>, +) -> Result<(), String> { + let workspace = WorkspaceEnv::from_option(workspace); + if let WorkspaceEnv::Ssh { profile_id } = &workspace { + let conn = ssh_state.get_or_err(profile_id)?; + return crate::modules::ssh::sftp::sftp_write_file(&conn, &path, &content).await; + } + let p = resolve_path(&path, &workspace); + tauri::async_runtime::spawn_blocking(move || { + // existing sync body + }).await.map_err(|e| e.to_string())? +} +``` + +- [ ] **Step 3: Make `fs_stat` async and add SSH branch** + +```rust +#[tauri::command] +pub async fn fs_stat( + path: String, + workspace: Option, + ssh_state: tauri::State<'_, crate::modules::ssh::SshState>, +) -> Result { + let workspace = WorkspaceEnv::from_option(workspace); + if let WorkspaceEnv::Ssh { profile_id } = &workspace { + let conn = ssh_state.get_or_err(profile_id)?; + return crate::modules::ssh::sftp::sftp_stat(&conn, &path).await; + } + let p = resolve_path(&path, &workspace); + tauri::async_runtime::spawn_blocking(move || { + // existing sync body + }).await.map_err(|e| e.to_string())? +} +``` + +- [ ] **Step 4: Verify compiles** + +```bash +cd src-tauri && cargo check 2>&1 | grep "^error" | head -20 +``` + +- [ ] **Step 5: Commit** + +```bash +git add src-tauri/src/modules/fs/file.rs +git commit -m "feat(ssh): fs_read_file, fs_write_file, fs_stat — SSH branch via SFTP" +``` + +--- + +## Task 10: Branch `fs::tree` on SSH + +**Files:** +- Modify: `src-tauri/src/modules/fs/tree.rs` + +- [ ] **Step 1: Make `fs_read_dir` async and add SSH branch** + +```rust +#[tauri::command] +pub async fn fs_read_dir( + path: String, + show_hidden: bool, + workspace: Option, + ssh_state: tauri::State<'_, crate::modules::ssh::SshState>, +) -> Result, String> { + let workspace = WorkspaceEnv::from_option(workspace); + if let WorkspaceEnv::Ssh { profile_id } = &workspace { + let conn = ssh_state.get_or_err(profile_id)?; + return crate::modules::ssh::sftp::sftp_read_dir(&conn, &path, show_hidden).await; + } + let root = resolve_path(&path, &workspace); + tauri::async_runtime::spawn_blocking(move || { + // existing sync body + }).await.map_err(|e| e.to_string())? +} +``` + +- [ ] **Step 2: Make `list_subdirs` async and add SSH branch** + +`list_subdirs` uses `fs_read_dir` internally — once `fs_read_dir` is async, make `list_subdirs` call it: +```rust +#[tauri::command] +pub async fn list_subdirs( + path: String, + workspace: Option, + ssh_state: tauri::State<'_, crate::modules::ssh::SshState>, +) -> Result, String> { + let workspace = WorkspaceEnv::from_option(workspace); + if let WorkspaceEnv::Ssh { profile_id } = &workspace { + let conn = ssh_state.get_or_err(profile_id)?; + let entries = crate::modules::ssh::sftp::sftp_read_dir(&conn, &path, false).await?; + return Ok(entries.into_iter() + .filter(|e| matches!(e.kind, EntryKind::Dir)) + .map(|e| format!("{}/{}", path.trim_end_matches('/'), e.name)) + .collect()); + } + let root = resolve_path(&path, &workspace); + tauri::async_runtime::spawn_blocking(move || { + // existing sync body + }).await.map_err(|e| e.to_string())? +} +``` + +- [ ] **Step 3: Verify compiles** + +```bash +cd src-tauri && cargo check 2>&1 | grep "^error" | head -20 +``` + +- [ ] **Step 4: Commit** + +```bash +git add src-tauri/src/modules/fs/tree.rs +git commit -m "feat(ssh): fs_read_dir, list_subdirs — SSH branch via SFTP" +``` + +--- + +## Task 11: Branch `fs::mutate` on SSH + +**Files:** +- Modify: `src-tauri/src/modules/fs/mutate.rs` + +- [ ] **Step 1: Add SSH branches to all 4 mutate commands** + +Make each command `async` and add the SSH branch. Pattern is identical for all four — shown here for all: + +```rust +use crate::modules::workspace::WorkspaceEnv; + +#[tauri::command] +pub async fn fs_create_file( + path: String, + workspace: Option, + ssh_state: tauri::State<'_, crate::modules::ssh::SshState>, +) -> Result<(), String> { + let workspace = WorkspaceEnv::from_option(workspace); + if let WorkspaceEnv::Ssh { profile_id } = &workspace { + let conn = ssh_state.get_or_err(profile_id)?; + return crate::modules::ssh::sftp::sftp_create_file(&conn, &path).await; + } + let p = resolve_path(&path, &workspace); + tauri::async_runtime::spawn_blocking(move || { + if p.exists() { return Err(format!("already exists: {}", p.display())); } + std::fs::write(&p, "").map_err(|e| e.to_string()) + }).await.map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn fs_create_dir( + path: String, + workspace: Option, + ssh_state: tauri::State<'_, crate::modules::ssh::SshState>, +) -> Result<(), String> { + let workspace = WorkspaceEnv::from_option(workspace); + if let WorkspaceEnv::Ssh { profile_id } = &workspace { + let conn = ssh_state.get_or_err(profile_id)?; + return crate::modules::ssh::sftp::sftp_create_dir(&conn, &path).await; + } + let p = resolve_path(&path, &workspace); + tauri::async_runtime::spawn_blocking(move || { + if p.exists() { return Err(format!("already exists: {}", p.display())); } + std::fs::create_dir_all(&p).map_err(|e| e.to_string()) + }).await.map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn fs_rename( + from: String, + to: String, + workspace: Option, + ssh_state: tauri::State<'_, crate::modules::ssh::SshState>, +) -> Result<(), String> { + let workspace = WorkspaceEnv::from_option(workspace); + if let WorkspaceEnv::Ssh { profile_id } = &workspace { + let conn = ssh_state.get_or_err(profile_id)?; + return crate::modules::ssh::sftp::sftp_rename(&conn, &from, &to).await; + } + let fp = resolve_path(&from, &workspace); + let tp = resolve_path(&to, &workspace); + tauri::async_runtime::spawn_blocking(move || { + std::fs::rename(&fp, &tp).map_err(|e| e.to_string()) + }).await.map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn fs_delete( + path: String, + workspace: Option, + ssh_state: tauri::State<'_, crate::modules::ssh::SshState>, +) -> Result<(), String> { + let workspace = WorkspaceEnv::from_option(workspace); + if let WorkspaceEnv::Ssh { profile_id } = &workspace { + let conn = ssh_state.get_or_err(profile_id)?; + return crate::modules::ssh::sftp::sftp_delete(&conn, &path).await; + } + let p = resolve_path(&path, &workspace); + tauri::async_runtime::spawn_blocking(move || { + let meta = std::fs::metadata(&p).map_err(|e| e.to_string())?; + if meta.is_dir() { + std::fs::remove_dir_all(&p).map_err(|e| e.to_string()) + } else { + std::fs::remove_file(&p).map_err(|e| e.to_string()) + } + }).await.map_err(|e| e.to_string())? +} +``` + +- [ ] **Step 2: Verify compiles** + +```bash +cd src-tauri && cargo check 2>&1 | grep "^error" | head -20 +``` + +- [ ] **Step 3: Commit** + +```bash +git add src-tauri/src/modules/fs/mutate.rs +git commit -m "feat(ssh): fs_create_file/dir, fs_rename, fs_delete — SSH branch via SFTP" +``` + +--- + +## Task 12: Branch `fs::search` and `fs::grep` on SSH + +**Files:** +- Modify: `src-tauri/src/modules/fs/search.rs` +- Modify: `src-tauri/src/modules/fs/grep.rs` + +- [ ] **Step 1: Read current search/grep signatures** + +```bash +head -60 src-tauri/src/modules/fs/search.rs +head -60 src-tauri/src/modules/fs/grep.rs +``` + +- [ ] **Step 2: Add SSH branch to `fs_search`** + +Make `fs_search` async, add SSH branch at top: +```rust +#[tauri::command] +pub async fn fs_search( + path: String, + query: String, + workspace: Option, + ssh_state: tauri::State<'_, crate::modules::ssh::SshState>, +) -> Result, String> { + let workspace = WorkspaceEnv::from_option(workspace); + if let WorkspaceEnv::Ssh { profile_id } = &workspace { + let conn = ssh_state.get_or_err(profile_id)?; + return crate::modules::ssh::sftp::sftp_search(&conn, &path, &query).await; + } + let root = resolve_path(&path, &workspace); + tauri::async_runtime::spawn_blocking(move || { + // existing sync body + }).await.map_err(|e| e.to_string())? +} +``` + +- [ ] **Step 3: Add SSH branch to `fs_grep` and `fs_glob`** + +Same pattern — make each async, branch on SSH using `sftp_grep` for `fs_grep`. `fs_glob` uses `find` patterns — use `sftp_search` with the glob pattern: +```rust +#[tauri::command] +pub async fn fs_grep( + path: String, + pattern: String, + workspace: Option, + ssh_state: tauri::State<'_, crate::modules::ssh::SshState>, +) -> Result, String> { + let workspace = WorkspaceEnv::from_option(workspace); + if let WorkspaceEnv::Ssh { profile_id } = &workspace { + let conn = ssh_state.get_or_err(profile_id)?; + return crate::modules::ssh::sftp::sftp_grep(&conn, &path, &pattern).await; + } + let root = resolve_path(&path, &workspace); + tauri::async_runtime::spawn_blocking(move || { + // existing sync body + }).await.map_err(|e| e.to_string())? +} +``` + +- [ ] **Step 4: Verify compiles** + +```bash +cd src-tauri && cargo check 2>&1 | grep "^error" | head -20 +``` + +- [ ] **Step 5: Commit** + +```bash +git add src-tauri/src/modules/fs/search.rs src-tauri/src/modules/fs/grep.rs +git commit -m "feat(ssh): fs_search, fs_grep — SSH branch via remote exec" +``` + +--- + +## Task 13: Register all SSH commands in `lib.rs` and integration test + +**Files:** +- Modify: `src-tauri/src/lib.rs` + +- [ ] **Step 1: Register `SshState` and all SSH commands** + +In `src-tauri/src/lib.rs`: + +Add `.manage(ssh::SshState::default())` after the existing `.manage(secrets::SecretsState::default())`: +```rust +use modules::ssh; +// ... +.manage(ssh::SshState::default()) +``` + +Add to `invoke_handler`: +```rust +ssh::ssh_profile_list, +ssh::ssh_profile_save, +ssh::ssh_profile_delete, +ssh::ssh_connect, +ssh::ssh_disconnect, +ssh::ssh_fingerprint_get, +``` + +- [ ] **Step 2: Write integration test** + +Create `src-tauri/tests/ssh_profiles_test.rs`: +```rust +// Test profile round-trip (store + retrieve) +// This is a unit-level test of the profile serde logic — no network required. + +use terax_lib::modules::ssh::profiles::{AuthMethod, SshProfile}; + +#[test] +fn profile_serde_round_trip() { + let profile = SshProfile { + id: "test-id".into(), + name: "Test Server".into(), + host: "example.com".into(), + port: 22, + user: "alice".into(), + auth_method: AuthMethod::Key, + key_path: Some("~/.ssh/id_ed25519".into()), + known_fingerprint: None, + }; + + let json = serde_json::to_string(&profile).unwrap(); + let decoded: SshProfile = serde_json::from_str(&json).unwrap(); + + assert_eq!(decoded.id, "test-id"); + assert_eq!(decoded.host, "example.com"); + assert_eq!(decoded.port, 22); + assert!(decoded.known_fingerprint.is_none()); +} +``` + +Export `modules` from lib for tests. In `src-tauri/src/lib.rs` add: +```rust +pub mod modules; // make accessible to integration tests +``` + +- [ ] **Step 3: Run the test** + +```bash +cd src-tauri && cargo test ssh_profiles_test 2>&1 | tail -10 +``` +Expected: `test ssh_profiles_test::profile_serde_round_trip ... ok` + +- [ ] **Step 4: Verify full build** + +```bash +cd src-tauri && cargo check 2>&1 | grep "^error" | head -20 +``` +Expected: no errors + +- [ ] **Step 5: Commit** + +```bash +git add src-tauri/src/lib.rs src-tauri/tests/ +git commit -m "feat(ssh): register SshState + all SSH commands; add serde round-trip test" +``` + +--- + +## Task 14: Frontend — `WorkspaceEnvSelector` SSH section + TOFU dialog + +**Files:** +- Modify: `src/modules/statusbar/WorkspaceEnvSelector.tsx` +- Create: `src/modules/ssh/components/FingerprintDialog.tsx` + +- [ ] **Step 1: Create `FingerprintDialog`** + +Create `src/modules/ssh/components/FingerprintDialog.tsx`: +```tsx +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; + +type Props = { + host: string; + fingerprint: string; + open: boolean; + onConfirm: () => void; + onCancel: () => void; +}; + +export function FingerprintDialog({ host, fingerprint, open, onConfirm, onCancel }: Props) { + return ( + + + + Unknown host — verify fingerprint + +

+ You are connecting to {host} for the first time. + Verify the fingerprint below matches what you expect before continuing. +

+ + {fingerprint} + +
+
+ + Cancel + Trust & Connect + +
+
+ ); +} +``` + +- [ ] **Step 2: Update `WorkspaceEnvSelector` to include SSH profiles** + +In `src/modules/statusbar/WorkspaceEnvSelector.tsx`, add the SSH section. The component currently returns `null` if not Windows. Remove that guard — SSH is cross-platform. Add SSH profile loading and display: + +```tsx +import { useSshStore } from "@/modules/ssh/store"; +import { FingerprintDialog } from "@/modules/ssh/components/FingerprintDialog"; +import { useState } from "react"; +import { sshFingerprintGet } from "@/modules/ssh/commands"; + +// Inside the component (after existing hooks): +const sshProfiles = useSshStore((s) => s.profiles); +const sshConnect = useSshStore((s) => s.connect); +const loadSshProfiles = useSshStore((s) => s.loadProfiles); + +const [fingerprintDialog, setFingerprintDialog] = useState<{ + profileId: string; + host: string; + fingerprint: string; +} | null>(null); + +const handleSshSelect = async (profileId: string) => { + const profile = sshProfiles.find((p) => p.id === profileId); + if (!profile) return; + if (!profile.knownFingerprint) { + // First connect — show TOFU dialog after connecting (russh persists on connect) + await sshConnect(profileId); + const fp = await sshFingerprintGet(profileId); + if (fp) { + setFingerprintDialog({ profileId, host: profile.host, fingerprint: fp }); + return; + } + } + await sshConnect(profileId); + onSelect({ kind: "ssh", profileId }); +}; + +// In handleOpenChange, also load SSH profiles: +const handleOpenChange = (open: boolean) => { + if (open) { + if (IS_WINDOWS && distros.length === 0 && !loading) void refreshDistros(); + if (sshProfiles.length === 0) void loadSshProfiles(); + } +}; +``` + +Add SSH section to the dropdown content (before the final separator): +```tsx +{sshProfiles.length > 0 && ( + <> + + {sshProfiles.map((profile) => ( + void handleSshSelect(profile.id)} + > + SSH: {profile.name} ({profile.host}) + + ))} + +)} +``` + +Add `FingerprintDialog` at the bottom of the return: +```tsx +{fingerprintDialog && ( + { + onSelect({ kind: "ssh", profileId: fingerprintDialog.profileId }); + setFingerprintDialog(null); + }} + onCancel={() => { + setFingerprintDialog(null); + }} + /> +)} +``` + +Remove the `if (!IS_WINDOWS) return null;` guard — SSH works everywhere. + +- [ ] **Step 3: Verify TypeScript builds** + +```bash +pnpm build 2>&1 | grep "error TS" | head -20 +``` +Expected: no type errors + +- [ ] **Step 4: Commit** + +```bash +git add src/modules/statusbar/WorkspaceEnvSelector.tsx src/modules/ssh/components/FingerprintDialog.tsx +git commit -m "feat(ssh): WorkspaceEnvSelector SSH section + TOFU fingerprint dialog" +``` + +--- + +## Task 15: Frontend — SSH Settings tab + +**Files:** +- Create: `src/modules/ssh/components/SshProfilesSettings.tsx` +- Modify: `src/settings/SettingsApp.tsx` + +- [ ] **Step 1: Create `SshProfilesSettings`** + +Create `src/modules/ssh/components/SshProfilesSettings.tsx`: +```tsx +import { useEffect, useState } from "react"; +import { useSshStore } from "@/modules/ssh/store"; +import type { SshProfile, AuthMethod } from "@/modules/ssh/types"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { v4 as uuidv4 } from "uuid"; + +const EMPTY_FORM: Omit = { + name: "", + host: "", + port: 22, + user: "", + authMethod: "key", + keyPath: "", +}; + +export function SshProfilesSettings() { + const profiles = useSshStore((s) => s.profiles); + const loadProfiles = useSshStore((s) => s.loadProfiles); + const saveProfile = useSshStore((s) => s.saveProfile); + const deleteProfile = useSshStore((s) => s.deleteProfile); + + const [editing, setEditing] = useState(null); + const [form, setForm] = useState(EMPTY_FORM); + const [error, setError] = useState(null); + + useEffect(() => { void loadProfiles(); }, [loadProfiles]); + + const startEdit = (profile: SshProfile) => { + setEditing(profile); + setForm({ name: profile.name, host: profile.host, port: profile.port, user: profile.user, authMethod: profile.authMethod, keyPath: profile.keyPath ?? "" }); + setError(null); + }; + + const startNew = () => { + setEditing({ id: uuidv4(), ...EMPTY_FORM, knownFingerprint: undefined }); + setForm(EMPTY_FORM); + setError(null); + }; + + const handleSave = async () => { + if (!form.name || !form.host || !form.user) { + setError("Name, host, and user are required."); + return; + } + try { + await saveProfile({ ...form, id: editing!.id }); + setEditing(null); + setError(null); + } catch (e) { + setError(String(e)); + } + }; + + return ( +
+
+

SSH Profiles

+ +
+ + {profiles.length === 0 && !editing && ( +

No SSH profiles yet. Click New Profile to add one.

+ )} + +
    + {profiles.map((p) => ( +
  • +
    + {p.name} + {p.user}@{p.host}:{p.port} +
    +
    + + +
    +
  • + ))} +
+ + {editing && ( +
+

{editing.id ? "Edit Profile" : "New Profile"}

+
+
+ + setForm((f) => ({ ...f, name: e.target.value }))} placeholder="prod-server" /> +
+
+ + setForm((f) => ({ ...f, host: e.target.value }))} placeholder="example.com" /> +
+
+ + setForm((f) => ({ ...f, port: Number(e.target.value) }))} /> +
+
+ + setForm((f) => ({ ...f, user: e.target.value }))} placeholder="alice" /> +
+
+
+ +
+ {(["key", "agent"] as AuthMethod[]).map((m) => ( + + ))} +
+
+ {form.authMethod === "key" && ( +
+ + setForm((f) => ({ ...f, keyPath: e.target.value }))} placeholder="~/.ssh/id_ed25519" /> +
+ )} + {error &&

{error}

} +
+ + +
+
+ )} +
+ ); +} +``` + +- [ ] **Step 2: Add SSH tab to `SettingsApp.tsx`** + +In `src/modules/settings/openSettingsWindow.ts`, add `"ssh"` to the `SettingsTab` union: +```typescript +export type SettingsTab = + | "general" + | "shortcuts" + | "models" + | "agents" + | "ssh" + | "about"; +``` + +In `src/settings/SettingsApp.tsx`, add the import and register the tab: +```tsx +import { SshProfilesSettings } from "@/modules/ssh/components/SshProfilesSettings"; +import { Server01Icon } from "@hugeicons/core-free-icons"; +``` + +Add `"ssh"` to `VALID_TABS`: +```typescript +const VALID_TABS: SettingsTab[] = [ + "general", + "shortcuts", + "models", + "agents", + "ssh", + "about", +]; +``` + +Add the SSH tab to the `TABS` array (before `"about"`): +```typescript +{ id: "ssh", label: "SSH", icon: Server01Icon, component: SshProfilesSettings }, +``` + +No other changes needed — the existing tab rendering loop handles it automatically. + +- [ ] **Step 3: Verify TypeScript builds** + +```bash +pnpm build 2>&1 | grep "error TS" | head -20 +``` +Expected: no type errors + +- [ ] **Step 4: Commit** + +```bash +git add src/modules/ssh/components/SshProfilesSettings.tsx src/settings/SettingsApp.tsx +git commit -m "feat(ssh): SSH profiles settings tab — create/edit/delete profiles" +``` + +--- + +## Task 16: Push to personal fork and verify + +- [ ] **Step 1: Final compile check** + +```bash +cd src-tauri && cargo check 2>&1 | grep "^error" +pnpm build 2>&1 | grep "error TS" +``` +Expected: both clean + +- [ ] **Step 2: Run Rust tests** + +```bash +cd src-tauri && cargo test 2>&1 | tail -15 +``` +Expected: all tests pass including `profile_serde_round_trip` + +- [ ] **Step 3: Push to personal fork** + +```bash +git push personal main +``` + +- [ ] **Step 4: Verify push** + +```bash +gh repo view dcieslak19973/terax-ai --web +``` +Or confirm at: https://github.com/dcieslak19973/terax-ai + +--- + +## Notes for implementer + +- **`russh` API surface:** This plan targets russh 0.45. If the exact method signatures differ, check `cargo doc --open` after adding the dep in Task 1 before writing handler code. +- **Agent auth on Windows:** Named-pipe ssh-agent (e.g. 1Password, OpenSSH for Windows) uses a different socket mechanism. The plan stubs it as unsupported — add when needed. +- **SFTP `channel.into_stream()`:** russh-sftp 2.x requires the channel to be converted via `.into_stream()`. If the API differs, check `russh_sftp::client::SftpSession::new`'s expected argument type. +- **Shell commands (`fs_search`, `fs_grep`):** These run `find`/`grep` on the remote. Assumes a POSIX shell on the server. Adjust escaping if targeting Windows SSH servers. +- **`uuid` in frontend:** The plan uses `uuid` npm package in `store.ts`. Add it: `pnpm add uuid && pnpm add -D @types/uuid`. diff --git a/docs/superpowers/specs/2026-05-15-ssh-support-design.md b/docs/superpowers/specs/2026-05-15-ssh-support-design.md new file mode 100644 index 00000000..42d09748 --- /dev/null +++ b/docs/superpowers/specs/2026-05-15-ssh-support-design.md @@ -0,0 +1,194 @@ +# SSH Support Design + +**Date:** 2026-05-15 +**Status:** Approved +**Scope:** Native SSH as a first-class `WorkspaceEnv` variant — terminal PTY + remote FS (SFTP) + +--- + +## Architecture Overview + +SSH extends the existing `WorkspaceEnv` pattern across all layers. One `russh` session per profile is shared across terminal panes and FS operations opened to the same host. + +``` +Frontend Rust Backend +───────────────────────── ────────────────────────────────────── +WorkspaceEnv WorkspaceEnv enum + { kind: "ssh", ──────▶ Ssh { profile_id } + profileId } │ + ▼ +WorkspaceEnvSelector ssh::ConnectionManager + (SSH profiles in (HashMap>) + status bar dropdown) │ + ├─ PTY channel → pty_open +SSH Settings tab ssh::SshConn │ + (CRUD profiles) russh session ├─ SFTP subsystem → fs::* + + russh-sftp └─ shell channel → shell::* +``` + +--- + +## Data Model & Profile Storage + +Profiles stored in `tauri-plugin-store` under key `ssh_profiles`. Passphrases stored separately in the OS keyring via the existing `secrets` module under `ssh:`. + +```typescript +type SshProfile = { + id: string; // uuid + name: string; // display name, e.g. "prod-server" + host: string; + port: number; // default 22 + user: string; + authMethod: "key" | "agent"; + keyPath?: string; // absolute path, e.g. ~/.ssh/id_ed25519 + knownFingerprint?: string; // SHA256 of host key; absent until first connect +}; +``` + +`WorkspaceEnv` frontend type gains: +```typescript +| { kind: "ssh"; profileId: string } +``` + +Rust `WorkspaceEnv` enum gains: +```rust +Ssh { profile_id: String } +``` + +**Host key verification** uses TOFU (Trust On First Use): on first connect the fingerprint is shown to the user for confirmation, then stored in the profile. On subsequent connects a mismatch is a hard block. + +--- + +## Rust Module Structure + +New module at `src-tauri/src/modules/ssh/`: + +``` +ssh/ +├── mod.rs — SshState (connection manager), all tauri commands +├── connection.rs — SshConn: russh client session + SftpSession handle +├── handler.rs — russh ClientHandler impl (host key verification, auth) +├── pty.rs — open_shell_channel() wired to existing on_data/on_exit channels +└── sftp.rs — SFTP wrappers matching the fs:: command API surface +``` + +**`SshState`** registered with `.manage()`: +```rust +pub struct SshState { + conns: RwLock>>, +} +``` + +**`SshConn`** holds: +- `russh::client::Handle` — for opening new channels +- `russh_sftp::client::SftpSession` — for file operations, opened once on connect +- `known_fingerprint: String` — SHA256, matched on reconnect + +### New Tauri Commands + +| Command | Returns | Purpose | +|---|---|---| +| `ssh_profile_list` | `Vec` | List saved profiles | +| `ssh_profile_save` | `SshProfile` | Create or update a profile | +| `ssh_profile_delete` | `()` | Remove a profile and its keyring entry | +| `ssh_connect` | `()` | Open+cache connection, verify fingerprint | +| `ssh_disconnect` | `()` | Close connection for a profile | +| `ssh_fingerprint_get` | `Option` | Read live fingerprint before TOFU confirm | + +### New Cargo Dependencies + +```toml +russh = "0.45" +russh-sftp = "2" +uuid = { version = "1", features = ["v4"] } # already in wmux, add here +``` + +--- + +## PTY Channel Integration + +`pty_open` gains an SSH branch. The `on_data`/`on_exit` channel interface is identical to local PTY — `TerminalPane` requires no changes. + +```rust +WorkspaceEnv::Ssh { profile_id } => { + let conn = ssh_state.get_or_err(&profile_id)?; + ssh::pty::open_channel(conn, cols, rows, on_data, on_exit).await +} +``` + +Inside `ssh::pty::open_channel`: +1. `conn.handle.channel_open_session()` → `Channel` +2. `channel.request_pty("xterm-256color", cols, rows)` +3. `channel.request_shell()` +4. Reader thread: `channel.stdout` → buffer → flush via `on_data` (same backpressure logic as local PTY) +5. Waiter thread: `channel.wait()` → `on_exit` + +`pty_write` / `pty_resize` / `pty_close` are unchanged — `Session` gets a new variant wrapping the SSH channel handle. + +--- + +## SFTP / Remote FS + +Each `fs::*` command already takes `workspace: WorkspaceEnv`. The SSH branch uses `russh-sftp`'s async API: + +| Command | SFTP equivalent | +|---|---| +| `fs_read_dir` | `sftp.read_dir(path)` | +| `fs_read_file` | `sftp.open(path).read_to_end()` | +| `fs_write_file` | `sftp.create(path).write_all()` | +| `fs_stat` | `sftp.metadata(path)` | +| `fs_create_file` | `sftp.create(path)` | +| `fs_create_dir` | `sftp.create_dir(path)` | +| `fs_rename` | `sftp.rename(src, dst)` | +| `fs_delete` | `sftp.remove_file()` / `sftp.remove_dir()` | +| `fs_search` / `fs_grep` | Run remote `find`/`grep` via a shell channel | + +`SshConn` holds one persistent `SftpSession` opened during `ssh_connect` and reused for all FS ops. + +--- + +## Frontend Changes + +### `WorkspaceEnv` type +Add `ssh` variant; update `env.ts` and `WorkspaceEnvSelector`. SSH profiles appear in the selector on all platforms (not Windows-gated unlike WSL). + +### SSH Settings tab +New tab in the existing Settings window: +- Profile list (name, host:port, user) +- Create/edit form: name, host, port, user, auth method, key path picker +- Passphrase stored via existing `secrets_set` command + +### First-connect fingerprint dialog +When `ssh_connect` returns a new fingerprint, a modal displays it and requires user confirmation before caching. Standard TOFU flow. Fingerprint mismatch on reconnect shows a hard-block warning modal. + +### Connection lifecycle +- `ssh_connect` called when user selects an SSH profile in the workspace selector +- `ssh_disconnect` on workspace switch away or app close +- Status indicator in the selector: connecting / connected / error + +--- + +## Error Handling + +| Error | Behavior | +|---|---| +| Auth failure | Toast with "re-enter passphrase" prompt | +| Fingerprint mismatch | Hard-block modal — never silently connect | +| Connection drop mid-session | Terminal shows standard exit notice; FS ops return inline error | +| SFTP unavailable on server | FS ops return "remote FS unavailable"; terminal still works | + +--- + +## Testing + +- Unit tests for SFTP wrappers (mock `SftpSession`) +- Integration test: in-process `russh` server fixture, connect, open shell channel, assert round-trip data +- Frontend: existing terminal pane tests pass unchanged (PTY interface is identical) + +--- + +## Out of Scope (this iteration) + +- Jump hosts / SSH proxy chains +- Port forwarding +- Remote extension of AI tools over SSH diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 57d7bf2f..3e721eda 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.7.8" @@ -267,6 +302,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.21.7" @@ -279,6 +320,23 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bcrypt-pbkdf" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" +dependencies = [ + "blowfish", + "pbkdf2 0.12.2", + "sha2", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -330,6 +388,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -352,6 +419,16 @@ dependencies = [ "piper", ] +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "borsh" version = "1.6.1" @@ -536,6 +613,15 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.60" @@ -591,6 +677,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "chrono" version = "0.4.44" @@ -598,11 +695,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "combine" version = "4.6.7" @@ -622,6 +731,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "convert_case" version = "0.4.0" @@ -740,6 +855,18 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -747,6 +874,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -800,6 +928,42 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling" version = "0.23.0" @@ -834,6 +998,37 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -889,6 +1084,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher", +] + [[package]] name = "digest" version = "0.10.7" @@ -896,7 +1100,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", + "subtle", ] [[package]] @@ -1064,6 +1270,66 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "embed-resource" version = "3.0.8" @@ -1211,6 +1477,22 @@ dependencies = [ "log", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "field-offset" version = "0.3.6" @@ -1329,6 +1611,21 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1336,6 +1633,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1403,6 +1701,7 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1529,6 +1828,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1592,6 +1892,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gio" version = "0.18.4" @@ -1738,6 +2048,17 @@ dependencies = [ "memmap2", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "gtk" version = "0.18.2" @@ -1799,6 +2120,12 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -1838,6 +2165,39 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "html5ever" version = "0.29.1" @@ -2155,6 +2515,16 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2339,6 +2709,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "leb128fmt" @@ -2386,6 +2759,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.16" @@ -2482,6 +2861,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "memchr" version = "2.8.0" @@ -2626,12 +3011,59 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", + "rand 0.8.6", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2639,6 +3071,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2886,6 +3319,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" version = "5.3.4" @@ -2952,12 +3391,50 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" dependencies = [ - "objc2", - "objc2-foundation", - "objc2-osa-kit", - "serde", - "serde_json", - "thiserror 2.0.18", + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2", ] [[package]] @@ -3014,12 +3491,54 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pathdiff" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest", + "hmac", + "password-hash", + "sha2", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3230,6 +3749,44 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs5" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" +dependencies = [ + "aes", + "cbc", + "der", + "pbkdf2 0.12.2", + "scrypt", + "sha2", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "pkcs5", + "rand_core 0.6.4", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -3282,6 +3839,29 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-pty" version = "0.9.0" @@ -3343,6 +3923,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -3492,7 +4081,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -3816,6 +4405,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -3859,6 +4458,149 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "russh" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a229f2a03daea3f62cee897b40329ce548600cca615906d98d58b8db3029b19" +dependencies = [ + "aes", + "aes-gcm", + "async-trait", + "bitflags 2.11.1", + "byteorder", + "cbc", + "chacha20", + "ctr", + "curve25519-dalek", + "des", + "digest", + "elliptic-curve", + "flate2", + "futures", + "generic-array", + "hex-literal", + "hmac", + "log", + "num-bigint", + "once_cell", + "p256", + "p384", + "p521", + "poly1305", + "rand 0.8.6", + "rand_core 0.6.4", + "russh-cryptovec", + "russh-keys", + "sha1", + "sha2", + "ssh-encoding", + "ssh-key", + "subtle", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "russh-cryptovec" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fadd2c0ab350e21c66556f94ee06f766d8bdae3213857ba7610bfd8e10e51880" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "russh-keys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89757474f7c9ee30121d8cc7fe293a954ba10b204a82ccf5850a5352a532ebc7" +dependencies = [ + "aes", + "async-trait", + "bcrypt-pbkdf", + "block-padding", + "byteorder", + "cbc", + "ctr", + "data-encoding", + "der", + "digest", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "futures", + "hmac", + "home", + "inout", + "log", + "md5", + "num-integer", + "p256", + "p384", + "p521", + "pbkdf2 0.11.0", + "pkcs1", + "pkcs5", + "pkcs8", + "rand 0.8.6", + "rand_core 0.6.4", + "rsa", + "russh-cryptovec", + "sec1", + "serde", + "sha1", + "sha2", + "spki", + "ssh-encoding", + "ssh-key", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "typenum", + "zeroize", +] + +[[package]] +name = "russh-sftp" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09daa0ebcf53fb18d7b16167586a68b5bf2cfa3eaad49e661a19302552a2b879" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "chrono", + "dashmap", + "log", + "serde", + "serde_bytes", + "thiserror 2.0.18", + "tokio", + "tokio-util", +] + [[package]] name = "rust_decimal" version = "1.41.0" @@ -3990,6 +4732,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -4065,12 +4816,37 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2 0.12.2", + "salsa20", + "sha2", +] + [[package]] name = "seahash" version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -4176,6 +4952,16 @@ dependencies = [ "typeid", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -4344,6 +5130,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -4382,6 +5179,15 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" +[[package]] +name = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "dirs 6.0.0", +] + [[package]] name = "shlex" version = "1.3.0" @@ -4419,6 +5225,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -4513,6 +5329,73 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "aes", + "aes-gcm", + "cbc", + "chacha20", + "cipher", + "ctr", + "poly1305", + "ssh-encoding", + "subtle", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "pem-rfc7468", + "sha2", +] + +[[package]] +name = "ssh-key" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +dependencies = [ + "bcrypt-pbkdf", + "ed25519-dalek", + "num-bigint-dig", + "p256", + "p384", + "p521", + "rand_core 0.6.4", + "rsa", + "sec1", + "sha2", + "signature", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -5146,6 +6029,7 @@ dependencies = [ name = "terax" version = "0.6.6" dependencies = [ + "async-trait", "bytes", "dirs 5.0.1", "futures-util", @@ -5159,9 +6043,13 @@ dependencies = [ "log", "portable-pty", "reqwest 0.12.28", + "russh", + "russh-sftp", "serde", "serde_json", "shared_child", + "shellexpand", + "ssh-key", "tauri", "tauri-build", "tauri-plugin-autostart", @@ -5172,6 +6060,8 @@ dependencies = [ "tauri-plugin-store", "tauri-plugin-updater", "tauri-plugin-window-state", + "tokio", + "uuid", "windows-sys 0.59.0", ] @@ -5282,7 +6172,9 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -5309,6 +6201,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -5613,6 +6516,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a7404f24..ec0a925e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -41,6 +41,13 @@ reqwest = { version = "0.12", default-features = false, features = [ ] } bytes = "1" futures-util = "0.3" +russh = "0.45" +russh-sftp = "2" +ssh-key = { version = "0.6", features = ["std"] } +uuid = { version = "1", features = ["v4"] } +tokio = { version = "1", features = ["full"] } +async-trait = "0.1" +shellexpand = "3" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dcdf4250..665cbc70 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,6 @@ mod modules; -use modules::{fs, net, pty, secrets, shell, workspace}; +use modules::{fs, net, pty, secrets, shell, ssh, workspace}; use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; use tauri_plugin_window_state::StateFlags; @@ -84,6 +84,7 @@ pub fn run() { .manage(pty::PtyState::default()) .manage(shell::ShellState::default()) .manage(secrets::SecretsState::default()) + .manage(ssh::SshState::default()) .invoke_handler(tauri::generate_handler![ pty::pty_open, pty::pty_write, @@ -121,6 +122,14 @@ pub fn run() { net::lm_ping, net::ai_http_request, net::ai_http_stream, + ssh::ssh_connect, + ssh::ssh_disconnect, + ssh::ssh_fingerprint_get, + ssh::ssh_fingerprint_save, + ssh::ssh_home, + modules::ssh::profiles::ssh_profile_list, + modules::ssh::profiles::ssh_profile_save, + modules::ssh::profiles::ssh_profile_delete, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/modules/fs/file.rs b/src-tauri/src/modules/fs/file.rs index 03a8d439..e4b5993b 100644 --- a/src-tauri/src/modules/fs/file.rs +++ b/src-tauri/src/modules/fs/file.rs @@ -41,107 +41,140 @@ pub struct FileStat { } #[tauri::command] -pub fn fs_read_file(path: String, workspace: Option) -> Result { +pub async fn fs_read_file( + path: String, + workspace: Option, + ssh_state: tauri::State<'_, crate::modules::ssh::SshState>, +) -> Result { let workspace = WorkspaceEnv::from_option(workspace); - let p = resolve_path(&path, &workspace); - let meta = std::fs::metadata(&p).map_err(|e| { - log::debug!("fs_read_file stat({}) failed: {e}", p.display()); - e.to_string() - })?; - - let size = meta.len(); - if size > MAX_READ_BYTES { - return Ok(ReadResult::TooLarge { - size, - limit: MAX_READ_BYTES, - }); + if let WorkspaceEnv::Ssh { profile_id } = &workspace { + let conn = ssh_state.get_or_err(profile_id)?; + return crate::modules::ssh::sftp::sftp_read_file(&conn, &path).await; } + let p = resolve_path(&path, &workspace); + tauri::async_runtime::spawn_blocking(move || { + let meta = std::fs::metadata(&p).map_err(|e| { + log::debug!("fs_read_file stat({}) failed: {e}", p.display()); + e.to_string() + })?; - let bytes = std::fs::read(&p).map_err(|e| { - log::debug!("fs_read_file read({}) failed: {e}", p.display()); - e.to_string() - })?; + let size = meta.len(); + if size > MAX_READ_BYTES { + return Ok(ReadResult::TooLarge { + size, + limit: MAX_READ_BYTES, + }); + } - // Null-byte sniff on the first chunk. Not perfect (misses UTF-16 BOM - // cases) but catches the common "this is a PNG" mistake cheaply. - let sniff_len = bytes.len().min(BINARY_SNIFF_BYTES); - if bytes[..sniff_len].contains(&0) { - return Ok(ReadResult::Binary { size }); - } + let bytes = std::fs::read(&p).map_err(|e| { + log::debug!("fs_read_file read({}) failed: {e}", p.display()); + e.to_string() + })?; - match String::from_utf8(bytes) { - Ok(content) => Ok(ReadResult::Text { content, size }), - Err(_) => Ok(ReadResult::Binary { size }), - } + // Null-byte sniff on the first chunk. Not perfect (misses UTF-16 BOM + // cases) but catches the common "this is a PNG" mistake cheaply. + let sniff_len = bytes.len().min(BINARY_SNIFF_BYTES); + if bytes[..sniff_len].contains(&0) { + return Ok(ReadResult::Binary { size }); + } + + match String::from_utf8(bytes) { + Ok(content) => Ok(ReadResult::Text { content, size }), + Err(_) => Ok(ReadResult::Binary { size }), + } + }) + .await + .map_err(|e| e.to_string())? } /// Atomic write: stage into a sibling temp file, then rename over the target. /// Prevents partial writes from leaving a half-saved file on crash/power loss. #[tauri::command] -pub fn fs_write_file( +pub async fn fs_write_file( path: String, content: String, workspace: Option, + ssh_state: tauri::State<'_, crate::modules::ssh::SshState>, ) -> Result<(), String> { let workspace = WorkspaceEnv::from_option(workspace); + if let WorkspaceEnv::Ssh { profile_id } = &workspace { + let conn = ssh_state.get_or_err(profile_id)?; + return crate::modules::ssh::sftp::sftp_write_file(&conn, &path, &content).await; + } let target = resolve_path(&path, &workspace); - let parent = target - .parent() - .ok_or_else(|| "path has no parent".to_string())?; - let file_name = target - .file_name() - .and_then(|s| s.to_str()) - .ok_or_else(|| "path has no file name".to_string())?; - - let tmp = parent.join(format!(".{file_name}.terax.tmp")); - - { - let mut f = std::fs::File::create(&tmp).map_err(|e| { - log::debug!("fs_write_file create({}) failed: {e}", tmp.display()); + tauri::async_runtime::spawn_blocking(move || { + let parent = target + .parent() + .ok_or_else(|| "path has no parent".to_string())?; + let file_name = target + .file_name() + .and_then(|s| s.to_str()) + .ok_or_else(|| "path has no file name".to_string())?; + + let tmp = parent.join(format!(".{file_name}.terax.tmp")); + + { + let mut f = std::fs::File::create(&tmp).map_err(|e| { + log::debug!("fs_write_file create({}) failed: {e}", tmp.display()); + e.to_string() + })?; + f.write_all(content.as_bytes()).map_err(|e| { + log::debug!("fs_write_file write({}) failed: {e}", tmp.display()); + e.to_string() + })?; + f.sync_all().map_err(|e| e.to_string())?; + } + + std::fs::rename(&tmp, &target).map_err(|e| { + log::warn!( + "fs_write_file rename({} -> {}) failed: {e}", + tmp.display(), + target.display() + ); + // Best-effort cleanup of the staged temp. + let _ = std::fs::remove_file(&tmp); e.to_string() })?; - f.write_all(content.as_bytes()).map_err(|e| { - log::debug!("fs_write_file write({}) failed: {e}", tmp.display()); - e.to_string() - })?; - f.sync_all().map_err(|e| e.to_string())?; - } - std::fs::rename(&tmp, &target).map_err(|e| { - log::warn!( - "fs_write_file rename({} -> {}) failed: {e}", - tmp.display(), - target.display() - ); - // Best-effort cleanup of the staged temp. - let _ = std::fs::remove_file(&tmp); - e.to_string() - })?; - - Ok(()) + Ok(()) + }) + .await + .map_err(|e| e.to_string())? } #[tauri::command] -pub fn fs_stat(path: String, workspace: Option) -> Result { +pub async fn fs_stat( + path: String, + workspace: Option, + ssh_state: tauri::State<'_, crate::modules::ssh::SshState>, +) -> Result { let workspace = WorkspaceEnv::from_option(workspace); + if let WorkspaceEnv::Ssh { profile_id } = &workspace { + let conn = ssh_state.get_or_err(profile_id)?; + return crate::modules::ssh::sftp::sftp_stat(&conn, &path).await; + } let p = resolve_path(&path, &workspace); - let meta = std::fs::metadata(&p).map_err(|e| e.to_string())?; - let kind = if meta.is_dir() { - StatKind::Dir - } else if meta.file_type().is_symlink() { - StatKind::Symlink - } else { - StatKind::File - }; - let mtime = meta - .modified() - .ok() - .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) - .map(|d| d.as_millis() as u64) - .unwrap_or(0); - Ok(FileStat { - size: meta.len(), - mtime, - kind, + tauri::async_runtime::spawn_blocking(move || { + let meta = std::fs::metadata(&p).map_err(|e| e.to_string())?; + let kind = if meta.is_dir() { + StatKind::Dir + } else if meta.file_type().is_symlink() { + StatKind::Symlink + } else { + StatKind::File + }; + let mtime = meta + .modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + Ok(FileStat { + size: meta.len(), + mtime, + kind, + }) }) + .await + .map_err(|e| e.to_string())? } diff --git a/src-tauri/src/modules/fs/grep.rs b/src-tauri/src/modules/fs/grep.rs index 93391666..69f9bea4 100644 --- a/src-tauri/src/modules/fs/grep.rs +++ b/src-tauri/src/modules/fs/grep.rs @@ -44,127 +44,151 @@ fn build_globset(patterns: &[String]) -> Result, String> { } #[tauri::command] -pub fn fs_grep( +pub async fn fs_grep( pattern: String, root: String, glob: Option>, case_insensitive: Option, max_results: Option, workspace: Option, + ssh_state: tauri::State<'_, crate::modules::ssh::SshState>, ) -> Result { if pattern.is_empty() { return Err("empty pattern".into()); } let workspace = WorkspaceEnv::from_option(workspace); - let root_path = resolve_path(&root, &workspace); - if !root_path.is_dir() { - return Err(format!("not a directory: {root}")); + if let WorkspaceEnv::Ssh { profile_id } = &workspace { + let conn = ssh_state.get_or_err(profile_id)?; + let cap = max_results.unwrap_or(DEFAULT_MAX_RESULTS).clamp(1, HARD_MAX_RESULTS); + let lines = crate::modules::ssh::sftp::sftp_grep(&conn, &root, &pattern).await?; + let truncated = lines.len() >= cap; + let hits = lines + .into_iter() + .take(cap) + .filter_map(|l| { + let mut parts = l.splitn(3, ':'); + let path = parts.next()?.to_string(); + let line: u64 = parts.next()?.parse().ok()?; + let text = parts.next().unwrap_or("").to_string(); + let rel = path.strip_prefix(&root).unwrap_or(&path).trim_start_matches('/').to_string(); + Some(GrepHit { path, rel, line, text }) + }) + .collect(); + return Ok(GrepResponse { hits, truncated, files_scanned: 0 }); } let cap = max_results .unwrap_or(DEFAULT_MAX_RESULTS) .clamp(1, HARD_MAX_RESULTS); + let root_path = resolve_path(&root, &workspace); + if !root_path.is_dir() { + return Err(format!("not a directory: {root}")); + } - let matcher = RegexMatcherBuilder::new() - .case_insensitive(case_insensitive.unwrap_or(false)) - .line_terminator(Some(b'\n')) - .build(&pattern) - .map_err(|e| format!("bad regex: {e}"))?; + tauri::async_runtime::spawn_blocking(move || { + let matcher = RegexMatcherBuilder::new() + .case_insensitive(case_insensitive.unwrap_or(false)) + .line_terminator(Some(b'\n')) + .build(&pattern) + .map_err(|e| format!("bad regex: {e}"))?; - let globs = build_globset(glob.as_deref().unwrap_or(&[]))?; + let globs = build_globset(glob.as_deref().unwrap_or(&[]))?; - let walker = WalkBuilder::new(&root_path) - .hidden(true) - .git_ignore(true) - .git_global(true) - .git_exclude(true) - .ignore(true) - .parents(true) - .follow_links(false) - .build_parallel(); + let walker = WalkBuilder::new(&root_path) + .hidden(true) + .git_ignore(true) + .git_global(true) + .git_exclude(true) + .ignore(true) + .parents(true) + .follow_links(false) + .build_parallel(); - let hits: Arc>> = Arc::new(Mutex::new(Vec::new())); - let scanned = Arc::new(AtomicUsize::new(0)); - let truncated = Arc::new(AtomicBool::new(false)); + let hits: Arc>> = Arc::new(Mutex::new(Vec::new())); + let scanned = Arc::new(AtomicUsize::new(0)); + let truncated = Arc::new(AtomicBool::new(false)); - walker.run(|| { - let matcher = matcher.clone(); - let globs = globs.clone(); - let hits = hits.clone(); - let scanned = scanned.clone(); - let truncated = truncated.clone(); - let root_path = root_path.clone(); - let root_display = root.clone(); - let workspace = workspace.clone(); + walker.run(|| { + let matcher = matcher.clone(); + let globs = globs.clone(); + let hits = hits.clone(); + let scanned = scanned.clone(); + let truncated = truncated.clone(); + let root_path = root_path.clone(); + let root_display = root.clone(); + let workspace = workspace.clone(); - Box::new(move |dent_res| { - if truncated.load(Ordering::Relaxed) { - return WalkState::Quit; - } - let dent = match dent_res { - Ok(d) => d, - Err(_) => return WalkState::Continue, - }; - if !dent.file_type().map(|t| t.is_file()).unwrap_or(false) { - return WalkState::Continue; - } - let path = dent.path(); - let rel = match path.strip_prefix(&root_path) { - Ok(r) => to_canon(r), - Err(_) => return WalkState::Continue, - }; - if let Some(set) = globs.as_ref() { - if !set.is_match(&rel) { - return WalkState::Continue; + Box::new(move |dent_res| { + if truncated.load(Ordering::Relaxed) { + return WalkState::Quit; } - } - if let Ok(meta) = std::fs::metadata(path) { - if meta.len() > FILE_SIZE_CAP { + let dent = match dent_res { + Ok(d) => d, + Err(_) => return WalkState::Continue, + }; + if !dent.file_type().map(|t| t.is_file()).unwrap_or(false) { return WalkState::Continue; } - } + let path = dent.path(); + let rel = match path.strip_prefix(&root_path) { + Ok(r) => to_canon(r), + Err(_) => return WalkState::Continue, + }; + if let Some(set) = globs.as_ref() { + if !set.is_match(&rel) { + return WalkState::Continue; + } + } + if let Ok(meta) = std::fs::metadata(path) { + if meta.len() > FILE_SIZE_CAP { + return WalkState::Continue; + } + } - scanned.fetch_add(1, Ordering::Relaxed); + scanned.fetch_add(1, Ordering::Relaxed); - let abs = display_path(path, &root_path, &root_display, &workspace); - let rel_clone = rel.clone(); - let mut searcher = SearcherBuilder::new() - .binary_detection(BinaryDetection::quit(b'\x00')) - .line_number(true) - .build(); + let abs = display_path(path, &root_path, &root_display, &workspace); + let rel_clone = rel.clone(); + let mut searcher = SearcherBuilder::new() + .binary_detection(BinaryDetection::quit(b'\x00')) + .line_number(true) + .build(); - let _ = searcher.search_path( - &matcher, - path, - UTF8(|line_num, text| { - let line_text = text.trim_end_matches('\n').to_string(); - let mut guard = hits.lock().unwrap(); - if guard.len() >= cap { - truncated.store(true, Ordering::Relaxed); - return Ok(false); - } - guard.push(GrepHit { - path: abs.clone(), - rel: rel_clone.clone(), - line: line_num, - text: line_text, - }); - Ok(true) - }), - ); + let _ = searcher.search_path( + &matcher, + path, + UTF8(|line_num, text| { + let line_text = text.trim_end_matches('\n').to_string(); + let mut guard = hits.lock().unwrap(); + if guard.len() >= cap { + truncated.store(true, Ordering::Relaxed); + return Ok(false); + } + guard.push(GrepHit { + path: abs.clone(), + rel: rel_clone.clone(), + line: line_num, + text: line_text, + }); + Ok(true) + }), + ); - WalkState::Continue - }) - }); + WalkState::Continue + }) + }); - let final_hits = Arc::try_unwrap(hits) - .map(|m| m.into_inner().unwrap()) - .unwrap_or_default(); + let final_hits = Arc::try_unwrap(hits) + .map(|m| m.into_inner().unwrap()) + .unwrap_or_default(); - Ok(GrepResponse { - hits: final_hits, - truncated: truncated.load(Ordering::Relaxed), - files_scanned: scanned.load(Ordering::Relaxed), + Ok(GrepResponse { + hits: final_hits, + truncated: truncated.load(Ordering::Relaxed), + files_scanned: scanned.load(Ordering::Relaxed), + }) }) + .await + .map_err(|e| e.to_string())? } #[derive(Serialize)] @@ -180,62 +204,70 @@ pub struct GlobResponse { } #[tauri::command] -pub fn fs_glob( +pub async fn fs_glob( pattern: String, root: String, max_results: Option, workspace: Option, + _ssh_state: tauri::State<'_, crate::modules::ssh::SshState>, ) -> Result { if pattern.is_empty() { return Err("empty pattern".into()); } let workspace = WorkspaceEnv::from_option(workspace); - let root_path = resolve_path(&root, &workspace); - if !root_path.is_dir() { - return Err(format!("not a directory: {root}")); + if let WorkspaceEnv::Ssh { .. } = &workspace { + return Ok(GlobResponse { hits: Vec::new(), truncated: false }); } let cap = max_results.unwrap_or(500).clamp(1, HARD_MAX_RESULTS); + tauri::async_runtime::spawn_blocking(move || { + let root_path = resolve_path(&root, &workspace); + if !root_path.is_dir() { + return Err(format!("not a directory: {root}")); + } - let glob = Glob::new(&pattern).map_err(|e| format!("bad glob: {e}"))?; - let mut gb = GlobSetBuilder::new(); - gb.add(glob); - let set = gb.build().map_err(|e| format!("globset build: {e}"))?; + let glob = Glob::new(&pattern).map_err(|e| format!("bad glob: {e}"))?; + let mut gb = GlobSetBuilder::new(); + gb.add(glob); + let set = gb.build().map_err(|e| format!("globset build: {e}"))?; - let walker = WalkBuilder::new(&root_path) - .hidden(true) - .git_ignore(true) - .git_global(true) - .git_exclude(true) - .ignore(true) - .parents(true) - .follow_links(false) - .build(); + let walker = WalkBuilder::new(&root_path) + .hidden(true) + .git_ignore(true) + .git_global(true) + .git_exclude(true) + .ignore(true) + .parents(true) + .follow_links(false) + .build(); - let mut hits: Vec = Vec::new(); - let mut truncated = false; - for dent in walker.flatten() { - if hits.len() >= cap { - truncated = true; - break; - } - if !dent.file_type().map(|t| t.is_file()).unwrap_or(false) { - continue; - } - let path = dent.path(); - let rel = match path.strip_prefix(&root_path) { - Ok(r) => to_canon(r), - Err(_) => continue, - }; - if !set.is_match(&rel) { - continue; + let mut hits: Vec = Vec::new(); + let mut truncated = false; + for dent in walker.flatten() { + if hits.len() >= cap { + truncated = true; + break; + } + if !dent.file_type().map(|t| t.is_file()).unwrap_or(false) { + continue; + } + let path = dent.path(); + let rel = match path.strip_prefix(&root_path) { + Ok(r) => to_canon(r), + Err(_) => continue, + }; + if !set.is_match(&rel) { + continue; + } + hits.push(GlobHit { + path: display_path(path, &root_path, &root, &workspace), + rel, + }); } - hits.push(GlobHit { - path: display_path(path, &root_path, &root, &workspace), - rel, - }); - } - Ok(GlobResponse { hits, truncated }) + Ok(GlobResponse { hits, truncated }) + }) + .await + .map_err(|e| e.to_string())? } fn display_path( diff --git a/src-tauri/src/modules/fs/mutate.rs b/src-tauri/src/modules/fs/mutate.rs index 7e47957f..10381b86 100644 --- a/src-tauri/src/modules/fs/mutate.rs +++ b/src-tauri/src/modules/fs/mutate.rs @@ -2,75 +2,124 @@ use crate::modules::workspace::{resolve_path, WorkspaceEnv}; /// Creates a new empty file. Fails if the file already exists. #[tauri::command] -pub fn fs_create_file(path: String, workspace: Option) -> Result<(), String> { +pub async fn fs_create_file( + path: String, + workspace: Option, + ssh_state: tauri::State<'_, crate::modules::ssh::SshState>, +) -> Result<(), String> { let workspace = WorkspaceEnv::from_option(workspace); - let p = resolve_path(&path, &workspace); - if p.exists() { - return Err(format!("already exists: {}", p.display())); + if let WorkspaceEnv::Ssh { profile_id } = &workspace { + let conn = ssh_state.get_or_err(profile_id)?; + return crate::modules::ssh::sftp::sftp_create_file(&conn, &path).await; } - std::fs::write(&p, "").map_err(|e| { - log::debug!("fs_create_file({}) failed: {e}", p.display()); - e.to_string() + let p = resolve_path(&path, &workspace); + tauri::async_runtime::spawn_blocking(move || { + if p.exists() { + return Err(format!("already exists: {}", p.display())); + } + std::fs::write(&p, "").map_err(|e| { + log::debug!("fs_create_file({}) failed: {e}", p.display()); + e.to_string() + }) }) + .await + .map_err(|e| e.to_string())? } /// Creates a new directory. Fails if the directory already exists. /// Parents are created as needed — matches the common "new folder" UX /// where typing "a/b/c" creates the full chain. #[tauri::command] -pub fn fs_create_dir(path: String, workspace: Option) -> Result<(), String> { +pub async fn fs_create_dir( + path: String, + workspace: Option, + ssh_state: tauri::State<'_, crate::modules::ssh::SshState>, +) -> Result<(), String> { let workspace = WorkspaceEnv::from_option(workspace); - let p = resolve_path(&path, &workspace); - if p.exists() { - return Err(format!("already exists: {}", p.display())); + if let WorkspaceEnv::Ssh { profile_id } = &workspace { + let conn = ssh_state.get_or_err(profile_id)?; + return crate::modules::ssh::sftp::sftp_create_dir(&conn, &path).await; } - std::fs::create_dir_all(&p).map_err(|e| { - log::debug!("fs_create_dir({}) failed: {e}", p.display()); - e.to_string() + let p = resolve_path(&path, &workspace); + tauri::async_runtime::spawn_blocking(move || { + if p.exists() { + return Err(format!("already exists: {}", p.display())); + } + std::fs::create_dir_all(&p).map_err(|e| { + log::debug!("fs_create_dir({}) failed: {e}", p.display()); + e.to_string() + }) }) + .await + .map_err(|e| e.to_string())? } /// Renames (or moves) a path. Refuses to overwrite an existing target. #[tauri::command] -pub fn fs_rename(from: String, to: String, workspace: Option) -> Result<(), String> { +pub async fn fs_rename( + from: String, + to: String, + workspace: Option, + ssh_state: tauri::State<'_, crate::modules::ssh::SshState>, +) -> Result<(), String> { let workspace = WorkspaceEnv::from_option(workspace); + if let WorkspaceEnv::Ssh { profile_id } = &workspace { + let conn = ssh_state.get_or_err(profile_id)?; + return crate::modules::ssh::sftp::sftp_rename(&conn, &from, &to).await; + } let from_p = resolve_path(&from, &workspace); let to_p = resolve_path(&to, &workspace); - if !from_p.exists() { - return Err(format!("not found: {}", from_p.display())); - } - if to_p.exists() { - return Err(format!("already exists: {}", to_p.display())); - } - std::fs::rename(&from_p, &to_p).map_err(|e| { - log::debug!( - "fs_rename({} -> {}) failed: {e}", - from_p.display(), - to_p.display() - ); - e.to_string() + tauri::async_runtime::spawn_blocking(move || { + if !from_p.exists() { + return Err(format!("not found: {}", from_p.display())); + } + if to_p.exists() { + return Err(format!("already exists: {}", to_p.display())); + } + std::fs::rename(&from_p, &to_p).map_err(|e| { + log::debug!( + "fs_rename({} -> {}) failed: {e}", + from_p.display(), + to_p.display() + ); + e.to_string() + }) }) + .await + .map_err(|e| e.to_string())? } /// Deletes a file or directory (recursively for dirs). Callers are /// responsible for confirming destructive operations with the user. #[tauri::command] -pub fn fs_delete(path: String, workspace: Option) -> Result<(), String> { +pub async fn fs_delete( + path: String, + workspace: Option, + ssh_state: tauri::State<'_, crate::modules::ssh::SshState>, +) -> Result<(), String> { let workspace = WorkspaceEnv::from_option(workspace); + if let WorkspaceEnv::Ssh { profile_id } = &workspace { + let conn = ssh_state.get_or_err(profile_id)?; + return crate::modules::ssh::sftp::sftp_delete(&conn, &path).await; + } let p = resolve_path(&path, &workspace); - let meta = std::fs::symlink_metadata(&p).map_err(|e| { - log::debug!("fs_delete stat({}) failed: {e}", p.display()); - e.to_string() - })?; + tauri::async_runtime::spawn_blocking(move || { + let meta = std::fs::symlink_metadata(&p).map_err(|e| { + log::debug!("fs_delete stat({}) failed: {e}", p.display()); + e.to_string() + })?; - let result = if meta.is_dir() { - std::fs::remove_dir_all(&p) - } else { - std::fs::remove_file(&p) - }; + let result = if meta.is_dir() { + std::fs::remove_dir_all(&p) + } else { + std::fs::remove_file(&p) + }; - result.map_err(|e| { - log::warn!("fs_delete({}) failed: {e}", p.display()); - e.to_string() + result.map_err(|e| { + log::warn!("fs_delete({}) failed: {e}", p.display()); + e.to_string() + }) }) + .await + .map_err(|e| e.to_string())? } diff --git a/src-tauri/src/modules/fs/search.rs b/src-tauri/src/modules/fs/search.rs index 98f188ca..542a824c 100644 --- a/src-tauri/src/modules/fs/search.rs +++ b/src-tauri/src/modules/fs/search.rs @@ -43,12 +43,13 @@ const PRUNE_DIRS: &[&str] = &[ ]; #[tauri::command] -pub fn fs_search( +pub async fn fs_search( root: String, query: String, limit: Option, workspace: Option, show_hidden: Option, + ssh_state: tauri::State<'_, crate::modules::ssh::SshState>, ) -> Result { let q = query.trim().to_lowercase(); if q.is_empty() { @@ -58,83 +59,100 @@ pub fn fs_search( }); } let cap = limit.unwrap_or(200).min(1000); - let show_hidden = show_hidden.unwrap_or(false); let workspace = WorkspaceEnv::from_option(workspace); - let root_path = resolve_path(&root, &workspace); - if !root_path.is_dir() { - return Err(format!("not a directory: {root}")); + if let WorkspaceEnv::Ssh { profile_id } = &workspace { + let conn = ssh_state.get_or_err(profile_id)?; + let lines = crate::modules::ssh::sftp::sftp_search(&conn, &root, &q).await?; + let truncated = lines.len() >= cap; + let hits = lines + .into_iter() + .take(cap) + .map(|p| { + let name = p.rsplit('/').next().unwrap_or(&p).to_string(); + let rel = p.strip_prefix(&root).unwrap_or(&p).trim_start_matches('/').to_string(); + let is_dir = false; + SearchHit { path: p, rel, name, is_dir } + }) + .collect(); + return Ok(SearchResult { hits, truncated }); } + let show_hidden = show_hidden.unwrap_or(false); + tauri::async_runtime::spawn_blocking(move || { + let root_path = resolve_path(&root, &workspace); + if !root_path.is_dir() { + return Err(format!("not a directory: {root}")); + } - let mut out: Vec = Vec::with_capacity(cap.min(64)); - let mut scanned: usize = 0; - let mut truncated = false; + let mut out: Vec = Vec::with_capacity(cap.min(64)); + let mut scanned: usize = 0; + let mut truncated = false; - let walker = WalkBuilder::new(&root_path) - .hidden(!show_hidden) - .git_ignore(true) - .git_global(true) - .git_exclude(true) - .ignore(true) - .parents(true) - .follow_links(false) - .filter_entry(|dent| { - // Prune known-heavy dirs even when no .gitignore is present (e.g. - // searching from $HOME). - if dent.depth() == 0 { - return true; + let walker = WalkBuilder::new(&root_path) + .hidden(!show_hidden) + .git_ignore(true) + .git_global(true) + .git_exclude(true) + .ignore(true) + .parents(true) + .follow_links(false) + .filter_entry(|dent| { + if dent.depth() == 0 { + return true; + } + match dent.file_name().to_str() { + Some(name) => !PRUNE_DIRS.contains(&name), + None => true, + } + }) + .build(); + + for dent in walker.flatten() { + scanned += 1; + if scanned > MAX_SCANNED { + truncated = true; + break; } - match dent.file_name().to_str() { - Some(name) => !PRUNE_DIRS.contains(&name), - None => true, + if out.len() >= cap { + truncated = true; + break; } - }) - .build(); - - for dent in walker.flatten() { - scanned += 1; - if scanned > MAX_SCANNED { - truncated = true; - break; - } - if out.len() >= cap { - truncated = true; - break; - } - let path = dent.path(); - if path == root_path { - continue; - } - let rel = match path.strip_prefix(&root_path) { - Ok(r) => to_canon(r), - Err(_) => continue, - }; - if !rel.to_lowercase().contains(&q) { - continue; + let path = dent.path(); + if path == root_path { + continue; + } + let rel = match path.strip_prefix(&root_path) { + Ok(r) => to_canon(r), + Err(_) => continue, + }; + if !rel.to_lowercase().contains(&q) { + continue; + } + let name = path + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + let is_dir = dent.file_type().map(|t| t.is_dir()).unwrap_or(false); + out.push(SearchHit { + path: display_path(path, &root_path, &root, &workspace), + rel, + name, + is_dir, + }); } - let name = path - .file_name() - .map(|s| s.to_string_lossy().into_owned()) - .unwrap_or_default(); - let is_dir = dent.file_type().map(|t| t.is_dir()).unwrap_or(false); - out.push(SearchHit { - path: display_path(path, &root_path, &root, &workspace), - rel, - name, - is_dir, - }); - } - // Rank: filename matches first, then shorter relative paths. - out.sort_by(|a, b| { - let an = a.name.to_lowercase().contains(&q); - let bn = b.name.to_lowercase().contains(&q); - bn.cmp(&an).then(a.rel.len().cmp(&b.rel.len())) - }); + out.sort_by(|a, b| { + let an = a.name.to_lowercase().contains(&q); + let bn = b.name.to_lowercase().contains(&q); + bn.cmp(&an).then(a.rel.len().cmp(&b.rel.len())) + }); - Ok(SearchResult { - hits: out, - truncated, + Ok(SearchResult { + hits: out, + truncated, + }) }) + .await + .map_err(|e| e.to_string())? } #[derive(Serialize)] diff --git a/src-tauri/src/modules/fs/tree.rs b/src-tauri/src/modules/fs/tree.rs index 68769155..7fade6f1 100644 --- a/src-tauri/src/modules/fs/tree.rs +++ b/src-tauri/src/modules/fs/tree.rs @@ -25,74 +25,83 @@ pub struct DirEntry { /// case-insensitively. Dot-prefixed entries (files and dirs) are hidden unless /// `show_hidden` is set. #[tauri::command] -pub fn fs_read_dir( +pub async fn fs_read_dir( path: String, show_hidden: bool, workspace: Option, + ssh_state: tauri::State<'_, crate::modules::ssh::SshState>, ) -> Result, String> { let workspace = WorkspaceEnv::from_option(workspace); + if let WorkspaceEnv::Ssh { profile_id } = &workspace { + let conn = ssh_state.get_or_err(profile_id)?; + return crate::modules::ssh::sftp::sftp_read_dir(&conn, &path, show_hidden).await; + } let root = resolve_path(&path, &workspace); - let read = std::fs::read_dir(&root).map_err(|e| { - log::debug!("fs_read_dir({}) failed: {e}", root.display()); - e.to_string() - })?; - - let mut entries: Vec = read - .filter_map(Result::ok) - .filter_map(|entry| { - let name = entry.file_name().into_string().ok()?; - - // `metadata()` follows symlinks → it returns the target's stat in - // one syscall (file_type + size + mtime all derived from it). We - // fall back to `symlink_metadata` for broken symlinks so we don't - // silently drop them from the listing. - let (meta, was_symlink) = match std::fs::metadata(entry.path()) { - Ok(m) => (Some(m), false), - Err(_) => (entry.metadata().ok(), true), - }; - let meta = meta?; - - let kind = if was_symlink { - EntryKind::Symlink - } else if meta.is_dir() { - EntryKind::Dir - } else { - EntryKind::File - }; - - if name.starts_with('.') && !show_hidden { - return None; - } - - let size = meta.len(); - let mtime = meta - .modified() - .ok() - .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) - .map(|d| d.as_millis() as u64) - .unwrap_or(0); - - Some(DirEntry { - name, - kind, - size, - mtime, + tauri::async_runtime::spawn_blocking(move || { + let read = std::fs::read_dir(&root).map_err(|e| { + log::debug!("fs_read_dir({}) failed: {e}", root.display()); + e.to_string() + })?; + + let mut entries: Vec = read + .filter_map(Result::ok) + .filter_map(|entry| { + let name = entry.file_name().into_string().ok()?; + + // `metadata()` follows symlinks → it returns the target's stat in + // one syscall (file_type + size + mtime all derived from it). We + // fall back to `symlink_metadata` for broken symlinks so we don't + // silently drop them from the listing. + let (meta, was_symlink) = match std::fs::metadata(entry.path()) { + Ok(m) => (Some(m), false), + Err(_) => (entry.metadata().ok(), true), + }; + let meta = meta?; + + let kind = if was_symlink { + EntryKind::Symlink + } else if meta.is_dir() { + EntryKind::Dir + } else { + EntryKind::File + }; + + if name.starts_with('.') && !show_hidden { + return None; + } + + let size = meta.len(); + let mtime = meta + .modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + + Some(DirEntry { + name, + kind, + size, + mtime, + }) }) - }) - .collect(); - - entries.sort_by(|a, b| { - let rank = |k: &EntryKind| match k { - EntryKind::Dir => 0, - EntryKind::Symlink => 1, - EntryKind::File => 2, - }; - rank(&a.kind) - .cmp(&rank(&b.kind)) - .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) - }); - - Ok(entries) + .collect(); + + entries.sort_by(|a, b| { + let rank = |k: &EntryKind| match k { + EntryKind::Dir => 0, + EntryKind::Symlink => 1, + EntryKind::File => 2, + }; + rank(&a.kind) + .cmp(&rank(&b.kind)) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + + Ok(entries) + }) + .await + .map_err(|e| e.to_string())? } /// Lists immediate subdirectories of `path`. Kept for the CwdBreadcrumb. @@ -100,31 +109,45 @@ pub fn fs_read_dir( /// Symlinks to directories are included (matches shell `cd` semantics). /// Hidden entries are filtered by dot-prefix only. #[tauri::command] -pub fn list_subdirs( +pub async fn list_subdirs( path: String, show_hidden: bool, workspace: Option, + ssh_state: tauri::State<'_, crate::modules::ssh::SshState>, ) -> Result, String> { let workspace = WorkspaceEnv::from_option(workspace); + if let WorkspaceEnv::Ssh { profile_id } = &workspace { + let conn = ssh_state.get_or_err(profile_id)?; + let entries = crate::modules::ssh::sftp::sftp_read_dir(&conn, &path, show_hidden).await?; + return Ok(entries + .into_iter() + .filter(|e| matches!(e.kind, EntryKind::Dir)) + .map(|e| e.name) + .collect()); + } let root = resolve_path(&path, &workspace); - let read = std::fs::read_dir(&root).map_err(|e| { - log::debug!("list_subdirs({}) read_dir failed: {e}", root.display()); - e.to_string() - })?; - - let mut dirs: Vec = read - .filter_map(Result::ok) - .filter(|entry| match entry.file_type() { - Ok(t) if t.is_dir() => true, - Ok(t) if t.is_symlink() => std::fs::metadata(entry.path()) - .map(|m| m.is_dir()) - .unwrap_or(false), - _ => false, - }) - .filter_map(|entry| entry.file_name().into_string().ok()) - .filter(|name| show_hidden || !name.starts_with('.')) - .collect(); - - dirs.sort_by_key(|a| a.to_lowercase()); - Ok(dirs) + tauri::async_runtime::spawn_blocking(move || { + let read = std::fs::read_dir(&root).map_err(|e| { + log::debug!("list_subdirs({}) read_dir failed: {e}", root.display()); + e.to_string() + })?; + + let mut dirs: Vec = read + .filter_map(Result::ok) + .filter(|entry| match entry.file_type() { + Ok(t) if t.is_dir() => true, + Ok(t) if t.is_symlink() => std::fs::metadata(entry.path()) + .map(|m| m.is_dir()) + .unwrap_or(false), + _ => false, + }) + .filter_map(|entry| entry.file_name().into_string().ok()) + .filter(|name| show_hidden || !name.starts_with('.')) + .collect(); + + dirs.sort_by_key(|a| a.to_lowercase()); + Ok(dirs) + }) + .await + .map_err(|e| e.to_string())? } diff --git a/src-tauri/src/modules/mod.rs b/src-tauri/src/modules/mod.rs index 2eac4f49..2c476cb1 100644 --- a/src-tauri/src/modules/mod.rs +++ b/src-tauri/src/modules/mod.rs @@ -4,3 +4,4 @@ pub mod pty; pub mod secrets; pub mod shell; pub mod workspace; +pub mod ssh; diff --git a/src-tauri/src/modules/pty/mod.rs b/src-tauri/src/modules/pty/mod.rs index e1a0a49a..2eb4708f 100644 --- a/src-tauri/src/modules/pty/mod.rs +++ b/src-tauri/src/modules/pty/mod.rs @@ -1,23 +1,21 @@ mod da_filter; #[cfg(windows)] mod job; -mod session; +pub(crate) mod session; pub(crate) mod shell_init; use std::collections::HashMap; -use std::io::Write; use std::sync::atomic::{AtomicU32, Ordering}; -use std::sync::{Arc, RwLock}; +use std::sync::RwLock; use std::thread; -use portable_pty::PtySize; use tauri::ipc::{Channel, Response}; use crate::modules::workspace::WorkspaceEnv; -use session::Session; +use session::PtyHandle; pub struct PtyState { - sessions: RwLock>>, + sessions: RwLock>, // Starts at 1 so freshly-handed-out ids are never 0, which the frontend // sometimes treats as "unset". Increments monotonically; never reused. next_id: AtomicU32, @@ -33,8 +31,10 @@ impl Default for PtyState { } #[tauri::command] -pub fn pty_open( - state: tauri::State, +#[allow(clippy::too_many_arguments)] +pub async fn pty_open( + state: tauri::State<'_, PtyState>, + ssh_state: tauri::State<'_, crate::modules::ssh::SshState>, cols: u16, rows: u16, cwd: Option, @@ -43,42 +43,44 @@ pub fn pty_open( on_exit: Channel, ) -> Result { let workspace = WorkspaceEnv::from_option(workspace); - let (session, _) = - session::spawn(cols, rows, cwd, workspace, on_data, on_exit).map_err(|e| { - log::error!("pty_open failed: {e}"); - e - })?; + + let handle = match &workspace { + WorkspaceEnv::Ssh { profile_id } => { + let conn = ssh_state.get_or_err(profile_id)?; + crate::modules::ssh::pty::open_ssh_pty_channel(conn, cols, rows, on_data, on_exit) + .await? + } + _ => { + let (session, _) = tauri::async_runtime::spawn_blocking(move || { + session::spawn(cols, rows, cwd, workspace, on_data, on_exit) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| { + log::error!("pty_open failed: {e}"); + e + })?; + PtyHandle::Local(session) + } + }; + let id = state.next_id.fetch_add(1, Ordering::Relaxed); - state.sessions.write().unwrap().insert(id, session); + state.sessions.write().unwrap().insert(id, handle); log::info!("pty opened id={id} cols={cols} rows={rows}"); Ok(id) } #[tauri::command] pub fn pty_write(state: tauri::State, id: u32, data: String) -> Result<(), String> { - let session = state - .sessions - .read() - .unwrap() - .get(&id) - .cloned() - .ok_or_else(|| { - log::warn!("pty_write: unknown id={id}"); - "no session".to_string() - })?; - // Bind to a local so the MutexGuard temporary drops before `session` — - // see rustc note on tail-expression temporary drop order. - let result = session - .writer - .lock() - .unwrap() - .write_all(data.as_bytes()) - .map_err(|e| { - // EPIPE is expected if the child already exited. - log::debug!("pty_write id={id} failed: {e}"); - e.to_string() - }); - result + let sessions = state.sessions.read().unwrap(); + let handle = sessions.get(&id).ok_or_else(|| { + log::warn!("pty_write: unknown id={id}"); + "no session".to_string() + })?; + handle.write(data.as_bytes()).map_err(|e| { + log::debug!("pty_write id={id} failed: {e}"); + e + }) } #[tauri::command] @@ -88,54 +90,32 @@ pub fn pty_resize( cols: u16, rows: u16, ) -> Result<(), String> { - let session = state - .sessions - .read() - .unwrap() - .get(&id) - .cloned() - .ok_or_else(|| { - log::warn!("pty_resize: unknown id={id}"); - "no session".to_string() - })?; - let result = session - .master - .lock() - .unwrap() - .resize(PtySize { - rows, - cols, - pixel_width: 0, - pixel_height: 0, - }) - .map_err(|e| { - log::warn!("pty_resize id={id} failed: {e}"); - e.to_string() - }); - result + let sessions = state.sessions.read().unwrap(); + let handle = sessions.get(&id).ok_or_else(|| { + log::warn!("pty_resize: unknown id={id}"); + "no session".to_string() + })?; + handle.resize(cols, rows).map_err(|e| { + log::warn!("pty_resize id={id} failed: {e}"); + e + }) } #[tauri::command] pub fn pty_close(state: tauri::State, id: u32) -> Result<(), String> { - let session = state.sessions.write().unwrap().remove(&id); - if let Some(s) = session { - if let Err(e) = s.killer.lock().unwrap().kill() { - // Non-fatal: the child may already have exited on its own (e.g. the - // user ran `exit`). Log so this isn't invisible during debugging. + let handle = state.sessions.write().unwrap().remove(&id); + if let Some(h) = handle { + if let Err(e) = h.kill() { log::debug!("pty_close: kill id={id} returned {e}"); } log::info!("pty closed id={id}"); - // Drop the Arc on a detached thread. On Windows `MasterPty`'s Drop - // calls `ClosePseudoConsole`, which can block until conhost finishes - // draining its output buffer. Doing it here would freeze the Tauri - // worker thread that handled this command — and on Windows that - // sometimes manifests as the closed pane refusing to disappear from - // the React tree because subsequent IPC stalls behind it. + // Drop on a detached thread to avoid blocking the Tauri worker on Windows + // (ClosePseudoConsole can block until conhost drains its output buffer). thread::Builder::new() .name(format!("terax-pty-drop-{id}")) .spawn(move || { let t0 = std::time::Instant::now(); - drop(s); + drop(h); log::info!( "pty session id={id} dropped in {}ms", t0.elapsed().as_millis() diff --git a/src-tauri/src/modules/pty/session.rs b/src-tauri/src/modules/pty/session.rs index 30830e21..79cc650f 100644 --- a/src-tauri/src/modules/pty/session.rs +++ b/src-tauri/src/modules/pty/session.rs @@ -220,3 +220,68 @@ pub fn spawn( Ok((session, size)) } + +use tokio::sync::mpsc; + +pub enum SshPtyCmd { + Data(Vec), + Resize { cols: u16, rows: u16 }, + Close, +} + +/// Thin handle to a tokio task that owns the russh channel. +pub struct SshPtySession { + pub cmd_tx: mpsc::Sender, +} + +pub enum PtyHandle { + Local(Arc), + Ssh(Arc), +} + +impl PtyHandle { + pub fn write(&self, data: &[u8]) -> Result<(), String> { + match self { + PtyHandle::Local(s) => s + .writer + .lock() + .unwrap() + .write_all(data) + .map_err(|e| e.to_string()), + PtyHandle::Ssh(s) => s + .cmd_tx + .try_send(SshPtyCmd::Data(data.to_vec())) + .map_err(|e| e.to_string()), + } + } + + pub fn resize(&self, cols: u16, rows: u16) -> Result<(), String> { + match self { + PtyHandle::Local(s) => s + .master + .lock() + .unwrap() + .resize(portable_pty::PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|e| e.to_string()), + PtyHandle::Ssh(s) => s + .cmd_tx + .try_send(SshPtyCmd::Resize { cols, rows }) + .map_err(|e| e.to_string()), + } + } + + pub fn kill(&self) -> Result<(), String> { + match self { + PtyHandle::Local(s) => s.killer.lock().unwrap().kill().map_err(|e| e.to_string()), + PtyHandle::Ssh(s) => { + let _ = s.cmd_tx.try_send(SshPtyCmd::Close); + Ok(()) + } + } + } +} diff --git a/src-tauri/src/modules/ssh/connection.rs b/src-tauri/src/modules/ssh/connection.rs new file mode 100644 index 00000000..6dada3d1 --- /dev/null +++ b/src-tauri/src/modules/ssh/connection.rs @@ -0,0 +1,43 @@ +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +use russh::client::Handle; +use russh_sftp::client::SftpSession; + +use super::handler::SshHandler; + +pub struct SshConn { + pub handle: Handle, + pub sftp: SftpSession, +} + +pub struct SshState { + pub conns: RwLock>>, +} + +impl Default for SshState { + fn default() -> Self { + Self { + conns: RwLock::new(HashMap::new()), + } + } +} + +impl SshState { + pub fn get(&self, profile_id: &str) -> Option> { + self.conns.read().unwrap().get(profile_id).cloned() + } + + pub fn get_or_err(&self, profile_id: &str) -> Result, String> { + self.get(profile_id) + .ok_or_else(|| format!("SSH: no active connection for profile {profile_id}")) + } + + pub fn insert(&self, profile_id: String, conn: Arc) { + self.conns.write().unwrap().insert(profile_id, conn); + } + + pub fn remove(&self, profile_id: &str) -> Option> { + self.conns.write().unwrap().remove(profile_id) + } +} diff --git a/src-tauri/src/modules/ssh/handler.rs b/src-tauri/src/modules/ssh/handler.rs new file mode 100644 index 00000000..a94b57fd --- /dev/null +++ b/src-tauri/src/modules/ssh/handler.rs @@ -0,0 +1,44 @@ +use std::sync::{Arc, Mutex}; + +use russh::client; +use russh::keys::key::PublicKey; + +pub struct SshHandler { + /// Fingerprint stored in the profile. `None` on first connect (TOFU). + pub known_fingerprint: Option, + /// The fingerprint seen during this handshake — written by `check_server_key`, + /// read back by the caller after `connect()` returns. + pub observed_fingerprint: Arc>>, +} + +impl SshHandler { + pub fn new(known_fingerprint: Option) -> (Self, Arc>>) { + let observed = Arc::new(Mutex::new(None)); + let handler = Self { + known_fingerprint, + observed_fingerprint: observed.clone(), + }; + (handler, observed) + } +} + +#[async_trait::async_trait] +impl client::Handler for SshHandler { + type Error = russh::Error; + + async fn check_server_key( + &mut self, + server_public_key: &PublicKey, + ) -> Result { + let fingerprint = server_public_key.fingerprint(); + *self.observed_fingerprint.lock().unwrap() = Some(fingerprint.clone()); + + if let Some(known) = &self.known_fingerprint { + if &fingerprint != known { + log::warn!("SSH host key mismatch! Expected {known}, got {fingerprint}"); + return Ok(false); + } + } + Ok(true) + } +} diff --git a/src-tauri/src/modules/ssh/mod.rs b/src-tauri/src/modules/ssh/mod.rs new file mode 100644 index 00000000..8d18cc6d --- /dev/null +++ b/src-tauri/src/modules/ssh/mod.rs @@ -0,0 +1,188 @@ +mod connection; +mod handler; +pub(crate) mod profiles; +pub(crate) mod pty; +pub(crate) mod sftp; + +pub use connection::{SshConn, SshState}; +pub use profiles::{ssh_profile_list, AuthMethod, SshProfile}; +use profiles::update_fingerprint; + +use std::net::ToSocketAddrs; +use std::sync::Arc; + +use russh::client; +use russh::keys; +use russh_sftp::client::SftpSession; + +use self::handler::SshHandler; + +fn load_profile(app: &tauri::AppHandle, profile_id: &str) -> Result { + let profiles = ssh_profile_list(app.clone())?; + profiles + .into_iter() + .find(|p| p.id == profile_id) + .ok_or_else(|| format!("SSH profile not found: {profile_id}")) +} + +#[tauri::command] +pub async fn ssh_connect( + app: tauri::AppHandle, + state: tauri::State<'_, SshState>, + profile_id: String, +) -> Result<(), String> { + if state.get(&profile_id).is_some() { + return Ok(()); // already connected + } + + let profile = load_profile(&app, &profile_id)?; + + let (handler, observed_fp) = SshHandler::new(profile.known_fingerprint.clone()); + + let config = Arc::new(client::Config::default()); + let addr_str = format!("{}:{}", profile.host, profile.port); + let addr = addr_str + .to_socket_addrs() + .map_err(|e| e.to_string())? + .next() + .ok_or("could not resolve host")?; + + let mut handle = client::connect(config, addr, handler) + .await + .map_err(|e| e.to_string())?; + + // TOFU: verify fingerprint BEFORE sending any credentials. + // If the host is unknown, disconnect immediately and return the fingerprint + // so the frontend can show a confirmation dialog. The caller must call + // ssh_fingerprint_save and retry — only then will authentication proceed. + if profile.known_fingerprint.is_none() { + // Clone out of the Mutex before any await so MutexGuard is not held + // across a suspension point (MutexGuard is !Send). + let tofu_fp = observed_fp.lock().unwrap().clone(); + if let Some(fp) = tofu_fp { + let _ = handle + .disconnect(russh::Disconnect::ByApplication, "", "English") + .await; + return Err(format!("TOFU_REQUIRED:{fp}")); + } + } + + // Authenticate + match profile.auth_method { + AuthMethod::Key => { + let key_path = profile + .key_path + .as_deref() + .ok_or("key auth requires keyPath")?; + let key_path = shellexpand::tilde(key_path).into_owned(); + let key = keys::load_secret_key( + std::path::Path::new(&key_path), + None, + ) + .map_err(|e| e.to_string())?; + let authed = handle + .authenticate_publickey(&profile.user, Arc::new(key)) + .await + .map_err(|e| e.to_string())?; + if !authed { + return Err("SSH key authentication rejected".into()); + } + } + AuthMethod::Agent => { + #[cfg(unix)] + { + use keys::agent::client::AgentClient; + let agent_sock = std::env::var("SSH_AUTH_SOCK") + .map_err(|_| "SSH_AUTH_SOCK not set — is ssh-agent running?")?; + let mut agent = AgentClient::connect_uds(&agent_sock) + .await + .map_err(|e| e.to_string())?; + let identities = agent.request_identities().await.map_err(|e| e.to_string())?; + let mut authed = false; + for key in identities { + let (new_agent, result) = handle + .authenticate_future(&profile.user, key, agent) + .await; + agent = new_agent; + match result { + Ok(true) => { + authed = true; + break; + } + Ok(false) => continue, + Err(e) => return Err(e.to_string()), + } + } + if !authed { + return Err("SSH agent authentication rejected".into()); + } + } + #[cfg(windows)] + { + return Err("SSH agent auth on Windows is not yet supported".into()); + } + } + } + + // Open SFTP subsystem + let sftp_channel = handle + .channel_open_session() + .await + .map_err(|e| e.to_string())?; + sftp_channel + .request_subsystem(true, "sftp") + .await + .map_err(|e| e.to_string())?; + let sftp = SftpSession::new(sftp_channel.into_stream()) + .await + .map_err(|e| e.to_string())?; + + state.insert(profile_id, Arc::new(SshConn { handle, sftp })); + log::info!("SSH connected to {}:{}", profile.host, profile.port); + Ok(()) +} + +#[tauri::command] +pub async fn ssh_disconnect( + state: tauri::State<'_, SshState>, + profile_id: String, +) -> Result<(), String> { + if let Some(conn) = state.remove(&profile_id) { + let _ = conn + .handle + .disconnect(russh::Disconnect::ByApplication, "", "English") + .await; + log::info!("SSH disconnected profile {profile_id}"); + } + Ok(()) +} + +#[tauri::command] +pub async fn ssh_home( + state: tauri::State<'_, SshState>, + profile_id: String, +) -> Result { + let conn = state.get_or_err(&profile_id)?; + let output = crate::modules::ssh::sftp::run_remote_command(&conn, "echo $HOME").await?; + Ok(output.trim().to_string()) +} + +#[tauri::command] +pub async fn ssh_fingerprint_get( + app: tauri::AppHandle, + profile_id: String, +) -> Result, String> { + let profile = load_profile(&app, &profile_id)?; + Ok(profile.known_fingerprint) +} + +/// Persist a trusted host fingerprint after the user explicitly accepts the TOFU +/// prompt, then the caller should retry `ssh_connect`. +#[tauri::command] +pub fn ssh_fingerprint_save( + app: tauri::AppHandle, + profile_id: String, + fingerprint: String, +) -> Result<(), String> { + update_fingerprint(&app, &profile_id, fingerprint) +} diff --git a/src-tauri/src/modules/ssh/profiles.rs b/src-tauri/src/modules/ssh/profiles.rs new file mode 100644 index 00000000..79234b80 --- /dev/null +++ b/src-tauri/src/modules/ssh/profiles.rs @@ -0,0 +1,132 @@ +use serde::{Deserialize, Serialize}; +use tauri_plugin_store::StoreExt; + +const STORE_KEY: &str = "ssh_profiles"; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SshProfile { + pub id: String, + pub name: String, + pub host: String, + pub port: u16, + pub user: String, + pub auth_method: AuthMethod, + #[serde(skip_serializing_if = "Option::is_none")] + pub key_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub known_fingerprint: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AuthMethod { + Key, + Agent, +} + +#[tauri::command] +pub fn ssh_profile_list(app: tauri::AppHandle) -> Result, String> { + let store = app.store("terax.json").map_err(|e| e.to_string())?; + let profiles = store + .get(STORE_KEY) + .and_then(|v| serde_json::from_value(v).ok()) + .unwrap_or_default(); + Ok(profiles) +} + +#[tauri::command] +pub fn ssh_profile_save(app: tauri::AppHandle, profile: SshProfile) -> Result { + let store = app.store("terax.json").map_err(|e| e.to_string())?; + let mut profiles: Vec = store + .get(STORE_KEY) + .and_then(|v| serde_json::from_value(v).ok()) + .unwrap_or_default(); + if let Some(existing) = profiles.iter_mut().find(|p| p.id == profile.id) { + *existing = profile.clone(); + } else { + profiles.push(profile.clone()); + } + store.set(STORE_KEY, serde_json::to_value(&profiles).map_err(|e| e.to_string())?); + store.save().map_err(|e| e.to_string())?; + Ok(profile) +} + +#[tauri::command] +pub fn ssh_profile_delete(app: tauri::AppHandle, id: String) -> Result<(), String> { + let store = app.store("terax.json").map_err(|e| e.to_string())?; + let mut profiles: Vec = store + .get(STORE_KEY) + .and_then(|v| serde_json::from_value(v).ok()) + .unwrap_or_default(); + profiles.retain(|p| p.id != id); + store.set(STORE_KEY, serde_json::to_value(&profiles).map_err(|e| e.to_string())?); + store.save().map_err(|e| e.to_string()) +} + +pub fn update_fingerprint(app: &tauri::AppHandle, id: &str, fingerprint: String) -> Result<(), String> { + let store = app.store("terax.json").map_err(|e| e.to_string())?; + let mut profiles: Vec = store + .get(STORE_KEY) + .and_then(|v| serde_json::from_value(v).ok()) + .unwrap_or_default(); + if let Some(p) = profiles.iter_mut().find(|p| p.id == id) { + p.known_fingerprint = Some(fingerprint); + } + store.set(STORE_KEY, serde_json::to_value(&profiles).map_err(|e| e.to_string())?); + store.save().map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ssh_profile_serde_round_trip_key_auth() { + let profile = SshProfile { + id: "abc-123".into(), + name: "prod".into(), + host: "example.com".into(), + port: 22, + user: "alice".into(), + auth_method: AuthMethod::Key, + key_path: Some("/home/alice/.ssh/id_ed25519".into()), + known_fingerprint: Some("SHA256:abc123".into()), + }; + let json = serde_json::to_string(&profile).unwrap(); + let back: SshProfile = serde_json::from_str(&json).unwrap(); + assert_eq!(back.id, profile.id); + assert_eq!(back.host, profile.host); + assert_eq!(back.port, profile.port); + assert!(matches!(back.auth_method, AuthMethod::Key)); + assert_eq!(back.key_path, profile.key_path); + assert_eq!(back.known_fingerprint, profile.known_fingerprint); + } + + #[test] + fn ssh_profile_serde_round_trip_agent_auth() { + let profile = SshProfile { + id: "xyz-456".into(), + name: "dev".into(), + host: "dev.internal".into(), + port: 2222, + user: "bob".into(), + auth_method: AuthMethod::Agent, + key_path: None, + known_fingerprint: None, + }; + let json = serde_json::to_string(&profile).unwrap(); + assert!(!json.contains("keyPath")); + assert!(!json.contains("knownFingerprint")); + let back: SshProfile = serde_json::from_str(&json).unwrap(); + assert_eq!(back.id, profile.id); + assert!(matches!(back.auth_method, AuthMethod::Agent)); + assert_eq!(back.key_path, None); + } + + #[test] + fn auth_method_serializes_lowercase() { + assert_eq!(serde_json::to_string(&AuthMethod::Key).unwrap(), "\"key\""); + assert_eq!(serde_json::to_string(&AuthMethod::Agent).unwrap(), "\"agent\""); + } +} diff --git a/src-tauri/src/modules/ssh/pty.rs b/src-tauri/src/modules/ssh/pty.rs new file mode 100644 index 00000000..92ae84e1 --- /dev/null +++ b/src-tauri/src/modules/ssh/pty.rs @@ -0,0 +1,109 @@ +use std::sync::Arc; +use std::time::Duration; + +use russh::ChannelMsg; +use tauri::ipc::{Channel, Response}; +use tokio::sync::mpsc; + +use crate::modules::pty::session::{PtyHandle, SshPtyCmd, SshPtySession}; +use super::connection::SshConn; + +const FLUSH_INTERVAL: Duration = Duration::from_millis(4); + +pub async fn open_ssh_pty_channel( + conn: Arc, + cols: u16, + rows: u16, + on_data: Channel, + on_exit: Channel, +) -> Result { + let mut channel = conn + .handle + .channel_open_session() + .await + .map_err(|e| e.to_string())?; + + channel + .request_pty( + false, + "xterm-256color", + cols as u32, + rows as u32, + 0, + 0, + &[], + ) + .await + .map_err(|e| e.to_string())?; + + channel + .request_shell(false) + .await + .map_err(|e| e.to_string())?; + + let (cmd_tx, mut cmd_rx) = mpsc::channel::(256); + + tauri::async_runtime::spawn(async move { + let mut pending: Vec = Vec::with_capacity(16 * 1024); + let mut flush_timer = tokio::time::interval(FLUSH_INTERVAL); + flush_timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + tokio::select! { + cmd = cmd_rx.recv() => { + match cmd { + Some(SshPtyCmd::Data(bytes)) => { + let _ = channel.data(bytes.as_ref()).await; + } + Some(SshPtyCmd::Resize { cols, rows }) => { + let _ = channel.window_change(cols as u32, rows as u32, 0, 0).await; + } + Some(SshPtyCmd::Close) | None => { + let _ = channel.close().await; + break; + } + } + } + msg = channel.wait() => { + match msg { + Some(ChannelMsg::Data { ref data }) => { + pending.extend_from_slice(data); + } + Some(ChannelMsg::ExtendedData { ref data, .. }) => { + pending.extend_from_slice(data); + } + Some(ChannelMsg::ExitStatus { exit_status }) => { + if !pending.is_empty() { + let chunk = std::mem::take(&mut pending); + let _ = on_data.send(Response::new(chunk)); + } + let _ = on_exit.send(exit_status as i32); + break; + } + None => { + if !pending.is_empty() { + let chunk = std::mem::take(&mut pending); + let _ = on_data.send(Response::new(chunk)); + } + let _ = on_exit.send(-1); + break; + } + Some(msg) => { + log::debug!("ssh pty: unhandled channel msg {msg:?}"); + } + } + } + _ = flush_timer.tick() => { + if !pending.is_empty() { + let chunk = std::mem::take(&mut pending); + if on_data.send(Response::new(chunk)).is_err() { + break; + } + } + } + } + } + }); + + Ok(PtyHandle::Ssh(Arc::new(SshPtySession { cmd_tx }))) +} diff --git a/src-tauri/src/modules/ssh/sftp.rs b/src-tauri/src/modules/ssh/sftp.rs new file mode 100644 index 00000000..3395a569 --- /dev/null +++ b/src-tauri/src/modules/ssh/sftp.rs @@ -0,0 +1,155 @@ +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use crate::modules::fs::file::{FileStat, ReadResult, StatKind}; +use crate::modules::fs::tree::{DirEntry, EntryKind}; +use super::connection::SshConn; + +const MAX_READ_BYTES: u64 = 10 * 1024 * 1024; + +pub async fn sftp_read_dir(conn: &SshConn, path: &str, show_hidden: bool) -> Result, String> { + let read_dir = conn.sftp.read_dir(path).await.map_err(|e| e.to_string())?; + let mut result: Vec = read_dir + .into_iter() + .filter(|e| show_hidden || !e.file_name().starts_with('.')) + .map(|e| { + let meta = e.metadata(); + let ft = meta.file_type(); + DirEntry { + name: e.file_name(), + kind: if ft.is_dir() { + EntryKind::Dir + } else if ft.is_symlink() { + EntryKind::Symlink + } else { + EntryKind::File + }, + size: meta.size.unwrap_or(0), + mtime: meta.modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as u64) + .unwrap_or(0), + } + }) + .collect(); + result.sort_by(|a, b| { + let ak = matches!(a.kind, EntryKind::Dir); + let bk = matches!(b.kind, EntryKind::Dir); + bk.cmp(&ak) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + Ok(result) +} + +pub async fn sftp_read_file(conn: &SshConn, path: &str) -> Result { + let meta = conn.sftp.metadata(path).await.map_err(|e| e.to_string())?; + let size = meta.size.unwrap_or(0); + if size > MAX_READ_BYTES { + return Ok(ReadResult::TooLarge { size, limit: MAX_READ_BYTES }); + } + let mut file = conn.sftp.open(path).await.map_err(|e| e.to_string())?; + let mut buf = Vec::with_capacity(size as usize); + file.read_to_end(&mut buf).await.map_err(|e| e.to_string())?; + + let sniff = &buf[..buf.len().min(8192)]; + if sniff.contains(&0u8) { + return Ok(ReadResult::Binary { size }); + } + match String::from_utf8(buf) { + Ok(s) => Ok(ReadResult::Text { content: s, size }), + Err(_) => Ok(ReadResult::Binary { size }), + } +} + +pub async fn sftp_write_file(conn: &SshConn, path: &str, content: &str) -> Result<(), String> { + let mut file = conn.sftp.create(path).await.map_err(|e| e.to_string())?; + file.write_all(content.as_bytes()).await.map_err(|e| e.to_string())?; + file.flush().await.map_err(|e| e.to_string()) +} + +pub async fn sftp_stat(conn: &SshConn, path: &str) -> Result { + let meta = conn.sftp.metadata(path).await.map_err(|e| e.to_string())?; + let ft = meta.file_type(); + let kind = if ft.is_dir() { + StatKind::Dir + } else if ft.is_symlink() { + StatKind::Symlink + } else { + StatKind::File + }; + Ok(FileStat { + size: meta.size.unwrap_or(0), + mtime: meta.modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as u64) + .unwrap_or(0), + kind, + }) +} + +pub async fn sftp_create_file(conn: &SshConn, path: &str) -> Result<(), String> { + if conn.sftp.try_exists(path).await.map_err(|e| e.to_string())? { + return Err(format!("already exists: {path}")); + } + let mut f = conn.sftp.create(path).await.map_err(|e| e.to_string())?; + f.flush().await.map_err(|e| e.to_string()) +} + +pub async fn sftp_create_dir(conn: &SshConn, path: &str) -> Result<(), String> { + if conn.sftp.try_exists(path).await.map_err(|e| e.to_string())? { + return Err(format!("already exists: {path}")); + } + conn.sftp.create_dir(path).await.map_err(|e| e.to_string()) +} + +pub async fn sftp_rename(conn: &SshConn, from: &str, to: &str) -> Result<(), String> { + conn.sftp.rename(from, to).await.map_err(|e| e.to_string()) +} + +pub async fn sftp_delete(conn: &SshConn, path: &str) -> Result<(), String> { + let meta = conn.sftp.metadata(path).await.map_err(|e| e.to_string())?; + if meta.file_type().is_dir() { + conn.sftp.remove_dir(path).await.map_err(|e| e.to_string()) + } else { + conn.sftp.remove_file(path).await.map_err(|e| e.to_string()) + } +} + +pub async fn sftp_search(conn: &SshConn, path: &str, query: &str) -> Result, String> { + let cmd = format!( + "find {} -maxdepth 10 -iname '*{}*' 2>/dev/null", + shell_escape(path), + shell_escape(query) + ); + let output = run_remote_command(conn, &cmd).await?; + Ok(output.lines().map(|l| l.to_string()).collect()) +} + +pub async fn sftp_grep(conn: &SshConn, path: &str, pattern: &str) -> Result, String> { + let cmd = format!( + "grep -rn --include='*' {} {} 2>/dev/null", + shell_escape(pattern), + shell_escape(path) + ); + let output = run_remote_command(conn, &cmd).await?; + Ok(output.lines().map(|l| l.to_string()).collect()) +} + +pub async fn run_remote_command(conn: &SshConn, cmd: &str) -> Result { + let mut channel = conn.handle.channel_open_session().await.map_err(|e| e.to_string())?; + channel.exec(true, cmd).await.map_err(|e| e.to_string())?; + let mut output = Vec::new(); + while let Some(msg) = channel.wait().await { + match msg { + russh::ChannelMsg::Data { ref data } => output.extend_from_slice(data), + russh::ChannelMsg::ExitStatus { .. } | russh::ChannelMsg::Eof => break, + _ => {} + } + } + String::from_utf8(output).map_err(|e| e.to_string()) +} + +fn shell_escape(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} diff --git a/src-tauri/src/modules/workspace.rs b/src-tauri/src/modules/workspace.rs index fd6326b8..fbd8e8dd 100644 --- a/src-tauri/src/modules/workspace.rs +++ b/src-tauri/src/modules/workspace.rs @@ -10,6 +10,10 @@ pub enum WorkspaceEnv { Wsl { distro: String, }, + Ssh { + #[serde(rename = "profileId")] + profile_id: String, + }, } impl WorkspaceEnv { @@ -20,6 +24,12 @@ impl WorkspaceEnv { pub fn is_wsl(&self) -> bool { matches!(self, Self::Wsl { .. }) } + + #[allow(dead_code)] + #[allow(dead_code)] + pub fn is_ssh(&self) -> bool { + matches!(self, Self::Ssh { .. }) + } } #[derive(Clone, Debug, Serialize)] @@ -34,12 +44,22 @@ pub fn resolve_path(path: &str, workspace: &WorkspaceEnv) -> PathBuf { match workspace { WorkspaceEnv::Local => PathBuf::from(path), WorkspaceEnv::Wsl { distro } => wsl_path_to_unc(distro, path), + WorkspaceEnv::Ssh { .. } => { + log::error!("resolve_path called with SSH workspace — caller should have branched earlier; returning raw path as fallback"); + PathBuf::from(path) + } } } #[cfg(not(windows))] -pub fn resolve_path(path: &str, _workspace: &WorkspaceEnv) -> PathBuf { - PathBuf::from(path) +pub fn resolve_path(path: &str, workspace: &WorkspaceEnv) -> PathBuf { + match workspace { + WorkspaceEnv::Local | WorkspaceEnv::Wsl { .. } => PathBuf::from(path), + WorkspaceEnv::Ssh { .. } => { + log::error!("resolve_path called with SSH workspace — caller should have branched earlier; returning raw path as fallback"); + PathBuf::from(path) + } + } } #[cfg(windows)] diff --git a/src/app/App.tsx b/src/app/App.tsx index 8eca8cff..3260052d 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -69,12 +69,31 @@ import { useWorkspaceEnvStore, type WorkspaceEnv, } from "@/modules/workspace"; +import { sshHome } from "@/modules/ssh/commands"; +import { useSshStore } from "@/modules/ssh/store"; import { homeDir } from "@tauri-apps/api/path"; import type { SearchAddon } from "@xterm/addon-search"; import { AnimatePresence, motion } from "motion/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { PanelImperativeHandle } from "react-resizable-panels"; +const SSH_RETRY_WINDOW_MS = 20_000; +const SSH_RETRY_INITIAL_DELAY_MS = 500; +const SSH_RETRY_MAX_DELAY_MS = 3_000; + +function isRetryableSshError(error: unknown) { + const message = String(error).toLowerCase(); + return ( + message.includes("connection refused") || + message.includes("connection reset") || + message.includes("broken pipe") || + message.includes("timed out") || + message.includes("temporarily unavailable") || + message.includes("failed to connect") || + message.includes("no route to host") + ); +} + export default function App() { const { @@ -167,12 +186,86 @@ export default function App() { .catch(() => setHome(null)); }, []); + useEffect(() => { + if (workspaceEnv.kind !== "ssh") return; + + let cancelled = false; + let retryTimer: number | null = null; + let retryUntil = Date.now() + SSH_RETRY_WINDOW_MS; + let nextDelayMs = SSH_RETRY_INITIAL_DELAY_MS; + const profileId = workspaceEnv.profileId; + + const clearRetryTimer = () => { + if (retryTimer !== null) { + window.clearTimeout(retryTimer); + retryTimer = null; + } + }; + + const scheduleRetry = () => { + if (cancelled || retryTimer !== null) return; + const remainingMs = retryUntil - Date.now(); + if (remainingMs <= 0) return; + const delayMs = Math.min(nextDelayMs, remainingMs); + retryTimer = window.setTimeout(() => { + retryTimer = null; + void ensureConnected("retry"); + }, delayMs); + nextDelayMs = Math.min(nextDelayMs * 2, SSH_RETRY_MAX_DELAY_MS); + }; + + const ensureConnected = async (reason: string) => { + const state = useSshStore.getState().connState[profileId]; + if (state === "connected" || state === "connecting") return; + + clearRetryTimer(); + void useSshStore + .getState() + .connect(profileId) + .catch((error) => { + if (cancelled) return; + console.warn("ssh reconnect failed", { profileId, reason, error }); + if (isRetryableSshError(error)) { + scheduleRetry(); + } + }); + }; + + const resetRetryWindow = () => { + retryUntil = Date.now() + SSH_RETRY_WINDOW_MS; + nextDelayMs = SSH_RETRY_INITIAL_DELAY_MS; + }; + + resetRetryWindow(); + void ensureConnected("initial"); + + const onFocus = () => { + resetRetryWindow(); + void ensureConnected("focus"); + }; + const onVisibilityChange = () => { + if (document.visibilityState === "visible") { + resetRetryWindow(); + void ensureConnected("visibility"); + } + }; + + window.addEventListener("focus", onFocus); + document.addEventListener("visibilitychange", onVisibilityChange); + return () => { + cancelled = true; + clearRetryTimer(); + window.removeEventListener("focus", onFocus); + document.removeEventListener("visibilitychange", onVisibilityChange); + }; + }, [workspaceEnv]); + const switchWorkspace = useCallback( async (env: WorkspaceEnv) => { if ( env.kind === workspaceEnv.kind && (env.kind === "local" || - (workspaceEnv.kind === "wsl" && env.distro === workspaceEnv.distro)) + (env.kind === "wsl" && workspaceEnv.kind === "wsl" && env.distro === workspaceEnv.distro)) ) { return; } @@ -186,6 +279,9 @@ export default function App() { try { if (env.kind === "wsl") { nextHome = await getWslHome(env.distro); + } else if (env.kind === "ssh") { + await useSshStore.getState().connect(env.profileId); + nextHome = await sshHome(env.profileId); } else { nextHome = (await homeDir()).replace(/\\/g, "/"); } @@ -257,6 +353,7 @@ export default function App() { const initPrefs = usePreferencesStore((s) => s.init); const prefDefaultModel = usePreferencesStore((s) => s.defaultModelId); const prefsHydrated = usePreferencesStore((s) => s.hydrated); + const lastSshProfileId = usePreferencesStore((s) => s.lastSshProfileId); useEffect(() => { void initPrefs(); }, [initPrefs]); @@ -264,6 +361,12 @@ export default function App() { if (!prefsHydrated) return; setSelectedModelId(prefDefaultModel); }, [prefsHydrated, prefDefaultModel, setSelectedModelId]); + useEffect(() => { + if (!prefsHydrated || !lastSshProfileId) return; + const env: WorkspaceEnv = { kind: "ssh", profileId: lastSshProfileId }; + void switchWorkspace(env); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [prefsHydrated]); const hydrateSessions = useChatStore((s) => s.hydrateSessions); useEffect(() => { diff --git a/src/modules/settings/openSettingsWindow.ts b/src/modules/settings/openSettingsWindow.ts index 965eb59c..90a3b71e 100644 --- a/src/modules/settings/openSettingsWindow.ts +++ b/src/modules/settings/openSettingsWindow.ts @@ -5,6 +5,7 @@ export type SettingsTab = | "shortcuts" | "models" | "agents" + | "ssh" | "about"; export async function openSettingsWindow(tab?: SettingsTab): Promise { diff --git a/src/modules/settings/store.ts b/src/modules/settings/store.ts index a4af49a3..ffaea7ed 100644 --- a/src/modules/settings/store.ts +++ b/src/modules/settings/store.ts @@ -61,6 +61,7 @@ export type Preferences = { terminalScrollback: number; lastWslDistro: string | null; zoomLevel: number; + lastSshProfileId: string | null; shortcuts: Record; }; @@ -88,6 +89,7 @@ const KEY_TERMINAL_FONT_SIZE = "terminalFontSize"; const KEY_TERMINAL_SCROLLBACK = "terminalScrollback"; const KEY_LAST_WSL_DISTRO = "lastWslDistro"; const KEY_ZOOM_LEVEL = "zoomLevel"; +const KEY_LAST_SSH_PROFILE = "lastSshProfileId"; const KEY_SHORTCUTS = "shortcuts"; export const TERMINAL_FONT_SIZE_DEFAULT = 14; @@ -128,6 +130,7 @@ export const DEFAULT_PREFERENCES: Preferences = { terminalScrollback: TERMINAL_SCROLLBACK_DEFAULT, lastWslDistro: null, zoomLevel: 1.0, + lastSshProfileId: null, shortcuts: {} as Record, }; @@ -207,6 +210,9 @@ export async function loadPreferences(): Promise { get(KEY_LAST_WSL_DISTRO) ?? DEFAULT_PREFERENCES.lastWslDistro, zoomLevel: get(KEY_ZOOM_LEVEL) ?? DEFAULT_PREFERENCES.zoomLevel, + lastSshProfileId: + get(KEY_LAST_SSH_PROFILE) ?? + DEFAULT_PREFERENCES.lastSshProfileId, shortcuts: get>(KEY_SHORTCUTS) ?? DEFAULT_PREFERENCES.shortcuts, @@ -317,6 +323,10 @@ export async function setZoomLevel(value: number): Promise { await writePref(KEY_ZOOM_LEVEL, value); } +export async function setLastSshProfileId(value: string | null): Promise { + await writePref(KEY_LAST_SSH_PROFILE, value); +} + export async function setShortcuts( value: Record | {}, ): Promise { @@ -358,6 +368,7 @@ export async function onPreferencesChange( [KEY_TERMINAL_SCROLLBACK]: "terminalScrollback", [KEY_LAST_WSL_DISTRO]: "lastWslDistro", [KEY_ZOOM_LEVEL]: "zoomLevel", + [KEY_LAST_SSH_PROFILE]: "lastSshProfileId", [KEY_SHORTCUTS]: "shortcuts", }; // Same-process writes still fire onChange immediately; cross-window writes diff --git a/src/modules/ssh/FingerprintDialog.tsx b/src/modules/ssh/FingerprintDialog.tsx new file mode 100644 index 00000000..500aac9f --- /dev/null +++ b/src/modules/ssh/FingerprintDialog.tsx @@ -0,0 +1,46 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; + +type Props = { + open: boolean; + host: string; + fingerprint: string; + onAccept: () => void; + onReject: () => void; +}; + +export function FingerprintDialog({ open, host, fingerprint, onAccept, onReject }: Props) { + return ( + { if (!o) onReject(); }}> + + + Unknown host key + + The authenticity of {host} cannot be established. + + +
+ {fingerprint} +
+

+ Trust this fingerprint and continue? It will be saved for future connections. +

+ + + + +
+
+ ); +} diff --git a/src/modules/ssh/commands.ts b/src/modules/ssh/commands.ts new file mode 100644 index 00000000..c98adb36 --- /dev/null +++ b/src/modules/ssh/commands.ts @@ -0,0 +1,23 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { SshProfile } from "./types"; + +export const sshProfileList = () => + invoke("ssh_profile_list"); + +export const sshProfileSave = (profile: SshProfile) => + invoke("ssh_profile_save", { profile }); + +export const sshProfileDelete = (id: string) => + invoke("ssh_profile_delete", { id }); + +export const sshConnect = (profileId: string) => + invoke("ssh_connect", { profileId }); + +export const sshDisconnect = (profileId: string) => + invoke("ssh_disconnect", { profileId }); + +export const sshFingerprintGet = (profileId: string) => + invoke("ssh_fingerprint_get", { profileId }); + +export const sshHome = (profileId: string) => + invoke("ssh_home", { profileId }); diff --git a/src/modules/ssh/index.ts b/src/modules/ssh/index.ts new file mode 100644 index 00000000..886395c2 --- /dev/null +++ b/src/modules/ssh/index.ts @@ -0,0 +1,4 @@ +export * from "./types"; +export * from "./commands"; +export * from "./store"; +export * from "./FingerprintDialog"; diff --git a/src/modules/ssh/store.ts b/src/modules/ssh/store.ts new file mode 100644 index 00000000..2819e3ec --- /dev/null +++ b/src/modules/ssh/store.ts @@ -0,0 +1,69 @@ +import { create } from "zustand"; +import { sshProfileList, sshProfileSave, sshProfileDelete, sshConnect, sshDisconnect } from "./commands"; +import type { SshProfile } from "./types"; + +type ConnState = "disconnected" | "connecting" | "connected" | "error"; + +type State = { + profiles: SshProfile[]; + connState: Record; + loadProfiles: () => Promise; + saveProfile: (profile: Omit & { id?: string }) => Promise; + deleteProfile: (id: string) => Promise; + connect: (profileId: string) => Promise; + disconnect: (profileId: string) => Promise; + setConnState: (profileId: string, state: ConnState) => void; +}; + +export const useSshStore = create((set) => ({ + profiles: [], + connState: {}, + + loadProfiles: async () => { + try { + const profiles = await sshProfileList(); + set({ profiles }); + } catch (e) { + // Non-fatal — store stays at last known state + console.warn("ssh: failed to load profiles", e); + } + }, + + saveProfile: async (profile) => { + const toSave: SshProfile = { ...profile, id: profile.id ?? crypto.randomUUID() }; + const saved = await sshProfileSave(toSave); + set((s) => { + const exists = s.profiles.some((p) => p.id === saved.id); + return { + profiles: exists + ? s.profiles.map((p) => (p.id === saved.id ? saved : p)) + : [...s.profiles, saved], + }; + }); + return saved; + }, + + deleteProfile: async (id) => { + await sshProfileDelete(id); + set((s) => ({ profiles: s.profiles.filter((p) => p.id !== id) })); + }, + + connect: async (profileId) => { + set((s) => ({ connState: { ...s.connState, [profileId]: "connecting" } })); + try { + await sshConnect(profileId); + set((s) => ({ connState: { ...s.connState, [profileId]: "connected" } })); + } catch (e) { + set((s) => ({ connState: { ...s.connState, [profileId]: "error" } })); + throw e; + } + }, + + disconnect: async (profileId) => { + await sshDisconnect(profileId); + set((s) => ({ connState: { ...s.connState, [profileId]: "disconnected" } })); + }, + + setConnState: (profileId, state) => + set((s) => ({ connState: { ...s.connState, [profileId]: state } })), +})); diff --git a/src/modules/ssh/types.ts b/src/modules/ssh/types.ts new file mode 100644 index 00000000..1ed5139c --- /dev/null +++ b/src/modules/ssh/types.ts @@ -0,0 +1,12 @@ +export type AuthMethod = "key" | "agent"; + +export type SshProfile = { + id: string; + name: string; + host: string; + port: number; + user: string; + authMethod: AuthMethod; + keyPath?: string; + knownFingerprint?: string; +}; diff --git a/src/modules/statusbar/WorkspaceEnvSelector.tsx b/src/modules/statusbar/WorkspaceEnvSelector.tsx index 3b6e1eab..77c3b45b 100644 --- a/src/modules/statusbar/WorkspaceEnvSelector.tsx +++ b/src/modules/statusbar/WorkspaceEnvSelector.tsx @@ -1,7 +1,9 @@ +import { useState } from "react"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; @@ -13,73 +15,177 @@ import { } from "@/modules/workspace"; import { Refresh01Icon, ServerStack03Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; +import { useSshStore } from "@/modules/ssh/store"; +import { sshFingerprintGet } from "@/modules/ssh/commands"; +import { FingerprintDialog } from "@/modules/ssh/FingerprintDialog"; type Props = { onSelect: (env: WorkspaceEnv) => void; }; export function WorkspaceEnvSelector({ onSelect }: Props) { - if (!IS_WINDOWS) return null; - const env = useWorkspaceEnvStore((s) => s.env); const distros = useWorkspaceEnvStore((s) => s.distros); const loading = useWorkspaceEnvStore((s) => s.loading); const error = useWorkspaceEnvStore((s) => s.error); const refreshDistros = useWorkspaceEnvStore((s) => s.refreshDistros); + const profiles = useSshStore((s) => s.profiles); + const connState = useSshStore((s) => s.connState); + const loadProfiles = useSshStore((s) => s.loadProfiles); + const connect = useSshStore((s) => s.connect); + + const [tofu, setTofu] = useState<{ profileId: string; host: string; fingerprint: string } | null>(null); + const handleOpenChange = (open: boolean) => { - if (open && distros.length === 0 && !loading) { - void refreshDistros(); + if (open) { + if (IS_WINDOWS && distros.length === 0 && !loading) void refreshDistros(); + if (profiles.length === 0) void loadProfiles(); } }; - const label = env.kind === "wsl" ? `WSL: ${env.distro}` : "Windows"; + const handleSshSelect = async (profileId: string) => { + const profile = profiles.find((p) => p.id === profileId); + if (!profile) return; + + if (!profile.knownFingerprint) { + try { + const fp = await sshFingerprintGet(profileId); + if (fp) { + setTofu({ profileId, host: profile.host, fingerprint: fp }); + return; + } + } catch { + // If fingerprint fetch fails, proceed — connect will handle it + } + } + + try { + await connect(profileId); + onSelect({ kind: "ssh", profileId }); + } catch (e) { + console.error("ssh connect failed", e); + } + }; + + const handleTofuAccept = async () => { + if (!tofu) return; + const { profileId } = tofu; + setTofu(null); + try { + await connect(profileId); + onSelect({ kind: "ssh", profileId }); + } catch (e) { + console.error("ssh connect failed after TOFU", e); + } + }; + + const sshLabel = (() => { + if (env.kind === "ssh") { + const p = profiles.find((p) => p.id === env.profileId); + return p ? `SSH: ${p.name}` : "SSH"; + } + return null; + })(); + + const label = + sshLabel ?? + (env.kind === "wsl" ? `WSL: ${env.distro}` : IS_WINDOWS ? "Windows" : "Local"); return ( - - - - - - onSelect(LOCAL_WORKSPACE)}> - Windows Local - - - {distros.length === 0 ? ( - - {loading - ? "Loading WSL distros..." - : error - ? "WSL unavailable" - : "No WSL distros found"} + <> + + + + + + onSelect(LOCAL_WORKSPACE)}> + {IS_WINDOWS ? "Windows Local" : "Local"} - ) : ( - distros.map((distro) => ( - onSelect({ kind: "wsl", distro: distro.name })} - > - WSL: {distro.name} - - )) - )} - - void refreshDistros()}> - - Refresh - - - + + {IS_WINDOWS && ( + <> + + {distros.length === 0 ? ( + + {loading + ? "Loading WSL distros..." + : error + ? "WSL unavailable" + : "No WSL distros found"} + + ) : ( + distros.map((distro) => ( + onSelect({ kind: "wsl", distro: distro.name })} + > + WSL: {distro.name} + + )) + )} + + )} + + + + SSH + + {profiles.length === 0 ? ( + No SSH profiles saved + ) : ( + profiles.map((profile) => { + const state = connState[profile.id]; + const isConnecting = state === "connecting"; + return ( + void handleSshSelect(profile.id)} + disabled={isConnecting} + > + {profile.name} + {isConnecting && ( + connecting… + )} + {state === "connected" && ( + + )} + {state === "error" && ( + ! + )} + + ); + }) + )} + + + void refreshDistros()}> + + Refresh + + + + + {tofu && ( + void handleTofuAccept()} + onReject={() => setTofu(null)} + /> + )} + ); } diff --git a/src/modules/workspace/env.ts b/src/modules/workspace/env.ts index 3f7c573b..91285d26 100644 --- a/src/modules/workspace/env.ts +++ b/src/modules/workspace/env.ts @@ -1,10 +1,11 @@ import { invoke } from "@tauri-apps/api/core"; import { create } from "zustand"; -import { setLastWslDistro } from "@/modules/settings/store"; +import { setLastWslDistro, setLastSshProfileId } from "@/modules/settings/store"; export type WorkspaceEnv = | { kind: "local" } - | { kind: "wsl"; distro: string }; + | { kind: "wsl"; distro: string } + | { kind: "ssh"; profileId: string }; export type WslDistro = { name: string; @@ -31,6 +32,8 @@ export const useWorkspaceEnvStore = create((set) => ({ setEnv: (env) => { set({ env }); if (env.kind === "wsl") void setLastWslDistro(env.distro); + if (env.kind === "ssh") void setLastSshProfileId(env.profileId); + if (env.kind === "local") void setLastSshProfileId(null); }, refreshDistros: async () => { set({ loading: true, error: null }); diff --git a/src/settings/SettingsApp.tsx b/src/settings/SettingsApp.tsx index 9336fc6a..51835efd 100644 --- a/src/settings/SettingsApp.tsx +++ b/src/settings/SettingsApp.tsx @@ -9,6 +9,7 @@ import { Settings01Icon, UserMultiple02Icon, KeyboardIcon, + ServerStack03Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; @@ -18,6 +19,7 @@ import { AgentsSection } from "./sections/AgentsSection"; import { GeneralSection } from "./sections/GeneralSection"; import { ModelsSection } from "./sections/ModelsSection"; import { ShortcutsSection } from "./sections/ShortcutsSection"; +import { SshSection } from "./sections/SshSection"; const TABS: { id: SettingsTab; label: string; icon: typeof Settings01Icon, component: () => JSX.Element }[] = [ @@ -25,6 +27,7 @@ const TABS: { id: SettingsTab; label: string; icon: typeof Settings01Icon, compo { id: "shortcuts", label: "Shortcuts", icon: KeyboardIcon, component: ShortcutsSection }, { id: "models", label: "Models", icon: AiScanIcon, component: ModelsSection }, { id: "agents", label: "Agents", icon: UserMultiple02Icon, component: AgentsSection }, + { id: "ssh", label: "SSH", icon: ServerStack03Icon, component: SshSection }, { id: "about", label: "About", icon: InformationCircleIcon, component: AboutSection }, ]; @@ -33,6 +36,7 @@ const VALID_TABS: SettingsTab[] = [ "shortcuts", "models", "agents", + "ssh", "about", ]; diff --git a/src/settings/sections/SshSection.tsx b/src/settings/sections/SshSection.tsx new file mode 100644 index 00000000..dd0121bd --- /dev/null +++ b/src/settings/sections/SshSection.tsx @@ -0,0 +1,193 @@ +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useSshStore } from "@/modules/ssh/store"; +import type { SshProfile, AuthMethod } from "@/modules/ssh/types"; +import { Delete02Icon, Add01Icon, CheckmarkCircle02Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; + +const BLANK: Omit = { + name: "", + host: "", + port: 22, + user: "", + authMethod: "key", + keyPath: "", +}; + +export function SshSection() { + const profiles = useSshStore((s) => s.profiles); + const loadProfiles = useSshStore((s) => s.loadProfiles); + const saveProfile = useSshStore((s) => s.saveProfile); + const deleteProfile = useSshStore((s) => s.deleteProfile); + + const [editing, setEditing] = useState<(Omit & { id?: string }) | null>(null); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + void loadProfiles(); + }, [loadProfiles]); + + const startNew = () => { + setEditing({ ...BLANK }); + setError(null); + }; + + const startEdit = (p: SshProfile) => { + setEditing({ ...p }); + setError(null); + }; + + const handleSave = async () => { + if (!editing) return; + if (!editing.name.trim() || !editing.host.trim() || !editing.user.trim()) { + setError("Name, host, and user are required."); + return; + } + setSaving(true); + setError(null); + try { + await saveProfile(editing); + setEditing(null); + } catch (e) { + setError(String(e)); + } finally { + setSaving(false); + } + }; + + const handleDelete = async (id: string) => { + await deleteProfile(id); + if (editing && editing.id === id) setEditing(null); + }; + + return ( +
+
+

SSH Profiles

+

+ Saved SSH connections. Profiles are stored in your app data directory. +

+
+ +
+ {profiles.length === 0 && ( +

No profiles yet.

+ )} + {profiles.map((p) => ( +
+
+

{p.name}

+

+ {p.user}@{p.host}:{p.port} · {p.authMethod} +

+
+
+ + +
+
+ ))} +
+ + {editing ? ( +
+

+ {editing.id ? "Edit Profile" : "New Profile"} +

+
+
+ + setEditing({ ...editing, name: e.target.value })} + placeholder="Production" + /> +
+
+ + setEditing({ ...editing, host: e.target.value })} + placeholder="192.168.1.1" + /> +
+
+ + setEditing({ ...editing, user: e.target.value })} + placeholder="alice" + /> +
+
+ + + setEditing({ ...editing, port: parseInt(e.target.value, 10) || 22 }) + } + /> +
+
+
+ +
+ {(["key", "agent"] as AuthMethod[]).map((m) => ( + + ))} +
+
+ {editing.authMethod === "key" && ( +
+ + setEditing({ ...editing, keyPath: e.target.value })} + placeholder="~/.ssh/id_ed25519" + /> +
+ )} + {error &&

{error}

} +
+ + +
+
+ ) : ( + + )} +
+ ); +}