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
4555
Cargo.lock
generated
4555
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
61
README.md
61
README.md
|
|
@ -1,60 +1,43 @@
|
|||
# LoFlum
|
||||
|
||||
Современный FTP/SFTP клиент на Tauri 2 + Rust + Svelte 5.
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
FTP/SFTP клиент написанный на Rust с UI на egui/eframe.
|
||||
|
||||
## Стек
|
||||
|
||||
- **Tauri 2** — шелл
|
||||
- **egui/eframe** — нативный GUI (чистый Rust, без веба)
|
||||
- **Rust** — протоколы, файловая система, очередь передач
|
||||
- **Svelte 5** — UI
|
||||
- **tokio** — async runtime
|
||||
|
||||
## Протоколы
|
||||
|
||||
| Версия | Протоколы |
|
||||
|--------|-----------|
|
||||
| v1 | SFTP, FTP, FTPS |
|
||||
| v2 | S3, WebDAV |
|
||||
| v3 | Google Drive, Dropbox |
|
||||
| Версия | Протоколы |
|
||||
|--------|-------------------|
|
||||
| v1 | SFTP, FTP, FTPS |
|
||||
| v2 | S3, WebDAV |
|
||||
| v3 | Google Drive, Dropbox |
|
||||
|
||||
## Разработка
|
||||
|
||||
```bash
|
||||
# Зависимости
|
||||
bun install
|
||||
|
||||
# Dev режим
|
||||
bun run tauri dev
|
||||
|
||||
# Сборка
|
||||
bun run tauri build
|
||||
cargo run
|
||||
```
|
||||
|
||||
## Зависимости системы
|
||||
|
||||
- Rust + cargo
|
||||
- Bun (или npm/pnpm)
|
||||
- libssh2 (`pacman -S libssh2` на Arch)
|
||||
- webkit2gtk (`pacman -S webkit2gtk-4.1` на Arch)
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
src-tauri/src/
|
||||
├── domain/ — модели данных (Connection, FileEntry, TransferTask, Site)
|
||||
├── protocols/ — RemoteFs trait + sftp.rs, ftp.rs
|
||||
├── transfer/ — очередь, воркеры, прогресс
|
||||
├── storage/ — SQLite (сайты), keychain (пароли)
|
||||
├── fs/ — локальная ФС + реестр удалённых соединений
|
||||
└── commands/ — tauri commands (вызовы из фронта)
|
||||
src/
|
||||
├── domain/ -- модели данных (Connection, FileEntry, TransferTask, Site)
|
||||
├── protocols/ -- RemoteFs trait + sftp.rs, ftp.rs
|
||||
├── transfer/ -- очередь, воркеры, прогресс
|
||||
├── storage/ -- SQLite (сайты), keychain (пароли)
|
||||
├── fs/ -- локальная ФС + реестр удалённых соединений
|
||||
├── ui/ -- egui панели (в разработке)
|
||||
└── main.rs -- точка входа eframe
|
||||
```
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] Chunked upload/download с прогресс-событиями
|
||||
- [x] FTP/FTPS — FTP реализован (FTPS — stub)
|
||||
- [ ] Site Manager UI
|
||||
- [x] Диалог нового соединения
|
||||
- [x] Transfer Queue с воркером и прогресс-событиями
|
||||
- [ ] Drag & drop
|
||||
- [ ] Горячие клавиши
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
10
todo.md
Normal file
10
todo.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# TODO
|
||||
|
||||
- [ ] Chunked upload/download с прогресс-событиями
|
||||
- [x] FTP/FTPS — FTP реализован (FTPS — stub)
|
||||
- [ ] Site Manager UI
|
||||
- [x] Диалог нового соединения
|
||||
- [x] Transfer Queue с воркером и прогресс-событиями
|
||||
- [ ] Drag & drop
|
||||
- [ ] Горячие клавиши
|
||||
- [ ] egui UI (переезд с Svelte)
|
||||
Loading…
Add table
Add a link
Reference in a new issue