feat: remote file ops, chunked transfers with progress, pause/cancel

- Remove dead Tauri commands/ module
- Add New Folder / Delete / Rename dialogs for remote FS
- Implement chunked upload/download with progress for FTP and SFTP
- Wrap SFTP ops in spawn_blocking
- Add ProgressAction to support cancel/pause mid-transfer
- Update todo.md
This commit is contained in:
loki5512344 2026-07-01 01:38:11 +02:00
parent 42c147a13e
commit e0edd14fa5
14 changed files with 866 additions and 384 deletions

View file

@ -1,60 +0,0 @@
use crate::domain::connection::{ConnectionParams, Protocol};
use crate::fs::remote::RemoteRegistry;
use crate::protocols::ftp::FtpClient;
use crate::protocols::sftp::SftpClient;
use crate::storage::keychain;
use std::sync::Arc;
use tauri::State;
#[tauri::command]
pub async fn connect(
registry: State<'_, Arc<RemoteRegistry>>,
params: ConnectionParams,
) -> Result<(), String> {
let password = if let Some(p) = &params.password {
p.clone()
} else {
keychain::get_password(&params.id).map_err(|e| e.to_string())?
};
let fs: Arc<dyn crate::protocols::RemoteFs> = match params.protocol {
Protocol::Sftp => {
if let Some(key_path) = &params.key_path {
Arc::new(
SftpClient::connect_key(&params.host, params.port, &params.username, key_path)
.map_err(|e| e.to_string())?,
)
} else {
Arc::new(
SftpClient::connect_password(
&params.host,
params.port,
&params.username,
&password,
)
.map_err(|e| e.to_string())?,
)
}
}
Protocol::Ftp => Arc::new(
FtpClient::connect(&params.host, params.port, &params.username, &password)
.await
.map_err(|e| e.to_string())?,
),
Protocol::Ftps => {
return Err("FTPS not yet implemented (suppaftp API compat)".to_string());
}
};
registry.insert(params.id, fs);
Ok(())
}
#[tauri::command]
pub async fn disconnect(
registry: State<'_, Arc<RemoteRegistry>>,
connection_id: String,
) -> Result<(), String> {
registry.remove(&connection_id);
Ok(())
}

View file

