egui/eframe UI, clean up deps, remove Svelte/Tauri frontend
This commit is contained in:
parent
77c84ba3bd
commit
ff9a1b9b4a
37 changed files with 3982 additions and 2704 deletions
|
|
@ -1,7 +1,8 @@
|
|||
[package]
|
||||
name = "loflum"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
license = "GPL-3.0-only"
|
||||
|
||||
[lib]
|
||||
name = "loflum_lib"
|
||||
|
|
@ -11,32 +12,27 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||
name = "loflum"
|
||||
path = "src/main.rs"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-shell = "2"
|
||||
eframe = { version = "0.28", features = ["default"] }
|
||||
egui = "0.28"
|
||||
egui_extras = "0.28"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
anyhow = "1"
|
||||
thiserror = "1"
|
||||
camino = { version = "1", features = ["serde1"] }
|
||||
async-trait = "0.1"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
keyring = "2"
|
||||
suppaftp = { version = "6", features = ["async", "async-native-tls"] }
|
||||
ssh2 = "0.9"
|
||||
once_cell = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
dirs = "5"
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
futures = "0.3"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
chrono = "0.4"
|
||||
async-std = "1"
|
||||
futures-lite = "2"
|
||||
image = "0.25"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
use tauri::State;
|
||||
use std::sync::Arc;
|
||||
use crate::domain::connection::{ConnectionParams, Protocol};
|
||||
use crate::fs::remote::RemoteRegistry;
|
||||
use crate::protocols::sftp::SftpClient;
|
||||
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(
|
||||
|
|
@ -22,22 +22,25 @@ pub async fn connect(
|
|||
if let Some(key_path) = ¶ms.key_path {
|
||||
Arc::new(
|
||||
SftpClient::connect_key(¶ms.host, params.port, ¶ms.username, key_path)
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
} else {
|
||||
Arc::new(
|
||||
SftpClient::connect_password(¶ms.host, params.port, ¶ms.username, &password)
|
||||
.map_err(|e| e.to_string())?
|
||||
SftpClient::connect_password(
|
||||
¶ms.host,
|
||||
params.port,
|
||||
¶ms.username,
|
||||
&password,
|
||||
)
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
}
|
||||
}
|
||||
Protocol::Ftp => {
|
||||
Arc::new(
|
||||
FtpClient::connect(¶ms.host, params.port, ¶ms.username, &password)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
)
|
||||
}
|
||||
Protocol::Ftp => Arc::new(
|
||||
FtpClient::connect(¶ms.host, params.port, ¶ms.username, &password)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?,
|
||||
),
|
||||
Protocol::Ftps => {
|
||||
return Err("FTPS not yet implemented (suppaftp API compat)".to_string());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
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> {
|
||||
|
|
@ -14,7 +14,8 @@ pub async fn list_remote(
|
|||
connection_id: String,
|
||||
path: String,
|
||||
) -> Result<Vec<FileEntry>, String> {
|
||||
let fs = registry.get(&connection_id)
|
||||
let fs = registry
|
||||
.get(&connection_id)
|
||||
.ok_or_else(|| format!("no connection: {}", connection_id))?;
|
||||
fs.list(&path).await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
|
@ -25,7 +26,8 @@ pub async fn remote_mkdir(
|
|||
connection_id: String,
|
||||
path: String,
|
||||
) -> Result<(), String> {
|
||||
let fs = registry.get(&connection_id)
|
||||
let fs = registry
|
||||
.get(&connection_id)
|
||||
.ok_or_else(|| format!("no connection: {}", connection_id))?;
|
||||
fs.mkdir(&path).await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
|
@ -37,7 +39,8 @@ pub async fn remote_rename(
|
|||
from: String,
|
||||
to: String,
|
||||
) -> Result<(), String> {
|
||||
let fs = registry.get(&connection_id)
|
||||
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())
|
||||
}
|
||||
|
|
@ -48,7 +51,8 @@ pub async fn remote_delete(
|
|||
connection_id: String,
|
||||
path: String,
|
||||
) -> Result<(), String> {
|
||||
let fs = registry.get(&connection_id)
|
||||
let fs = registry
|
||||
.get(&connection_id)
|
||||
.ok_or_else(|| format!("no connection: {}", connection_id))?;
|
||||
fs.delete(&path).await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use tauri::State;
|
||||
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> {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
use tauri::State;
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use crate::domain::transfer::{TransferTask, TransferKind, TaskState};
|
||||
use crate::transfer::manager::TransferManager;
|
||||
use crate::domain::file_entry::EntryKind;
|
||||
use crate::domain::transfer::{TaskState, TransferKind, TransferTask};
|
||||
use crate::transfer::manager::TransferManager;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn upload(
|
||||
|
|
@ -12,7 +12,8 @@ pub async fn upload(
|
|||
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))?;
|
||||
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());
|
||||
}
|
||||
|
|
@ -50,9 +51,13 @@ pub async fn download(
|
|||
) -> Result<String, String> {
|
||||
// Узнаём размер удалённого файла через stat
|
||||
let registry = manager.registry();
|
||||
let fs = registry.get(&connection_id)
|
||||
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 stat = fs
|
||||
.stat(&remote_path)
|
||||
.await
|
||||
.map_err(|e| format!("stat failed: {}", e))?;
|
||||
|
||||
let file_name = std::path::Path::new(&remote_path)
|
||||
.file_name()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use crate::domain::connection::Protocol;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use std::fmt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
|
|
|
|||
|
|
@ -1,23 +1,31 @@
|
|||
use crate::domain::file_entry::{EntryKind, FileEntry};
|
||||
use anyhow::Result;
|
||||
use std::fs;
|
||||
use crate::domain::file_entry::{FileEntry, EntryKind};
|
||||
|
||||
pub fn list(path: &str) -> Result<Vec<FileEntry>> {
|
||||
let mut entries: Vec<FileEntry> = fs::read_dir(path)?
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| {
|
||||
let meta = e.metadata().ok();
|
||||
let kind = meta.as_ref().map(|m| {
|
||||
if m.is_dir() { EntryKind::Dir }
|
||||
else if m.is_symlink() { EntryKind::Symlink }
|
||||
else { EntryKind::File }
|
||||
}).unwrap_or(EntryKind::File);
|
||||
let kind = meta
|
||||
.as_ref()
|
||||
.map(|m| {
|
||||
if m.is_dir() {
|
||||
EntryKind::Dir
|
||||
} else if m.is_symlink() {
|
||||
EntryKind::Symlink
|
||||
} else {
|
||||
EntryKind::File
|
||||
}
|
||||
})
|
||||
.unwrap_or(EntryKind::File);
|
||||
|
||||
let size = meta.as_ref().and_then(|m| {
|
||||
if m.is_file() { Some(m.len()) } else { None }
|
||||
});
|
||||
let size = meta
|
||||
.as_ref()
|
||||
.and_then(|m| if m.is_file() { Some(m.len()) } else { None });
|
||||
|
||||
let modified = meta.as_ref()
|
||||
let modified = meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs() as i64);
|
||||
|
|
@ -34,13 +42,11 @@ pub fn list(path: &str) -> Result<Vec<FileEntry>> {
|
|||
.collect();
|
||||
|
||||
// папки сначала, потом файлы, по имени
|
||||
entries.sort_by(|a, b| {
|
||||
match (&a.kind, &b.kind) {
|
||||
(EntryKind::Dir, EntryKind::Dir) => a.name.cmp(&b.name),
|
||||
(EntryKind::Dir, _) => std::cmp::Ordering::Less,
|
||||
(_, EntryKind::Dir) => std::cmp::Ordering::Greater,
|
||||
_ => a.name.cmp(&b.name),
|
||||
}
|
||||
entries.sort_by(|a, b| match (&a.kind, &b.kind) {
|
||||
(EntryKind::Dir, EntryKind::Dir) => a.name.cmp(&b.name),
|
||||
(EntryKind::Dir, _) => std::cmp::Ordering::Less,
|
||||
(_, EntryKind::Dir) => std::cmp::Ordering::Greater,
|
||||
_ => a.name.cmp(&b.name),
|
||||
});
|
||||
|
||||
Ok(entries)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
// Абстракция над RemoteFs для хранения активных соединений
|
||||
// TODO: HashMap<connection_id, Arc<dyn RemoteFs>>
|
||||
|
||||
use crate::protocols::RemoteFs;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use crate::protocols::RemoteFs;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct RemoteRegistry {
|
||||
|
|
|
|||
|
|
@ -1,47 +1,52 @@
|
|||
pub mod commands;
|
||||
pub mod domain;
|
||||
pub mod fs;
|
||||
pub mod protocols;
|
||||
pub mod storage;
|
||||
pub mod transfer;
|
||||
pub mod ui;
|
||||
|
||||
use crate::fs::remote::RemoteRegistry;
|
||||
use crate::transfer::queue::TransferQueue;
|
||||
use crate::ui::app::FileManagerApp;
|
||||
use std::sync::Arc;
|
||||
use tauri::Manager;
|
||||
use crate::transfer::manager::TransferManager;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tracing_subscriber::fmt::init();
|
||||
tracing_subscriber::fmt().with_target(false).init();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::fs::list_local,
|
||||
commands::fs::list_remote,
|
||||
commands::fs::remote_mkdir,
|
||||
commands::fs::remote_rename,
|
||||
commands::fs::remote_delete,
|
||||
commands::connection::connect,
|
||||
commands::connection::disconnect,
|
||||
commands::transfer::upload,
|
||||
commands::transfer::download,
|
||||
commands::transfer::get_queue,
|
||||
commands::transfer::pause_task,
|
||||
commands::transfer::cancel_task,
|
||||
commands::sites::get_sites,
|
||||
commands::sites::save_site,
|
||||
commands::sites::delete_site,
|
||||
])
|
||||
.setup(|app| {
|
||||
let registry = Arc::new(crate::fs::remote::RemoteRegistry::default());
|
||||
let manager = TransferManager::new(registry.clone(), app.handle().clone());
|
||||
let db_path = dirs::data_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join("loflum")
|
||||
.join("loflum.db");
|
||||
if let Some(parent) = db_path.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
let conn = rusqlite::Connection::open(&db_path).expect("failed to open loflum database");
|
||||
storage::db::init_tables(&conn).expect("failed to init db tables");
|
||||
let db = Arc::new(std::sync::Mutex::new(conn));
|
||||
|
||||
app.manage(registry);
|
||||
app.manage(manager);
|
||||
let sites = {
|
||||
let c = db.lock().unwrap();
|
||||
storage::db::get_sites(&c).unwrap_or_default()
|
||||
};
|
||||
|
||||
storage::db::init(app.handle())?;
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running loflum");
|
||||
let rt = tokio::runtime::Runtime::new().expect("failed to create tokio runtime");
|
||||
let rt_handle = rt.handle().clone();
|
||||
|
||||
let registry = Arc::new(RemoteRegistry::default());
|
||||
let queue = TransferQueue::default();
|
||||
|
||||
transfer::worker::spawn_worker(queue.clone(), registry.clone(), rt_handle);
|
||||
|
||||
let app = FileManagerApp::new(registry, queue, rt, db, sites);
|
||||
|
||||
let native_options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("LoFlum")
|
||||
.with_inner_size([1280.0, 800.0])
|
||||
.with_min_inner_size([900.0, 600.0]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
eframe::run_native("LoFlum", native_options, Box::new(|_cc| Ok(Box::new(app))))
|
||||
.expect("failed to start LoFlum");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Prevents additional console window on Windows in release
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
use async_trait::async_trait;
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use futures_lite::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use std::str::FromStr;
|
||||
use std::time::SystemTime;
|
||||
use tokio::sync::Mutex;
|
||||
use async_std::io::{ReadExt, WriteExt};
|
||||
|
||||
use suppaftp::{AsyncFtpStream, list::File};
|
||||
|
||||
use crate::domain::file_entry::{FileEntry, EntryKind};
|
||||
use super::RemoteFs;
|
||||
use crate::domain::file_entry::{EntryKind, FileEntry};
|
||||
|
||||
pub struct FtpClient {
|
||||
pub conn: Mutex<Option<AsyncFtpStream>>,
|
||||
|
|
@ -17,11 +17,16 @@ pub struct FtpClient {
|
|||
impl FtpClient {
|
||||
pub async fn connect(host: &str, port: u16, user: &str, pass: &str) -> Result<Self> {
|
||||
let addr = format!("{}:{}", host, port);
|
||||
let mut stream = AsyncFtpStream::connect(&addr).await
|
||||
let mut stream = AsyncFtpStream::connect(&addr)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("FTP connect failed: {}", e))?;
|
||||
stream.login(user, pass).await
|
||||
stream
|
||||
.login(user, pass)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("FTP login failed: {}", e))?;
|
||||
Ok(Self { conn: Mutex::new(Some(stream)) })
|
||||
Ok(Self {
|
||||
conn: Mutex::new(Some(stream)),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_list_entry(line: &str) -> Option<FileEntry> {
|
||||
|
|
@ -34,7 +39,8 @@ impl FtpClient {
|
|||
EntryKind::File
|
||||
};
|
||||
|
||||
let modified = file.modified()
|
||||
let modified = file
|
||||
.modified()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.ok()
|
||||
.map(|d| d.as_secs() as i64);
|
||||
|
|
@ -54,49 +60,66 @@ impl FtpClient {
|
|||
impl RemoteFs for FtpClient {
|
||||
async fn list(&self, path: &str) -> Result<Vec<FileEntry>> {
|
||||
let mut guard = self.conn.lock().await;
|
||||
let conn = guard.as_mut().ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||
let lines = conn.list(Some(path)).await
|
||||
let conn = guard
|
||||
.as_mut()
|
||||
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||
let lines = conn
|
||||
.list(Some(path))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("FTP list failed: {}", e))?;
|
||||
|
||||
let mut entries: Vec<FileEntry> = lines.iter()
|
||||
let mut entries: Vec<FileEntry> = lines
|
||||
.iter()
|
||||
.filter_map(|line| Self::parse_list_entry(line))
|
||||
.collect();
|
||||
|
||||
entries.sort_by(|a, b| {
|
||||
match (&a.kind, &b.kind) {
|
||||
(EntryKind::Dir, EntryKind::Dir) => a.name.cmp(&b.name),
|
||||
(EntryKind::Dir, _) => std::cmp::Ordering::Less,
|
||||
(_, EntryKind::Dir) => std::cmp::Ordering::Greater,
|
||||
_ => a.name.cmp(&b.name),
|
||||
}
|
||||
entries.sort_by(|a, b| match (&a.kind, &b.kind) {
|
||||
(EntryKind::Dir, EntryKind::Dir) => a.name.cmp(&b.name),
|
||||
(EntryKind::Dir, _) => std::cmp::Ordering::Less,
|
||||
(_, EntryKind::Dir) => std::cmp::Ordering::Greater,
|
||||
_ => a.name.cmp(&b.name),
|
||||
});
|
||||
|
||||
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))?;
|
||||
let data =
|
||||
std::fs::read(local).map_err(|e| anyhow::anyhow!("cannot read local file: {}", e))?;
|
||||
let mut guard = self.conn.lock().await;
|
||||
let conn = guard.as_mut().ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||
let mut stream = conn.put_with_stream(remote).await
|
||||
let conn = guard
|
||||
.as_mut()
|
||||
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||
let mut stream = conn
|
||||
.put_with_stream(remote)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("FTP upload stream failed: {}", e))?;
|
||||
stream.write_all(&data).await
|
||||
stream
|
||||
.write_all(&data)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("FTP upload write failed: {}", e))?;
|
||||
conn.finalize_put_stream(stream).await
|
||||
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<()> {
|
||||
let mut guard = self.conn.lock().await;
|
||||
let conn = guard.as_mut().ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||
let mut stream = conn.retr_as_stream(remote).await
|
||||
let conn = guard
|
||||
.as_mut()
|
||||
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||
let mut stream = conn
|
||||
.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).await
|
||||
stream
|
||||
.read_to_end(&mut buf)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("FTP download read failed: {}", e))?;
|
||||
conn.finalize_retr_stream(stream).await
|
||||
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))?;
|
||||
|
|
@ -105,25 +128,34 @@ impl RemoteFs for FtpClient {
|
|||
|
||||
async fn mkdir(&self, path: &str) -> Result<()> {
|
||||
let mut guard = self.conn.lock().await;
|
||||
let conn = guard.as_mut().ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||
conn.mkdir(path).await
|
||||
let conn = guard
|
||||
.as_mut()
|
||||
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||
conn.mkdir(path)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("FTP mkdir failed: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn rename(&self, from: &str, to: &str) -> Result<()> {
|
||||
let mut guard = self.conn.lock().await;
|
||||
let conn = guard.as_mut().ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||
conn.rename(from, to).await
|
||||
let conn = guard
|
||||
.as_mut()
|
||||
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||
conn.rename(from, to)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("FTP rename failed: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, path: &str) -> Result<()> {
|
||||
let mut guard = self.conn.lock().await;
|
||||
let conn = guard.as_mut().ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||
let conn = guard
|
||||
.as_mut()
|
||||
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||
if conn.rm(path).await.is_err() {
|
||||
conn.rmdir(path).await
|
||||
conn.rmdir(path)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("FTP delete failed: {}", e))?;
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -131,10 +163,16 @@ impl RemoteFs for FtpClient {
|
|||
|
||||
async fn stat(&self, path: &str) -> Result<FileEntry> {
|
||||
let mut guard = self.conn.lock().await;
|
||||
let conn = guard.as_mut().ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||
let size = conn.size(path).await
|
||||
let conn = guard
|
||||
.as_mut()
|
||||
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||
let size = conn
|
||||
.size(path)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("FTP stat failed: {}", e))?;
|
||||
let modified = conn.mdtm(path).await
|
||||
let modified = conn
|
||||
.mdtm(path)
|
||||
.await
|
||||
.map(|dt| dt.and_utc().timestamp())
|
||||
.ok();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
pub mod ftp;
|
||||
pub mod sftp;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use crate::domain::file_entry::FileEntry;
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// Единый контракт для всех протоколов.
|
||||
/// Очередь передач не знает — FTP это или SFTP.
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
use anyhow::{Context, Result, bail};
|
||||
use async_trait::async_trait;
|
||||
use anyhow::{Result, Context};
|
||||
use ssh2::Session;
|
||||
use ssh2::{CheckResult, HashType, KnownHostFileKind, Session};
|
||||
use std::net::TcpStream;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::domain::file_entry::{FileEntry, EntryKind};
|
||||
use super::RemoteFs;
|
||||
use crate::domain::file_entry::{EntryKind, FileEntry};
|
||||
|
||||
pub struct SftpClient {
|
||||
session: Session,
|
||||
|
|
@ -14,29 +14,93 @@ pub struct SftpClient {
|
|||
impl SftpClient {
|
||||
/// Подключение по паролю
|
||||
pub fn connect_password(host: &str, port: u16, user: &str, password: &str) -> Result<Self> {
|
||||
let tcp = TcpStream::connect(format!("{}:{}", host, port))
|
||||
.context("TCP connect failed")?;
|
||||
let tcp = TcpStream::connect(format!("{}:{}", host, port)).context("TCP connect failed")?;
|
||||
let mut session = Session::new().context("SSH session init failed")?;
|
||||
session.set_tcp_stream(tcp);
|
||||
session.handshake().context("SSH handshake failed")?;
|
||||
session.userauth_password(user, password)
|
||||
verify_host_key(&session, host, port)?;
|
||||
session
|
||||
.userauth_password(user, password)
|
||||
.context("SSH password auth failed")?;
|
||||
Ok(Self { session })
|
||||
}
|
||||
|
||||
/// Подключение по ключу
|
||||
pub fn connect_key(host: &str, port: u16, user: &str, key_path: &str) -> Result<Self> {
|
||||
let tcp = TcpStream::connect(format!("{}:{}", host, port))
|
||||
.context("TCP connect failed")?;
|
||||
let tcp = TcpStream::connect(format!("{}:{}", host, port)).context("TCP connect failed")?;
|
||||
let mut session = Session::new().context("SSH session init failed")?;
|
||||
session.set_tcp_stream(tcp);
|
||||
session.handshake().context("SSH handshake failed")?;
|
||||
session.userauth_pubkey_file(user, None, Path::new(key_path), None)
|
||||
verify_host_key(&session, host, port)?;
|
||||
session
|
||||
.userauth_pubkey_file(user, None, Path::new(key_path), None)
|
||||
.context("SSH key auth failed")?;
|
||||
Ok(Self { session })
|
||||
}
|
||||
}
|
||||
|
||||
fn known_hosts_path() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|p| p.join(".ssh/known_hosts"))
|
||||
}
|
||||
|
||||
fn fingerprint_hex(session: &Session) -> String {
|
||||
session
|
||||
.host_key_hash(HashType::Sha256)
|
||||
.map(|h| {
|
||||
h.iter()
|
||||
.map(|b| format!("{:02x}", b))
|
||||
.collect::<Vec<_>>()
|
||||
.join(":")
|
||||
})
|
||||
.unwrap_or_else(|| "unknown".into())
|
||||
}
|
||||
|
||||
fn verify_host_key(session: &Session, host: &str, port: u16) -> Result<()> {
|
||||
let mut known = session.known_hosts().context("known_hosts init failed")?;
|
||||
|
||||
if let Some(ref path) = known_hosts_path() {
|
||||
let _ = known.read_file(path, KnownHostFileKind::OpenSSH);
|
||||
}
|
||||
|
||||
let (key, key_type) = session
|
||||
.host_key()
|
||||
.context("no host key received from server")?;
|
||||
|
||||
match known.check_port(host, port, key) {
|
||||
CheckResult::Match => {} // known and verified
|
||||
CheckResult::Mismatch => {
|
||||
bail!(
|
||||
"SSH host key mismatch for {}!\n\
|
||||
The server's host key has changed since the last connection.\n\
|
||||
This could mean someone is intercepting the connection (MITM attack).\n\
|
||||
Fingerprint (SHA256): {}",
|
||||
host,
|
||||
fingerprint_hex(session)
|
||||
);
|
||||
}
|
||||
CheckResult::NotFound => {
|
||||
tracing::info!(
|
||||
"Unknown host key for {}, adding to known_hosts (SHA256: {})",
|
||||
host,
|
||||
fingerprint_hex(session)
|
||||
);
|
||||
known
|
||||
.add(host, key, "loflum", key_type.into())
|
||||
.context("failed to add host key to known_hosts")?;
|
||||
if let Some(ref path) = known_hosts_path()
|
||||
&& let Err(e) = known.write_file(path, KnownHostFileKind::OpenSSH)
|
||||
{
|
||||
tracing::warn!("failed to write known_hosts: {}", e);
|
||||
}
|
||||
}
|
||||
CheckResult::Failure => {
|
||||
bail!("known_hosts check failed for {}", host);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RemoteFs for SftpClient {
|
||||
async fn list(&self, path: &str) -> Result<Vec<FileEntry>> {
|
||||
|
|
@ -44,26 +108,30 @@ impl RemoteFs for SftpClient {
|
|||
let sftp = self.session.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,
|
||||
}
|
||||
}).collect();
|
||||
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)
|
||||
}
|
||||
|
|
@ -71,9 +139,9 @@ impl RemoteFs for SftpClient {
|
|||
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))
|
||||
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(())
|
||||
|
|
@ -82,10 +150,10 @@ impl RemoteFs for SftpClient {
|
|||
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))
|
||||
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")?;
|
||||
let mut local_file = std::fs::File::create(local).context("create local file failed")?;
|
||||
std::io::copy(&mut remote_file, &mut local_file)?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -122,7 +190,11 @@ impl RemoteFs for SftpClient {
|
|||
.to_string_lossy()
|
||||
.to_string(),
|
||||
path: path.to_string(),
|
||||
kind: if stat.is_dir() { EntryKind::Dir } else { EntryKind::File },
|
||||
kind: if stat.is_dir() {
|
||||
EntryKind::Dir
|
||||
} else {
|
||||
EntryKind::File
|
||||
},
|
||||
size: stat.size,
|
||||
modified: stat.mtime.map(|t| t as i64),
|
||||
permissions: None,
|
||||
|
|
|
|||
|
|
@ -1,21 +1,11 @@
|
|||
use crate::domain::connection::Protocol;
|
||||
use crate::domain::site::Site;
|
||||
use anyhow::Result;
|
||||
use rusqlite::{Connection, params};
|
||||
use tauri::{AppHandle, Manager};
|
||||
use std::sync::Mutex;
|
||||
use crate::domain::site::Site;
|
||||
use crate::domain::connection::Protocol;
|
||||
|
||||
pub struct Db(pub Mutex<Connection>);
|
||||
|
||||
pub fn init(app: &AppHandle) -> Result<()> {
|
||||
let path = app.path().app_data_dir()
|
||||
.expect("no app data dir")
|
||||
.join("loflum.db");
|
||||
|
||||
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||
let conn = Connection::open(&path)?;
|
||||
|
||||
conn.execute_batch("
|
||||
pub fn init_tables(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
"
|
||||
PRAGMA journal_mode=WAL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sites (
|
||||
|
|
@ -29,35 +19,37 @@ pub fn init(app: &AppHandle) -> Result<()> {
|
|||
folder TEXT,
|
||||
note TEXT
|
||||
);
|
||||
")?;
|
||||
|
||||
app.manage(Db(Mutex::new(conn)));
|
||||
",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_sites(conn: &Connection) -> Result<Vec<Site>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, protocol, host, port, username, key_path, folder, note FROM sites ORDER BY name"
|
||||
"SELECT id, name, protocol, host, port, username, key_path, folder, note FROM sites ORDER BY name",
|
||||
)?;
|
||||
let sites = stmt.query_map([], |row| {
|
||||
let proto_str: String = row.get(2)?;
|
||||
let protocol = match proto_str.as_str() {
|
||||
"sftp" => Protocol::Sftp,
|
||||
"ftps" => Protocol::Ftps,
|
||||
_ => Protocol::Ftp,
|
||||
};
|
||||
Ok(Site {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
protocol,
|
||||
host: row.get(3)?,
|
||||
port: row.get(4)?,
|
||||
username: row.get(5)?,
|
||||
key_path: row.get(6)?,
|
||||
folder: row.get(7)?,
|
||||
note: row.get(8)?,
|
||||
})
|
||||
})?.filter_map(|r| r.ok()).collect();
|
||||
let sites = stmt
|
||||
.query_map([], |row| {
|
||||
let proto_str: String = row.get(2)?;
|
||||
let protocol = match proto_str.as_str() {
|
||||
"sftp" => Protocol::Sftp,
|
||||
"ftps" => Protocol::Ftps,
|
||||
_ => Protocol::Ftp,
|
||||
};
|
||||
Ok(Site {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
protocol,
|
||||
host: row.get(3)?,
|
||||
port: row.get(4)?,
|
||||
username: row.get(5)?,
|
||||
key_path: row.get(6)?,
|
||||
folder: row.get(7)?,
|
||||
note: row.get(8)?,
|
||||
})
|
||||
})?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
Ok(sites)
|
||||
}
|
||||
|
||||
|
|
@ -72,8 +64,15 @@ pub fn save_site(conn: &Connection, site: &Site) -> Result<()> {
|
|||
(id, name, protocol, host, port, username, key_path, folder, note)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)",
|
||||
params![
|
||||
site.id, site.name, proto, site.host, site.port,
|
||||
site.username, site.key_path, site.folder, site.note
|
||||
site.id,
|
||||
site.name,
|
||||
proto,
|
||||
site.host,
|
||||
site.port,
|
||||
site.username,
|
||||
site.key_path,
|
||||
site.folder,
|
||||
site.note
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -1,32 +1,21 @@
|
|||
use std::sync::Arc;
|
||||
use tauri::AppHandle;
|
||||
use crate::transfer::queue::TransferQueue;
|
||||
use crate::transfer::worker::spawn_worker;
|
||||
use crate::fs::remote::RemoteRegistry;
|
||||
use crate::transfer::queue::TransferQueue;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Хранит очередь передач и запускает воркер.
|
||||
pub struct TransferManager {
|
||||
pub queue: TransferQueue,
|
||||
registry: Arc<RemoteRegistry>,
|
||||
app: AppHandle,
|
||||
pub registry: Arc<RemoteRegistry>,
|
||||
}
|
||||
|
||||
impl TransferManager {
|
||||
pub fn new(registry: Arc<RemoteRegistry>, app: AppHandle) -> Arc<Self> {
|
||||
let manager = Arc::new(Self {
|
||||
pub fn new(registry: Arc<RemoteRegistry>) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
queue: TransferQueue::default(),
|
||||
registry: registry.clone(),
|
||||
app: app.clone(),
|
||||
});
|
||||
spawn_worker(manager.clone(), registry, app);
|
||||
manager
|
||||
registry,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn registry(&self) -> &RemoteRegistry {
|
||||
&self.registry
|
||||
}
|
||||
|
||||
pub fn app(&self) -> &AppHandle {
|
||||
&self.app
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use crate::domain::transfer::{TaskState, TransferTask};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use crate::domain::transfer::{TransferTask, TaskState};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct TransferQueue {
|
||||
|
|
@ -32,10 +32,8 @@ impl TransferQueue {
|
|||
if let Some(t) = q.iter_mut().find(|t| t.id == id) {
|
||||
t.transferred_bytes = transferred;
|
||||
t.speed = Some(speed);
|
||||
if speed > 0 {
|
||||
let remaining = t.total_bytes.saturating_sub(transferred);
|
||||
t.eta_secs = Some(remaining / speed);
|
||||
}
|
||||
let remaining = t.total_bytes.saturating_sub(transferred);
|
||||
t.eta_secs = remaining.checked_div(speed);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use crate::domain::transfer::{TransferTask, TransferKind};
|
||||
use crate::domain::transfer::{TransferKind, TransferTask};
|
||||
|
||||
pub fn new_task(
|
||||
kind: TransferKind,
|
||||
|
|
|
|||
|
|
@ -1,40 +1,25 @@
|
|||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::time::{sleep, Duration};
|
||||
use tokio::time::{Duration, sleep};
|
||||
|
||||
use crate::domain::transfer::{TaskState, TransferKind};
|
||||
use crate::fs::remote::RemoteRegistry;
|
||||
use crate::transfer::manager::TransferManager;
|
||||
|
||||
/// Событие для фронтенда
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TransferProgress {
|
||||
task_id: String,
|
||||
state: String,
|
||||
transferred_bytes: u64,
|
||||
total_bytes: u64,
|
||||
speed: Option<u64>,
|
||||
eta_secs: Option<u64>,
|
||||
error: Option<String>,
|
||||
}
|
||||
use crate::transfer::queue::TransferQueue;
|
||||
|
||||
pub fn spawn_worker(
|
||||
manager: Arc<TransferManager>,
|
||||
queue: TransferQueue,
|
||||
registry: Arc<RemoteRegistry>,
|
||||
app: AppHandle,
|
||||
rt_handle: tokio::runtime::Handle,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
rt_handle.spawn(async move {
|
||||
loop {
|
||||
let task = {
|
||||
let task = manager.queue.pop();
|
||||
if let Some(ref t) = task {
|
||||
if t.state != TaskState::Queued {
|
||||
// paused/cancelled — не трогаем, вернём
|
||||
manager.queue.push(t.clone());
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
continue;
|
||||
}
|
||||
let task = queue.pop();
|
||||
if let Some(ref t) = task
|
||||
&& t.state != TaskState::Queued
|
||||
{
|
||||
queue.push(t.clone());
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
continue;
|
||||
}
|
||||
task
|
||||
};
|
||||
|
|
@ -47,17 +32,14 @@ pub fn spawn_worker(
|
|||
}
|
||||
};
|
||||
|
||||
// Старт
|
||||
task.state = TaskState::Running;
|
||||
manager.queue.update_state(&task.id, TaskState::Running);
|
||||
emit_progress(&app, &task, None);
|
||||
queue.update_state(&task.id, TaskState::Running);
|
||||
|
||||
let fs = match registry.get(&task.connection_id) {
|
||||
Some(fs) => fs,
|
||||
None => {
|
||||
task.state = TaskState::Failed("connection not found".into());
|
||||
manager.queue.update_state(&task.id, task.state.clone());
|
||||
emit_progress(&app, &task, Some("connection not found"));
|
||||
queue.update_state(&task.id, task.state.clone());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
|
@ -71,29 +53,15 @@ pub fn spawn_worker(
|
|||
Ok(()) => {
|
||||
task.state = TaskState::Completed;
|
||||
task.transferred_bytes = task.total_bytes;
|
||||
manager.queue.update_state(&task.id, TaskState::Completed);
|
||||
manager.queue.update_progress(&task.id, task.total_bytes, 0);
|
||||
emit_progress(&app, &task, None);
|
||||
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.clone());
|
||||
manager.queue.update_state(&task.id, TaskState::Failed(msg.clone()));
|
||||
emit_progress(&app, &task, Some(&msg));
|
||||
task.state = TaskState::Failed(msg);
|
||||
queue.update_state(&task.id, task.state.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn emit_progress(app: &AppHandle, task: &crate::domain::transfer::TransferTask, error: Option<&str>) {
|
||||
let _ = app.emit("transfer-progress", TransferProgress {
|
||||
task_id: task.id.clone(),
|
||||
state: task.state.to_string(),
|
||||
transferred_bytes: task.transferred_bytes,
|
||||
total_bytes: task.total_bytes,
|
||||
speed: task.speed,
|
||||
eta_secs: task.eta_secs,
|
||||
error: error.map(|s| s.to_string()),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
245
src-tauri/src/ui/app.rs
Normal file
245
src-tauri/src/ui/app.rs
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use egui::{CentralPanel, TopBottomPanel};
|
||||
|
||||
use crate::domain::connection::ConnectionStatus;
|
||||
use crate::domain::file_entry::{EntryKind, FileEntry};
|
||||
use crate::domain::site::Site;
|
||||
use crate::fs::remote::RemoteRegistry;
|
||||
use crate::transfer::queue::TransferQueue;
|
||||
use crate::ui::dialogs::connection;
|
||||
use crate::ui::panels::local_pane;
|
||||
use crate::ui::panels::queue;
|
||||
use crate::ui::panels::remote_pane;
|
||||
use crate::ui::panels::status_bar;
|
||||
use crate::ui::panels::tabs;
|
||||
use crate::ui::panels::toolbar;
|
||||
|
||||
pub struct FileManagerApp {
|
||||
pub state: crate::ui::state::AppState,
|
||||
pub registry: Arc<RemoteRegistry>,
|
||||
pub queue: TransferQueue,
|
||||
pub rt: tokio::runtime::Runtime,
|
||||
pub db: Arc<std::sync::Mutex<rusqlite::Connection>>,
|
||||
pub sites: Vec<Site>,
|
||||
pub first_frame: bool,
|
||||
}
|
||||
|
||||
impl FileManagerApp {
|
||||
pub fn new(
|
||||
registry: Arc<RemoteRegistry>,
|
||||
queue: TransferQueue,
|
||||
rt: tokio::runtime::Runtime,
|
||||
db: Arc<std::sync::Mutex<rusqlite::Connection>>,
|
||||
sites: Vec<Site>,
|
||||
) -> Self {
|
||||
let mut state = crate::ui::state::AppState {
|
||||
local_path: crate::fs::local::home_dir(),
|
||||
..Default::default()
|
||||
};
|
||||
local_pane::refresh_local(&mut state);
|
||||
|
||||
Self {
|
||||
state,
|
||||
registry,
|
||||
queue,
|
||||
rt,
|
||||
db,
|
||||
sites,
|
||||
first_frame: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_pending(&mut self) {
|
||||
let opt = self.state.pending_connect.take();
|
||||
if let Some(pc) = opt.as_ref() {
|
||||
let mut guard = pc.result.lock().unwrap();
|
||||
if let Some(res) = guard.take() {
|
||||
drop(guard);
|
||||
match res {
|
||||
Ok((params, entries)) => {
|
||||
let path = "/".to_string();
|
||||
let mut list = vec![FileEntry {
|
||||
name: "..".into(),
|
||||
path: path.clone(),
|
||||
kind: EntryKind::Dir,
|
||||
size: None,
|
||||
modified: None,
|
||||
permissions: None,
|
||||
}];
|
||||
list.extend(entries);
|
||||
|
||||
let tab = crate::ui::state::ConnectionTab {
|
||||
id: params.id.clone(),
|
||||
label: params.label.clone(),
|
||||
params,
|
||||
status: ConnectionStatus::Connected,
|
||||
remote_path: path,
|
||||
remote_entries: list,
|
||||
remote_selected: None,
|
||||
loading: false,
|
||||
};
|
||||
self.state.tabs.push(tab);
|
||||
self.state.active_tab = self.state.tabs.len() - 1;
|
||||
self.state.status_message = "Connected".into();
|
||||
self.state.connect_loading = false;
|
||||
self.state.show_connect_dialog = false;
|
||||
}
|
||||
Err(e) => {
|
||||
self.state.status_message = format!("Connection failed: {}", e);
|
||||
self.state.connect_error = e;
|
||||
self.state.connect_loading = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
drop(guard);
|
||||
self.state.pending_connect = opt;
|
||||
}
|
||||
}
|
||||
|
||||
let mut done: Vec<usize> = Vec::new();
|
||||
for (i, pending) in self.state.pending_remote_list.iter().enumerate() {
|
||||
let mut guard = pending.result.lock().unwrap();
|
||||
if let Some(res) = guard.take() {
|
||||
let tab_idx = pending.tab_idx;
|
||||
match res {
|
||||
Ok(entries) => {
|
||||
let path = if tab_idx < self.state.tabs.len() {
|
||||
self.state.tabs[tab_idx].remote_path.clone()
|
||||
} else {
|
||||
"/".into()
|
||||
};
|
||||
let mut list = Vec::new();
|
||||
if path != "/" {
|
||||
let parent = remote_pane::remote_parent(&path);
|
||||
list.push(FileEntry {
|
||||
name: "..".into(),
|
||||
path: parent,
|
||||
kind: EntryKind::Dir,
|
||||
size: None,
|
||||
modified: None,
|
||||
permissions: None,
|
||||
});
|
||||
}
|
||||
list.extend(entries);
|
||||
if tab_idx < self.state.tabs.len() {
|
||||
self.state.tabs[tab_idx].remote_entries = list;
|
||||
self.state.tabs[tab_idx].loading = false;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if tab_idx < self.state.tabs.len() {
|
||||
self.state.tabs[tab_idx].loading = false;
|
||||
}
|
||||
self.state.status_message = format!("Remote list error: {}", e);
|
||||
}
|
||||
}
|
||||
done.push(i);
|
||||
}
|
||||
}
|
||||
for i in done.into_iter().rev() {
|
||||
self.state.pending_remote_list.remove(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for FileManagerApp {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
if self.first_frame {
|
||||
self.first_frame = false;
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
self.poll_pending();
|
||||
self.state.queue_tasks = self.queue.all();
|
||||
self.state.connected_count = self
|
||||
.state
|
||||
.tabs
|
||||
.iter()
|
||||
.filter(|t| t.status == ConnectionStatus::Connected)
|
||||
.count();
|
||||
|
||||
TopBottomPanel::top("tabs").show(ctx, |ui| {
|
||||
tabs::render(ui, &mut self.state);
|
||||
});
|
||||
|
||||
TopBottomPanel::top("toolbar").show(ctx, |ui| {
|
||||
toolbar::render(
|
||||
ui,
|
||||
&mut self.state,
|
||||
&self.queue,
|
||||
&self.registry,
|
||||
self.rt.handle(),
|
||||
);
|
||||
});
|
||||
|
||||
let has_connection = self.state.active_tab_ref().is_some()
|
||||
&& self.state.active_tab_ref().unwrap().status == ConnectionStatus::Connected;
|
||||
|
||||
if has_connection {
|
||||
CentralPanel::default().show(ctx, |ui| {
|
||||
ui.columns(2, |columns| {
|
||||
columns[0].vertical(|ui| {
|
||||
local_pane::render(
|
||||
ui,
|
||||
&mut self.state,
|
||||
&self.queue,
|
||||
&self.registry,
|
||||
self.rt.handle(),
|
||||
);
|
||||
});
|
||||
|
||||
columns[1].vertical(|ui| {
|
||||
let idx = self.state.active_tab.min(self.state.tabs.len() - 1);
|
||||
remote_pane::render(
|
||||
ui,
|
||||
&mut self.state,
|
||||
idx,
|
||||
&self.registry,
|
||||
self.rt.handle(),
|
||||
&self.queue,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
CentralPanel::default().show(ctx, |ui| {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.add_space(ui.available_height() * 0.3);
|
||||
ui.heading("LoFlum");
|
||||
ui.label("FTP/SFTP client");
|
||||
ui.add_space(20.0);
|
||||
if ui.button("New Connection").clicked() {
|
||||
self.state.show_connect_dialog = true;
|
||||
}
|
||||
if !self.state.tabs.is_empty() {
|
||||
ui.add_space(10.0);
|
||||
ui.label("Select a connection tab above or create a new one");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let has_tasks = !self.state.queue_tasks.is_empty();
|
||||
let tasks = self.state.queue_tasks.clone();
|
||||
if has_tasks || self.state.show_queue {
|
||||
TopBottomPanel::bottom("queue_panel")
|
||||
.resizable(true)
|
||||
.default_height(150.0)
|
||||
.min_height(80.0)
|
||||
.show(ctx, |ui| {
|
||||
queue::render(ui, &mut self.state, &tasks);
|
||||
});
|
||||
}
|
||||
|
||||
TopBottomPanel::bottom("status_bar").show(ctx, |ui| {
|
||||
status_bar::render(ui, &self.state);
|
||||
});
|
||||
|
||||
if self.state.show_connect_dialog {
|
||||
connection::render(ctx, &mut self.state, &self.registry, self.rt.handle());
|
||||
}
|
||||
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(200));
|
||||
}
|
||||
}
|
||||
198
src-tauri/src/ui/dialogs/connection.rs
Normal file
198
src-tauri/src/ui/dialogs/connection.rs
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
use crate::domain::connection::{ConnectionParams, Protocol};
|
||||
use crate::domain::file_entry::FileEntry;
|
||||
use crate::fs::remote::RemoteRegistry;
|
||||
use crate::protocols::{RemoteFs, ftp::FtpClient, sftp::SftpClient};
|
||||
use crate::storage::keychain;
|
||||
use crate::ui::state::{AppState, PendingConnect};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub fn render(
|
||||
ctx: &egui::Context,
|
||||
state: &mut AppState,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
rt_handle: &tokio::runtime::Handle,
|
||||
) {
|
||||
let mut open = true;
|
||||
egui::Window::new("New Connection")
|
||||
.open(&mut open)
|
||||
.resizable(false)
|
||||
.show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Protocol:");
|
||||
let protocols = ["SFTP", "FTP", "FTPS"];
|
||||
egui::ComboBox::from_id_source("protocol")
|
||||
.selected_text(protocols[state.connect_protocol])
|
||||
.show_ui(ui, |ui| {
|
||||
for (i, p) in protocols.iter().enumerate() {
|
||||
ui.selectable_value(&mut state.connect_protocol, i, *p);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Label:");
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut state.connect_label)
|
||||
.id("label".into())
|
||||
.desired_width(200.0),
|
||||
);
|
||||
});
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Host:");
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut state.connect_host)
|
||||
.id("host".into())
|
||||
.desired_width(200.0),
|
||||
);
|
||||
});
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Port:");
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut state.connect_port)
|
||||
.id("port".into())
|
||||
.desired_width(60.0),
|
||||
);
|
||||
});
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Username:");
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut state.connect_user)
|
||||
.id("user".into())
|
||||
.desired_width(200.0),
|
||||
);
|
||||
});
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Password:");
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut state.connect_pass)
|
||||
.password(true)
|
||||
.id("pass".into())
|
||||
.desired_width(200.0),
|
||||
);
|
||||
});
|
||||
|
||||
if state.connect_protocol == 0 {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Key path:");
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut state.connect_key_path)
|
||||
.id("key".into())
|
||||
.desired_width(200.0),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if !state.connect_error.is_empty() {
|
||||
ui.colored_label(egui::Color32::RED, &state.connect_error);
|
||||
}
|
||||
|
||||
ui.add_enabled_ui(!state.connect_loading, |ui| {
|
||||
if ui.button("Connect").clicked() {
|
||||
state.connect_error.clear();
|
||||
|
||||
let protocol = match state.connect_protocol {
|
||||
0 => Protocol::Sftp,
|
||||
1 => Protocol::Ftp,
|
||||
_ => Protocol::Ftps,
|
||||
};
|
||||
|
||||
let port: u16 = state.connect_port.parse().unwrap_or(match protocol {
|
||||
Protocol::Sftp => 22,
|
||||
Protocol::Ftp => 21,
|
||||
Protocol::Ftps => 990,
|
||||
});
|
||||
|
||||
let params = ConnectionParams {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
label: if state.connect_label.is_empty() {
|
||||
state.connect_host.clone()
|
||||
} else {
|
||||
state.connect_label.clone()
|
||||
},
|
||||
protocol,
|
||||
host: state.connect_host.clone(),
|
||||
port,
|
||||
username: state.connect_user.clone(),
|
||||
password: if state.connect_pass.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(state.connect_pass.clone())
|
||||
},
|
||||
key_path: if state.connect_key_path.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(state.connect_key_path.clone())
|
||||
},
|
||||
};
|
||||
|
||||
let registry = registry.clone();
|
||||
let params_clone = params.clone();
|
||||
let result = Arc::new(std::sync::Mutex::new(None));
|
||||
let result_clone = result.clone();
|
||||
|
||||
state.connect_loading = true;
|
||||
rt_handle.spawn(async move {
|
||||
let r = do_connect(®istry, ¶ms_clone).await;
|
||||
*result_clone.lock().unwrap() = Some(r);
|
||||
});
|
||||
|
||||
state.pending_connect = Some(PendingConnect { result });
|
||||
}
|
||||
});
|
||||
|
||||
if state.connect_loading {
|
||||
ui.label("Connecting...");
|
||||
}
|
||||
});
|
||||
|
||||
if !open {
|
||||
state.show_connect_dialog = false;
|
||||
}
|
||||
}
|
||||
|
||||
async fn do_connect(
|
||||
registry: &RemoteRegistry,
|
||||
params: &ConnectionParams,
|
||||
) -> Result<(ConnectionParams, Vec<FileEntry>), String> {
|
||||
let password = if let Some(p) = ¶ms.password {
|
||||
p.clone()
|
||||
} else {
|
||||
keychain::get_password(¶ms.id).map_err(|e| e.to_string())?
|
||||
};
|
||||
|
||||
let fs: Arc<dyn RemoteFs> = match params.protocol {
|
||||
Protocol::Sftp => {
|
||||
if let Some(key_path) = ¶ms.key_path {
|
||||
Arc::new(
|
||||
SftpClient::connect_key(¶ms.host, params.port, ¶ms.username, key_path)
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
} else {
|
||||
Arc::new(
|
||||
SftpClient::connect_password(
|
||||
¶ms.host,
|
||||
params.port,
|
||||
¶ms.username,
|
||||
&password,
|
||||
)
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
}
|
||||
}
|
||||
Protocol::Ftp => Arc::new(
|
||||
FtpClient::connect(¶ms.host, params.port, ¶ms.username, &password)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?,
|
||||
),
|
||||
Protocol::Ftps => return Err("FTPS not yet implemented".into()),
|
||||
};
|
||||
|
||||
registry.insert(params.id.clone(), fs.clone());
|
||||
|
||||
let entries = fs.list("/").await.map_err(|e| e.to_string())?;
|
||||
Ok((params.clone(), entries))
|
||||
}
|
||||
1
src-tauri/src/ui/dialogs/mod.rs
Normal file
1
src-tauri/src/ui/dialogs/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod connection;
|
||||
47
src-tauri/src/ui/drag.rs
Normal file
47
src-tauri/src/ui/drag.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
use crate::domain::file_entry::FileEntry;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum DragPayload {
|
||||
LocalFile(String, String),
|
||||
RemoteFile(String, String, String),
|
||||
}
|
||||
|
||||
impl DragPayload {
|
||||
pub fn file_name(&self) -> &str {
|
||||
match self {
|
||||
DragPayload::LocalFile(_, name) => name,
|
||||
DragPayload::RemoteFile(_, name, _) => name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn make_local_payload(entry: &FileEntry) -> Option<DragPayload> {
|
||||
if entry.name == ".." {
|
||||
return None;
|
||||
}
|
||||
Some(DragPayload::LocalFile(
|
||||
entry.path.clone(),
|
||||
entry.name.clone(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn make_remote_payload(entry: &FileEntry, connection_id: &str) -> Option<DragPayload> {
|
||||
if entry.name == ".." {
|
||||
return None;
|
||||
}
|
||||
Some(DragPayload::RemoteFile(
|
||||
entry.path.clone(),
|
||||
entry.name.clone(),
|
||||
connection_id.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
impl DragPayload {
|
||||
pub fn is_from_local(&self) -> bool {
|
||||
matches!(self, DragPayload::LocalFile(_, _))
|
||||
}
|
||||
|
||||
pub fn is_from_remote(&self) -> bool {
|
||||
matches!(self, DragPayload::RemoteFile(_, _, _))
|
||||
}
|
||||
}
|
||||
5
src-tauri/src/ui/mod.rs
Normal file
5
src-tauri/src/ui/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
pub mod app;
|
||||
pub mod dialogs;
|
||||
pub mod drag;
|
||||
pub mod panels;
|
||||
pub mod state;
|
||||
100
src-tauri/src/ui/panels/file_pane.rs
Normal file
100
src-tauri/src/ui/panels/file_pane.rs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
use crate::domain::file_entry::{EntryKind, FileEntry};
|
||||
use crate::ui::drag::DragPayload;
|
||||
use egui::{Grid, Id};
|
||||
|
||||
pub fn format_size(bytes: Option<u64>) -> String {
|
||||
match bytes {
|
||||
None => String::new(),
|
||||
Some(b) if b < 1024 => format!("{} B", b),
|
||||
Some(b) if b < 1024 * 1024 => format!("{:.1} KB", b as f64 / 1024.0),
|
||||
Some(b) if b < 1024 * 1024 * 1024 => {
|
||||
format!("{:.1} MB", b as f64 / (1024.0 * 1024.0))
|
||||
}
|
||||
Some(b) => format!("{:.2} GB", b as f64 / (1024.0 * 1024.0 * 1024.0)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_time(ts: Option<i64>) -> String {
|
||||
match ts.and_then(|t| chrono::DateTime::from_timestamp(t, 0)) {
|
||||
Some(dt) => dt.format("%Y-%m-%d %H:%M").to_string(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FileTableResponse {
|
||||
pub double_clicked: Option<String>,
|
||||
pub clicked: Option<String>,
|
||||
}
|
||||
|
||||
pub fn file_table<F>(
|
||||
ui: &mut egui::Ui,
|
||||
id: &str,
|
||||
entries: &[FileEntry],
|
||||
selected: &mut Option<String>,
|
||||
make_drag_payload: F,
|
||||
) -> FileTableResponse
|
||||
where
|
||||
F: Fn(&FileEntry) -> Option<DragPayload>,
|
||||
{
|
||||
let mut double_clicked: Option<String> = None;
|
||||
let mut clicked: Option<String> = None;
|
||||
|
||||
let mut sorted = entries.to_vec();
|
||||
sorted.sort_by(|a, b| match (&a.kind, &b.kind) {
|
||||
(EntryKind::Dir, EntryKind::Dir) => a.name.cmp(&b.name),
|
||||
(EntryKind::Dir, _) => std::cmp::Ordering::Less,
|
||||
(_, EntryKind::Dir) => std::cmp::Ordering::Greater,
|
||||
_ => a.name.cmp(&b.name),
|
||||
});
|
||||
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false; 2])
|
||||
.show(ui, |ui| {
|
||||
Grid::new(id)
|
||||
.striped(true)
|
||||
.min_col_width(60.0)
|
||||
.show(ui, |ui| {
|
||||
ui.strong("Name");
|
||||
ui.strong("Size");
|
||||
ui.strong("Type");
|
||||
ui.strong("Modified");
|
||||
ui.end_row();
|
||||
|
||||
for entry in &sorted {
|
||||
let is_selected = selected.as_ref() == Some(&entry.name);
|
||||
|
||||
let response = if let Some(payload) = make_drag_payload(entry) {
|
||||
let drag_id = Id::new(("drag", id, &entry.name));
|
||||
ui.dnd_drag_source(drag_id, payload, |ui| {
|
||||
ui.selectable_label(is_selected, &entry.name)
|
||||
})
|
||||
.response
|
||||
} else {
|
||||
ui.selectable_label(is_selected, &entry.name)
|
||||
};
|
||||
|
||||
if response.clicked() {
|
||||
*selected = Some(entry.name.clone());
|
||||
clicked = Some(entry.name.clone());
|
||||
}
|
||||
if response.double_clicked() {
|
||||
double_clicked = Some(entry.name.clone());
|
||||
}
|
||||
|
||||
ui.label(format_size(entry.size));
|
||||
ui.label(match entry.kind {
|
||||
EntryKind::Dir => "Dir",
|
||||
EntryKind::File => "File",
|
||||
EntryKind::Symlink => "Link",
|
||||
});
|
||||
ui.label(format_time(entry.modified));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
FileTableResponse {
|
||||
double_clicked,
|
||||
clicked,
|
||||
}
|
||||
}
|
||||
194
src-tauri/src/ui/panels/local_pane.rs
Normal file
194
src-tauri/src/ui/panels/local_pane.rs
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
use crate::domain::connection::ConnectionStatus;
|
||||
use crate::domain::file_entry::{EntryKind, FileEntry};
|
||||
use crate::domain::transfer::{TransferKind, TransferTask};
|
||||
use crate::fs::local;
|
||||
use crate::fs::remote::RemoteRegistry;
|
||||
use crate::transfer::queue::TransferQueue;
|
||||
use crate::ui::drag::{make_local_payload, DragPayload};
|
||||
use crate::ui::panels::file_pane::{file_table, FileTableResponse};
|
||||
use crate::ui::state::AppState;
|
||||
use egui::TextEdit;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn parent_path(path: &str) -> String {
|
||||
let p = std::path::Path::new(path);
|
||||
p.parent()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
|
||||
fn make_parent_entry(path: &str) -> FileEntry {
|
||||
FileEntry {
|
||||
name: "..".into(),
|
||||
path: parent_path(path),
|
||||
kind: EntryKind::Dir,
|
||||
size: None,
|
||||
modified: None,
|
||||
permissions: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(
|
||||
ui: &mut egui::Ui,
|
||||
state: &mut AppState,
|
||||
queue: &TransferQueue,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
_rt_handle: &tokio::runtime::Handle,
|
||||
) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading("\u{1F4C1} Local");
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.small_button("\u{1F5C4}").clicked() {
|
||||
state.local_tree_open = !state.local_tree_open;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let frame = egui::Frame::none().inner_margin(egui::Margin::symmetric(4.0, 2.0));
|
||||
let (_inner, dropped) = ui.dnd_drop_zone::<DragPayload, _>(frame, |ui| {
|
||||
if state.local_tree_open {
|
||||
ui.horizontal(|ui| {
|
||||
ui.separator();
|
||||
ui.vertical(|ui| {
|
||||
render_tree(ui, state);
|
||||
});
|
||||
ui.separator();
|
||||
ui.vertical(|ui| {
|
||||
render_content(ui, state);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
render_content(ui, state);
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(payload_arc) = dropped {
|
||||
let payload = &*payload_arc;
|
||||
match payload {
|
||||
DragPayload::RemoteFile(remote_path, file_name, conn_id) => {
|
||||
if registry.get(conn_id).is_none() {
|
||||
state.status_message = "Connection no longer active".into();
|
||||
return;
|
||||
}
|
||||
let local_path_str =
|
||||
format!("{}/{}", state.local_path.trim_end_matches('/'), file_name);
|
||||
let task = TransferTask::new(
|
||||
TransferKind::Download,
|
||||
conn_id.clone(),
|
||||
local_path_str,
|
||||
remote_path.clone(),
|
||||
file_name.clone(),
|
||||
0,
|
||||
);
|
||||
queue.push(task);
|
||||
state.status_message = "Download queued via drag & drop".into();
|
||||
}
|
||||
DragPayload::LocalFile(_, _) => {
|
||||
state.status_message = "Cannot drop local file onto local pane".into();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_tree(ui: &mut egui::Ui, state: &mut AppState) {
|
||||
let home = dirs::home_dir()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false; 2])
|
||||
.min_scrolled_width(120.0)
|
||||
.show(ui, |ui| {
|
||||
ui.strong("Folders");
|
||||
ui.separator();
|
||||
|
||||
let roots = [
|
||||
("/", "Root"),
|
||||
(&home, "Home"),
|
||||
("/tmp", "tmp"),
|
||||
("/var", "var"),
|
||||
("/etc", "etc"),
|
||||
];
|
||||
|
||||
for (root_path, label) in &roots {
|
||||
let is_current = state.local_path == *root_path
|
||||
|| state.local_path.starts_with(root_path);
|
||||
let mut btn = egui::Button::new(*label).min_size(egui::vec2(120.0, 20.0));
|
||||
if is_current {
|
||||
btn = btn.fill(egui::Color32::from_rgb(50, 60, 80));
|
||||
}
|
||||
if ui.add(btn).clicked() {
|
||||
state.local_path = root_path.to_string();
|
||||
refresh_local(state);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn render_content(ui: &mut egui::Ui, state: &mut AppState) {
|
||||
ui.horizontal(|ui| {
|
||||
let mut path = state.local_path.clone();
|
||||
ui.add(
|
||||
TextEdit::singleline(&mut path)
|
||||
.id("local_path".into())
|
||||
.desired_width(f32::INFINITY),
|
||||
);
|
||||
if path != state.local_path {
|
||||
state.local_path = path;
|
||||
refresh_local(state);
|
||||
}
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
let entries = state.local_entries.clone();
|
||||
let mut selected = state.local_selected.clone();
|
||||
|
||||
let FileTableResponse { double_clicked, .. } =
|
||||
file_table(ui, "local_table", &entries, &mut selected, |entry| {
|
||||
let has_connection = state
|
||||
.active_tab_ref()
|
||||
.map(|t| t.status == ConnectionStatus::Connected)
|
||||
.unwrap_or(false);
|
||||
if has_connection {
|
||||
make_local_payload(entry)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
state.local_selected = selected;
|
||||
|
||||
if let Some(name) = double_clicked {
|
||||
if name == ".." {
|
||||
state.local_path = parent_path(&state.local_path);
|
||||
} else if let Some(entry) = state.local_entries.iter().find(|e| e.name == name)
|
||||
&& entry.kind == EntryKind::Dir
|
||||
{
|
||||
state.local_path = entry.path.clone();
|
||||
}
|
||||
refresh_local(state);
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(format!("{} items", state.local_entries.len()));
|
||||
});
|
||||
}
|
||||
|
||||
pub fn refresh_local(state: &mut AppState) {
|
||||
let mut entries = Vec::new();
|
||||
if state.local_path != "/" {
|
||||
entries.push(make_parent_entry(&state.local_path));
|
||||
}
|
||||
match local::list(&state.local_path) {
|
||||
Ok(mut list) => {
|
||||
entries.append(&mut list);
|
||||
state.local_entries = entries;
|
||||
}
|
||||
Err(e) => {
|
||||
state.status_message = format!("Local list error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
7
src-tauri/src/ui/panels/mod.rs
Normal file
7
src-tauri/src/ui/panels/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
pub mod file_pane;
|
||||
pub mod local_pane;
|
||||
pub mod queue;
|
||||
pub mod remote_pane;
|
||||
pub mod status_bar;
|
||||
pub mod tabs;
|
||||
pub mod toolbar;
|
||||
83
src-tauri/src/ui/panels/queue.rs
Normal file
83
src-tauri/src/ui/panels/queue.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
use crate::domain::transfer::TransferTask;
|
||||
use crate::ui::panels::file_pane::format_size;
|
||||
use crate::ui::state::AppState;
|
||||
use egui::Color32;
|
||||
|
||||
pub fn render(ui: &mut egui::Ui, state: &mut AppState, tasks: &[TransferTask]) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading("\u{1F4E6} Transfer Queue");
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.small_button("\u{2715}").clicked() {
|
||||
state.show_queue = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
if tasks.is_empty() {
|
||||
ui.label("No pending transfers");
|
||||
return;
|
||||
}
|
||||
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false; 2])
|
||||
.max_height(200.0)
|
||||
.show(ui, |ui| {
|
||||
for task in tasks {
|
||||
let bg = match &task.state {
|
||||
crate::domain::transfer::TaskState::Completed => {
|
||||
Color32::from_rgb(220, 255, 220)
|
||||
}
|
||||
crate::domain::transfer::TaskState::Failed(_) => {
|
||||
Color32::from_rgb(255, 220, 220)
|
||||
}
|
||||
crate::domain::transfer::TaskState::Running => Color32::from_rgb(220, 220, 255),
|
||||
_ => Color32::TRANSPARENT,
|
||||
};
|
||||
|
||||
egui::Frame::none()
|
||||
.fill(bg)
|
||||
.inner_margin(egui::Margin::symmetric(4.0, 2.0))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let arrow = match &task.kind {
|
||||
crate::domain::transfer::TransferKind::Upload => "\u{2191}",
|
||||
crate::domain::transfer::TransferKind::Download => "\u{2193}",
|
||||
};
|
||||
ui.label(arrow);
|
||||
ui.label(&task.file_name);
|
||||
ui.with_layout(
|
||||
egui::Layout::right_to_left(egui::Align::Center),
|
||||
|ui| {
|
||||
ui.label(task.state.to_string());
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
let pct = task.progress_pct();
|
||||
let pb = egui::ProgressBar::new(pct as f32 / 100.0)
|
||||
.text(format!("{:.1}%", pct))
|
||||
.desired_width(ui.available_width() * 0.6);
|
||||
ui.add(pb);
|
||||
|
||||
ui.label(format!(
|
||||
"{}/{}",
|
||||
format_size(Some(task.transferred_bytes)),
|
||||
format_size(Some(task.total_bytes))
|
||||
));
|
||||
});
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(speed) = task.speed {
|
||||
ui.label(format!("{}/s", format_size(Some(speed))));
|
||||
}
|
||||
if let Some(eta) = task.eta_secs {
|
||||
ui.label(format!("ETA: {}s", eta));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
215
src-tauri/src/ui/panels/remote_pane.rs
Normal file
215
src-tauri/src/ui/panels/remote_pane.rs
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
use crate::domain::connection::ConnectionStatus;
|
||||
use crate::domain::file_entry::{EntryKind, FileEntry};
|
||||
use crate::domain::transfer::{TransferKind, TransferTask};
|
||||
use crate::fs::remote::RemoteRegistry;
|
||||
use crate::transfer::queue::TransferQueue;
|
||||
use crate::ui::drag::{make_remote_payload, DragPayload};
|
||||
use crate::ui::panels::file_pane::{file_table, FileTableResponse};
|
||||
use crate::ui::state::{AppState, PendingRemoteList};
|
||||
use egui::TextEdit;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub fn remote_parent(path: &str) -> String {
|
||||
let trimmed = path.trim_end_matches('/');
|
||||
if trimmed.is_empty() || trimmed == "/" {
|
||||
return "/".into();
|
||||
}
|
||||
if let Some(idx) = trimmed.rfind('/') {
|
||||
let parent = &trimmed[..idx];
|
||||
if parent.is_empty() {
|
||||
"/".into()
|
||||
} else {
|
||||
parent.to_string()
|
||||
}
|
||||
} else {
|
||||
"/".into()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(
|
||||
ui: &mut egui::Ui,
|
||||
state: &mut AppState,
|
||||
tab_idx: usize,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
rt_handle: &tokio::runtime::Handle,
|
||||
queue: &TransferQueue,
|
||||
) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading("\u{1F310} Remote");
|
||||
});
|
||||
|
||||
let frame = egui::Frame::none().inner_margin(egui::Margin::symmetric(4.0, 2.0));
|
||||
let (_inner, dropped) = ui.dnd_drop_zone::<DragPayload, _>(frame, |ui| {
|
||||
render_content(ui, state, tab_idx, registry, rt_handle);
|
||||
});
|
||||
|
||||
let is_connected = state.tabs[tab_idx].status == ConnectionStatus::Connected;
|
||||
let connection_id = state.tabs[tab_idx].id.clone();
|
||||
let remote_path = state.tabs[tab_idx].remote_path.clone();
|
||||
|
||||
if let Some(payload_arc) = dropped {
|
||||
let payload = &*payload_arc;
|
||||
match payload {
|
||||
DragPayload::LocalFile(local_path, file_name) => {
|
||||
if !is_connected {
|
||||
state.status_message = "No active connection".into();
|
||||
return;
|
||||
}
|
||||
let remote_path_str =
|
||||
format!("{}/{}", remote_path.trim_end_matches('/'), file_name);
|
||||
let task = TransferTask::new(
|
||||
TransferKind::Upload,
|
||||
connection_id.clone(),
|
||||
local_path.clone(),
|
||||
remote_path_str,
|
||||
file_name.clone(),
|
||||
0,
|
||||
);
|
||||
queue.push(task);
|
||||
state.status_message = "Upload queued via drag & drop".into();
|
||||
}
|
||||
DragPayload::RemoteFile(_, _, _) => {
|
||||
state.status_message = "Cannot drop remote file onto remote pane".into();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_content(
|
||||
ui: &mut egui::Ui,
|
||||
state: &mut AppState,
|
||||
tab_idx: usize,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
rt_handle: &tokio::runtime::Handle,
|
||||
) {
|
||||
let label = state.tabs[tab_idx].label.clone();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(format!("\u{25CF} {}", label));
|
||||
if state.tabs[tab_idx].loading {
|
||||
ui.label("loading...");
|
||||
}
|
||||
});
|
||||
|
||||
let path_changed;
|
||||
{
|
||||
let tab = &state.tabs[tab_idx];
|
||||
let mut path = tab.remote_path.clone();
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
TextEdit::singleline(&mut path)
|
||||
.id("remote_path".into())
|
||||
.desired_width(f32::INFINITY),
|
||||
);
|
||||
});
|
||||
path_changed = path != tab.remote_path;
|
||||
if path_changed {
|
||||
state.tabs[tab_idx].remote_path = path;
|
||||
}
|
||||
}
|
||||
|
||||
if path_changed {
|
||||
let loading = state.tabs[tab_idx].loading;
|
||||
if !loading {
|
||||
trigger_list(state, tab_idx, registry, rt_handle);
|
||||
}
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
let entries;
|
||||
let mut selected;
|
||||
{
|
||||
let tab = &state.tabs[tab_idx];
|
||||
entries = tab.remote_entries.clone();
|
||||
selected = tab.remote_selected.clone();
|
||||
}
|
||||
|
||||
let is_connected = state.tabs[tab_idx].status == ConnectionStatus::Connected;
|
||||
let conn_id = state.tabs[tab_idx].id.clone();
|
||||
|
||||
let table_id = format!("remote_table_{}", tab_idx);
|
||||
let FileTableResponse { double_clicked, .. } =
|
||||
file_table(ui, &table_id, &entries, &mut selected, move |entry| {
|
||||
if is_connected {
|
||||
make_remote_payload(entry, &conn_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
state.tabs[tab_idx].remote_selected = selected;
|
||||
|
||||
if let Some(name) = double_clicked {
|
||||
let (nav_path, is_dir) = {
|
||||
if name == ".." {
|
||||
let parent = remote_parent(state.tabs[tab_idx].remote_path.as_str());
|
||||
(parent, true)
|
||||
} else {
|
||||
let entry = state.tabs[tab_idx]
|
||||
.remote_entries
|
||||
.iter()
|
||||
.find(|e| e.name == name);
|
||||
match entry {
|
||||
Some(e) if e.kind == EntryKind::Dir => (e.path.clone(), true),
|
||||
_ => (state.tabs[tab_idx].remote_path.clone(), false),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if is_dir {
|
||||
state.tabs[tab_idx].remote_path = nav_path;
|
||||
let loading = state.tabs[tab_idx].loading;
|
||||
if !loading {
|
||||
trigger_list(state, tab_idx, registry, rt_handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
{
|
||||
let tab = &state.tabs[tab_idx];
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(format!("{} items", tab.remote_entries.len()));
|
||||
if tab.loading {
|
||||
ui.label(" loading...");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn trigger_list(
|
||||
state: &mut AppState,
|
||||
tab_idx: usize,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
rt_handle: &tokio::runtime::Handle,
|
||||
) {
|
||||
if state.tabs[tab_idx].status != ConnectionStatus::Connected {
|
||||
state.status_message = "Not connected".into();
|
||||
return;
|
||||
}
|
||||
state.tabs[tab_idx].loading = true;
|
||||
|
||||
let connection_id = state.tabs[tab_idx].id.clone();
|
||||
let path = state.tabs[tab_idx].remote_path.clone();
|
||||
let registry = registry.clone();
|
||||
|
||||
let result: Arc<std::sync::Mutex<Option<Result<Vec<FileEntry>, String>>>> =
|
||||
Arc::new(std::sync::Mutex::new(None));
|
||||
let result_clone = result.clone();
|
||||
|
||||
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.list(&path).await.map_err(|e| e.to_string());
|
||||
*result_clone.lock().unwrap() = Some(r);
|
||||
});
|
||||
|
||||
state
|
||||
.pending_remote_list
|
||||
.push(PendingRemoteList { tab_idx, result });
|
||||
}
|
||||
20
src-tauri/src/ui/panels/status_bar.rs
Normal file
20
src-tauri/src/ui/panels/status_bar.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
use crate::domain::connection::ConnectionStatus;
|
||||
use crate::ui::state::AppState;
|
||||
|
||||
pub fn render(ui: &mut egui::Ui, state: &AppState) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(&state.status_message);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let queued = state.queue_tasks.len();
|
||||
ui.label(format!("Queue: {}", queued));
|
||||
ui.separator();
|
||||
let connected = state
|
||||
.tabs
|
||||
.iter()
|
||||
.filter(|t| t.status == ConnectionStatus::Connected)
|
||||
.count();
|
||||
let total = state.tabs.len();
|
||||
ui.label(format!("Connections: {}/{}", connected, total));
|
||||
});
|
||||
});
|
||||
}
|
||||
55
src-tauri/src/ui/panels/tabs.rs
Normal file
55
src-tauri/src/ui/panels/tabs.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
use crate::domain::connection::ConnectionStatus;
|
||||
use crate::ui::state::AppState;
|
||||
|
||||
pub fn render(ui: &mut egui::Ui, state: &mut AppState) {
|
||||
ui.horizontal(|ui| {
|
||||
if state.tabs.is_empty() {
|
||||
ui.label("No connections");
|
||||
if ui.button("+ New Connection").clicked() {
|
||||
state.show_connect_dialog = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let mut to_close: Option<usize> = None;
|
||||
|
||||
for (i, tab) in state.tabs.iter().enumerate() {
|
||||
let is_active = i == state.active_tab;
|
||||
|
||||
let dot = match &tab.status {
|
||||
ConnectionStatus::Connected => "\u{25CF}",
|
||||
ConnectionStatus::Disconnected => "\u{25CB}",
|
||||
ConnectionStatus::Connecting => "\u{25D0}",
|
||||
ConnectionStatus::Error(_) => "\u{2716}",
|
||||
};
|
||||
|
||||
let label = format!("{} {}", dot, tab.label);
|
||||
|
||||
let mut tab_button = egui::Button::new(&label);
|
||||
if is_active {
|
||||
tab_button = tab_button.fill(egui::Color32::from_rgb(60, 60, 80));
|
||||
}
|
||||
let response = ui.add(tab_button);
|
||||
if response.clicked() {
|
||||
state.active_tab = i;
|
||||
}
|
||||
|
||||
if ui.small_button("\u{2715}").clicked() {
|
||||
to_close = Some(i);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(idx) = to_close {
|
||||
state.tabs.remove(idx);
|
||||
if state.active_tab >= state.tabs.len() && !state.tabs.is_empty() {
|
||||
state.active_tab = state.tabs.len() - 1;
|
||||
}
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
if ui.button("+").clicked() {
|
||||
state.show_connect_dialog = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
139
src-tauri/src/ui/panels/toolbar.rs
Normal file
139
src-tauri/src/ui/panels/toolbar.rs
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
use crate::domain::connection::ConnectionStatus;
|
||||
use crate::domain::file_entry::EntryKind;
|
||||
use crate::domain::transfer::{TransferKind, TransferTask};
|
||||
use crate::fs::remote::RemoteRegistry;
|
||||
use crate::transfer::queue::TransferQueue;
|
||||
use crate::ui::state::AppState;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub fn render(
|
||||
ui: &mut egui::Ui,
|
||||
state: &mut AppState,
|
||||
queue: &TransferQueue,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
_rt_handle: &tokio::runtime::Handle,
|
||||
) {
|
||||
ui.horizontal(|ui| {
|
||||
let add_btn = egui::Button::new("+ New Connection");
|
||||
if ui.add(add_btn).clicked() {
|
||||
state.show_connect_dialog = true;
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
let has_local_selection = state.local_selected.is_some();
|
||||
let has_remote_selection = state
|
||||
.active_tab_ref()
|
||||
.and_then(|t| t.remote_selected.as_ref())
|
||||
.is_some();
|
||||
let (is_connected, tab_exists) = {
|
||||
let active = state.active_tab_ref();
|
||||
(
|
||||
active
|
||||
.map(|t| t.status == ConnectionStatus::Connected)
|
||||
.unwrap_or(false),
|
||||
active.is_some(),
|
||||
)
|
||||
};
|
||||
|
||||
let tab_id = state.active_tab_ref().map(|t| t.id.clone());
|
||||
let remote_path = state.active_tab_ref().map(|t| t.remote_path.clone());
|
||||
|
||||
let upload_btn = egui::Button::new("\u{2191} Upload")
|
||||
.min_size(egui::vec2(100.0, 24.0));
|
||||
ui.add_enabled_ui(is_connected && has_local_selection && tab_exists, |ui| {
|
||||
if ui.add(upload_btn).clicked()
|
||||
&& let Some(sel) = state.local_selected.clone()
|
||||
&& let Some(entry) = state.local_entries.iter().find(|e| e.name == sel)
|
||||
{
|
||||
if entry.kind == EntryKind::File {
|
||||
let file_name = entry.name.clone();
|
||||
let remote_base = remote_path.clone().unwrap_or_else(|| "/".into());
|
||||
let remote_path_str =
|
||||
format!("{}/{}", remote_base.trim_end_matches('/'), file_name);
|
||||
let cid = tab_id.clone().unwrap_or_default();
|
||||
let task = TransferTask::new(
|
||||
TransferKind::Upload,
|
||||
cid,
|
||||
entry.path.clone(),
|
||||
remote_path_str,
|
||||
file_name,
|
||||
entry.size.unwrap_or(0),
|
||||
);
|
||||
if registry.get(&task.connection_id).is_some() {
|
||||
queue.push(task);
|
||||
state.status_message = "Upload queued".into();
|
||||
} else {
|
||||
state.status_message = "No connection".into();
|
||||
}
|
||||
} else {
|
||||
state.status_message = "Cannot upload directory".into();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let download_btn = egui::Button::new("\u{2193} Download")
|
||||
.min_size(egui::vec2(100.0, 24.0));
|
||||
ui.add_enabled_ui(is_connected && has_remote_selection && tab_exists, |ui| {
|
||||
if ui.add(download_btn).clicked()
|
||||
&& let Some(sel) = tab_remote_selected(state)
|
||||
{
|
||||
let file_name = sel.clone();
|
||||
let remote_base = remote_path.clone().unwrap_or_else(|| "/".into());
|
||||
let remote_path_str =
|
||||
format!("{}/{}", remote_base.trim_end_matches('/'), file_name);
|
||||
let local_path_str =
|
||||
format!("{}/{}", state.local_path.trim_end_matches('/'), file_name);
|
||||
let size = state
|
||||
.active_tab_ref()
|
||||
.map(|t| {
|
||||
t.remote_entries
|
||||
.iter()
|
||||
.find(|e| e.name == sel)
|
||||
.and_then(|e| e.size)
|
||||
.unwrap_or(0)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let cid = tab_id.clone().unwrap_or_default();
|
||||
let task = TransferTask::new(
|
||||
TransferKind::Download,
|
||||
cid,
|
||||
local_path_str,
|
||||
remote_path_str,
|
||||
file_name,
|
||||
size,
|
||||
);
|
||||
if registry.get(&task.connection_id).is_some() {
|
||||
queue.push(task);
|
||||
state.status_message = "Download queued".into();
|
||||
} else {
|
||||
state.status_message = "No connection".into();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
let queue_label = if state.show_queue {
|
||||
"Queue \u{25BC}"
|
||||
} else {
|
||||
"Queue \u{25B2}"
|
||||
};
|
||||
if ui.button(queue_label).clicked() {
|
||||
state.show_queue = !state.show_queue;
|
||||
}
|
||||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.separator();
|
||||
let task_count = state.queue_tasks.len();
|
||||
if task_count > 0 {
|
||||
ui.label(format!("{} tasks", task_count));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn tab_remote_selected(state: &AppState) -> Option<String> {
|
||||
let idx = state.active_tab.min(state.tabs.len().saturating_sub(1));
|
||||
state.tabs.get(idx).and_then(|t| t.remote_selected.clone())
|
||||
}
|
||||
107
src-tauri/src/ui/state.rs
Normal file
107
src-tauri/src/ui/state.rs
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use crate::domain::connection::{ConnectionParams, ConnectionStatus};
|
||||
use crate::domain::file_entry::FileEntry;
|
||||
use crate::domain::transfer::TransferTask;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ConnectionTab {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub params: ConnectionParams,
|
||||
pub status: ConnectionStatus,
|
||||
pub remote_path: String,
|
||||
pub remote_entries: Vec<FileEntry>,
|
||||
pub remote_selected: Option<String>,
|
||||
pub loading: bool,
|
||||
}
|
||||
|
||||
type PendingResult<T> = Arc<std::sync::Mutex<Option<Result<T, String>>>>;
|
||||
|
||||
pub struct PendingConnect {
|
||||
pub result: PendingResult<(ConnectionParams, Vec<FileEntry>)>,
|
||||
}
|
||||
|
||||
pub struct PendingRemoteList {
|
||||
pub tab_idx: usize,
|
||||
pub result: PendingResult<Vec<FileEntry>>,
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
pub tabs: Vec<ConnectionTab>,
|
||||
pub active_tab: usize,
|
||||
pub local_path: String,
|
||||
pub local_entries: Vec<FileEntry>,
|
||||
pub local_selected: Option<String>,
|
||||
pub local_tree_open: bool,
|
||||
pub show_connect_dialog: bool,
|
||||
pub connect_label: String,
|
||||
pub connect_host: String,
|
||||
pub connect_port: String,
|
||||
pub connect_user: String,
|
||||
pub connect_pass: String,
|
||||
pub connect_key_path: String,
|
||||
pub connect_protocol: usize,
|
||||
pub connect_error: String,
|
||||
pub connect_loading: bool,
|
||||
pub show_queue: bool,
|
||||
pub queue_tasks: Vec<TransferTask>,
|
||||
pub status_message: String,
|
||||
pub agg_speed: u64,
|
||||
pub connected_count: usize,
|
||||
pub pending_connect: Option<PendingConnect>,
|
||||
pub pending_remote_list: Vec<PendingRemoteList>,
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tabs: Vec::new(),
|
||||
active_tab: 0,
|
||||
local_path: dirs::home_dir()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
local_entries: Vec::new(),
|
||||
local_selected: None,
|
||||
local_tree_open: false,
|
||||
show_connect_dialog: false,
|
||||
connect_label: String::new(),
|
||||
connect_host: String::new(),
|
||||
connect_port: "22".into(),
|
||||
connect_user: String::new(),
|
||||
connect_pass: String::new(),
|
||||
connect_key_path: String::new(),
|
||||
connect_protocol: 0,
|
||||
connect_error: String::new(),
|
||||
connect_loading: false,
|
||||
show_queue: false,
|
||||
queue_tasks: Vec::new(),
|
||||
status_message: "Ready".into(),
|
||||
agg_speed: 0,
|
||||
connected_count: 0,
|
||||
pending_connect: None,
|
||||
pending_remote_list: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn active_tab_mut(&mut self) -> Option<&mut ConnectionTab> {
|
||||
if self.tabs.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let idx = self.active_tab.min(self.tabs.len() - 1);
|
||||
Some(&mut self.tabs[idx])
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_tab_ref(&self) -> Option<&ConnectionTab> {
|
||||
if self.tabs.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let idx = self.active_tab.min(self.tabs.len() - 1);
|
||||
Some(&self.tabs[idx])
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue