From a8656b08123d0bd2be62103728a142abb91050d3 Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Tue, 17 Mar 2026 15:46:35 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=203=20complete=20=E2=80=94=20SFTP?= =?UTF-8?q?=20sidebar=20with=20full=20file=20operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/sftp_commands.rs | 78 ++++++ src-tauri/src/commands/ssh_commands.rs | 9 +- src-tauri/src/lib.rs | 11 + src-tauri/src/sftp/mod.rs | 324 +++++++++++++++++++++++ src-tauri/src/ssh/session.rs | 66 ++++- src/components/sftp/FileTree.vue | 298 +++++++++++++++++++++ src/components/sftp/TransferProgress.vue | 62 +++++ src/composables/useSftp.ts | 99 +++++++ src/composables/useTransfers.ts | 40 +++ src/layouts/MainLayout.vue | 48 +++- 13 files changed, 1031 insertions(+), 7 deletions(-) create mode 100644 src-tauri/src/commands/sftp_commands.rs create mode 100644 src-tauri/src/sftp/mod.rs create mode 100644 src/components/sftp/FileTree.vue create mode 100644 src/components/sftp/TransferProgress.vue create mode 100644 src/composables/useSftp.ts create mode 100644 src/composables/useTransfers.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a88b395..4bdd44c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6252,6 +6252,7 @@ dependencies = [ "rand 0.9.2", "rusqlite", "russh", + "russh-sftp", "serde", "serde_json", "ssh-key", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e1934eb..0c0d5a0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,4 +29,5 @@ log = "0.4" env_logger = "0.11" thiserror = "2" russh = "0.48" +russh-sftp = "2.1.1" ssh-key = { version = "0.6", features = ["ed25519", "rsa"] } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index f84731b..ed3d902 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -3,3 +3,4 @@ pub mod settings; pub mod connections; pub mod credentials; pub mod ssh_commands; +pub mod sftp_commands; diff --git a/src-tauri/src/commands/sftp_commands.rs b/src-tauri/src/commands/sftp_commands.rs new file mode 100644 index 0000000..3798814 --- /dev/null +++ b/src-tauri/src/commands/sftp_commands.rs @@ -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, 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 { + 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 +} diff --git a/src-tauri/src/commands/ssh_commands.rs b/src-tauri/src/commands/ssh_commands.rs index 5b5e137..44c4999 100644 --- a/src-tauri/src/commands/ssh_commands.rs +++ b/src-tauri/src/commands/ssh_commands.rs @@ -12,7 +12,8 @@ use crate::AppState; /// Connect to an SSH server with password authentication. /// /// 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] pub async fn connect_ssh( hostname: String, @@ -34,6 +35,7 @@ pub async fn connect_ssh( AuthMethod::Password(password), cols, rows, + &state.sftp, ) .await } @@ -70,6 +72,7 @@ pub async fn connect_ssh_with_key( }, cols, rows, + &state.sftp, ) .await } @@ -98,12 +101,14 @@ pub async fn ssh_resize( } /// Disconnect an SSH session — closes the channel and removes it. +/// +/// Also removes the associated SFTP client. #[tauri::command] pub async fn disconnect_ssh( session_id: String, state: State<'_, AppState>, ) -> Result<(), String> { - state.ssh.disconnect(&session_id).await + state.ssh.disconnect(&session_id, &state.sftp).await } /// List all active SSH sessions (metadata only). diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c4ce53b..2529743 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ pub mod settings; pub mod connections; pub mod credentials; pub mod ssh; +pub mod sftp; pub mod commands; use std::path::PathBuf; @@ -14,6 +15,7 @@ use vault::VaultService; use credentials::CredentialService; use settings::SettingsService; use connections::ConnectionService; +use sftp::SftpService; use ssh::session::SshService; /// Application state shared across all Tauri commands via State. @@ -24,6 +26,7 @@ pub struct AppState { pub connections: ConnectionService, pub credentials: Mutex>, pub ssh: SshService, + pub sftp: SftpService, } impl AppState { @@ -37,6 +40,7 @@ impl AppState { let settings = SettingsService::new(database.clone()); let connections = ConnectionService::new(database.clone()); let ssh = SshService::new(database.clone()); + let sftp = SftpService::new(); Ok(Self { db: database, @@ -45,6 +49,7 @@ impl AppState { connections, credentials: Mutex::new(None), ssh, + sftp, }) } @@ -114,6 +119,12 @@ pub fn run() { commands::ssh_commands::ssh_resize, commands::ssh_commands::disconnect_ssh, 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!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/sftp/mod.rs b/src-tauri/src/sftp/mod.rs new file mode 100644 index 0000000..3e05e0c --- /dev/null +++ b/src-tauri/src/sftp/mod.rs @@ -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) -> 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>>, +} + +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, 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 = 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 { + 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 { + 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>, String> { + self.clients + .get(session_id) + .map(|r| r.clone()) + .ok_or_else(|| format!("No SFTP client for session {}", session_id)) + } +} diff --git a/src-tauri/src/ssh/session.rs b/src-tauri/src/ssh/session.rs index 339f696..ac9c9d2 100644 --- a/src-tauri/src/ssh/session.rs +++ b/src-tauri/src/ssh/session.rs @@ -20,6 +20,7 @@ use tauri::{AppHandle, Emitter}; use tokio::sync::Mutex as TokioMutex; use crate::db::Database; +use crate::sftp::SftpService; use crate::ssh::cwd::CwdTracker; use crate::ssh::host_key::{HostKeyResult, HostKeyStore}; @@ -157,6 +158,9 @@ impl SshService { /// Establish an SSH connection, authenticate, open a PTY, start a shell, /// 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. pub async fn connect( &self, @@ -167,6 +171,7 @@ impl SshService { auth: AuthMethod, cols: u32, rows: u32, + sftp_service: &SftpService, ) -> Result { let session_id = uuid::Uuid::new_v4().to_string(); @@ -265,6 +270,55 @@ impl SshService { 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. let sid = session_id.clone(); let chan = channel.clone(); @@ -370,7 +424,14 @@ impl SshService { } /// 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 .sessions .remove(session_id) @@ -392,6 +453,9 @@ impl SshService { .await; } + // Clean up the SFTP client for this session. + sftp_service.remove_client(session_id); + info!("SSH session {} disconnected", session_id); Ok(()) } diff --git a/src/components/sftp/FileTree.vue b/src/components/sftp/FileTree.vue new file mode 100644 index 0000000..1341146 --- /dev/null +++ b/src/components/sftp/FileTree.vue @@ -0,0 +1,298 @@ + + + diff --git a/src/components/sftp/TransferProgress.vue b/src/components/sftp/TransferProgress.vue new file mode 100644 index 0000000..01f03ca --- /dev/null +++ b/src/components/sftp/TransferProgress.vue @@ -0,0 +1,62 @@ + + + diff --git a/src/composables/useSftp.ts b/src/composables/useSftp.ts new file mode 100644 index 0000000..2367588 --- /dev/null +++ b/src/composables/useSftp.ts @@ -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; + entries: Ref; + isLoading: Ref; + followTerminal: Ref; + navigateTo: (path: string) => Promise; + goUp: () => Promise; + refresh: () => Promise; +} + +/** + * 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([]); + 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 { + try { + const result = await invoke("sftp_list", { sessionId, path }); + return result ?? []; + } catch (err) { + console.error("SFTP list error:", err); + return []; + } + } + + async function navigateTo(path: string): Promise { + isLoading.value = true; + try { + currentPath.value = path; + entries.value = await listDirectory(path); + } finally { + isLoading.value = false; + } + } + + async function goUp(): Promise { + const parts = currentPath.value.split("/").filter(Boolean); + if (parts.length <= 1) { + await navigateTo("/"); + return; + } + parts.pop(); + await navigateTo("/" + parts.join("/")); + } + + async function refresh(): Promise { + await navigateTo(currentPath.value); + } + + // Listen for CWD changes from the Rust backend (OSC 7 tracking). + // listen() returns Promise — store it for cleanup. + listen(`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, + }; +} diff --git a/src/composables/useTransfers.ts b/src/composables/useTransfers.ts new file mode 100644 index 0000000..c8378f1 --- /dev/null +++ b/src/composables/useTransfers.ts @@ -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([]); + +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 }; +} diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue index bc80adb..de0c4c1 100644 --- a/src/layouts/MainLayout.vue +++ b/src/layouts/MainLayout.vue @@ -125,11 +125,19 @@ - + @@ -202,7 +210,7 @@