egui/eframe UI, clean up deps, remove Svelte/Tauri frontend

This commit is contained in:
loki5512344 2026-06-15 21:11:08 +02:00
parent 77c84ba3bd
commit ff9a1b9b4a
37 changed files with 3982 additions and 2704 deletions

View file

@ -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"

View file

@ -1,3 +1,3 @@
fn main() {
tauri_build::build()
println!("cargo:rerun-if-changed=build.rs");
}

View file

@ -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) = &params.key_path {
Arc::new(
SftpClient::connect_key(&params.host, params.port, &params.username, key_path)
.map_err(|e| e.to_string())?
.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())?
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::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());
}

View file

@ -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())
}

View file

@ -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> {

View file

@ -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()

View file

@ -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")]

View file

@ -1,5 +1,5 @@
use std::fmt;
use serde::{Deserialize, Serialize};
use std::fmt;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]

View file

@ -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)

View file

@ -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 {

View file

@ -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");
}

View file

@ -1,4 +1,3 @@
// Prevents additional console window on Windows in release
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {

View file

@ -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();

View file

@ -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.

View file

@ -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,

View file

@ -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(())

View file

@ -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
}
}

View file

@ -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);
}
}

View file

@ -1,4 +1,4 @@
use crate::domain::transfer::{TransferTask, TransferKind};
use crate::domain::transfer::{TransferKind, TransferTask};
pub fn new_task(
kind: TransferKind,

View file

@ -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
View 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));
}
}

View 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(&registry, &params_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) = &params.password {
p.clone()
} else {
keychain::get_password(&params.id).map_err(|e| e.to_string())?
};
let fs: Arc<dyn 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".into()),
};
registry.insert(params.id.clone(), fs.clone());
let entries = fs.list("/").await.map_err(|e| e.to_string())?;
Ok((params.clone(), entries))
}

View file

@ -0,0 +1 @@
pub mod connection;

47
src-tauri/src/ui/drag.rs Normal file
View 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
View file

@ -0,0 +1,5 @@
pub mod app;
pub mod dialogs;
pub mod drag;
pub mod panels;
pub mod state;

View 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,
}
}

View 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);
}
}
}

View 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;

View 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));
}
});
});
}
});
}

View 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 });
}

View 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));
});
});
}

View 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;
}
});
}

View 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
View 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])
}
}
}