@ -1,58 +0,0 @@
use crate::domain::file_entry::FileEntry;
use crate::fs::{local, remote::RemoteRegistry};
use std::sync::Arc;
use tauri::State;
#[tauri::command]
pub fn list_local(path: String) -> Result<Vec<FileEntry>, String> {
local::list(&path).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn list_remote(
registry: State<'_, Arc<RemoteRegistry>>,
connection_id: String,
path: String,
) -> Result<Vec<FileEntry>, String> {
let fs = registry
.get(&connection_id)
.ok_or_else(|| format!("no connection: {}", connection_id))?;
fs.list(&path).await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn remote_mkdir(
registry: State<'_, Arc<RemoteRegistry>>,
connection_id: String,
path: String,
) -> Result<(), String> {
let fs = registry
.get(&connection_id)
.ok_or_else(|| format!("no connection: {}", connection_id))?;
fs.mkdir(&path).await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn remote_rename(
registry: State<'_, Arc<RemoteRegistry>>,
connection_id: String,
from: String,
to: String,
) -> Result<(), String> {
let fs = registry
.get(&connection_id)
.ok_or_else(|| format!("no connection: {}", connection_id))?;
fs.rename(&from, &to).await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn remote_delete(
registry: State<'_, Arc<RemoteRegistry>>,
connection_id: String,
path: String,
) -> Result<(), String> {
let fs = registry
.get(&connection_id)
.ok_or_else(|| format!("no connection: {}", connection_id))?;
fs.delete(&path).await.map_err(|e| e.to_string())
}

View file

@ -1,4 +0,0 @@
pub mod connection;
pub mod fs;
pub mod sites;
pub mod transfer;

View file

@ -1,21 +0,0 @@
use crate::domain::site::Site;
use crate::storage::db::Db;
use tauri::State;
#[tauri::command]
pub fn get_sites(db: State<'_, Db>) -> Result<Vec<Site>, String> {
let conn = db.0.lock().unwrap();
crate::storage::db::get_sites(&conn).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn save_site(db: State<'_, Db>, site: Site) -> Result<(), String> {
let conn = db.0.lock().unwrap();
crate::storage::db::save_site(&conn, &site).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn delete_site(db: State<'_, Db>, id: String) -> Result<(), String> {
let conn = db.0.lock().unwrap();
crate::storage::db::delete_site(&conn, &id).map_err(|e| e.to_string())
}

View file

@ -1,108 +0,0 @@
use std::sync::Arc;
use tauri::State;
use crate::domain::file_entry::EntryKind;
use crate::domain::transfer::{TaskState, TransferKind, TransferTask};
use crate::transfer::manager::TransferManager;
#[tauri::command]
pub async fn upload(
manager: State<'_, Arc<TransferManager>>,
connection_id: String,
local_path: String,
remote_path: String,
) -> Result<String, String> {
let meta =
std::fs::metadata(&local_path).map_err(|e| format!("cannot stat local file: {}", e))?;
if !meta.is_file() {
return Err("local path is not a file".into());
}
let file_name = std::path::Path::new(&local_path)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
let task = TransferTask::new(
TransferKind::Upload,
connection_id,
local_path,
remote_path,
file_name,
meta.len(),
);
let task_id = task.id.clone();
// Проверяем, что соединение живо
if manager.registry().get(&task.connection_id).is_none() {
return Err(format!("no connection: {}", task.connection_id));
}
manager.queue.push(task);
Ok(task_id)
}
#[tauri::command]
pub async fn download(
manager: State<'_, Arc<TransferManager>>,
connection_id: String,
remote_path: String,
local_path: String,
) -> Result<String, String> {
// Узнаём размер удалённого файла через stat
let registry = manager.registry();
let fs = registry
.get(&connection_id)
.ok_or_else(|| format!("no connection: {}", connection_id))?;
let stat = fs
.stat(&remote_path)
.await
.map_err(|e| format!("stat failed: {}", e))?;
let file_name = std::path::Path::new(&remote_path)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
let total = match stat.kind {
EntryKind::File => stat.size.unwrap_or(0),
_ => return Err("remote path is not a file".into()),
};
let task = TransferTask::new(
TransferKind::Download,
connection_id,
local_path,
remote_path,
file_name,
total,
);
let task_id = task.id.clone();
manager.queue.push(task);
Ok(task_id)
}
#[tauri::command]
pub async fn get_queue(
manager: State<'_, Arc<TransferManager>>,
) -> Result<Vec<TransferTask>, String> {
Ok(manager.queue.all())
}
#[tauri::command]
pub async fn pause_task(
manager: State<'_, Arc<TransferManager>>,
task_id: String,
) -> Result<(), String> {
manager.queue.update_state(&task_id, TaskState::Paused);
Ok(())
}
#[tauri::command]
pub async fn cancel_task(
manager: State<'_, Arc<TransferManager>>,
task_id: String,
) -> Result<(), String> {
manager.queue.update_state(&task_id, TaskState::Cancelled);
Ok(())
}

View file

@ -3,13 +3,17 @@ use async_trait::async_trait;
use futures_lite::io::{AsyncReadExt, AsyncWriteExt};
use std::str::FromStr;
use std::time::SystemTime;
use tokio::fs::File;
use tokio::io::{AsyncReadExt as TokioReadExt, AsyncWriteExt as TokioWriteExt};
use tokio::sync::Mutex;
use suppaftp::{AsyncFtpStream, list::File};
use suppaftp::{AsyncFtpStream, list::File as FtpListFile};
use super::RemoteFs;
use super::{ProgressAction, RemoteFs};
use crate::domain::file_entry::{EntryKind, FileEntry};
const CHUNK_SIZE: usize = 64 * 1024;
pub struct FtpClient {
pub conn: Mutex<Option<AsyncFtpStream>>,
}
@ -30,7 +34,7 @@ impl FtpClient {
}
fn parse_list_entry(line: &str) -> Option<FileEntry> {
let file = File::from_str(line).ok()?;
let file = FtpListFile::from_str(line).ok()?;
let kind = if file.is_directory() {
EntryKind::Dir
} else if file.is_symlink() {
@ -83,9 +87,16 @@ impl RemoteFs for FtpClient {
Ok(entries)
}
async fn upload(&self, local: &str, remote: &str) -> Result<()> {
let data =
std::fs::read(local).map_err(|e| anyhow::anyhow!("cannot read local file: {}", e))?;
async fn upload_with_progress(
&self,
local: &str,
remote: &str,
on_progress: Option<Box<dyn Fn(u64) -> ProgressAction + Send>>,
) -> Result<()> {
let mut file = File::open(local)
.await
.map_err(|e| anyhow::anyhow!("cannot open local file: {}", e))?;
let mut guard = self.conn.lock().await;
let conn = guard
.as_mut()
@ -94,17 +105,47 @@ impl RemoteFs for FtpClient {
.put_with_stream(remote)
.await
.map_err(|e| anyhow::anyhow!("FTP upload stream failed: {}", e))?;
stream
.write_all(&data)
.await
.map_err(|e| anyhow::anyhow!("FTP upload write failed: {}", e))?;
let mut buf = vec![0u8; CHUNK_SIZE];
let mut total = 0u64;
loop {
let n = file
.read(&mut buf)
.await
.map_err(|e| anyhow::anyhow!("FTP upload read failed: {}", e))?;
if n == 0 {
break;
}
stream
.write_all(&buf[..n])
.await
.map_err(|e| anyhow::anyhow!("FTP upload write failed: {}", e))?;
total += n as u64;
if let Some(ref cb) = on_progress {
match cb(total) {
ProgressAction::Continue => {}
ProgressAction::Cancel => {
return Err(anyhow::anyhow!("cancelled"));
}
ProgressAction::Pause => {
return Err(anyhow::anyhow!("paused"));
}
}
}
}
conn.finalize_put_stream(stream)
.await
.map_err(|e| anyhow::anyhow!("FTP upload finalize failed: {}", e))?;
Ok(())
}
async fn download(&self, remote: &str, local: &str) -> Result<()> {
async fn download_with_progress(
&self,
remote: &str,
local: &str,
on_progress: Option<Box<dyn Fn(u64) -> ProgressAction + Send>>,
) -> Result<()> {
let mut guard = self.conn.lock().await;
let conn = guard
.as_mut()
@ -113,16 +154,41 @@ impl RemoteFs for FtpClient {
.retr_as_stream(remote)
.await
.map_err(|e| anyhow::anyhow!("FTP download stream failed: {}", e))?;
let mut buf = Vec::new();
stream
.read_to_end(&mut buf)
let mut file = File::create(local)
.await
.map_err(|e| anyhow::anyhow!("FTP download read failed: {}", e))?;
.map_err(|e| anyhow::anyhow!("cannot create local file: {}", e))?;
let mut buf = vec![0u8; CHUNK_SIZE];
let mut total = 0u64;
loop {
let n = stream
.read(&mut buf)
.await
.map_err(|e| anyhow::anyhow!("FTP download read failed: {}", e))?;
if n == 0 {
break;
}
file.write_all(&buf[..n])
.await
.map_err(|e| anyhow::anyhow!("FTP download write failed: {}", e))?;
total += n as u64;
if let Some(ref cb) = on_progress {
match cb(total) {
ProgressAction::Continue => {}
ProgressAction::Cancel => {
return Err(anyhow::anyhow!("cancelled"));
}
ProgressAction::Pause => {
return Err(anyhow::anyhow!("paused"));
}
}
}
}
conn.finalize_retr_stream(stream)
.await
.map_err(|e| anyhow::anyhow!("FTP download finalize failed: {}", e))?;
std::fs::write(local, &buf)
.map_err(|e| anyhow::anyhow!("cannot write local file: {}", e))?;
Ok(())
}

View file

@ -5,13 +5,42 @@ use crate::domain::file_entry::FileEntry;
use anyhow::Result;
use async_trait::async_trait;
/// Результат выполнения колбэка прогресса: продолжить, отменить или приостановить.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProgressAction {
Continue,
Cancel,
Pause,
}
/// Единый контракт для всех протоколов.
/// Очередь передач не знает — FTP это или SFTP.
#[async_trait]
pub trait RemoteFs: Send + Sync {
async fn list(&self, path: &str) -> Result<Vec<FileEntry>>;
async fn upload(&self, local: &str, remote: &str) -> Result<()>;
async fn download(&self, remote: &str, local: &str) -> Result<()>;
async fn upload(&self, local: &str, remote: &str) -> Result<()> {
self.upload_with_progress(local, remote, None).await
}
async fn download(&self, remote: &str, local: &str) -> Result<()> {
self.download_with_progress(remote, local, None).await
}
async fn upload_with_progress(
&self,
local: &str,
remote: &str,
on_progress: Option<Box<dyn Fn(u64) -> ProgressAction + Send>>,
) -> Result<()>;
async fn download_with_progress(
&self,
remote: &str,
local: &str,
on_progress: Option<Box<dyn Fn(u64) -> ProgressAction + Send>>,
) -> Result<()>;
async fn mkdir(&self, path: &str) -> Result<()>;
async fn rename(&self, from: &str, to: &str) -> Result<()>;
async fn delete(&self, path: &str) -> Result<()>;

View file

@ -1,14 +1,18 @@
use anyhow::{Context, Result, bail};
use async_trait::async_trait;
use ssh2::{CheckResult, HashType, KnownHostFileKind, Session};
use std::io::{Read, Write};
use std::net::TcpStream;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use super::RemoteFs;
use super::{ProgressAction, RemoteFs};
use crate::domain::file_entry::{EntryKind, FileEntry};
const CHUNK_SIZE: usize = 64 * 1024;
pub struct SftpClient {
session: Session,
session: Arc<Mutex<Session>>,
}
impl SftpClient {
@ -22,7 +26,9 @@ impl SftpClient {
session
.userauth_password(user, password)
.context("SSH password auth failed")?;
Ok(Self { session })
Ok(Self {
session: Arc::new(Mutex::new(session)),
})
}
/// Подключение по ключу
@ -35,7 +41,9 @@ impl SftpClient {
session
.userauth_pubkey_file(user, None, Path::new(key_path), None)
.context("SSH key auth failed")?;
Ok(Self { session })
Ok(Self {
session: Arc::new(Mutex::new(session)),
})
}
}
@ -104,100 +112,197 @@ fn verify_host_key(session: &Session, host: &str, port: u16) -> Result<()> {
#[async_trait]
impl RemoteFs for SftpClient {
async fn list(&self, path: &str) -> Result<Vec<FileEntry>> {
// TODO: перенести в spawn_blocking когда будет Arc<Session>
let sftp = self.session.sftp().context("SFTP subsystem failed")?;
let entries = sftp.readdir(Path::new(path)).context("readdir failed")?;
let session = self.session.clone();
let path = path.to_string();
tokio::task::spawn_blocking(move || {
let sftp = session.lock().unwrap().sftp().context("SFTP subsystem failed")?;
let entries = sftp.readdir(Path::new(&path)).context("readdir failed")?;
let result = entries
.into_iter()
.map(|(pb, stat)| {
let kind = if stat.is_dir() {
EntryKind::Dir
} else if stat.file_type().is_symlink() {
EntryKind::Symlink
} else {
EntryKind::File
};
FileEntry {
name: pb
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
path: pb.to_string_lossy().to_string(),
kind,
size: stat.size,
modified: stat.mtime.map(|t| t as i64),
permissions: None,
let result = entries
.into_iter()
.map(|(pb, stat)| {
let kind = if stat.is_dir() {
EntryKind::Dir
} else if stat.file_type().is_symlink() {
EntryKind::Symlink
} else {
EntryKind::File
};
FileEntry {
name: pb
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
path: pb.to_string_lossy().to_string(),
kind,
size: stat.size,
modified: stat.mtime.map(|t| t as i64),
permissions: None,
}
})
.collect();
Ok(result)
})
.await
.map_err(|e| anyhow::anyhow!("SFTP list task failed: {}", e))?
}
async fn upload_with_progress(
&self,
local: &str,
remote: &str,
on_progress: Option<Box<dyn Fn(u64) -> ProgressAction + Send>>,
) -> Result<()> {
let session = self.session.clone();
let local = local.to_string();
let remote = remote.to_string();
tokio::task::spawn_blocking(move || {
let sftp = session.lock().unwrap().sftp().context("SFTP subsystem failed")?;
let mut local_file = std::fs::File::open(&local).context("open local file failed")?;
let mut remote_file = sftp
.create(Path::new(&remote))
.context("create remote file failed")?;
let mut buf = vec![0u8; CHUNK_SIZE];
let mut total = 0u64;
loop {
let n = local_file.read(&mut buf).context("read local file failed")?;
if n == 0 {
break;
}
})
.collect();
Ok(result)
remote_file
.write_all(&buf[..n])
.context("write remote file failed")?;
total += n as u64;
if let Some(ref cb) = on_progress {
match cb(total) {
ProgressAction::Continue => {}
ProgressAction::Cancel => {
return Err(anyhow::anyhow!("cancelled"));
}
ProgressAction::Pause => {
return Err(anyhow::anyhow!("paused"));
}
}
}
}
Ok(())
})
.await
.map_err(|e| anyhow::anyhow!("SFTP upload task failed: {}", e))?
}
async fn upload(&self, local: &str, remote: &str) -> Result<()> {
// TODO: chunked upload с прогресс-событиями
let sftp = self.session.sftp().context("SFTP subsystem failed")?;
let mut local_file = std::fs::File::open(local).context("open local file failed")?;
let mut remote_file = sftp
.create(Path::new(remote))
.context("create remote file failed")?;
std::io::copy(&mut local_file, &mut remote_file)?;
Ok(())
}
async fn download_with_progress(
&self,
remote: &str,
local: &str,
on_progress: Option<Box<dyn Fn(u64) -> ProgressAction + Send>>,
) -> Result<()> {
let session = self.session.clone();
let remote = remote.to_string();
let local = local.to_string();
tokio::task::spawn_blocking(move || {
let sftp = session.lock().unwrap().sftp().context("SFTP subsystem failed")?;
let mut remote_file = sftp.open(Path::new(&remote)).context("open remote file failed")?;
let mut local_file =
std::fs::File::create(&local).context("create local file failed")?;
async fn download(&self, remote: &str, local: &str) -> Result<()> {
// TODO: chunked download с прогресс-событиями
let sftp = self.session.sftp().context("SFTP subsystem failed")?;
let mut remote_file = sftp
.open(Path::new(remote))
.context("open remote file failed")?;
let mut local_file = std::fs::File::create(local).context("create local file failed")?;
std::io::copy(&mut remote_file, &mut local_file)?;
Ok(())
let mut buf = vec![0u8; CHUNK_SIZE];
let mut total = 0u64;
loop {
let n = remote_file.read(&mut buf).context("read remote file failed")?;
if n == 0 {
break;
}
local_file
.write_all(&buf[..n])
.context("write local file failed")?;
total += n as u64;
if let Some(ref cb) = on_progress {
match cb(total) {
ProgressAction::Continue => {}
ProgressAction::Cancel => {
return Err(anyhow::anyhow!("cancelled"));
}
ProgressAction::Pause => {
return Err(anyhow::anyhow!("paused"));
}
}
}
}
Ok(())
})
.await
.map_err(|e| anyhow::anyhow!("SFTP download task failed: {}", e))?
}
async fn mkdir(&self, path: &str) -> Result<()> {
let sftp = self.session.sftp().context("SFTP subsystem failed")?;
sftp.mkdir(Path::new(path), 0o755).context("mkdir failed")?;
Ok(())
let session = self.session.clone();
let path = path.to_string();
tokio::task::spawn_blocking(move || {
let sftp = session.lock().unwrap().sftp().context("SFTP subsystem failed")?;
sftp.mkdir(Path::new(&path), 0o755).context("mkdir failed")?;
Ok(())
})
.await
.map_err(|e| anyhow::anyhow!("SFTP mkdir task failed: {}", e))?
}
async fn rename(&self, from: &str, to: &str) -> Result<()> {
let sftp = self.session.sftp().context("SFTP subsystem failed")?;
sftp.rename(Path::new(from), Path::new(to), None)
.context("rename failed")?;
Ok(())
let session = self.session.clone();
let from = from.to_string();
let to = to.to_string();
tokio::task::spawn_blocking(move || {
let sftp = session.lock().unwrap().sftp().context("SFTP subsystem failed")?;
sftp.rename(Path::new(&from), Path::new(&to), None)
.context("rename failed")?;
Ok(())
})
.await
.map_err(|e| anyhow::anyhow!("SFTP rename task failed: {}", e))?
}
async fn delete(&self, path: &str) -> Result<()> {
let sftp = self.session.sftp().context("SFTP subsystem failed")?;
// пробуем как файл, потом как директорию
if sftp.unlink(Path::new(path)).is_err() {
sftp.rmdir(Path::new(path)).context("delete failed")?;
}
Ok(())
let session = self.session.clone();
let path = path.to_string();
tokio::task::spawn_blocking(move || {
let sftp = session.lock().unwrap().sftp().context("SFTP subsystem failed")?;
// пробуем как файл, потом как директорию
if sftp.unlink(Path::new(&path)).is_err() {
sftp.rmdir(Path::new(&path)).context("delete failed")?;
}
Ok(())
})
.await
.map_err(|e| anyhow::anyhow!("SFTP delete task failed: {}", e))?
}
async fn stat(&self, path: &str) -> Result<FileEntry> {
let sftp = self.session.sftp().context("SFTP subsystem failed")?;
let stat = sftp.stat(Path::new(path)).context("stat failed")?;
Ok(FileEntry {
name: Path::new(path)
.file_name()
.unwrap_or_default()
.to_string_lossy()
let session = self.session.clone();
let path = path.to_string();
tokio::task::spawn_blocking(move || {
let sftp = session.lock().unwrap().sftp().context("SFTP subsystem failed")?;
let stat = sftp.stat(Path::new(&path)).context("stat failed")?;
Ok(FileEntry {
name: Path::new(&path)
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
path: path.to_string(),
kind: if stat.is_dir() {
EntryKind::Dir
} else {
EntryKind::File
},
size: stat.size,
modified: stat.mtime.map(|t| t as i64),
permissions: None,
path: path.to_string(),
kind: if stat.is_dir() {
EntryKind::Dir
} else {
EntryKind::File
},
size: stat.size,
modified: stat.mtime.map(|t| t as i64),
permissions: None,
})
})
.await
.map_err(|e| anyhow::anyhow!("SFTP stat task failed: {}", e))?
}
}

View file

@ -16,6 +16,10 @@ impl TransferQueue {
self.inner.lock().unwrap().pop_front()
}
pub fn get(&self, id: &str) -> Option<TransferTask> {
self.inner.lock().unwrap().iter().find(|t| t.id == id).cloned()
}
pub fn all(&self) -> Vec<TransferTask> {
self.inner.lock().unwrap().iter().cloned().collect()
}

View file

@ -1,10 +1,16 @@
use std::sync::Arc;
use tokio::time::{Duration, sleep};
use std::time::{Duration, Instant};
use tokio::time::sleep;
use crate::domain::transfer::{TaskState, TransferKind};
use crate::fs::remote::RemoteRegistry;
use crate::protocols::ProgressAction;
use crate::transfer::progress::ProgressThrottle;
use crate::transfer::queue::TransferQueue;
const POLL_INTERVAL_MS: u64 = 500;
const PAUSED_SLEEP_MS: u64 = 1000;
pub fn spawn_worker(
queue: TransferQueue,
registry: Arc<RemoteRegistry>,
@ -12,25 +18,63 @@ pub fn spawn_worker(
) {
rt_handle.spawn(async move {
loop {
let task = {
let task = queue.pop();
if let Some(ref t) = task
&& t.state != TaskState::Queued
{
queue.push(t.clone());
sleep(Duration::from_millis(500)).await;
let front = queue.all().into_iter().next();
match front.as_ref().map(|t| &t.state) {
None => {
sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
continue;
}
task
Some(TaskState::Paused) => {
sleep(Duration::from_millis(PAUSED_SLEEP_MS)).await;
continue;
}
Some(TaskState::Cancelled) => {
if let Some(t) = queue.pop() {
queue.remove(&t.id);
}
continue;
}
Some(TaskState::Running) => {
// Anomalous state: a previous worker died or the task
// was left running. Pop it and mark failed so the queue
// does not spin forever.
if let Some(mut t) = queue.pop() {
t.state = TaskState::Failed("stale running task".into());
queue.update_state(&t.id, t.state.clone());
}
continue;
}
Some(TaskState::Completed) | Some(TaskState::Failed(_)) => {
// Terminal tasks should not block new work. Move them to
// the back of the queue so they remain visible in the UI.
if let Some(t) = queue.pop() {
queue.push(t);
}
continue;
}
Some(TaskState::Retrying(_)) => {
// Not implemented yet: demote to queued so it can run.
if let Some(mut t) = queue.pop() {
t.state = TaskState::Queued;
queue.push(t);
}
continue;
}
Some(TaskState::Queued) => {
// Pop and run below.
}
}
let mut task = match queue.pop() {
Some(t) => t,
None => continue,
};
let mut task = match task {
Some(t) => t,
None => {
sleep(Duration::from_millis(500)).await;
continue;
}
};
// State may have changed between peek and pop.
if task.state == TaskState::Cancelled {
queue.remove(&task.id);
continue;
}
task.state = TaskState::Running;
queue.update_state(&task.id, TaskState::Running);
@ -44,22 +88,77 @@ pub fn spawn_worker(
}
};
// Progress callback: throttles queue updates to ~10 FPS,
// computes current speed, and reacts to Cancel/Pause requests.
let queue_for_progress = queue.clone();
let task_id_for_progress = task.id.clone();
let throttle = Arc::new(std::sync::Mutex::new(ProgressThrottle::default()));
let last_sample = Arc::new(std::sync::Mutex::new((Instant::now(), 0u64)));
let on_progress: Option<Box<dyn Fn(u64) -> ProgressAction + Send>> = Some(Box::new(
move |transferred: u64| {
// Check for user-initiated cancel/pause first.
if let Some(t) = queue_for_progress.get(&task_id_for_progress) {
match t.state {
TaskState::Cancelled => return ProgressAction::Cancel,
TaskState::Paused => return ProgressAction::Pause,
_ => {}
}
}
let speed = {
let mut guard = last_sample.lock().unwrap();
let (last_time, last_bytes) = *guard;
let now = Instant::now();
let elapsed = now.duration_since(last_time).as_secs_f64();
let speed = if elapsed > 0.0 {
((transferred.saturating_sub(last_bytes)) as f64 / elapsed) as u64
} else {
0
};
*guard = (now, transferred);
speed
};
if throttle.lock().unwrap().should_emit() {
queue_for_progress.update_progress(&task_id_for_progress, transferred, speed);
}
ProgressAction::Continue
},
));
let result = match task.kind {
TransferKind::Upload => fs.upload(&task.local_path, &task.remote_path).await,
TransferKind::Download => fs.download(&task.remote_path, &task.local_path).await,
TransferKind::Upload => {
fs.upload_with_progress(&task.local_path, &task.remote_path, on_progress).await
}
TransferKind::Download => {
fs.download_with_progress(&task.remote_path, &task.local_path, on_progress).await
}
};
match result {
Ok(()) => {
task.state = TaskState::Completed;
task.transferred_bytes = task.total_bytes;
queue.update_state(&task.id, TaskState::Completed);
queue.update_progress(&task.id, task.total_bytes, 0);
}
Err(e) => {
let msg = e.to_string();
task.state = TaskState::Failed(msg);
queue.update_state(&task.id, task.state.clone());
if msg == "cancelled" {
queue.update_state(&task.id, TaskState::Cancelled);
queue.remove(&task.id);
} else if msg == "paused" {
let transferred = queue
.get(&task.id)
.map(|t| t.transferred_bytes)
.unwrap_or(0);
let mut paused_task = task.clone();
paused_task.state = TaskState::Paused;
paused_task.transferred_bytes = transferred;
paused_task.speed = None;
paused_task.eta_secs = None;
queue.update_state(&task.id, TaskState::Paused);
queue.update_progress(&task.id, transferred, 0);
} else {
queue.update_state(&task.id, TaskState::Failed(msg));
}
}
}
}

View file

@ -148,6 +148,94 @@ impl FileManagerApp {
remote_pane::trigger_list(&mut self.state, idx, &self.registry, self.rt.handle());
}
}
// --- Mkdir result ---
let opt = self.state.pending_mkdir_result.take();
if let Some(res) = opt.as_ref() {
let mut guard = res.lock().unwrap();
if let Some(r) = guard.take() {
drop(guard);
match r {
Ok(()) => {
self.state.status_message = "Folder created".into();
self.state.mkdir_name.clear();
if let Some(idx) = self.active_tab_idx() {
remote_pane::trigger_list(
&mut self.state,
idx,
&self.registry,
self.rt.handle(),
);
}
}
Err(e) => {
self.state.status_message = format!("Create folder failed: {}", e);
}
}
} else {
drop(guard);
self.state.pending_mkdir_result = opt;
}
}
// --- Delete result ---
let opt = self.state.pending_delete_result.take();
if let Some(res) = opt.as_ref() {
let mut guard = res.lock().unwrap();
if let Some(r) = guard.take() {
drop(guard);
match r {
Ok(()) => {
self.state.status_message = "Deleted".into();
self.state.delete_name.clear();
if let Some(idx) = self.active_tab_idx() {
remote_pane::trigger_list(
&mut self.state,
idx,
&self.registry,
self.rt.handle(),
);
}
}
Err(e) => {
self.state.status_message = format!("Delete failed: {}", e);
}
}
} else {
drop(guard);
self.state.pending_delete_result = opt;
}
}
// --- Rename result ---
let opt = self.state.pending_rename_result.take();
if let Some(res) = opt.as_ref() {
let mut guard = res.lock().unwrap();
if let Some(r) = guard.take() {
drop(guard);
match r {
Ok(()) => {
self.state.status_message = "Renamed".into();
self.state.rename_old_name.clear();
self.state.rename_new_name.clear();
if let Some(idx) = self.active_tab_idx() {
remote_pane::trigger_list(
&mut self.state,
idx,
&self.registry,
self.rt.handle(),
);
}
}
Err(e) => {
self.state.status_message = format!("Rename failed: {}", e);
}
}
} else {
drop(guard);
self.state.pending_rename_result = opt;
}
}
}
fn active_tab_idx(&self) -> Option<usize> {
@ -162,6 +250,102 @@ impl FileManagerApp {
}
}
fn start_mkdir(&mut self, tab_idx: usize, name: String) {
let tab = &self.state.tabs[tab_idx];
let connection_id = tab.id.clone();
let remote_path = tab.remote_path.clone();
let registry = self.registry.clone();
let path = format!("{}/{}", remote_path.trim_end_matches('/'), name);
let result = Arc::new(std::sync::Mutex::new(None::<Result<(), String>>));
let result_clone = result.clone();
self.rt.handle().spawn(async move {
let fs = match registry.get(&connection_id) {
Some(fs) => fs,
None => {
*result_clone.lock().unwrap() = Some(Err("connection not found".into()));
return;
}
};
let r = fs.mkdir(&path).await.map_err(|e| e.to_string());
*result_clone.lock().unwrap() = Some(r);
});
self.state.pending_mkdir_result = Some(result);
}
fn start_delete(&mut self, tab_idx: usize, name: String) {
let tab = &self.state.tabs[tab_idx];
let connection_id = tab.id.clone();
let registry = self.registry.clone();
let entry_path = tab
.remote_entries
.iter()
.find(|e| e.name == name)
.map(|e| e.path.clone());
if name == ".." {
self.state.status_message = "Cannot delete parent entry".into();
return;
}
let Some(path) = entry_path else {
self.state.status_message = "Selected entry not found".into();
return;
};
let result = Arc::new(std::sync::Mutex::new(None::<Result<(), String>>));
let result_clone = result.clone();
self.rt.handle().spawn(async move {
let fs = match registry.get(&connection_id) {
Some(fs) => fs,
None => {
*result_clone.lock().unwrap() = Some(Err("connection not found".into()));
return;
}
};
let r = fs.delete(&path).await.map_err(|e| e.to_string());
*result_clone.lock().unwrap() = Some(r);
});
self.state.pending_delete_result = Some(result);
}
fn start_rename(&mut self, tab_idx: usize, old_name: String, new_name: String) {
let tab = &self.state.tabs[tab_idx];
let connection_id = tab.id.clone();
let registry = self.registry.clone();
let Some(entry) = tab.remote_entries.iter().find(|e| e.name == old_name) else {
self.state.status_message = "Selected entry not found".into();
return;
};
let from = entry.path.clone();
let parent_dir = remote_pane::remote_parent(&from);
let to = format!("{}/{}", parent_dir.trim_end_matches('/'), new_name);
let result = Arc::new(std::sync::Mutex::new(None::<Result<(), String>>));
let result_clone = result.clone();
self.rt.handle().spawn(async move {
let fs = match registry.get(&connection_id) {
Some(fs) => fs,
None => {
*result_clone.lock().unwrap() = Some(Err("connection not found".into()));
return;
}
};
let r = fs.rename(&from, &to).await.map_err(|e| e.to_string());
*result_clone.lock().unwrap() = Some(r);
});
self.state.pending_rename_result = Some(result);
}
fn apply_visuals(&self, ctx: &egui::Context) {
let mut vis = Visuals::dark();
vis.window_fill = BG_PANEL;
@ -361,11 +545,222 @@ impl eframe::App for FileManagerApp {
connection::render(ctx, &mut self.state, &self.registry, self.rt.handle());
}
// ── Remote operation dialogs ─────────────────────────────────────────
self.render_remote_op_dialogs(ctx);
// 200ms repaint для обновления прогресса
ctx.request_repaint_after(std::time::Duration::from_millis(200));
}
}
impl FileManagerApp {
fn render_remote_op_dialogs(&mut self, ctx: &egui::Context) {
let screen = ctx.screen_rect();
let center = screen.center();
// затемнённый оверлей под диалогами
if self.state.show_mkdir_dialog
|| self.state.show_delete_dialog
|| self.state.show_rename_dialog
{
egui::Area::new(egui::Id::new("remote_op_overlay"))
.order(egui::Order::Background)
.show(ctx, |ui| {
ui.painter().rect_filled(
screen,
0.0,
egui::Color32::from_rgba_premultiplied(0, 0, 0, 120),
);
});
}
// --- New Folder ---
if self.state.show_mkdir_dialog {
let mut open = true;
let mut clicked_ok = false;
egui::Window::new("new_folder_dialog")
.collapsible(false)
.title_bar(false)
.resizable(false)
.default_pos(center)
.pivot(egui::Align2::CENTER_CENTER)
.frame(
egui::Frame::none()
.fill(BG_PANEL)
.stroke(egui::Stroke::new(1.0, BORDER))
.rounding(8.0)
.inner_margin(egui::Margin::same(16.0)),
)
.open(&mut open)
.show(ctx, |ui| {
ui.set_width(260.0);
ui.label(RichText::new("New Folder").color(TEXT_PRIMARY).strong());
ui.add_space(12.0);
ui.text_edit_singleline(&mut self.state.mkdir_name);
ui.add_space(16.0);
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let ok = egui::Button::new(
RichText::new("OK").color(Color32::WHITE).size(12.0),
)
.fill(ACCENT)
.rounding(4.0);
if ui.add(ok).clicked() {
clicked_ok = true;
}
ui.add_space(8.0);
let cancel = egui::Button::new(
RichText::new("Cancel").color(TEXT_DIM).size(12.0),
)
.fill(egui::Color32::TRANSPARENT);
if ui.add(cancel).clicked() {
self.state.show_mkdir_dialog = false;
self.state.mkdir_name.clear();
}
});
});
});
if clicked_ok && !self.state.mkdir_name.is_empty() {
if let Some(idx) = self.active_tab_idx() {
let name = self.state.mkdir_name.clone();
self.start_mkdir(idx, name);
}
self.state.show_mkdir_dialog = false;
}
if !open {
self.state.show_mkdir_dialog = false;
self.state.mkdir_name.clear();
}
}
// --- Delete ---
if self.state.show_delete_dialog {
let mut open = true;
let mut clicked_ok = false;
let name = self.state.delete_name.clone();
egui::Window::new("delete_dialog")
.collapsible(false)
.title_bar(false)
.resizable(false)
.default_pos(center)
.pivot(egui::Align2::CENTER_CENTER)
.frame(
egui::Frame::none()
.fill(BG_PANEL)
.stroke(egui::Stroke::new(1.0, BORDER))
.rounding(8.0)
.inner_margin(egui::Margin::same(16.0)),
)
.open(&mut open)
.show(ctx, |ui| {
ui.set_width(260.0);
ui.label(
RichText::new(format!("Delete '{}' ?", name))
.color(TEXT_PRIMARY)
.strong(),
);
ui.add_space(16.0);
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let ok = egui::Button::new(
RichText::new("OK").color(Color32::WHITE).size(12.0),
)
.fill(RED)
.rounding(4.0);
if ui.add(ok).clicked() {
clicked_ok = true;
}
ui.add_space(8.0);
let cancel = egui::Button::new(
RichText::new("Cancel").color(TEXT_DIM).size(12.0),
)
.fill(egui::Color32::TRANSPARENT);
if ui.add(cancel).clicked() {
self.state.show_delete_dialog = false;
self.state.delete_name.clear();
}
});
});
});
if clicked_ok {
if let Some(idx) = self.active_tab_idx() {
self.start_delete(idx, name);
}
self.state.show_delete_dialog = false;
}
if !open {
self.state.show_delete_dialog = false;
self.state.delete_name.clear();
}
}
// --- Rename ---
if self.state.show_rename_dialog {
let mut open = true;
let mut clicked_ok = false;
egui::Window::new("rename_dialog")
.collapsible(false)
.title_bar(false)
.resizable(false)
.default_pos(center)
.pivot(egui::Align2::CENTER_CENTER)
.frame(
egui::Frame::none()
.fill(BG_PANEL)
.stroke(egui::Stroke::new(1.0, BORDER))
.rounding(8.0)
.inner_margin(egui::Margin::same(16.0)),
)
.open(&mut open)
.show(ctx, |ui| {
ui.set_width(260.0);
ui.label(RichText::new("Rename").color(TEXT_PRIMARY).strong());
ui.add_space(12.0);
ui.text_edit_singleline(&mut self.state.rename_new_name);
ui.add_space(16.0);
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let ok = egui::Button::new(
RichText::new("OK").color(Color32::WHITE).size(12.0),
)
.fill(ACCENT)
.rounding(4.0);
if ui.add(ok).clicked() {
clicked_ok = true;
}
ui.add_space(8.0);
let cancel = egui::Button::new(
RichText::new("Cancel").color(TEXT_DIM).size(12.0),
)
.fill(egui::Color32::TRANSPARENT);
if ui.add(cancel).clicked() {
self.state.show_rename_dialog = false;
self.state.rename_new_name.clear();
self.state.rename_old_name.clear();
}
});
});
});
if clicked_ok && !self.state.rename_new_name.is_empty() {
if let Some(idx) = self.active_tab_idx() {
let old_name = self.state.rename_old_name.clone();
let new_name = self.state.rename_new_name.clone();
self.start_rename(idx, old_name, new_name);
}
self.state.show_rename_dialog = false;
}
if !open {
self.state.show_rename_dialog = false;
self.state.rename_new_name.clear();
self.state.rename_old_name.clear();
}
}
}
}
fn pane_header(ui: &mut egui::Ui, label: &str, width: f32) {
egui::Frame::none()
.fill(BG_BASE)

View file

@ -69,13 +69,27 @@ pub fn render(ui: &mut Ui, state: &mut AppState) {
state.pending_refresh = true;
});
toolbar_btn_enabled(ui, "📁 New Folder", has_conn, || {
state.pending_mkdir = true;
state.show_mkdir_dialog = true;
state.mkdir_name.clear();
});
toolbar_btn_enabled(ui, "× Delete", has_conn && has_remote_sel, || {
state.pending_delete = true;
if let Some(name) = state
.active_tab_ref()
.and_then(|t| t.remote_selected.clone())
{
state.show_delete_dialog = true;
state.delete_name = name;
}
});
toolbar_btn_enabled(ui, "✎ Rename", has_conn && has_remote_sel, || {
state.pending_rename = true;
if let Some(name) = state
.active_tab_ref()
.and_then(|t| t.remote_selected.clone())
{
state.show_rename_dialog = true;
state.rename_old_name = name.clone();
state.rename_new_name = name;
}
});
// Правая часть — History / Bookmarks

View file

@ -74,9 +74,20 @@ pub struct AppState {
// action flags — выставляются тулбаром, обрабатываются в app.rs
pub pending_refresh: bool,
pub pending_mkdir: bool,
pub pending_delete: bool,
pub pending_rename: bool,
// диалоги операций над удалённой ФС
pub show_mkdir_dialog: bool,
pub show_delete_dialog: bool,
pub show_rename_dialog: bool,
pub mkdir_name: String,
pub delete_name: String,
pub rename_old_name: String,
pub rename_new_name: String,
pub pending_mkdir_result: Option<PendingResult<()>>,
pub pending_delete_result: Option<PendingResult<()>>,
pub pending_rename_result: Option<PendingResult<()>>,
pub pending_connect: Option<PendingConnect>,
pub pending_remote_list: Vec<PendingRemoteList>,
@ -144,9 +155,16 @@ impl Default for AppState {
status_message: "Ready".into(),
connected_count: 0,
pending_refresh: false,
pending_mkdir: false,
pending_delete: false,
pending_rename: false,
show_mkdir_dialog: false,
show_delete_dialog: false,
show_rename_dialog: false,
mkdir_name: String::new(),
delete_name: String::new(),
rename_old_name: String::new(),
rename_new_name: String::new(),
pending_mkdir_result: None,
pending_delete_result: None,
pending_rename_result: None,
pending_connect: None,
pending_remote_list: Vec::new(),
}