feat: FTPS, AppError, Site Manager, tests, multi-window, auto-clear, CI/CD, virtual scroll

- FTPS: реализован FtpsClient через suppaftp + async-native-tls
- AppError: кастомный enum с Serialize вместо Result<T, String>
- Site Manager UI: создание/редактирование/удаление сайтов
- Тесты: 53 тестов (domain, storage, transfer, fs, remote)
- Multi-window: новое окно + сохранение геометрии
- Auto-clear: воркер чистит завершённые задачи по таймеру
- CI/CD: 3-ступенчатый пайплайн (lint→build→test) с кэшированием
- Virtual scroll: fileTable с рендером только видимых строк
- UI: исправлены close-кнопки, color-scheme для select, скриншоты в README
This commit is contained in:
loki5512344 2026-07-11 00:04:13 +02:00
parent 545dd4ed0b
commit e188fbef36
Signed by: boba
GPG key ID: 253067914055423B
52 changed files with 5188 additions and 523 deletions

View file

@ -4,13 +4,20 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use tauri::State;
use tauri::Manager;
use crate::domain::connection::{ConnectionParams, Protocol};
use crate::domain::error::AppError;
use crate::domain::file_entry::FileEntry;
use crate::domain::site::Site;
use crate::domain::transfer::{TaskState, TransferKind, TransferTask};
use crate::domain::window_state::WindowState;
use crate::fs::remote::RemoteRegistry;
use crate::protocols::{RemoteFs, ftp::FtpClient, sftp::SftpClient};
use crate::protocols::{
RemoteFs,
ftp::{FtpClient, FtpsClient},
sftp::SftpClient,
};
use crate::storage::db::{self, HistoryRow};
use crate::transfer::queue::TransferQueue;
@ -19,12 +26,16 @@ pub struct AppState {
pub registry: Arc<RemoteRegistry>,
pub queue: TransferQueue,
pub max_concurrent: Arc<AtomicU32>,
pub auto_clear_secs: Arc<AtomicU32>,
}
// --- Connections ---------------------------------------------------------
#[tauri::command]
pub async fn connect(state: State<'_, AppState>, params: ConnectionParams) -> Result<String, String> {
pub async fn connect(
state: State<'_, AppState>,
params: ConnectionParams,
) -> Result<String, AppError> {
let registry = state.registry.clone();
let db = state.db.clone();
@ -44,40 +55,44 @@ pub async fn connect(state: State<'_, AppState>, params: ConnectionParams) -> Re
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,
params.password.as_deref(),
)
.map_err(|e| e.to_string())?,
)
Arc::new(SftpClient::connect_key(
&params.host,
params.port,
&params.username,
key_path,
params.password.as_deref(),
)?)
} else if let Some(password) = &params.password {
Arc::new(
SftpClient::connect_password(&params.host, params.port, &params.username, password)
.map_err(|e| e.to_string())?,
)
Arc::new(SftpClient::connect_password(
&params.host,
params.port,
&params.username,
password,
)?)
} else {
Arc::new(
SftpClient::connect_auto(&params.host, params.port, &params.username)
.map_err(|e| e.to_string())?,
)
Arc::new(SftpClient::connect_auto(
&params.host,
params.port,
&params.username,
)?)
}
}
Protocol::Ftp => {
let password = params
.password
.clone()
.ok_or("Password is required for FTP connections.")?;
let password = params.password.clone().ok_or_else(|| {
AppError::InvalidInput("Password is required for FTP connections.".into())
})?;
Arc::new(
FtpClient::connect(&params.host, params.port, &params.username, &password)
.await
.map_err(|e| e.to_string())?,
FtpClient::connect(&params.host, params.port, &params.username, &password).await?,
)
}
Protocol::Ftps => {
let password = params.password.clone().ok_or_else(|| {
AppError::InvalidInput("Password is required for FTPS connections.".into())
})?;
Arc::new(
FtpsClient::connect(&params.host, params.port, &params.username, &password).await?,
)
}
Protocol::Ftps => return Err("FTPS not yet implemented".into()),
};
registry.insert(params.id.clone(), fs);
@ -104,11 +119,11 @@ pub fn disconnect(state: State<'_, AppState>, connection_id: String) {
// --- Remote file ops -------------------------------------------------------
fn get_remote(state: &AppState, connection_id: &str) -> Result<Arc<dyn RemoteFs>, String> {
fn get_remote(state: &AppState, connection_id: &str) -> Result<Arc<dyn RemoteFs>, AppError> {
state
.registry
.get(connection_id)
.ok_or_else(|| "connection not found".to_string())
.ok_or_else(|| AppError::NotFound("connection not found".into()))
}
#[tauri::command]
@ -116,11 +131,8 @@ pub async fn remote_list(
state: State<'_, AppState>,
connection_id: String,
path: String,
) -> Result<Vec<FileEntry>, String> {
get_remote(&state, &connection_id)?
.list(&path)
.await
.map_err(|e| e.to_string())
) -> Result<Vec<FileEntry>, AppError> {
Ok(get_remote(&state, &connection_id)?.list(&path).await?)
}
#[tauri::command]
@ -128,11 +140,8 @@ pub async fn remote_stat(
state: State<'_, AppState>,
connection_id: String,
path: String,
) -> Result<FileEntry, String> {
get_remote(&state, &connection_id)?
.stat(&path)
.await
.map_err(|e| e.to_string())
) -> Result<FileEntry, AppError> {
Ok(get_remote(&state, &connection_id)?.stat(&path).await?)
}
#[tauri::command]
@ -140,11 +149,8 @@ pub async fn remote_mkdir(
state: State<'_, AppState>,
connection_id: String,
path: String,
) -> Result<(), String> {
get_remote(&state, &connection_id)?
.mkdir(&path)
.await
.map_err(|e| e.to_string())
) -> Result<(), AppError> {
Ok(get_remote(&state, &connection_id)?.mkdir(&path).await?)
}
#[tauri::command]
@ -153,11 +159,10 @@ pub async fn remote_rename(
connection_id: String,
from: String,
to: String,
) -> Result<(), String> {
get_remote(&state, &connection_id)?
) -> Result<(), AppError> {
Ok(get_remote(&state, &connection_id)?
.rename(&from, &to)
.await
.map_err(|e| e.to_string())
.await?)
}
#[tauri::command]
@ -165,18 +170,15 @@ pub async fn remote_delete(
state: State<'_, AppState>,
connection_id: String,
path: String,
) -> Result<(), String> {
get_remote(&state, &connection_id)?
.delete(&path)
.await
.map_err(|e| e.to_string())
) -> Result<(), AppError> {
Ok(get_remote(&state, &connection_id)?.delete(&path).await?)
}
// --- Local file ops --------------------------------------------------------
#[tauri::command]
pub fn local_list(path: String) -> Result<Vec<FileEntry>, String> {
crate::fs::local::list(&path).map_err(|e| e.to_string())
pub fn local_list(path: String) -> Result<Vec<FileEntry>, AppError> {
Ok(crate::fs::local::list(&path)?)
}
#[tauri::command]
@ -185,28 +187,28 @@ pub fn local_home_dir() -> String {
}
#[tauri::command]
pub fn local_mkdir(path: String) -> Result<(), String> {
crate::fs::local::mkdir(&path).map_err(|e| e.to_string())
pub fn local_mkdir(path: String) -> Result<(), AppError> {
Ok(crate::fs::local::mkdir(&path)?)
}
#[tauri::command]
pub fn local_rename(from: String, to: String) -> Result<(), String> {
crate::fs::local::rename(&from, &to).map_err(|e| e.to_string())
pub fn local_rename(from: String, to: String) -> Result<(), AppError> {
Ok(crate::fs::local::rename(&from, &to)?)
}
#[tauri::command]
pub fn local_delete(path: String) -> Result<(), String> {
crate::fs::local::delete(&path).map_err(|e| e.to_string())
pub fn local_delete(path: String) -> Result<(), AppError> {
Ok(crate::fs::local::delete(&path)?)
}
#[tauri::command]
pub fn local_open(path: String) -> Result<(), String> {
crate::fs::local::open(&path).map_err(|e| e.to_string())
pub fn local_open(path: String) -> Result<(), AppError> {
Ok(crate::fs::local::open(&path)?)
}
#[tauri::command]
pub fn local_move_into(src_path: String, dest_dir: String) -> Result<(), String> {
crate::fs::local::move_into(&src_path, &dest_dir).map_err(|e| e.to_string())
pub fn local_move_into(src_path: String, dest_dir: String) -> Result<(), AppError> {
Ok(crate::fs::local::move_into(&src_path, &dest_dir)?)
}
// --- Transfers --------------------------------------------------------------
@ -222,7 +224,14 @@ pub fn enqueue_transfer(
file_name: String,
total_bytes: u64,
) -> String {
let task = TransferTask::new(kind, connection_id, local_path, remote_path, file_name, total_bytes);
let task = TransferTask::new(
kind,
connection_id,
local_path,
remote_path,
file_name,
total_bytes,
);
let id = task.id.clone();
state.queue.push(task);
id
@ -264,51 +273,79 @@ pub fn set_max_concurrent(state: State<'_, AppState>, n: u32) {
// --- Sites / bookmarks / history / settings ---------------------------------
#[tauri::command]
pub fn list_sites(state: State<'_, AppState>) -> Result<Vec<Site>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
db::get_sites(&conn).map_err(|e| e.to_string())
pub fn list_sites(state: State<'_, AppState>) -> Result<Vec<Site>, AppError> {
let conn = state
.db
.lock()
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(db::get_sites(&conn)?)
}
#[tauri::command]
pub fn save_site(state: State<'_, AppState>, site: Site) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
db::save_site(&conn, &site).map_err(|e| e.to_string())
pub fn save_site(state: State<'_, AppState>, site: Site) -> Result<(), AppError> {
let conn = state
.db
.lock()
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(db::save_site(&conn, &site)?)
}
#[tauri::command]
pub fn delete_site(state: State<'_, AppState>, id: String) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
db::delete_site(&conn, &id).map_err(|e| e.to_string())
pub fn delete_site(state: State<'_, AppState>, id: String) -> Result<(), AppError> {
let conn = state
.db
.lock()
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(db::delete_site(&conn, &id)?)
}
#[tauri::command]
pub fn list_bookmarks(state: State<'_, AppState>) -> Result<Vec<(i64, String, String)>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
db::get_bookmarks(&conn).map_err(|e| e.to_string())
pub fn list_bookmarks(state: State<'_, AppState>) -> Result<Vec<(i64, String, String)>, AppError> {
let conn = state
.db
.lock()
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(db::get_bookmarks(&conn)?)
}
#[tauri::command]
pub fn add_bookmark(state: State<'_, AppState>, name: String, path: String) -> Result<i64, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
db::add_bookmark(&conn, &name, &path).map_err(|e| e.to_string())
pub fn add_bookmark(
state: State<'_, AppState>,
name: String,
path: String,
) -> Result<i64, AppError> {
let conn = state
.db
.lock()
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(db::add_bookmark(&conn, &name, &path)?)
}
#[tauri::command]
pub fn remove_bookmark(state: State<'_, AppState>, id: i64) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
db::remove_bookmark(&conn, id).map_err(|e| e.to_string())
pub fn remove_bookmark(state: State<'_, AppState>, id: i64) -> Result<(), AppError> {
let conn = state
.db
.lock()
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(db::remove_bookmark(&conn, id)?)
}
#[tauri::command]
pub fn list_history(state: State<'_, AppState>) -> Result<Vec<HistoryRow>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
db::get_history(&conn).map_err(|e| e.to_string())
pub fn list_history(state: State<'_, AppState>) -> Result<Vec<HistoryRow>, AppError> {
let conn = state
.db
.lock()
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(db::get_history(&conn)?)
}
#[tauri::command]
pub fn clear_history(state: State<'_, AppState>) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
db::clear_history(&conn).map_err(|e| e.to_string())
pub fn clear_history(state: State<'_, AppState>) -> Result<(), AppError> {
let conn = state
.db
.lock()
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(db::clear_history(&conn)?)
}
#[tauri::command]
@ -317,32 +354,47 @@ pub fn find_history_conn_id(
host: String,
port: u16,
username: String,
) -> Result<Option<String>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
db::find_history_conn_id(&conn, &host, port, &username).map_err(|e| e.to_string())
) -> Result<Option<String>, AppError> {
let conn = state
.db
.lock()
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(db::find_history_conn_id(&conn, &host, port, &username)?)
}
#[tauri::command]
pub fn get_pref(state: State<'_, AppState>, key: String) -> Result<Option<String>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
pub fn get_pref(state: State<'_, AppState>, key: String) -> Result<Option<String>, AppError> {
let conn = state
.db
.lock()
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(db::get_setting(&conn, &key))
}
#[tauri::command]
pub fn set_pref(state: State<'_, AppState>, key: String, value: String) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
db::set_setting(&conn, &key, &value).map_err(|e| e.to_string())
pub fn set_pref(state: State<'_, AppState>, key: String, value: String) -> Result<(), AppError> {
if key == "auto_clear_completed_secs" && let Ok(secs) = value.parse::<u32>() {
state.auto_clear_secs.store(secs, Ordering::Relaxed);
}
let conn = state
.db
.lock()
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(db::set_setting(&conn, &key, &value)?)
}
/// Удалить пароль из сохранённого сайта (очистить поле password в БД).
#[tauri::command]
pub fn delete_password(state: State<'_, AppState>, site_id: String) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
pub fn delete_password(state: State<'_, AppState>, site_id: String) -> Result<(), AppError> {
let conn = state
.db
.lock()
.map_err(|e| AppError::Internal(e.to_string()))?;
conn.execute(
"UPDATE sites SET password = NULL WHERE id = ?1",
rusqlite::params![site_id],
)
.map_err(|e| e.to_string())?;
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(())
}
@ -381,5 +433,110 @@ pub fn platform_info() -> PlatformInfo {
} else {
(None, None)
};
PlatformInfo { os, desktop, session }
PlatformInfo {
os,
desktop,
session,
}
}
// --- Multi-window support ---------------------------------------------------
#[tauri::command]
pub fn save_window_state(
state: State<'_, AppState>,
window_state: WindowState,
) -> Result<(), AppError> {
let conn = state
.db
.lock()
.map_err(|e| AppError::Internal(e.to_string()))?;
let key = format!("window_state_{}", window_state.label);
let json =
serde_json::to_string(&window_state).map_err(|e| AppError::Internal(e.to_string()))?;
db::set_setting(&conn, &key, &json)?;
Ok(())
}
#[tauri::command]
pub fn load_window_state(
state: State<'_, AppState>,
label: String,
) -> Result<Option<WindowState>, AppError> {
let conn = state
.db
.lock()
.map_err(|e| AppError::Internal(e.to_string()))?;
let key = format!("window_state_{}", label);
match db::get_setting(&conn, &key) {
Some(json) => {
let ws: WindowState =
serde_json::from_str(&json).map_err(|e| AppError::Internal(e.to_string()))?;
Ok(Some(ws))
}
None => Ok(None),
}
}
#[tauri::command]
pub async fn new_window(
app_handle: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
let label = format!("browser-{}", uuid::Uuid::new_v4());
let window = tauri::WebviewWindowBuilder::new(
&app_handle,
&label,
tauri::WebviewUrl::App("/".into()),
)
.title("Wherry")
.inner_size(1280.0, 800.0)
.min_inner_size(900.0, 600.0)
.center()
.build()
.map_err(|e| e.to_string())?;
let db = state.db.clone();
let app_handle = app_handle.clone();
let label_clone = label.clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { api: _, .. } = event
&& let Some(win) = app_handle.get_webview_window(&label_clone)
{
save_window_state_internal(&win, &db);
}
});
Ok(())
}
pub(crate) fn save_window_state_internal(
window: &tauri::WebviewWindow,
db: &Arc<std::sync::Mutex<rusqlite::Connection>>,
) {
let label = window.label().to_string();
let pos = window.outer_position().ok();
let size = window
.outer_size()
.unwrap_or(tauri::PhysicalSize {
width: 1280,
height: 800,
});
let maximized = window.is_maximized().unwrap_or(false);
let ws = WindowState {
label: label.clone(),
x: pos.map(|p| p.x),
y: pos.map(|p| p.y),
width: size.width,
height: size.height,
maximized,
};
if let Ok(conn) = db.lock() {
let key = format!("window_state_{}", label);
if let Ok(json) = serde_json::to_string(&ws) {
let _ = db::set_setting(&conn, &key, &json);
}
}
}