feat: Phase 3 complete — SFTP sidebar with full file operations
Rust SFTP service: russh-sftp client on same SSH connection, DashMap storage, list/read/write/mkdir/delete/rename/stat ops. 5MB file size guard. Non-fatal SFTP failure (terminal still works). Vue frontend: FileTree with all toolbar buttons wired (upload, download, delete, mkdir, refresh), TransferProgress panel, useSftp composable with CWD following via Tauri events. MainLayout wired with SFTP sidebar. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
737491d3f0
commit
a8656b0812
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
@ -6252,6 +6252,7 @@ dependencies = [
|
|||||||
"rand 0.9.2",
|
"rand 0.9.2",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"russh",
|
"russh",
|
||||||
|
"russh-sftp",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"ssh-key",
|
"ssh-key",
|
||||||
|
|||||||
@ -29,4 +29,5 @@ log = "0.4"
|
|||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
russh = "0.48"
|
russh = "0.48"
|
||||||
|
russh-sftp = "2.1.1"
|
||||||
ssh-key = { version = "0.6", features = ["ed25519", "rsa"] }
|
ssh-key = { version = "0.6", features = ["ed25519", "rsa"] }
|
||||||
|
|||||||
@ -3,3 +3,4 @@ pub mod settings;
|
|||||||
pub mod connections;
|
pub mod connections;
|
||||||
pub mod credentials;
|
pub mod credentials;
|
||||||
pub mod ssh_commands;
|
pub mod ssh_commands;
|
||||||
|
pub mod sftp_commands;
|
||||||
|
|||||||
78
src-tauri/src/commands/sftp_commands.rs
Normal file
78
src-tauri/src/commands/sftp_commands.rs
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
//! Tauri commands for SFTP file operations.
|
||||||
|
//!
|
||||||
|
//! All commands delegate directly to `AppState::sftp` (the `SftpService`).
|
||||||
|
//! They require that the SSH session is already connected and its SFTP subsystem
|
||||||
|
//! has been initialized — that happens automatically inside `SshService::connect`.
|
||||||
|
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::sftp::FileEntry;
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
/// List the contents of a remote directory.
|
||||||
|
///
|
||||||
|
/// Returns entries sorted: directories first (alphabetically), then files.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn sftp_list(
|
||||||
|
session_id: String,
|
||||||
|
path: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<FileEntry>, String> {
|
||||||
|
state.sftp.list(&session_id, &path).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a remote file as a UTF-8 string.
|
||||||
|
///
|
||||||
|
/// Returns an error if the file exceeds 5 MB or is not valid UTF-8.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn sftp_read_file(
|
||||||
|
session_id: String,
|
||||||
|
path: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
state.sftp.read_file(&session_id, &path).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a UTF-8 string to a remote file (create or truncate).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn sftp_write_file(
|
||||||
|
session_id: String,
|
||||||
|
path: String,
|
||||||
|
content: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
state.sftp.write_file(&session_id, &path, &content).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a remote directory.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn sftp_mkdir(
|
||||||
|
session_id: String,
|
||||||
|
path: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
state.sftp.mkdir(&session_id, &path).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a remote file or directory.
|
||||||
|
///
|
||||||
|
/// Tries to remove as a file first; falls back to directory removal.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn sftp_delete(
|
||||||
|
session_id: String,
|
||||||
|
path: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
state.sftp.delete(&session_id, &path).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rename or move a remote path.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn sftp_rename(
|
||||||
|
session_id: String,
|
||||||
|
old_path: String,
|
||||||
|
new_path: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
state.sftp.rename(&session_id, &old_path, &new_path).await
|
||||||
|
}
|
||||||
@ -12,7 +12,8 @@ use crate::AppState;
|
|||||||
/// Connect to an SSH server with password authentication.
|
/// Connect to an SSH server with password authentication.
|
||||||
///
|
///
|
||||||
/// Opens a PTY, starts a shell, and begins streaming output via
|
/// Opens a PTY, starts a shell, and begins streaming output via
|
||||||
/// `ssh:data:{session_id}` events. Returns the session UUID.
|
/// `ssh:data:{session_id}` events. Also opens an SFTP subsystem channel on
|
||||||
|
/// the same connection. Returns the session UUID.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn connect_ssh(
|
pub async fn connect_ssh(
|
||||||
hostname: String,
|
hostname: String,
|
||||||
@ -34,6 +35,7 @@ pub async fn connect_ssh(
|
|||||||
AuthMethod::Password(password),
|
AuthMethod::Password(password),
|
||||||
cols,
|
cols,
|
||||||
rows,
|
rows,
|
||||||
|
&state.sftp,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@ -70,6 +72,7 @@ pub async fn connect_ssh_with_key(
|
|||||||
},
|
},
|
||||||
cols,
|
cols,
|
||||||
rows,
|
rows,
|
||||||
|
&state.sftp,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@ -98,12 +101,14 @@ pub async fn ssh_resize(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Disconnect an SSH session — closes the channel and removes it.
|
/// Disconnect an SSH session — closes the channel and removes it.
|
||||||
|
///
|
||||||
|
/// Also removes the associated SFTP client.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn disconnect_ssh(
|
pub async fn disconnect_ssh(
|
||||||
session_id: String,
|
session_id: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
state.ssh.disconnect(&session_id).await
|
state.ssh.disconnect(&session_id, &state.sftp).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all active SSH sessions (metadata only).
|
/// List all active SSH sessions (metadata only).
|
||||||
|
|||||||
@ -4,6 +4,7 @@ pub mod settings;
|
|||||||
pub mod connections;
|
pub mod connections;
|
||||||
pub mod credentials;
|
pub mod credentials;
|
||||||
pub mod ssh;
|
pub mod ssh;
|
||||||
|
pub mod sftp;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@ -14,6 +15,7 @@ use vault::VaultService;
|
|||||||
use credentials::CredentialService;
|
use credentials::CredentialService;
|
||||||
use settings::SettingsService;
|
use settings::SettingsService;
|
||||||
use connections::ConnectionService;
|
use connections::ConnectionService;
|
||||||
|
use sftp::SftpService;
|
||||||
use ssh::session::SshService;
|
use ssh::session::SshService;
|
||||||
|
|
||||||
/// Application state shared across all Tauri commands via State<AppState>.
|
/// Application state shared across all Tauri commands via State<AppState>.
|
||||||
@ -24,6 +26,7 @@ pub struct AppState {
|
|||||||
pub connections: ConnectionService,
|
pub connections: ConnectionService,
|
||||||
pub credentials: Mutex<Option<CredentialService>>,
|
pub credentials: Mutex<Option<CredentialService>>,
|
||||||
pub ssh: SshService,
|
pub ssh: SshService,
|
||||||
|
pub sftp: SftpService,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@ -37,6 +40,7 @@ impl AppState {
|
|||||||
let settings = SettingsService::new(database.clone());
|
let settings = SettingsService::new(database.clone());
|
||||||
let connections = ConnectionService::new(database.clone());
|
let connections = ConnectionService::new(database.clone());
|
||||||
let ssh = SshService::new(database.clone());
|
let ssh = SshService::new(database.clone());
|
||||||
|
let sftp = SftpService::new();
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
db: database,
|
db: database,
|
||||||
@ -45,6 +49,7 @@ impl AppState {
|
|||||||
connections,
|
connections,
|
||||||
credentials: Mutex::new(None),
|
credentials: Mutex::new(None),
|
||||||
ssh,
|
ssh,
|
||||||
|
sftp,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,6 +119,12 @@ pub fn run() {
|
|||||||
commands::ssh_commands::ssh_resize,
|
commands::ssh_commands::ssh_resize,
|
||||||
commands::ssh_commands::disconnect_ssh,
|
commands::ssh_commands::disconnect_ssh,
|
||||||
commands::ssh_commands::list_ssh_sessions,
|
commands::ssh_commands::list_ssh_sessions,
|
||||||
|
commands::sftp_commands::sftp_list,
|
||||||
|
commands::sftp_commands::sftp_read_file,
|
||||||
|
commands::sftp_commands::sftp_write_file,
|
||||||
|
commands::sftp_commands::sftp_mkdir,
|
||||||
|
commands::sftp_commands::sftp_delete,
|
||||||
|
commands::sftp_commands::sftp_rename,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
324
src-tauri/src/sftp/mod.rs
Normal file
324
src-tauri/src/sftp/mod.rs
Normal file
@ -0,0 +1,324 @@
|
|||||||
|
//! SFTP service — high-level file operations over an existing SSH connection.
|
||||||
|
//!
|
||||||
|
//! SFTP sessions are created from SSH handles via a separate subsystem channel
|
||||||
|
//! (not the PTY channel). One `SftpSession` is stored per SSH session ID and
|
||||||
|
//! provides all file operations needed by the frontend.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use dashmap::DashMap;
|
||||||
|
use log::{debug, info};
|
||||||
|
use russh_sftp::client::SftpSession;
|
||||||
|
use russh_sftp::protocol::OpenFlags;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
|
|
||||||
|
// ── file entry ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Metadata about a single remote file or directory, returned to the frontend.
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FileEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub path: String,
|
||||||
|
pub size: u64,
|
||||||
|
pub is_dir: bool,
|
||||||
|
/// Unix permission string, e.g. "0755"
|
||||||
|
pub permissions: String,
|
||||||
|
/// Human-readable modification time, e.g. "Mar 17 14:30"
|
||||||
|
pub mod_time: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Format a Unix timestamp (seconds since epoch) as "Mon DD HH:MM".
|
||||||
|
fn format_mtime(unix_secs: u32) -> String {
|
||||||
|
// Build a SystemTime from the raw epoch value.
|
||||||
|
let st = UNIX_EPOCH + Duration::from_secs(unix_secs as u64);
|
||||||
|
|
||||||
|
// Convert to seconds-since-epoch for manual formatting. We avoid pulling
|
||||||
|
// in chrono just for this; a simple manual decomposition is sufficient for
|
||||||
|
// the "Mar 17 14:30" display format expected by the frontend.
|
||||||
|
let secs = unix_secs as i64;
|
||||||
|
|
||||||
|
// Days since epoch → calendar date (Gregorian, ignoring leap seconds).
|
||||||
|
let days = secs / 86_400;
|
||||||
|
let time_of_day = secs % 86_400;
|
||||||
|
let hours = time_of_day / 3_600;
|
||||||
|
let minutes = (time_of_day % 3_600) / 60;
|
||||||
|
|
||||||
|
// Gregorian calendar calculation (Zeller / Julian Day approach).
|
||||||
|
let z = days + 719_468;
|
||||||
|
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||||
|
let doe = z - era * 146_097;
|
||||||
|
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
|
||||||
|
let y = yoe + era * 400;
|
||||||
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||||
|
let mp = (5 * doy + 2) / 153;
|
||||||
|
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||||
|
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||||
|
let _y = if m <= 2 { y + 1 } else { y };
|
||||||
|
|
||||||
|
let month = match m {
|
||||||
|
1 => "Jan",
|
||||||
|
2 => "Feb",
|
||||||
|
3 => "Mar",
|
||||||
|
4 => "Apr",
|
||||||
|
5 => "May",
|
||||||
|
6 => "Jun",
|
||||||
|
7 => "Jul",
|
||||||
|
8 => "Aug",
|
||||||
|
9 => "Sep",
|
||||||
|
10 => "Oct",
|
||||||
|
11 => "Nov",
|
||||||
|
12 => "Dec",
|
||||||
|
_ => "???",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Suppress unused variable warning — st is only used as a sanity anchor.
|
||||||
|
let _ = st;
|
||||||
|
|
||||||
|
format!("{} {:2} {:02}:{:02}", month, d, hours, minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert raw permission bits to a "0755"-style octal string.
|
||||||
|
fn format_permissions(raw: Option<u32>) -> String {
|
||||||
|
match raw {
|
||||||
|
Some(p) => format!("{:04o}", p & 0o7777),
|
||||||
|
None => "----".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SFTP service ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Manages SFTP sessions keyed by SSH session ID.
|
||||||
|
pub struct SftpService {
|
||||||
|
/// One `SftpSession` per SSH session, behind a mutex so async commands can
|
||||||
|
/// take a shared reference to the `SftpService` and still mutably borrow
|
||||||
|
/// individual sessions.
|
||||||
|
clients: DashMap<String, Arc<TokioMutex<SftpSession>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SftpService {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
clients: DashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store an SFTP client for the given session.
|
||||||
|
pub fn register_client(&self, session_id: &str, client: SftpSession) {
|
||||||
|
info!("SFTP client registered for session {}", session_id);
|
||||||
|
self.clients
|
||||||
|
.insert(session_id.to_string(), Arc::new(TokioMutex::new(client)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove and drop the SFTP client for a session (called on SSH disconnect).
|
||||||
|
pub fn remove_client(&self, session_id: &str) {
|
||||||
|
if self.clients.remove(session_id).is_some() {
|
||||||
|
info!("SFTP client removed for session {}", session_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List the contents of a remote directory.
|
||||||
|
///
|
||||||
|
/// Returns entries sorted: directories first (alphabetically), then files
|
||||||
|
/// (alphabetically).
|
||||||
|
pub async fn list(&self, session_id: &str, path: &str) -> Result<Vec<FileEntry>, String> {
|
||||||
|
let arc = self.get_client(session_id)?;
|
||||||
|
let client = arc.lock().await;
|
||||||
|
|
||||||
|
let read_dir = client
|
||||||
|
.read_dir(path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("SFTP list '{}' failed: {}", path, e))?;
|
||||||
|
|
||||||
|
let mut entries: Vec<FileEntry> = read_dir
|
||||||
|
.map(|entry| {
|
||||||
|
let meta = entry.metadata();
|
||||||
|
let name = entry.file_name();
|
||||||
|
let full_path = if path.ends_with('/') {
|
||||||
|
format!("{}{}", path, name)
|
||||||
|
} else {
|
||||||
|
format!("{}/{}", path, name)
|
||||||
|
};
|
||||||
|
let is_dir = meta.is_dir();
|
||||||
|
FileEntry {
|
||||||
|
name,
|
||||||
|
path: full_path,
|
||||||
|
size: meta.size.unwrap_or(0),
|
||||||
|
is_dir,
|
||||||
|
permissions: format_permissions(meta.permissions),
|
||||||
|
mod_time: meta.mtime.map(format_mtime).unwrap_or_default(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Sort: directories first, then files; each group alphabetical.
|
||||||
|
entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
|
||||||
|
(true, false) => std::cmp::Ordering::Less,
|
||||||
|
(false, true) => std::cmp::Ordering::Greater,
|
||||||
|
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||||
|
});
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"SFTP list '{}' for session {}: {} entries",
|
||||||
|
path,
|
||||||
|
session_id,
|
||||||
|
entries.len()
|
||||||
|
);
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a remote file as a UTF-8 string.
|
||||||
|
///
|
||||||
|
/// Enforces a 5 MB guard — returns an error for larger files.
|
||||||
|
pub async fn read_file(&self, session_id: &str, path: &str) -> Result<String, String> {
|
||||||
|
const MAX_BYTES: u64 = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
let arc = self.get_client(session_id)?;
|
||||||
|
let client = arc.lock().await;
|
||||||
|
|
||||||
|
// Stat first to check size before pulling bytes.
|
||||||
|
let meta = client
|
||||||
|
.metadata(path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("SFTP stat '{}' failed: {}", path, e))?;
|
||||||
|
|
||||||
|
if let Some(size) = meta.size {
|
||||||
|
if size > MAX_BYTES {
|
||||||
|
return Err(format!(
|
||||||
|
"File '{}' is {} bytes — exceeds 5 MB read limit",
|
||||||
|
path, size
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut file = client
|
||||||
|
.open(path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("SFTP open '{}' failed: {}", path, e))?;
|
||||||
|
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
file.read_to_end(&mut buf)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("SFTP read '{}' failed: {}", path, e))?;
|
||||||
|
|
||||||
|
String::from_utf8(buf)
|
||||||
|
.map_err(|_| format!("File '{}' is not valid UTF-8", path))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a UTF-8 string to a remote file (create or truncate).
|
||||||
|
pub async fn write_file(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
path: &str,
|
||||||
|
content: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let arc = self.get_client(session_id)?;
|
||||||
|
let client = arc.lock().await;
|
||||||
|
|
||||||
|
let mut file = client
|
||||||
|
.open_with_flags(
|
||||||
|
path,
|
||||||
|
OpenFlags::CREATE | OpenFlags::TRUNCATE | OpenFlags::WRITE,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("SFTP open '{}' for write failed: {}", path, e))?;
|
||||||
|
|
||||||
|
file.write_all(content.as_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("SFTP write '{}' failed: {}", path, e))?;
|
||||||
|
|
||||||
|
file.flush()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("SFTP flush '{}' failed: {}", path, e))?;
|
||||||
|
|
||||||
|
debug!("SFTP wrote {} bytes to '{}'", content.len(), path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a remote directory.
|
||||||
|
pub async fn mkdir(&self, session_id: &str, path: &str) -> Result<(), String> {
|
||||||
|
let arc = self.get_client(session_id)?;
|
||||||
|
let client = arc.lock().await;
|
||||||
|
|
||||||
|
client
|
||||||
|
.create_dir(path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("SFTP mkdir '{}' failed: {}", path, e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a remote file or directory.
|
||||||
|
///
|
||||||
|
/// Tries `remove_file` first; falls back to `remove_dir` if that fails.
|
||||||
|
pub async fn delete(&self, session_id: &str, path: &str) -> Result<(), String> {
|
||||||
|
let arc = self.get_client(session_id)?;
|
||||||
|
let client = arc.lock().await;
|
||||||
|
|
||||||
|
match client.remove_file(path).await {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(_) => client
|
||||||
|
.remove_dir(path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("SFTP delete '{}' failed: {}", path, e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rename (or move) a remote path.
|
||||||
|
pub async fn rename(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
old_path: &str,
|
||||||
|
new_path: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let arc = self.get_client(session_id)?;
|
||||||
|
let client = arc.lock().await;
|
||||||
|
|
||||||
|
client
|
||||||
|
.rename(old_path, new_path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("SFTP rename '{}' → '{}' failed: {}", old_path, new_path, e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stat a single remote path and return its metadata as a `FileEntry`.
|
||||||
|
pub async fn stat(&self, session_id: &str, path: &str) -> Result<FileEntry, String> {
|
||||||
|
let arc = self.get_client(session_id)?;
|
||||||
|
let client = arc.lock().await;
|
||||||
|
|
||||||
|
let meta = client
|
||||||
|
.metadata(path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("SFTP stat '{}' failed: {}", path, e))?;
|
||||||
|
|
||||||
|
// Extract the file name from the path.
|
||||||
|
let name = path
|
||||||
|
.rsplit('/')
|
||||||
|
.next()
|
||||||
|
.unwrap_or(path)
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Ok(FileEntry {
|
||||||
|
name,
|
||||||
|
path: path.to_string(),
|
||||||
|
size: meta.size.unwrap_or(0),
|
||||||
|
is_dir: meta.is_dir(),
|
||||||
|
permissions: format_permissions(meta.permissions),
|
||||||
|
mod_time: meta.mtime.map(format_mtime).unwrap_or_default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── private helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn get_client(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
) -> Result<Arc<TokioMutex<SftpSession>>, String> {
|
||||||
|
self.clients
|
||||||
|
.get(session_id)
|
||||||
|
.map(|r| r.clone())
|
||||||
|
.ok_or_else(|| format!("No SFTP client for session {}", session_id))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -20,6 +20,7 @@ use tauri::{AppHandle, Emitter};
|
|||||||
use tokio::sync::Mutex as TokioMutex;
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
|
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
|
use crate::sftp::SftpService;
|
||||||
use crate::ssh::cwd::CwdTracker;
|
use crate::ssh::cwd::CwdTracker;
|
||||||
use crate::ssh::host_key::{HostKeyResult, HostKeyStore};
|
use crate::ssh::host_key::{HostKeyResult, HostKeyStore};
|
||||||
|
|
||||||
@ -157,6 +158,9 @@ impl SshService {
|
|||||||
/// Establish an SSH connection, authenticate, open a PTY, start a shell,
|
/// Establish an SSH connection, authenticate, open a PTY, start a shell,
|
||||||
/// and begin streaming output to the frontend.
|
/// and begin streaming output to the frontend.
|
||||||
///
|
///
|
||||||
|
/// Also opens an SFTP subsystem channel on the same connection and registers
|
||||||
|
/// it with `sftp_service` so file-manager commands work immediately.
|
||||||
|
///
|
||||||
/// Returns the session UUID on success.
|
/// Returns the session UUID on success.
|
||||||
pub async fn connect(
|
pub async fn connect(
|
||||||
&self,
|
&self,
|
||||||
@ -167,6 +171,7 @@ impl SshService {
|
|||||||
auth: AuthMethod,
|
auth: AuthMethod,
|
||||||
cols: u32,
|
cols: u32,
|
||||||
rows: u32,
|
rows: u32,
|
||||||
|
sftp_service: &SftpService,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let session_id = uuid::Uuid::new_v4().to_string();
|
let session_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
|
||||||
@ -265,6 +270,55 @@ impl SshService {
|
|||||||
|
|
||||||
self.sessions.insert(session_id.clone(), session);
|
self.sessions.insert(session_id.clone(), session);
|
||||||
|
|
||||||
|
// Open a separate SFTP subsystem channel on the same SSH connection.
|
||||||
|
// This is distinct from the PTY channel — both are multiplexed over
|
||||||
|
// the same underlying transport.
|
||||||
|
{
|
||||||
|
let sftp_channel_result = {
|
||||||
|
let h = handle.lock().await;
|
||||||
|
h.channel_open_session().await
|
||||||
|
};
|
||||||
|
|
||||||
|
match sftp_channel_result {
|
||||||
|
Ok(sftp_channel) => {
|
||||||
|
match sftp_channel.request_subsystem(true, "sftp").await {
|
||||||
|
Ok(()) => {
|
||||||
|
match russh_sftp::client::SftpSession::new(
|
||||||
|
sftp_channel.into_stream(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(sftp_client) => {
|
||||||
|
sftp_service.register_client(&session_id, sftp_client);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
"SFTP session init failed for {}: {} — \
|
||||||
|
file manager will be unavailable",
|
||||||
|
session_id, e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
"SFTP subsystem request failed for {}: {} — \
|
||||||
|
file manager will be unavailable",
|
||||||
|
session_id, e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
"Failed to open SFTP channel for {}: {} — \
|
||||||
|
file manager will be unavailable",
|
||||||
|
session_id, e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Spawn the stdout read loop.
|
// Spawn the stdout read loop.
|
||||||
let sid = session_id.clone();
|
let sid = session_id.clone();
|
||||||
let chan = channel.clone();
|
let chan = channel.clone();
|
||||||
@ -370,7 +424,14 @@ impl SshService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Disconnect a session — close the channel and remove it from the map.
|
/// Disconnect a session — close the channel and remove it from the map.
|
||||||
pub async fn disconnect(&self, session_id: &str) -> Result<(), String> {
|
///
|
||||||
|
/// Pass the `sftp_service` so the SFTP client can be dropped at the same
|
||||||
|
/// time as the SSH handle.
|
||||||
|
pub async fn disconnect(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
sftp_service: &SftpService,
|
||||||
|
) -> Result<(), String> {
|
||||||
let (_, session) = self
|
let (_, session) = self
|
||||||
.sessions
|
.sessions
|
||||||
.remove(session_id)
|
.remove(session_id)
|
||||||
@ -392,6 +453,9 @@ impl SshService {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up the SFTP client for this session.
|
||||||
|
sftp_service.remove_client(session_id);
|
||||||
|
|
||||||
info!("SSH session {} disconnected", session_id);
|
info!("SSH session {} disconnected", session_id);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
298
src/components/sftp/FileTree.vue
Normal file
298
src/components/sftp/FileTree.vue
Normal file
@ -0,0 +1,298 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-full text-xs">
|
||||||
|
<!-- Path bar -->
|
||||||
|
<div class="flex items-center gap-1 px-3 py-1.5 border-b border-[var(--wraith-border)] bg-[var(--wraith-bg-tertiary)]">
|
||||||
|
<button
|
||||||
|
class="text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer shrink-0"
|
||||||
|
title="Go up"
|
||||||
|
@click="goUp"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M3.22 9.78a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1-1.06 1.06L8 6.06 4.28 9.78a.75.75 0 0 1-1.06 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span class="text-[var(--wraith-text-secondary)] truncate font-mono text-[10px]">
|
||||||
|
{{ currentPath }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="flex items-center gap-1 px-2 py-1 border-b border-[var(--wraith-border)]">
|
||||||
|
<button
|
||||||
|
class="p-1 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-accent-blue)] transition-colors cursor-pointer rounded hover:bg-[var(--wraith-bg-tertiary)]"
|
||||||
|
title="Upload file"
|
||||||
|
@click="handleUpload"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14H2.75z" />
|
||||||
|
<path d="M11.78 4.72a.75.75 0 0 1-1.06 1.06L8.75 3.81V9.5a.75.75 0 0 1-1.5 0V3.81L5.28 5.78a.75.75 0 0 1-1.06-1.06l3.25-3.25a.75.75 0 0 1 1.06 0l3.25 3.25z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-1 transition-colors cursor-pointer rounded hover:bg-[var(--wraith-bg-tertiary)]"
|
||||||
|
:class="selectedEntry && !selectedEntry.isDir ? 'text-[var(--wraith-text-muted)] hover:text-[var(--wraith-accent-blue)]' : 'text-[var(--wraith-text-muted)] opacity-40 cursor-not-allowed'"
|
||||||
|
title="Download file"
|
||||||
|
@click="handleDownload"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14H2.75z" />
|
||||||
|
<path d="M7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.749.749 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06l1.97 1.969z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-1 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-accent-yellow)] transition-colors cursor-pointer rounded hover:bg-[var(--wraith-bg-tertiary)]"
|
||||||
|
title="New folder"
|
||||||
|
@click="handleNewFolder"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75zM8.75 8v1.75a.75.75 0 0 1-1.5 0V8H5.5a.75.75 0 0 1 0-1.5h1.75V4.75a.75.75 0 0 1 1.5 0V6.5h1.75a.75.75 0 0 1 0 1.5H8.75z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-1 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer rounded hover:bg-[var(--wraith-bg-tertiary)]"
|
||||||
|
title="Refresh"
|
||||||
|
@click="refresh"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.001 7.001 0 0 1 14.95 7.16a.75.75 0 0 1-1.49.178A5.501 5.501 0 0 0 8 2.5zM1.705 8.005a.75.75 0 0 1 .834.656 5.501 5.501 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.001 7.001 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-1 transition-colors cursor-pointer rounded hover:bg-[var(--wraith-bg-tertiary)]"
|
||||||
|
:class="selectedEntry ? 'text-[var(--wraith-text-muted)] hover:text-[var(--wraith-accent-red)]' : 'text-[var(--wraith-text-muted)] opacity-40 cursor-not-allowed'"
|
||||||
|
title="Delete"
|
||||||
|
@click="handleDelete"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M11 1.75V3h2.25a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75zM6.5 1.75v1.25h3V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25zM4.997 6.178a.75.75 0 1 0-1.493.144l.685 7.107A2.25 2.25 0 0 0 6.427 15.5h3.146a2.25 2.25 0 0 0 2.238-2.071l.685-7.107a.75.75 0 1 0-1.493-.144l-.685 7.107a.75.75 0 0 1-.746.715H6.427a.75.75 0 0 1-.746-.715l-.684-7.107z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden file input for upload -->
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
class="hidden"
|
||||||
|
@change="handleFileSelected"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- File list -->
|
||||||
|
<div class="flex-1 overflow-y-auto">
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||||
|
<span class="text-[var(--wraith-text-muted)]">Loading...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty -->
|
||||||
|
<div v-else-if="entries.length === 0" class="flex items-center justify-center py-8">
|
||||||
|
<span class="text-[var(--wraith-text-muted)]">Empty directory</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Entries -->
|
||||||
|
<template v-else>
|
||||||
|
<button
|
||||||
|
v-for="entry in entries"
|
||||||
|
:key="entry.path"
|
||||||
|
class="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-[var(--wraith-bg-tertiary)] transition-colors cursor-pointer group"
|
||||||
|
:class="{ 'bg-[var(--wraith-bg-tertiary)] ring-1 ring-inset ring-[var(--wraith-accent-blue)]': selectedEntry?.path === entry.path }"
|
||||||
|
@click="selectedEntry = entry"
|
||||||
|
@dblclick="handleEntryDblClick(entry)"
|
||||||
|
>
|
||||||
|
<!-- Icon -->
|
||||||
|
<svg
|
||||||
|
v-if="entry.isDir"
|
||||||
|
class="w-3.5 h-3.5 text-[var(--wraith-accent-yellow)] shrink-0"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75z" />
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
v-else
|
||||||
|
class="w-3.5 h-3.5 text-[var(--wraith-text-muted)] shrink-0"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M3.75 1.5a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25V6H9.75A1.75 1.75 0 0 1 8 4.25V1.5H3.75zm5.75.56v2.19c0 .138.112.25.25.25h2.19L9.5 2.06zM2 1.75C2 .784 2.784 0 3.75 0h5.086c.464 0 .909.184 1.237.513l3.414 3.414c.329.328.513.773.513 1.237v8.086A1.75 1.75 0 0 1 12.25 15h-8.5A1.75 1.75 0 0 1 2 13.25V1.75z" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- Name -->
|
||||||
|
<span class="text-[var(--wraith-text-primary)] truncate">{{ entry.name }}</span>
|
||||||
|
|
||||||
|
<!-- Size (files only) -->
|
||||||
|
<span
|
||||||
|
v-if="!entry.isDir"
|
||||||
|
class="ml-auto text-[var(--wraith-text-muted)] text-[10px] shrink-0"
|
||||||
|
>
|
||||||
|
{{ humanizeSize(entry.size) }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Modified date -->
|
||||||
|
<span class="text-[var(--wraith-text-muted)] text-[10px] shrink-0 w-[68px] text-right">
|
||||||
|
{{ entry.modTime }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Follow terminal toggle -->
|
||||||
|
<div class="flex items-center gap-2 px-3 py-1.5 border-t border-[var(--wraith-border)]">
|
||||||
|
<label class="flex items-center gap-1.5 cursor-pointer text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-secondary)] transition-colors">
|
||||||
|
<input
|
||||||
|
v-model="followTerminal"
|
||||||
|
type="checkbox"
|
||||||
|
class="w-3 h-3 accent-[var(--wraith-accent-blue)] cursor-pointer"
|
||||||
|
/>
|
||||||
|
<span>Follow terminal folder</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { useSftp, type FileEntry } from "@/composables/useSftp";
|
||||||
|
import { useTransfers } from "@/composables/useTransfers";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
sessionId: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
openFile: [entry: FileEntry];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { currentPath, entries, isLoading, followTerminal, navigateTo, goUp, refresh } = useSftp(props.sessionId);
|
||||||
|
const { addTransfer, completeTransfer, failTransfer } = useTransfers();
|
||||||
|
|
||||||
|
/** Currently selected entry (single-click to select, double-click to open/navigate). */
|
||||||
|
const selectedEntry = ref<FileEntry | null>(null);
|
||||||
|
|
||||||
|
/** Hidden file input element used for the upload flow. */
|
||||||
|
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
function handleEntryDblClick(entry: FileEntry): void {
|
||||||
|
if (entry.isDir) {
|
||||||
|
selectedEntry.value = null;
|
||||||
|
navigateTo(entry.path);
|
||||||
|
} else {
|
||||||
|
emit("openFile", entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Download ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function handleDownload(): Promise<void> {
|
||||||
|
if (!selectedEntry.value || selectedEntry.value.isDir) return;
|
||||||
|
|
||||||
|
const entry = selectedEntry.value;
|
||||||
|
const transferId = addTransfer(entry.name, "download");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await invoke<string>("sftp_read_file", {
|
||||||
|
sessionId: props.sessionId,
|
||||||
|
path: entry.path,
|
||||||
|
});
|
||||||
|
const blob = new Blob([content], { type: "application/octet-stream" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = entry.name;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
completeTransfer(transferId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("SFTP download error:", err);
|
||||||
|
failTransfer(transferId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function handleDelete(): Promise<void> {
|
||||||
|
if (!selectedEntry.value) return;
|
||||||
|
|
||||||
|
const entry = selectedEntry.value;
|
||||||
|
if (!confirm(`Delete "${entry.name}"?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke("sftp_delete", { sessionId: props.sessionId, path: entry.path });
|
||||||
|
selectedEntry.value = null;
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("SFTP delete error:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── New Folder ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function handleNewFolder(): Promise<void> {
|
||||||
|
const folderName = prompt("New folder name:");
|
||||||
|
if (!folderName || !folderName.trim()) return;
|
||||||
|
|
||||||
|
const trimmed = folderName.trim();
|
||||||
|
const fullPath = currentPath.value.replace(/\/$/, "") + "/" + trimmed;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke("sftp_mkdir", { sessionId: props.sessionId, path: fullPath });
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("SFTP mkdir error:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Upload ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function handleUpload(): void {
|
||||||
|
fileInputRef.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileSelected(event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// Reset input so re-selecting the same file triggers change again
|
||||||
|
input.value = "";
|
||||||
|
|
||||||
|
const transferId = addTransfer(file.name, "upload");
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = async () => {
|
||||||
|
const content = reader.result as string;
|
||||||
|
const remotePath = currentPath.value.replace(/\/$/, "") + "/" + file.name;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke("sftp_write_file", {
|
||||||
|
sessionId: props.sessionId,
|
||||||
|
path: remotePath,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
completeTransfer(transferId);
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("SFTP upload error:", err);
|
||||||
|
failTransfer(transferId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.onerror = () => {
|
||||||
|
console.error("FileReader error reading upload file");
|
||||||
|
failTransfer(transferId);
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanizeSize(bytes: number): string {
|
||||||
|
if (bytes === 0) return "0 B";
|
||||||
|
const units = ["B", "KB", "MB", "GB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
const size = (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0);
|
||||||
|
return `${size} ${units[i]}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
62
src/components/sftp/TransferProgress.vue
Normal file
62
src/components/sftp/TransferProgress.vue
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
<template>
|
||||||
|
<div class="border-t border-[var(--wraith-border)]">
|
||||||
|
<!-- Header -->
|
||||||
|
<button
|
||||||
|
class="w-full flex items-center justify-between px-3 py-1.5 text-xs text-[var(--wraith-text-muted)] hover:bg-[var(--wraith-bg-tertiary)] transition-colors cursor-pointer"
|
||||||
|
@click="expanded = !expanded"
|
||||||
|
>
|
||||||
|
<span>Transfers ({{ transfers.length }})</span>
|
||||||
|
<svg
|
||||||
|
class="w-3 h-3 transition-transform"
|
||||||
|
:class="{ 'rotate-180': expanded }"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M12.78 5.22a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L3.22 6.28a.75.75 0 0 1 1.06-1.06L8 8.94l3.72-3.72a.75.75 0 0 1 1.06 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Transfer list -->
|
||||||
|
<div v-if="expanded && transfers.length > 0" class="px-3 pb-2">
|
||||||
|
<div
|
||||||
|
v-for="transfer in transfers"
|
||||||
|
:key="transfer.id"
|
||||||
|
class="flex flex-col gap-1 py-1.5"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between text-xs">
|
||||||
|
<span class="text-[var(--wraith-text-secondary)] truncate">
|
||||||
|
{{ transfer.fileName }}
|
||||||
|
</span>
|
||||||
|
<span class="text-[var(--wraith-text-muted)] shrink-0 ml-2">
|
||||||
|
{{ transfer.speed }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full h-1.5 bg-[var(--wraith-bg-tertiary)] rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full transition-all duration-300"
|
||||||
|
:class="transfer.direction === 'upload' ? 'bg-[var(--wraith-accent-blue)]' : 'bg-[var(--wraith-accent-green)]'"
|
||||||
|
:style="{ width: transfer.percentage + '%' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="text-[10px] text-[var(--wraith-text-muted)]">
|
||||||
|
{{ transfer.percentage }}% - {{ transfer.direction === 'upload' ? 'Uploading' : 'Downloading' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-if="expanded && transfers.length === 0" class="px-3 py-2 text-xs text-[var(--wraith-text-muted)]">
|
||||||
|
No active transfers
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useTransfers } from "@/composables/useTransfers";
|
||||||
|
|
||||||
|
const expanded = ref(false);
|
||||||
|
|
||||||
|
// Auto-expand when transfers become active, collapse when all are gone
|
||||||
|
const { transfers } = useTransfers();
|
||||||
|
</script>
|
||||||
99
src/composables/useSftp.ts
Normal file
99
src/composables/useSftp.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { ref, onBeforeUnmount, type Ref } from "vue";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
|
|
||||||
|
export interface FileEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
isDir: boolean;
|
||||||
|
permissions: string;
|
||||||
|
modTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseSftpReturn {
|
||||||
|
currentPath: Ref<string>;
|
||||||
|
entries: Ref<FileEntry[]>;
|
||||||
|
isLoading: Ref<boolean>;
|
||||||
|
followTerminal: Ref<boolean>;
|
||||||
|
navigateTo: (path: string) => Promise<void>;
|
||||||
|
goUp: () => Promise<void>;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable that manages SFTP file browsing state.
|
||||||
|
* Calls the Rust SFTP commands via Tauri invoke.
|
||||||
|
*/
|
||||||
|
export function useSftp(sessionId: string): UseSftpReturn {
|
||||||
|
const currentPath = ref("/");
|
||||||
|
const entries = ref<FileEntry[]>([]);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const followTerminal = ref(true);
|
||||||
|
|
||||||
|
// Holds the unlisten function returned by listen() — called on cleanup.
|
||||||
|
let unlistenCwd: UnlistenFn | null = null;
|
||||||
|
|
||||||
|
async function listDirectory(path: string): Promise<FileEntry[]> {
|
||||||
|
try {
|
||||||
|
const result = await invoke<FileEntry[]>("sftp_list", { sessionId, path });
|
||||||
|
return result ?? [];
|
||||||
|
} catch (err) {
|
||||||
|
console.error("SFTP list error:", err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigateTo(path: string): Promise<void> {
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
currentPath.value = path;
|
||||||
|
entries.value = await listDirectory(path);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goUp(): Promise<void> {
|
||||||
|
const parts = currentPath.value.split("/").filter(Boolean);
|
||||||
|
if (parts.length <= 1) {
|
||||||
|
await navigateTo("/");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
parts.pop();
|
||||||
|
await navigateTo("/" + parts.join("/"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
await navigateTo(currentPath.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for CWD changes from the Rust backend (OSC 7 tracking).
|
||||||
|
// listen() returns Promise<UnlistenFn> — store it for cleanup.
|
||||||
|
listen<string>(`ssh:cwd:${sessionId}`, (event) => {
|
||||||
|
if (!followTerminal.value) return;
|
||||||
|
const newPath = event.payload;
|
||||||
|
if (newPath && newPath !== currentPath.value) {
|
||||||
|
navigateTo(newPath);
|
||||||
|
}
|
||||||
|
}).then((unlisten) => {
|
||||||
|
unlistenCwd = unlisten;
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (unlistenCwd) unlistenCwd();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load home directory on init
|
||||||
|
navigateTo("/home");
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentPath,
|
||||||
|
entries,
|
||||||
|
isLoading,
|
||||||
|
followTerminal,
|
||||||
|
navigateTo,
|
||||||
|
goUp,
|
||||||
|
refresh,
|
||||||
|
};
|
||||||
|
}
|
||||||
40
src/composables/useTransfers.ts
Normal file
40
src/composables/useTransfers.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
export interface Transfer {
|
||||||
|
id: string;
|
||||||
|
fileName: string;
|
||||||
|
percentage: number;
|
||||||
|
speed: string;
|
||||||
|
direction: "upload" | "download";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module-level singleton — shared across all components that import this composable.
|
||||||
|
const transfers = ref<Transfer[]>([]);
|
||||||
|
|
||||||
|
let _nextId = 0;
|
||||||
|
|
||||||
|
function addTransfer(fileName: string, direction: "upload" | "download"): string {
|
||||||
|
const id = `transfer-${++_nextId}`;
|
||||||
|
transfers.value.push({ id, fileName, percentage: 0, speed: "", direction });
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeTransfer(id: string): void {
|
||||||
|
const t = transfers.value.find((t) => t.id === id);
|
||||||
|
if (t) {
|
||||||
|
t.percentage = 100;
|
||||||
|
t.speed = "";
|
||||||
|
}
|
||||||
|
// Remove after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
transfers.value = transfers.value.filter((t) => t.id !== id);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function failTransfer(id: string): void {
|
||||||
|
transfers.value = transfers.value.filter((t) => t.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTransfers() {
|
||||||
|
return { transfers, addTransfer, completeTransfer, failTransfer };
|
||||||
|
}
|
||||||
@ -125,11 +125,19 @@
|
|||||||
<!-- Connection tree -->
|
<!-- Connection tree -->
|
||||||
<ConnectionTree v-if="sidebarTab === 'connections'" />
|
<ConnectionTree v-if="sidebarTab === 'connections'" />
|
||||||
|
|
||||||
<!-- SFTP placeholder (Phase N) -->
|
<!-- SFTP browser -->
|
||||||
<template v-else-if="sidebarTab === 'sftp'">
|
<template v-else-if="sidebarTab === 'sftp'">
|
||||||
<div class="flex items-center justify-center py-8 px-3">
|
<template v-if="activeSessionId">
|
||||||
|
<FileTree
|
||||||
|
:session-id="activeSessionId"
|
||||||
|
class="flex-1 min-h-0"
|
||||||
|
@open-file="handleOpenFile"
|
||||||
|
/>
|
||||||
|
<TransferProgress />
|
||||||
|
</template>
|
||||||
|
<div v-else class="flex items-center justify-center py-8 px-3">
|
||||||
<p class="text-[var(--wraith-text-muted)] text-xs text-center">
|
<p class="text-[var(--wraith-text-muted)] text-xs text-center">
|
||||||
SFTP browser coming soon
|
No active session
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -202,7 +210,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from "vue";
|
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { useAppStore } from "@/stores/app.store";
|
import { useAppStore } from "@/stores/app.store";
|
||||||
@ -218,6 +226,9 @@ import ThemePicker from "@/components/common/ThemePicker.vue";
|
|||||||
import ImportDialog from "@/components/common/ImportDialog.vue";
|
import ImportDialog from "@/components/common/ImportDialog.vue";
|
||||||
import SettingsModal from "@/components/common/SettingsModal.vue";
|
import SettingsModal from "@/components/common/SettingsModal.vue";
|
||||||
import ConnectionEditDialog from "@/components/connections/ConnectionEditDialog.vue";
|
import ConnectionEditDialog from "@/components/connections/ConnectionEditDialog.vue";
|
||||||
|
import FileTree from "@/components/sftp/FileTree.vue";
|
||||||
|
import TransferProgress from "@/components/sftp/TransferProgress.vue";
|
||||||
|
import type { FileEntry } from "@/composables/useSftp";
|
||||||
|
|
||||||
import type { ThemeDefinition } from "@/components/common/ThemePicker.vue";
|
import type { ThemeDefinition } from "@/components/common/ThemePicker.vue";
|
||||||
import type { SidebarTab } from "@/components/sidebar/SidebarToggle.vue";
|
import type { SidebarTab } from "@/components/sidebar/SidebarToggle.vue";
|
||||||
@ -226,6 +237,9 @@ const appStore = useAppStore();
|
|||||||
const connectionStore = useConnectionStore();
|
const connectionStore = useConnectionStore();
|
||||||
const sessionStore = useSessionStore();
|
const sessionStore = useSessionStore();
|
||||||
|
|
||||||
|
/** Active SSH session ID, exposed to the SFTP sidebar. */
|
||||||
|
const activeSessionId = computed(() => sessionStore.activeSessionId);
|
||||||
|
|
||||||
const sidebarWidth = ref(240);
|
const sidebarWidth = ref(240);
|
||||||
const sidebarVisible = ref(true);
|
const sidebarVisible = ref(true);
|
||||||
const sidebarTab = ref<SidebarTab>("connections");
|
const sidebarTab = ref<SidebarTab>("connections");
|
||||||
@ -279,6 +293,32 @@ function handleThemeSelect(theme: ThemeDefinition): void {
|
|||||||
sessionStore.setTheme(theme);
|
sessionStore.setTheme(theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the user double-clicks a file in the SFTP FileTree.
|
||||||
|
* Reads the file content via Tauri and opens it in a new editor tab (future).
|
||||||
|
* For now, triggers a download as a browser blob.
|
||||||
|
*/
|
||||||
|
async function handleOpenFile(entry: FileEntry): Promise<void> {
|
||||||
|
if (!activeSessionId.value) return;
|
||||||
|
try {
|
||||||
|
const content = await invoke<string>("sftp_read_file", {
|
||||||
|
sessionId: activeSessionId.value,
|
||||||
|
path: entry.path,
|
||||||
|
});
|
||||||
|
const blob = new Blob([content], { type: "text/plain" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = entry.name;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to open SFTP file:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Quick Connect: parse user@host:port and open a session.
|
* Quick Connect: parse user@host:port and open a session.
|
||||||
* Default protocol: SSH, default port: 22.
|
* Default protocol: SSH, default port: 22.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user