Дезайн ыыыы

This commit is contained in:
loki5512344 2026-07-11 00:01:26 +03:00
parent 403d7086d4
commit e7c02c7feb
174 changed files with 15802 additions and 9725 deletions

View file

@ -1,22 +1,24 @@
[package]
name = "loflum"
name = "wherry"
version = "0.1.0"
edition = "2024"
license = "GPL-3.0-only"
[lib]
name = "loflum_lib"
name = "wherry_lib"
path = "lib.rs"
crate-type = ["staticlib", "cdylib", "rlib"]
[[bin]]
name = "loflum"
name = "wherry"
path = "main.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
eframe = { version = "0.29", features = ["default"] }
egui = "0.29"
egui_extras = { version = "0.29", features = ["svg", "image"] }
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
@ -24,7 +26,6 @@ anyhow = "1"
thiserror = "1"
async-trait = "0.1"
rusqlite = { version = "0.31", features = ["bundled"] }
keyring = "2"
suppaftp = { version = "6", features = ["async", "async-native-tls"] }
ssh2 = "0.9"
uuid = { version = "1", features = ["v4"] }
@ -33,8 +34,4 @@ tracing = "0.1"
tracing-subscriber = "0.3"
chrono = "0.4"
futures-lite = "2"
image = "0.25"
muda = "0.19.3"
egui_dock = "0.14"
rfd = "0.15"

3
src/build.rs Normal file
View file

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View file

@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capabilities for the main Wherry window",
"windows": ["main"],
"permissions": ["core:default", "dialog:default"]
}

385
src/commands.rs Normal file
View file

@ -0,0 +1,385 @@
//! Tauri command surface — thin wrappers over domain/fs/protocols/storage/transfer.
//! See docs/TAURI_BACKEND_API.md for the design this implements.
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use tauri::State;
use crate::domain::connection::{ConnectionParams, Protocol};
use crate::domain::file_entry::FileEntry;
use crate::domain::site::Site;
use crate::domain::transfer::{TaskState, TransferKind, TransferTask};
use crate::fs::remote::RemoteRegistry;
use crate::protocols::{RemoteFs, ftp::FtpClient, sftp::SftpClient};
use crate::storage::db::{self, HistoryRow};
use crate::transfer::queue::TransferQueue;
pub struct AppState {
pub db: Arc<std::sync::Mutex<rusqlite::Connection>>,
pub registry: Arc<RemoteRegistry>,
pub queue: TransferQueue,
pub max_concurrent: Arc<AtomicU32>,
}
// --- Connections ---------------------------------------------------------
#[tauri::command]
pub async fn connect(state: State<'_, AppState>, params: ConnectionParams) -> Result<String, String> {
let registry = state.registry.clone();
let db = state.db.clone();
// Пароль приходит с фронтенда и хранится в БД (в sites.password).
// Для истории используем канонический conn_id, чтобы повторные
// подключения не плодили записи.
let canonical_id = db
.lock()
.ok()
.and_then(|conn| {
db::find_history_conn_id(&conn, &params.host, params.port, &params.username)
.ok()
.flatten()
})
.unwrap_or_else(|| params.id.clone());
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())?,
)
} 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())?,
)
} else {
Arc::new(
SftpClient::connect_auto(&params.host, params.port, &params.username)
.map_err(|e| e.to_string())?,
)
}
}
Protocol::Ftp => {
let password = params
.password
.clone()
.ok_or("Password is required for FTP connections.")?;
Arc::new(
FtpClient::connect(&params.host, params.port, &params.username, &password)
.await
.map_err(|e| e.to_string())?,
)
}
Protocol::Ftps => return Err("FTPS not yet implemented".into()),
};
registry.insert(params.id.clone(), fs);
if let Ok(conn) = db.lock() {
let _ = db::add_history_entry(
&conn,
&params.host,
params.port,
&params.username,
&canonical_id,
&params.protocol,
params.key_path.as_deref(),
);
}
Ok(params.id)
}
#[tauri::command]
pub fn disconnect(state: State<'_, AppState>, connection_id: String) {
state.registry.remove(&connection_id);
}
// --- Remote file ops -------------------------------------------------------
fn get_remote(state: &AppState, connection_id: &str) -> Result<Arc<dyn RemoteFs>, String> {
state
.registry
.get(connection_id)
.ok_or_else(|| "connection not found".to_string())
}
#[tauri::command]
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())
}
#[tauri::command]
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())
}
#[tauri::command]
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())
}
#[tauri::command]
pub async fn remote_rename(
state: State<'_, AppState>,
connection_id: String,
from: String,
to: String,
) -> Result<(), String> {
get_remote(&state, &connection_id)?
.rename(&from, &to)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
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())
}
// --- 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())
}
#[tauri::command]
pub fn local_home_dir() -> String {
crate::fs::local::home_dir()
}
#[tauri::command]
pub fn local_mkdir(path: String) -> Result<(), String> {
crate::fs::local::mkdir(&path).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn local_rename(from: String, to: String) -> Result<(), String> {
crate::fs::local::rename(&from, &to).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn local_delete(path: String) -> Result<(), String> {
crate::fs::local::delete(&path).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn local_open(path: String) -> Result<(), String> {
crate::fs::local::open(&path).map_err(|e| e.to_string())
}
#[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())
}
// --- Transfers --------------------------------------------------------------
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub fn enqueue_transfer(
state: State<'_, AppState>,
kind: TransferKind,
connection_id: String,
local_path: String,
remote_path: String,
file_name: String,
total_bytes: u64,
) -> String {
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
}
#[tauri::command]
pub fn list_tasks(state: State<'_, AppState>) -> Vec<TransferTask> {
state.queue.all()
}
#[tauri::command]
pub fn pause_task(state: State<'_, AppState>, id: String) {
state.queue.update_state(&id, TaskState::Paused);
}
#[tauri::command]
pub fn resume_task(state: State<'_, AppState>, id: String) {
state.queue.update_state(&id, TaskState::Queued);
}
#[tauri::command]
pub fn cancel_task(state: State<'_, AppState>, id: String) {
state.queue.update_state(&id, TaskState::Cancelled);
}
#[tauri::command]
pub fn remove_task(state: State<'_, AppState>, id: String) {
state.queue.remove(&id);
}
#[tauri::command]
pub fn set_max_concurrent(state: State<'_, AppState>, n: u32) {
state.max_concurrent.store(n.max(1), Ordering::Relaxed);
if let Ok(conn) = state.db.lock() {
db::set_u32(&conn, "max_concurrent_transfers", n.max(1));
}
}
// --- 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())
}
#[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())
}
#[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())
}
#[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())
}
#[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())
}
#[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())
}
#[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())
}
#[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())
}
#[tauri::command]
pub fn find_history_conn_id(
state: State<'_, AppState>,
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())
}
#[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())?;
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())
}
/// Удалить пароль из сохранённого сайта (очистить поле 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())?;
conn.execute(
"UPDATE sites SET password = NULL WHERE id = ?1",
rusqlite::params![site_id],
)
.map_err(|e| e.to_string())?;
Ok(())
}
/// The folder holding the sqlite db (Settings → About "reveal in folder").
#[tauri::command]
pub fn app_data_dir() -> String {
dirs::data_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("wherry")
.to_string_lossy()
.to_string()
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PlatformInfo {
pub os: &'static str,
/// Linux only: XDG_CURRENT_DESKTOP, lowercased ("kde", "gnome", "hyprland", …).
pub desktop: Option<String>,
/// Linux only: "wayland" | "x11".
pub session: Option<String>,
}
/// Из webview на Linux не различить KDE/GNOME/Hyprland — окружение видно
/// только процессу; фронтенд вешает результат как data-атрибуты на <html>.
#[tauri::command]
pub fn platform_info() -> PlatformInfo {
let os = std::env::consts::OS;
let (desktop, session) = if os == "linux" {
(
std::env::var("XDG_CURRENT_DESKTOP")
.ok()
.map(|d| d.to_lowercase()),
std::env::var("XDG_SESSION_TYPE").ok(),
)
} else {
(None, None)
};
PlatformInfo { os, desktop, session }
}

View file

@ -10,6 +10,7 @@ pub struct Site {
pub host: String,
pub port: u16,
pub username: String,
pub password: Option<String>,
pub key_path: Option<String>,
pub folder: Option<String>,
pub note: Option<String>,

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Capabilities for the main Wherry window","local":true,"windows":["main"],"permissions":["core:default","dialog:default"]}}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -213,18 +213,18 @@ pub const ENTRIES: &[Entry] = &[
(
"settings.general.startup_folder_hint",
[
"Local pane opens here when LoFlum starts.",
"Локальная панель будет открываться здесь при запуске LoFlum.",
"El panel local se abrirá aquí al iniciar LoFlum.",
"Le panneau local s'ouvrira ici au démarrage de LoFlum.",
"Der lokale Bereich wird hier geöffnet, wenn LoFlum startet.",
"Il pannello locale si aprirà qui all'avvio di LoFlum.",
"O painel local abrirá aqui quando o LoFlum iniciar.",
"Panel lokalny otworzy się tutaj po uruchomieniu LoFlum.",
"LoFlum 启动时,本地面板将在此处打开。",
"LoFlum 起動時にローカルパネルがここで開きます。",
"LoFlum을 시작하면 로컬 패널이 여기에서 열립니다.",
"LoFlum başlatıldığında yerel panel burada açılır.",
"Local pane opens here when Wherry starts.",
"Локальная панель будет открываться здесь при запуске Wherry.",
"El panel local se abrirá aquí al iniciar Wherry.",
"Le panneau local s'ouvrira ici au démarrage de Wherry.",
"Der lokale Bereich wird hier geöffnet, wenn Wherry startet.",
"Il pannello locale si aprirà qui all'avvio di Wherry.",
"O painel local abrirá aqui quando o Wherry iniciar.",
"Panel lokalny otworzy się tutaj po uruchomieniu Wherry.",
"Wherry 启动时,本地面板将在此处打开。",
"Wherry 起動時にローカルパネルがここで開きます。",
"Wherry을 시작하면 로컬 패널이 여기에서 열립니다.",
"Wherry başlatıldığında yerel panel burada açılır.",
],
),
(
@ -577,9 +577,9 @@ pub const ENTRIES: &[Entry] = &[
(
"settings.security.hint",
[
"Passwords for saved connections are stored in your system keychain.",
"Пароли для сохранённых подключений хранятся в системной связке ключей.",
"Las contraseñas de las conexiones guardadas se almacenan en el llavero del sistema.",
"Passwords are stored in Wherry's local database.",
"Пароли хранятся в локальной базе данных Wherry.",
"Las contraseñas se almacenan en la base de datos local de Wherry.",
"Les mots de passe des connexions enregistrées sont stockés dans le trousseau système.",
"Passwörter für gespeicherte Verbindungen werden im System-Schlüsselbund gespeichert.",
"Le password delle connessioni salvate sono conservate nel portachiavi di sistema.",
@ -775,18 +775,18 @@ pub const ENTRIES: &[Entry] = &[
(
"settings.about.title",
[
"About LoFlum",
"О LoFlum",
"Acerca de LoFlum",
"À propos de LoFlum",
"Über LoFlum",
"Informazioni su LoFlum",
"Sobre o LoFlum",
"O programie LoFlum",
"关于 LoFlum",
"LoFlum について",
"LoFlum 정보",
"LoFlum Hakkında",
"About Wherry",
"О Wherry",
"Acerca de Wherry",
"À propos de Wherry",
"Über Wherry",
"Informazioni su Wherry",
"Sobre o Wherry",
"O programie Wherry",
"关于 Wherry",
"Wherry について",
"Wherry 정보",
"Wherry Hakkında",
],
),
(

BIN
src/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
src/icons/128x128@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

BIN
src/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 988 B

BIN
src/icons/icon.icns Normal file

Binary file not shown.

BIN
src/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View file

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Before After
Before After

View file

@ -1,86 +1,100 @@
pub mod commands;
pub mod domain;
pub mod fs;
pub mod i18n;
pub mod protocols;
pub mod storage;
pub mod transfer;
pub mod ui;
use crate::commands::AppState;
use crate::fs::remote::RemoteRegistry;
use crate::transfer::queue::TransferQueue;
use crate::ui::app::FileManagerApp;
use std::sync::Arc;
use std::sync::atomic::AtomicU32;
const DEFAULT_MAX_CONCURRENT: u32 = 2;
pub fn run() {
tracing_subscriber::fmt().with_target(false).init();
let db_path = dirs::data_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("loflum")
.join("loflum.db");
.join("wherry")
.join("wherry.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");
let conn = rusqlite::Connection::open(&db_path).expect("failed to open wherry database");
storage::db::init_tables(&conn).expect("failed to init db tables");
let db = Arc::new(std::sync::Mutex::new(conn));
let sites = {
let max_concurrent = {
let c = db.lock().unwrap();
storage::db::get_sites(&c).unwrap_or_default()
Arc::new(AtomicU32::new(storage::db::get_u32(
&c,
"max_concurrent_transfers",
DEFAULT_MAX_CONCURRENT,
)))
};
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();
// Реальное значение подтягивается из БД внутри FileManagerApp::new —
// здесь только общий на воркер и UI атомик, который Settings → Transfers
// меняет на лету.
let max_concurrent = Arc::new(AtomicU32::new(2));
transfer::worker::spawn_worker(
queue.clone(),
registry.clone(),
rt_handle,
max_concurrent.clone(),
);
let app = FileManagerApp::new(registry, queue, rt, db, sites, max_concurrent);
let icon = load_app_icon();
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])
.with_icon(icon),
..Default::default()
};
eframe::run_native(
"LoFlum",
native_options,
Box::new(|_cc| {
crate::ui::menu::setup_native_menu();
Ok(Box::new(app))
}),
)
.expect("failed to start LoFlum");
}
fn load_app_icon() -> egui::IconData {
let bytes = include_bytes!("ui/icons/app_icon.png");
let image = image::load_from_memory(bytes)
.expect("failed to decode app icon")
.into_rgba8();
let (width, height) = image.dimensions();
egui::IconData {
rgba: image.into_raw(),
width,
height,
}
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.manage(AppState {
db,
registry: registry.clone(),
queue: queue.clone(),
max_concurrent: max_concurrent.clone(),
})
.setup(move |app| {
transfer::worker::spawn_worker(
queue.clone(),
registry.clone(),
tauri::async_runtime::handle().inner().clone(),
max_concurrent.clone(),
app.handle().clone(),
);
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::connect,
commands::disconnect,
commands::remote_list,
commands::remote_stat,
commands::remote_mkdir,
commands::remote_rename,
commands::remote_delete,
commands::local_list,
commands::local_home_dir,
commands::local_mkdir,
commands::local_rename,
commands::local_delete,
commands::local_open,
commands::local_move_into,
commands::enqueue_transfer,
commands::list_tasks,
commands::pause_task,
commands::resume_task,
commands::cancel_task,
commands::remove_task,
commands::set_max_concurrent,
commands::list_sites,
commands::save_site,
commands::delete_site,
commands::list_bookmarks,
commands::add_bookmark,
commands::remove_bookmark,
commands::list_history,
commands::clear_history,
commands::find_history_conn_id,
commands::get_pref,
commands::set_pref,
commands::delete_password,
commands::app_data_dir,
commands::platform_info,
])
.run(tauri::generate_context!())
.expect("failed to start Wherry");
}

View file

@ -1,5 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
loflum_lib::run();
wherry_lib::run();
}

View file

@ -49,7 +49,7 @@ pub(super) fn verify_host_key(session: &Session, host: &str, port: u16) -> Resul
fingerprint_hex(session)
);
known
.add(host, key, "loflum", key_type.into())
.add(host, key, "wherry", 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)

View file

@ -43,6 +43,7 @@ pub fn init_tables(conn: &Connection) -> Result<()> {
host TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT NOT NULL,
password TEXT,
key_path TEXT,
folder TEXT,
note TEXT
@ -69,8 +70,7 @@ pub fn init_tables(conn: &Connection) -> Result<()> {
",
)?;
// Миграция: старые базы созданы без conn_id/protocol/key_path — добавляем,
// если их ещё нет (ошибку "duplicate column" просто игнорируем).
// Миграции
let _ = conn.execute("ALTER TABLE connection_history ADD COLUMN conn_id TEXT", []);
let _ = conn.execute(
"ALTER TABLE connection_history ADD COLUMN protocol TEXT NOT NULL DEFAULT 'sftp'",
@ -80,6 +80,7 @@ pub fn init_tables(conn: &Connection) -> Result<()> {
"ALTER TABLE connection_history ADD COLUMN key_path TEXT",
[],
);
let _ = conn.execute("ALTER TABLE sites ADD COLUMN password TEXT", []);
// На случай старых записей без уникальности (host,port,username) — оставляем
// только самую свежую строку на каждую цель перед созданием индекса.
conn.execute(

View file

@ -7,7 +7,7 @@ use crate::domain::site::Site;
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, password, key_path, folder, note FROM sites ORDER BY name",
)?;
let sites = stmt
.query_map([], |row| {
@ -20,9 +20,10 @@ pub fn get_sites(conn: &Connection) -> Result<Vec<Site>> {
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)?,
password: row.get(6)?,
key_path: row.get(7)?,
folder: row.get(8)?,
note: row.get(9)?,
})
})?
.filter_map(|r| r.ok())
@ -34,8 +35,8 @@ pub fn save_site(conn: &Connection, site: &Site) -> Result<()> {
let proto = protocol_to_str(&site.protocol);
conn.execute(
"INSERT OR REPLACE INTO sites
(id, name, protocol, host, port, username, key_path, folder, note)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)",
(id, name, protocol, host, port, username, password, key_path, folder, note)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)",
params![
site.id,
site.name,
@ -43,6 +44,7 @@ pub fn save_site(conn: &Connection, site: &Site) -> Result<()> {
site.host,
site.port,
site.username,
site.password,
site.key_path,
site.folder,
site.note

View file

@ -1,21 +0,0 @@
use anyhow::Result;
use keyring::Entry;
const SERVICE: &str = "loflum-ftp-client";
pub fn store_password(site_id: &str, password: &str) -> Result<()> {
let entry = Entry::new(SERVICE, site_id)?;
entry.set_password(password)?;
Ok(())
}
pub fn get_password(site_id: &str) -> Result<String> {
let entry = Entry::new(SERVICE, site_id)?;
Ok(entry.get_password()?)
}
pub fn delete_password(site_id: &str) -> Result<()> {
let entry = Entry::new(SERVICE, site_id)?;
entry.delete_password()?;
Ok(())
}

View file

@ -1,2 +1 @@
pub mod db;
pub mod keychain;

37
src/tauri.conf.json Normal file
View file

@ -0,0 +1,37 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Wherry",
"version": "0.1.0",
"identifier": "dev.wherry.app",
"build": {
"frontendDist": "../frontend"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"title": "Wherry",
"width": 1280,
"height": 800,
"minWidth": 900,
"minHeight": 600,
"center": true,
"dragDropEnabled": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

View file

@ -1,6 +1,7 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter};
use tokio::time::sleep;
use crate::domain::transfer::{TaskState, TransferKind, TransferTask};
@ -11,6 +12,44 @@ use crate::transfer::queue::TransferQueue;
const POLL_INTERVAL_MS: u64 = 200;
#[derive(Clone, serde::Serialize)]
struct ProgressPayload {
id: String,
transferred_bytes: u64,
speed: u64,
eta_secs: Option<u64>,
}
#[derive(Clone, serde::Serialize)]
struct StatePayload {
id: String,
state: TaskState,
}
fn emit_progress(app: &AppHandle, queue: &TransferQueue, id: &str) {
if let Some(t) = queue.get(id) {
let _ = app.emit(
"transfer-progress",
ProgressPayload {
id: t.id,
transferred_bytes: t.transferred_bytes,
speed: t.speed.unwrap_or(0),
eta_secs: t.eta_secs,
},
);
}
}
fn emit_state(app: &AppHandle, id: &str, state: TaskState) {
let _ = app.emit(
"transfer-state-changed",
StatePayload {
id: id.to_string(),
state,
},
);
}
/// Уменьшает счётчик активных передач при выходе из скоупа — в том числе при
/// панике внутри задачи (Drop отрабатывает и во время размотки стека), иначе
/// упавшая задача навсегда съедала бы один слот параллелизма.
@ -30,6 +69,7 @@ pub fn spawn_worker(
registry: Arc<RemoteRegistry>,
rt_handle: tokio::runtime::Handle,
max_concurrent: Arc<AtomicU32>,
app: AppHandle,
) {
let in_flight = Arc::new(AtomicUsize::new(0));
rt_handle.spawn(async move {
@ -44,14 +84,16 @@ pub fn spawn_worker(
break;
};
queue.update_state(&task.id, TaskState::Running);
emit_state(&app, &task.id, TaskState::Running);
in_flight.fetch_add(1, Ordering::Relaxed);
let queue = queue.clone();
let registry = registry.clone();
let guard_counter = in_flight.clone();
let app = app.clone();
tokio::spawn(async move {
let _guard = InFlightGuard(guard_counter);
run_transfer(task, queue, registry).await;
run_transfer(task, queue, registry, app).await;
});
}
sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
@ -60,11 +102,21 @@ pub fn spawn_worker(
}
/// Выполняет одну задачу передачи целиком и записывает итог в очередь.
async fn run_transfer(task: TransferTask, queue: TransferQueue, registry: Arc<RemoteRegistry>) {
async fn run_transfer(
task: TransferTask,
queue: TransferQueue,
registry: Arc<RemoteRegistry>,
app: AppHandle,
) {
let fs = match registry.get(&task.connection_id) {
Some(fs) => fs,
None => {
queue.update_state(&task.id, TaskState::Failed("connection not found".into()));
emit_state(
&app,
&task.id,
TaskState::Failed("connection not found".into()),
);
return;
}
};
@ -73,6 +125,7 @@ async fn run_transfer(task: TransferTask, queue: TransferQueue, registry: Arc<Re
// текущую скорость и реагирует на отмену/паузу, выставленные пользователем.
let queue_for_progress = queue.clone();
let task_id_for_progress = task.id.clone();
let app_for_progress = app.clone();
let throttle = Arc::new(std::sync::Mutex::new(ProgressThrottle::default()));
let last_sample = Arc::new(std::sync::Mutex::new((Instant::now(), 0u64)));
let on_progress: Option<Box<dyn Fn(u64) -> ProgressAction + Send>> =
@ -100,6 +153,7 @@ async fn run_transfer(task: TransferTask, queue: TransferQueue, registry: Arc<Re
};
if throttle.lock().unwrap().should_emit() {
queue_for_progress.update_progress(&task_id_for_progress, transferred, speed);
emit_progress(&app_for_progress, &queue_for_progress, &task_id_for_progress);
}
ProgressAction::Continue
}));
@ -119,12 +173,15 @@ async fn run_transfer(task: TransferTask, queue: TransferQueue, registry: Arc<Re
Ok(()) => {
queue.update_state(&task.id, TaskState::Completed);
queue.update_progress(&task.id, task.total_bytes, 0);
emit_progress(&app, &queue, &task.id);
emit_state(&app, &task.id, TaskState::Completed);
}
Err(e) => {
let msg = e.to_string();
if msg == "cancelled" {
queue.update_state(&task.id, TaskState::Cancelled);
queue.remove(&task.id);
emit_state(&app, &task.id, TaskState::Cancelled);
} else if msg == "paused" {
let transferred = queue
.get(&task.id)
@ -132,8 +189,10 @@ async fn run_transfer(task: TransferTask, queue: TransferQueue, registry: Arc<Re
.unwrap_or(0);
queue.update_state(&task.id, TaskState::Paused);
queue.update_progress(&task.id, transferred, 0);
emit_state(&app, &task.id, TaskState::Paused);
} else {
queue.update_state(&task.id, TaskState::Failed(msg));
queue.update_state(&task.id, TaskState::Failed(msg.clone()));
emit_state(&app, &task.id, TaskState::Failed(msg));
}
}
}

View file

@ -1,164 +0,0 @@
//! Модальные диалоги ФС (New Folder / Delete / Rename) — общий каркас модалки
//! (окно/кнопки) вынесен в [`crate::ui::widgets::modal`].
use egui::{Context, RichText, Ui};
use super::FileManagerApp;
// Алиас `tr`: в этом файле `t` — анимационная «открытость» (f32), не перевод.
use crate::i18n::{t as tr, tf};
use crate::ui::icons;
use crate::ui::state::Pane;
use crate::ui::theme::*;
use crate::ui::widgets::modal::{self, Outcome};
use crate::ui::widgets::overlay;
impl FileManagerApp {
pub(super) fn render_remote_op_dialogs(&mut self, ctx: &Context) {
// Settings → General → "Confirm before deleting" выключен — удаляем
// сразу, до расчёта затемнения, чтобы не мелькнуть окном на 1 кадр.
if self.state.show_delete_dialog && !self.state.confirm_before_delete {
let name = self.state.delete_name.clone();
self.dispatch_delete(name);
self.state.show_delete_dialog = false;
}
// «Открытость» считается каждый кадр для каждого диалога (см. док к
// overlay::openness) — окна дорисовываются, пока доигрывает fade-out.
let t_mkdir = overlay::openness(ctx, "mkdir_dlg", self.state.show_mkdir_dialog);
let t_delete = overlay::openness(ctx, "delete_dlg", self.state.show_delete_dialog);
let t_rename = overlay::openness(ctx, "rename_dlg", self.state.show_rename_dialog);
let t_any = t_mkdir.max(t_delete).max(t_rename);
if t_any > 0.0 {
let _ = overlay::dim(ctx, "remote_op_overlay", t_any);
}
if t_mkdir > 0.0 {
match text_modal(
ctx,
"new_folder_dialog",
t_mkdir,
tr("common.new_folder"),
&mut self.state.mkdir_name,
) {
Outcome::Confirm if !self.state.mkdir_name.is_empty() => {
let name = self.state.mkdir_name.clone();
self.dispatch_mkdir(name);
self.state.show_mkdir_dialog = false;
}
// Поля не чистим при отмене: открыватели инициализируют их
// сами, а очистка здесь заставила бы контент мигать в fade-out.
Outcome::Cancel => self.state.show_mkdir_dialog = false,
_ => {}
}
}
if t_delete > 0.0 {
let name = self.state.delete_name.clone();
match confirm_modal(ctx, "delete_dialog", t_delete, &name) {
Outcome::Confirm => {
self.dispatch_delete(name);
self.state.show_delete_dialog = false;
}
Outcome::Cancel => self.state.show_delete_dialog = false,
_ => {}
}
}
if t_rename > 0.0 {
match text_modal(
ctx,
"rename_dialog",
t_rename,
tr("common.rename"),
&mut self.state.rename_new_name,
) {
Outcome::Confirm if !self.state.rename_new_name.is_empty() => {
let old_name = self.state.rename_old_name.clone();
let new_name = self.state.rename_new_name.clone();
self.dispatch_rename(old_name, new_name);
self.state.show_rename_dialog = false;
}
Outcome::Cancel => self.state.show_rename_dialog = false,
_ => {}
}
}
}
fn dispatch_mkdir(&mut self, name: String) {
match self.state.op_target {
Pane::Local => self.start_local_mkdir(name),
Pane::Remote => {
if let Some(idx) = self.active_tab_idx() {
self.start_mkdir(idx, name);
}
}
}
}
fn dispatch_delete(&mut self, name: String) {
match self.state.op_target {
Pane::Local => self.start_local_delete(name),
Pane::Remote => {
if let Some(idx) = self.active_tab_idx() {
self.start_delete(idx, name);
}
}
}
}
fn dispatch_rename(&mut self, old_name: String, new_name: String) {
match self.state.op_target {
Pane::Local => self.start_local_rename(old_name, new_name),
Pane::Remote => {
if let Some(idx) = self.active_tab_idx() {
self.start_rename(idx, old_name, new_name);
}
}
}
}
}
/// Модалка с одним текстовым полем (New Folder / Rename): фокус в поле сразу,
/// Enter подтверждает.
fn text_modal(ctx: &Context, id: &str, t: f32, title: &str, value: &mut String) -> Outcome {
let mut outcome = Outcome::Idle;
modal::window(ctx, id, t, |ui| {
ui.label(RichText::new(title).color(TEXT_PRIMARY).strong());
ui.add_space(12.0);
let resp = ui.text_edit_singleline(value);
if t >= 1.0 {
resp.request_focus();
}
let entered = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
ui.add_space(16.0);
let row = modal::ok_cancel_row(ui, false);
outcome = if entered { Outcome::Confirm } else { row };
});
outcome
}
/// Диалог подтверждения удаления (красная кнопка OK).
fn confirm_modal(ctx: &Context, id: &str, t: f32, name: &str) -> Outcome {
let mut outcome = Outcome::Idle;
modal::window(ctx, id, t, |ui: &mut Ui| {
ui.horizontal(|ui| {
icons::icon(ui, icons::Icon::DangerTriangle, 18.0, RED);
ui.add_space(8.0);
ui.vertical(|ui| {
ui.label(
RichText::new(tf("dialogs.confirm_delete_title", &[("{name}", name)]))
.color(TEXT_PRIMARY)
.strong(),
);
ui.label(
RichText::new(tr("dialogs.cannot_undo"))
.color(TEXT_DIM)
.size(11.0),
);
});
});
ui.add_space(16.0);
outcome = modal::ok_cancel_row(ui, true);
});
outcome
}

View file

@ -1,187 +0,0 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Instant;
use crate::domain::connection::ConnectionStatus;
use crate::domain::site::Site;
use crate::fs::remote::RemoteRegistry;
use crate::transfer::queue::TransferQueue;
use crate::ui::dialogs::connection;
use crate::ui::dock::PaneTab;
use crate::ui::panels::local_pane;
use crate::ui::widgets::overlay;
mod dialogs;
mod ops;
mod poll;
mod results;
mod screens;
mod settings;
mod visuals;
mod welcome;
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,
pub dock_state: egui_dock::DockState<PaneTab>,
/// Момент, когда задача передачи впервые замечена в состоянии Completed —
/// нужен для авто-очистки очереди (Settings → Transfers).
completed_since: HashMap<String, Instant>,
/// Какой экран был в прошлом кадре (welcome/main) + момент переключения —
/// для фейда при переходе между экранами. None до первого кадра.
prev_is_welcome: Option<bool>,
screen_changed_at: f64,
}
impl FileManagerApp {
pub fn new(
registry: Arc<RemoteRegistry>,
queue: TransferQueue,
rt: tokio::runtime::Runtime,
db: Arc<std::sync::Mutex<rusqlite::Connection>>,
sites: Vec<Site>,
max_concurrent: Arc<AtomicU32>,
) -> Self {
let prefs = settings::prefs::load(&db);
max_concurrent.store(prefs.max_concurrent, Ordering::Relaxed);
crate::i18n::set_lang(prefs.language.unwrap_or_else(crate::i18n::detect_system_lang));
let start_path = if !prefs.default_local_folder.is_empty()
&& std::path::Path::new(&prefs.default_local_folder).is_dir()
{
prefs.default_local_folder.clone()
} else {
crate::fs::local::home_dir()
};
let mut state = crate::ui::state::AppState {
local_path: start_path,
confirm_before_delete: prefs.confirm_before_delete,
default_local_folder: prefs.default_local_folder,
auto_clear_completed_secs: prefs.auto_clear_completed_secs,
max_concurrent,
..Default::default()
};
local_pane::refresh_local(&mut state);
state.reload_history(&db);
if let Ok(rows) = crate::storage::db::get_bookmarks(&db.lock().unwrap()) {
state.bookmarks = rows
.into_iter()
.map(|(id, name, path)| crate::ui::state::Bookmark { id, name, path })
.collect();
}
Self {
state,
registry,
queue,
rt,
db,
sites,
first_frame: true,
dock_state: egui_dock::DockState::new(vec![PaneTab::Local]),
completed_since: HashMap::new(),
prev_is_welcome: None,
screen_changed_at: f64::NEG_INFINITY,
}
}
fn active_tab_idx(&self) -> Option<usize> {
if self.state.tabs.is_empty() {
return None;
}
let idx = self.state.active_tab.min(self.state.tabs.len() - 1);
if self.state.tabs[idx].status == ConnectionStatus::Connected {
Some(idx)
} else {
None
}
}
}
impl eframe::App for FileManagerApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
if self.first_frame {
self.first_frame = false;
egui_extras::install_image_loaders(ctx);
self.apply_visuals(ctx);
ctx.request_repaint();
}
self.poll_pending();
crate::ui::menu::poll_menu_events(&mut self.state);
self.state.queue_tasks = self.queue.all();
self.sweep_completed_tasks();
// ── Esc закрывает верхний из открытых диалогов ───────────────────────
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
let s = &mut self.state;
if s.show_mkdir_dialog {
s.show_mkdir_dialog = false;
} else if s.show_delete_dialog {
s.show_delete_dialog = false;
} else if s.show_rename_dialog {
s.show_rename_dialog = false;
} else if s.show_connect_dialog && !s.connect_loading {
s.show_connect_dialog = false;
} else if s.show_settings_dialog {
s.show_settings_dialog = false;
}
}
// ── Переход welcome ↔ main с фейдом ──────────────────────────────────
let is_welcome = self.state.tabs.is_empty();
if self.prev_is_welcome != Some(is_welcome) {
// На самом первом кадре не анимируем — окно и так только появилось.
if self.prev_is_welcome.is_some() {
self.screen_changed_at = ctx.input(|i| i.time);
}
self.prev_is_welcome = Some(is_welcome);
}
let screen_fade =
(((ctx.input(|i| i.time) - self.screen_changed_at) / 0.2).clamp(0.0, 1.0)) as f32;
if screen_fade < 1.0 {
ctx.request_repaint();
}
if is_welcome {
self.render_welcome_screen(ctx, screen_fade);
} else {
self.render_main_ui(ctx, screen_fade);
}
// «Открытость» диалогов тикает каждый кадр (см. док к overlay::openness),
// окна рисуются, пока не доиграл fade-out.
// ── Connection dialog ────────────────────────────────────────────────
let t_connect = overlay::openness(ctx, "connect_dlg", self.state.show_connect_dialog);
if t_connect > 0.0 {
connection::render(
ctx,
&mut self.state,
&self.registry,
self.rt.handle(),
t_connect,
);
}
// ── Remote operation dialogs ─────────────────────────────────────────
self.render_remote_op_dialogs(ctx);
// ── Settings ─────────────────────────────────────────────────────────
let t_settings = overlay::openness(ctx, "settings_dlg", self.state.show_settings_dialog);
if t_settings > 0.0 {
self.render_settings_dialog(ctx, t_settings);
}
// 200ms repaint для обновления прогресса
ctx.request_repaint_after(std::time::Duration::from_millis(200));
}
}

View file

@ -1,152 +0,0 @@
//! Запуск операций над ФС из диалогов (mkdir/delete/rename), локально и удалённо.
use std::sync::Arc;
use super::FileManagerApp;
use crate::ui::panels::{local_pane, remote_pane};
impl FileManagerApp {
pub(super) fn start_mkdir(&mut self, tab_idx: usize, name: String) {
let tab = &self.state.tabs[tab_idx];
let connection_id = tab.id.clone();
let remote_path = tab.remote_path.clone();
let registry = self.registry.clone();
let path = format!("{}/{}", remote_path.trim_end_matches('/'), name);
let result = Arc::new(std::sync::Mutex::new(None::<Result<(), String>>));
let result_clone = result.clone();
self.rt.handle().spawn(async move {
let fs = match registry.get(&connection_id) {
Some(fs) => fs,
None => {
*result_clone.lock().unwrap() = Some(Err("connection not found".into()));
return;
}
};
let r = fs.mkdir(&path).await.map_err(|e| e.to_string());
*result_clone.lock().unwrap() = Some(r);
});
self.state.pending_mkdir_result = Some(result);
}
pub(super) fn start_delete(&mut self, tab_idx: usize, name: String) {
let tab = &self.state.tabs[tab_idx];
let connection_id = tab.id.clone();
let registry = self.registry.clone();
let entry_path = tab
.remote_entries
.iter()
.find(|e| e.name == name)
.map(|e| e.path.clone());
if name == ".." {
self.state.status_message = "Cannot delete parent entry".into();
return;
}
let Some(path) = entry_path else {
self.state.status_message = "Selected entry not found".into();
return;
};
let result = Arc::new(std::sync::Mutex::new(None::<Result<(), String>>));
let result_clone = result.clone();
self.rt.handle().spawn(async move {
let fs = match registry.get(&connection_id) {
Some(fs) => fs,
None => {
*result_clone.lock().unwrap() = Some(Err("connection not found".into()));
return;
}
};
let r = fs.delete(&path).await.map_err(|e| e.to_string());
*result_clone.lock().unwrap() = Some(r);
});
self.state.pending_delete_result = Some(result);
}
pub(super) fn start_rename(&mut self, tab_idx: usize, old_name: String, new_name: String) {
let tab = &self.state.tabs[tab_idx];
let connection_id = tab.id.clone();
let registry = self.registry.clone();
let Some(entry) = tab.remote_entries.iter().find(|e| e.name == old_name) else {
self.state.status_message = "Selected entry not found".into();
return;
};
let from = entry.path.clone();
let parent_dir = remote_pane::remote_parent(&from);
let to = format!("{}/{}", parent_dir.trim_end_matches('/'), new_name);
let result = Arc::new(std::sync::Mutex::new(None::<Result<(), String>>));
let result_clone = result.clone();
self.rt.handle().spawn(async move {
let fs = match registry.get(&connection_id) {
Some(fs) => fs,
None => {
*result_clone.lock().unwrap() = Some(Err("connection not found".into()));
return;
}
};
let r = fs.rename(&from, &to).await.map_err(|e| e.to_string());
*result_clone.lock().unwrap() = Some(r);
});
self.state.pending_rename_result = Some(result);
}
pub(super) fn start_local_mkdir(&mut self, name: String) {
let path = format!("{}/{}", self.state.local_path.trim_end_matches('/'), name);
match crate::fs::local::mkdir(&path) {
Ok(()) => {
self.state.status_message = "Folder created".into();
local_pane::refresh_local(&mut self.state);
}
Err(e) => self.state.status_message = format!("Create folder failed: {}", e),
}
}
pub(super) fn start_local_delete(&mut self, name: String) {
if name == ".." {
self.state.status_message = "Cannot delete parent entry".into();
return;
}
let Some(entry) = self.state.local_entries.iter().find(|e| e.name == name) else {
self.state.status_message = "Selected entry not found".into();
return;
};
match crate::fs::local::delete(&entry.path) {
Ok(()) => {
self.state.status_message = "Deleted".into();
local_pane::refresh_local(&mut self.state);
}
Err(e) => self.state.status_message = format!("Delete failed: {}", e),
}
}
pub(super) fn start_local_rename(&mut self, old_name: String, new_name: String) {
let Some(entry) = self.state.local_entries.iter().find(|e| e.name == old_name) else {
self.state.status_message = "Selected entry not found".into();
return;
};
let from = entry.path.clone();
let parent_dir = std::path::Path::new(&from)
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| self.state.local_path.clone());
let to = format!("{}/{}", parent_dir.trim_end_matches('/'), new_name);
match crate::fs::local::rename(&from, &to) {
Ok(()) => {
self.state.status_message = "Renamed".into();
local_pane::refresh_local(&mut self.state);
}
Err(e) => self.state.status_message = format!("Rename failed: {}", e),
}
}
}

View file

@ -1,135 +0,0 @@
//! Опрос результатов асинхронных операций раз в кадр из `update`.
//! Разбор connect/list — в [`super::results`], здесь — диспетчер, флаги действий
//! и результаты mkdir/delete/rename.
use std::sync::Arc;
use super::FileManagerApp;
use crate::ui::dialogs::connection;
use crate::ui::panels::remote_pane;
impl FileManagerApp {
/// Убирает завершённые задачи из очереди передач через
/// `auto_clear_completed_secs` секунд после завершения (0 — никогда).
/// Вызывается каждый кадр, сразу после обновления `state.queue_tasks`.
pub(super) fn sweep_completed_tasks(&mut self) {
use crate::domain::transfer::TaskState;
use std::collections::HashSet;
let now = std::time::Instant::now();
let live_ids: HashSet<&str> = self
.state
.queue_tasks
.iter()
.map(|t| t.id.as_str())
.collect();
self.completed_since
.retain(|id, _| live_ids.contains(id.as_str()));
for task in &self.state.queue_tasks {
if task.state == TaskState::Completed {
self.completed_since.entry(task.id.clone()).or_insert(now);
}
}
let secs = self.state.auto_clear_completed_secs;
if secs == 0 {
return;
}
let expired: Vec<String> = self
.completed_since
.iter()
.filter(|(_, since)| now.duration_since(**since).as_secs() >= secs as u64)
.map(|(id, _)| id.clone())
.collect();
for id in expired {
self.queue.remove(&id);
self.completed_since.remove(&id);
}
}
pub(super) fn poll_pending(&mut self) {
self.poll_connect();
self.poll_remote_list();
self.poll_action_flags();
self.poll_op_results();
}
// --- Toolbar/history action flags ---
fn poll_action_flags(&mut self) {
if self.state.pending_refresh {
self.state.pending_refresh = false;
if let Some(idx) = self.active_tab_idx() {
remote_pane::trigger_list(&mut self.state, idx, &self.registry, self.rt.handle());
}
}
// История: переподключение по клику
if let Some(entry) = self.state.pending_history_reconnect.take() {
connection::reconnect_from_history(
&mut self.state,
&self.registry,
self.rt.handle(),
&entry,
);
}
// История: «Save» → постоянный Site
if let Some(entry) = self.state.pending_history_save.take() {
match connection::save_history_as_site(&self.db, &mut self.sites, &entry) {
Ok(()) => self.state.status_message = "Saved to sites".into(),
Err(e) => self.state.status_message = format!("Save failed: {}", e),
}
}
}
// --- mkdir/delete/rename results ---
fn poll_op_results(&mut self) {
if let Some(res) = take_ready(&mut self.state.pending_mkdir_result) {
self.on_op_done(res, "Folder created", "Create folder failed");
}
if let Some(res) = take_ready(&mut self.state.pending_delete_result) {
self.on_op_done(res, "Deleted", "Delete failed");
}
if let Some(res) = take_ready(&mut self.state.pending_rename_result) {
self.on_op_done(res, "Renamed", "Rename failed");
}
}
/// Общая обработка результата операции над ФС: статус + refresh списка при успехе.
fn on_op_done(&mut self, res: Result<(), String>, ok_msg: &str, err_prefix: &str) {
match res {
Ok(()) => {
self.state.status_message = ok_msg.into();
self.state.mkdir_name.clear();
self.state.delete_name.clear();
self.state.rename_old_name.clear();
self.state.rename_new_name.clear();
if let Some(idx) = self.active_tab_idx() {
remote_pane::trigger_list(
&mut self.state,
idx,
&self.registry,
self.rt.handle(),
);
}
}
Err(e) => self.state.status_message = format!("{}: {}", err_prefix, e),
}
}
}
/// Забирает готовый результат из отложенной операции (и очищает слот), если он есть.
type PendingResult = Arc<std::sync::Mutex<Option<Result<(), String>>>>;
fn take_ready(slot: &mut Option<PendingResult>) -> Option<Result<(), String>> {
let opt = slot.take();
let res = opt.as_ref()?;
let mut guard = res.lock().unwrap();
if let Some(r) = guard.take() {
drop(guard);
Some(r)
} else {
drop(guard);
*slot = opt;
None
}
}

View file

@ -1,143 +0,0 @@
//! Разбор результатов подключения и листинга удалённой директории.
use super::FileManagerApp;
use crate::domain::connection::{ConnectionParams, ConnectionStatus};
use crate::domain::file_entry::{EntryKind, FileEntry};
use crate::ui::dock::PaneTab;
use crate::ui::panels::remote_pane;
use crate::ui::state::ConnectionTab;
impl FileManagerApp {
// --- Connect result ---
pub(super) fn poll_connect(&mut self) {
let opt = self.state.pending_connect.take();
let Some(pc) = opt.as_ref() else {
return;
};
let mut guard = pc.result.lock().unwrap();
let Some(res) = guard.take() else {
drop(guard);
self.state.pending_connect = opt;
return;
};
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 = ConnectionTab {
id: params.id.clone(),
label: params.label.clone(),
params: params.clone(),
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.dock_state
.push_to_focused_leaf(PaneTab::Remote(params.id.clone()));
self.state.status_message = format!("Connected to {}", params.host);
self.state.connect_loading = false;
self.state.show_connect_dialog = false;
self.persist_connection(&params);
self.state.reload_history(&self.db);
}
Err(e) => {
self.state.status_message = format!("Connection failed: {}", e);
self.state.connect_error = e;
self.state.connect_loading = false;
}
}
}
/// Один и тот же host/port/username всегда должен использовать один conn_id —
/// под ним лежит пароль в keychain, иначе повторное подключение через
/// «New Connection» развело бы историю и keychain.
fn persist_connection(&self, params: &ConnectionParams) {
if let Ok(conn) = self.db.lock() {
let canonical_id = crate::storage::db::find_history_conn_id(
&conn,
&params.host,
params.port,
&params.username,
)
.ok()
.flatten()
.unwrap_or_else(|| params.id.clone());
if let Some(password) = &params.password {
let _ = crate::storage::keychain::store_password(&canonical_id, password);
}
let _ = crate::storage::db::add_history_entry(
&conn,
&params.host,
params.port,
&params.username,
&canonical_id,
&params.protocol,
params.key_path.as_deref(),
);
}
}
// --- Remote list results ---
pub(super) fn poll_remote_list(&mut self) {
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);
}
}
}

View file

@ -1,106 +0,0 @@
//! Основная рабочая область: тулбар, статус-бар, очередь, сайдбар и док-вкладки.
use egui::{CentralPanel, SidePanel, TopBottomPanel};
use super::FileManagerApp;
use crate::ui::dock::{AppTabViewer, PaneTab};
use crate::ui::panels::{queue, sidebar, status_bar, toolbar};
use crate::ui::theme::*;
impl FileManagerApp {
pub(super) fn render_main_ui(&mut self, ctx: &egui::Context, fade: f32) {
// Панели используют show_animated: тумблеры View → Toolbar/Sidebar/...
// плавно въезжают/выезжают вместо мгновенного скачка раскладки.
// ── Toolbar ──────────────────────────────────────────────────────────
TopBottomPanel::top("toolbar")
.frame(egui::Frame::none().fill(BG_TOOLBAR))
.show_animated(ctx, self.state.show_toolbar, |ui| {
toolbar::render(ui, &mut self.state, &self.queue);
});
// ── Status bar ───────────────────────────────────────────────────────
TopBottomPanel::bottom("status_bar")
.frame(egui::Frame::none().fill(BG_BASE))
.exact_height(STATUS_H)
.show_animated(ctx, self.state.show_status_bar, |ui| {
status_bar::render(ui, &self.state);
});
// ── Queue panel ──────────────────────────────────────────────────────
if self.state.show_queue_panel {
let tasks = self.state.queue_tasks.clone();
// Плавное раскрытие/сворачивание очереди по клику на заголовок.
let expanded = self.state.show_queue && !tasks.is_empty();
let t = ctx.animate_bool_with_time(egui::Id::new("queue_expanded"), expanded, 0.18);
let q_h = egui::lerp(QUEUE_COLLAPSED_H..=QUEUE_EXPANDED_H, t);
TopBottomPanel::bottom("queue_panel")
.frame(
egui::Frame::none()
.fill(BG_QUEUE)
.stroke(egui::Stroke::new(1.0, BORDER)),
)
.resizable(false)
.exact_height(q_h)
.show(ctx, |ui| {
queue::render(ui, &mut self.state, &tasks);
});
}
// ── Sidebar ──────────────────────────────────────────────────────────
SidePanel::left("sidebar")
.frame(
egui::Frame::none()
.fill(BG_PANEL)
.stroke(egui::Stroke::new(1.0, BORDER)),
)
.resizable(true)
.default_width(SIDEBAR_W)
.width_range(120.0..=260.0)
.show_animated(ctx, self.state.show_sidebar, |ui| {
sidebar::render(ui, &mut self.state, &self.db);
});
// ── Main content: модульная рабочая область (Local + подключения) ────
// Каждое подключение — своя вкладка, которую можно перетаскивать,
// разбивать на сплиты или закрывать независимо от остальных.
CentralPanel::default()
.frame(egui::Frame::none().fill(BG_CONTENT))
.show(ctx, |ui| {
ui.multiply_opacity(crate::ui::widgets::overlay::ease_out(fade));
let mut closed_remote: Option<String> = None;
let mut viewer = AppTabViewer {
state: &mut self.state,
queue: &self.queue,
registry: &self.registry,
rt_handle: self.rt.handle(),
db: &self.db,
closed_remote: &mut closed_remote,
};
let style = egui_dock::Style::from_egui(ui.style());
egui_dock::DockArea::new(&mut self.dock_state)
.style(style)
.show_add_buttons(false)
.show_close_buttons(true)
.show_inside(ui, &mut viewer);
if let Some((_rect, tab)) = self.dock_state.find_active_focused() {
match tab {
PaneTab::Local => self.state.active_pane = crate::ui::state::Pane::Local,
PaneTab::Remote(id) => {
if let Some(idx) = self.state.tabs.iter().position(|t| &t.id == id) {
self.state.active_tab = idx;
self.state.active_pane = crate::ui::state::Pane::Remote;
}
}
}
}
if let Some(id) = closed_remote {
self.registry.remove(&id);
self.state.tabs.retain(|t| t.id != id);
if self.state.active_tab >= self.state.tabs.len() {
self.state.active_tab = self.state.tabs.len().saturating_sub(1);
}
}
});
}
}

View file

@ -1,36 +0,0 @@
//! Settings → About: версия, путь к БД, лицензия.
use egui::{RichText, Ui};
use super::{FileManagerApp, section_title, value_row};
use crate::i18n::t;
use crate::ui::theme::*;
use crate::ui::widgets::button;
impl FileManagerApp {
pub(super) fn render_about(&mut self, ui: &mut Ui) {
section_title(ui, t("settings.about.title"));
value_row(ui, t("settings.about.version"), env!("CARGO_PKG_VERSION"));
value_row(ui, t("settings.about.license"), env!("CARGO_PKG_LICENSE"));
let db_path = dirs::data_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("loflum")
.join("loflum.db");
value_row(ui, t("settings.about.database"), &db_path.to_string_lossy());
ui.add_space(10.0);
let reveal = egui::Button::new(
RichText::new(t("settings.about.reveal_in_finder"))
.color(TEXT_PRIMARY)
.size(11.5),
)
.rounding(RADIUS_MD)
.min_size(egui::vec2(0.0, 28.0));
if button::ghost(ui, reveal).clicked()
&& let Some(dir) = db_path.parent()
{
let _ = crate::fs::local::open(&dir.to_string_lossy());
}
}
}

View file

@ -1,192 +0,0 @@
//! Settings → Sites & Bookmarks / History / Security — всё, что относится к
//! сохранённым подключениям, в одном файле (соответствует группе "CONNECTIONS"
//! в левом меню).
use egui::{RichText, Ui};
use super::{FileManagerApp, empty_state, hint, list_row_with_action, section_title};
use crate::i18n::{t, tf};
use crate::ui::icons::Icon;
use crate::ui::theme::*;
use crate::ui::widgets::button;
impl FileManagerApp {
pub(super) fn render_connections(&mut self, ui: &mut Ui) {
section_title(ui, t("settings.connections.sites"));
if self.sites.is_empty() {
empty_state(ui, t("settings.connections.no_sites"));
} else {
let mut remove_idx: Option<usize> = None;
for (i, site) in self.sites.iter().enumerate() {
let sublabel = format!("{}@{}:{}", site.username, site.host, site.port);
if list_row_with_action(
ui,
&site.name,
&sublabel,
Icon::Trash,
t("settings.connections.delete_site_hover"),
) {
remove_idx = Some(i);
}
}
if let Some(i) = remove_idx {
let site = self.sites.remove(i);
if let Ok(conn) = self.db.lock() {
let _ = crate::storage::db::delete_site(&conn, &site.id);
}
let _ = crate::storage::keychain::delete_password(&site.id);
self.state.status_message =
tf("settings.connections.removed_site", &[("{name}", &site.name)]);
}
}
ui.add_space(20.0);
section_title(ui, t("settings.connections.bookmarks_title"));
if self.state.bookmarks.is_empty() {
empty_state(ui, t("settings.connections.no_bookmarks"));
} else {
let mut remove_id: Option<i64> = None;
for bm in &self.state.bookmarks {
if list_row_with_action(
ui,
&bm.name,
&bm.path,
Icon::Trash,
t("panels.sidebar.remove_bookmark"),
) {
remove_id = Some(bm.id);
}
}
if let Some(id) = remove_id {
self.state.bookmarks.retain(|b| b.id != id);
if let Ok(conn) = self.db.lock() {
let _ = crate::storage::db::remove_bookmark(&conn, id);
}
self.state.status_message = t("settings.connections.bookmark_removed").into();
}
}
}
pub(super) fn render_history_section(&mut self, ui: &mut Ui) {
section_title(ui, t("settings.history.title"));
if self.state.history.is_empty() {
empty_state(ui, t("settings.history.empty"));
} else {
for entry in &self.state.history {
ui.horizontal(|ui| {
ui.label(
RichText::new(format!("{}@{}:{}", entry.user, entry.host, entry.port))
.color(TEXT_PRIMARY)
.size(12.5),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(RichText::new(&entry.time).color(TEXT_HINT).size(10.5));
});
});
ui.add_space(4.0);
}
}
ui.add_space(16.0);
if self.state.history_clear_confirm {
hint(ui, t("settings.history.confirm_clear"));
ui.add_space(6.0);
ui.horizontal(|ui| {
let confirm = egui::Button::new(
RichText::new(t("settings.history.delete_all"))
.color(ON_ACCENT)
.size(12.0),
)
.rounding(RADIUS_MD)
.min_size(egui::vec2(0.0, 28.0));
if button::danger(ui, confirm).clicked() {
if let Ok(conn) = self.db.lock() {
let _ = crate::storage::db::clear_history(&conn);
}
self.state.history.clear();
self.state.history_clear_confirm = false;
self.state.status_message = t("settings.history.cleared").into();
}
ui.add_space(8.0);
let cancel = egui::Button::new(
RichText::new(t("common.cancel")).color(TEXT_DIM).size(12.0),
)
.rounding(RADIUS_MD)
.min_size(egui::vec2(0.0, 28.0));
if button::ghost(ui, cancel).clicked() {
self.state.history_clear_confirm = false;
}
});
} else if !self.state.history.is_empty() {
let clear = egui::Button::new(
RichText::new(t("settings.history.clear_all"))
.color(RED)
.size(12.0),
)
.rounding(RADIUS_MD)
.min_size(egui::vec2(0.0, 28.0));
if button::ghost(ui, clear).clicked() {
self.state.history_clear_confirm = true;
}
}
}
/// Не показываем, есть ли пароль у конкретной записи — это потребовало бы
/// вызвать `keychain::get_password`, который может дёрнуть системный запрос
/// доступа к keychain на каждую запись при каждом открытии Settings.
pub(super) fn render_security(&mut self, ui: &mut Ui) {
section_title(ui, t("settings.nav.security"));
hint(ui, t("settings.security.hint"));
ui.add_space(12.0);
if self.sites.is_empty() {
empty_state(ui, t("settings.security.no_connections"));
} else {
let mut forget_id: Option<String> = None;
for site in &self.sites {
ui.horizontal(|ui| {
ui.label(RichText::new(&site.name).color(TEXT_PRIMARY).size(12.5));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let btn = egui::Button::image(crate::ui::icons::image(
Icon::LockPassword,
13.0,
TEXT_DIM,
))
.rounding(RADIUS_SM)
.min_size(egui::vec2(26.0, 26.0));
if button::ghost(ui, btn)
.on_hover_text(t("settings.security.forget_password_hover"))
.clicked()
{
forget_id = Some(site.id.clone());
}
});
});
ui.add_space(4.0);
}
if let Some(id) = forget_id {
let _ = crate::storage::keychain::delete_password(&id);
self.state.status_message = t("settings.security.password_forgotten").into();
}
}
ui.add_space(18.0);
let forget_all = egui::Button::new(
RichText::new(t("settings.security.forget_all"))
.color(RED)
.size(12.0),
)
.rounding(RADIUS_MD)
.min_size(egui::vec2(0.0, 28.0));
if button::ghost(ui, forget_all).clicked() {
for site in &self.sites {
let _ = crate::storage::keychain::delete_password(&site.id);
}
for entry in &self.state.history {
let _ = crate::storage::keychain::delete_password(&entry.conn_id);
}
self.state.status_message = t("settings.security.all_forgotten").into();
}
}
}

View file

@ -1,157 +0,0 @@
//! Settings → General & Appearance: подтверждение удаления, стартовая папка,
//! язык интерфейса, видимость панелей (в согласии с меню Window на macOS).
use egui::{RichText, Ui};
use super::prefs::{KEY_CONFIRM_DELETE, KEY_DEFAULT_LOCAL_FOLDER, KEY_LANGUAGE};
use super::{FileManagerApp, hint, section_title};
use crate::i18n::{Lang, t};
use crate::ui::theme::*;
use crate::ui::widgets::button;
impl FileManagerApp {
pub(super) fn render_general(&mut self, ui: &mut Ui) {
section_title(ui, t("settings.nav.general"));
let changed = ui
.checkbox(
&mut self.state.confirm_before_delete,
RichText::new(t("settings.general.confirm_delete"))
.color(TEXT_PRIMARY)
.size(12.5),
)
.changed();
hint(ui, t("settings.general.confirm_delete_hint"));
if changed {
let value = self.state.confirm_before_delete;
self.save_pref_bool(KEY_CONFIRM_DELETE, value);
}
ui.add_space(18.0);
ui.label(
RichText::new(t("settings.general.language"))
.color(TEXT_PRIMARY)
.size(12.5)
.strong(),
);
hint(ui, t("settings.general.language_hint"));
ui.add_space(6.0);
if let Some(lang) = language_picker(ui) {
self.set_language(ui, lang);
}
ui.add_space(18.0);
ui.label(
RichText::new(t("settings.general.startup_folder"))
.color(TEXT_PRIMARY)
.size(12.5)
.strong(),
);
hint(ui, t("settings.general.startup_folder_hint"));
ui.add_space(6.0);
let current = if self.state.default_local_folder.is_empty() {
t("settings.general.home_directory").to_string()
} else {
self.state.default_local_folder.clone()
};
ui.label(
RichText::new(&current)
.color(TEXT_DIM)
.monospace()
.size(11.0),
);
ui.add_space(8.0);
ui.horizontal(|ui| {
let use_current = egui::Button::new(
RichText::new(t("settings.general.use_current_folder"))
.color(TEXT_PRIMARY)
.size(11.5),
)
.rounding(RADIUS_MD)
.min_size(egui::vec2(0.0, 28.0));
if button::ghost(ui, use_current).clicked() {
let path = self.state.local_path.clone();
self.state.default_local_folder = path.clone();
self.save_pref_str(KEY_DEFAULT_LOCAL_FOLDER, &path);
}
ui.add_space(8.0);
let reset = egui::Button::new(
RichText::new(t("settings.general.reset_to_home"))
.color(TEXT_DIM)
.size(11.5),
)
.rounding(RADIUS_MD)
.min_size(egui::vec2(0.0, 28.0));
if button::ghost(ui, reset).clicked() {
self.state.default_local_folder.clear();
self.save_pref_str(KEY_DEFAULT_LOCAL_FOLDER, "");
}
});
}
pub(super) fn render_appearance(&mut self, ui: &mut Ui) {
section_title(ui, t("settings.nav.appearance"));
hint(ui, t("settings.appearance.hint"));
ui.add_space(10.0);
appearance_checkbox(
ui,
t("settings.appearance.toolbar"),
&mut self.state.show_toolbar,
);
appearance_checkbox(
ui,
t("settings.appearance.sidebar"),
&mut self.state.show_sidebar,
);
appearance_checkbox(
ui,
t("settings.appearance.status_bar"),
&mut self.state.show_status_bar,
);
appearance_checkbox(
ui,
t("settings.appearance.transfer_queue"),
&mut self.state.show_queue_panel,
);
}
/// Сохраняет выбор языка и сразу переключает `t()`/шрифты — эффект виден
/// в этом же кадре, без перезапуска (см. правило про мгновенный отклик).
fn set_language(&self, ui: &Ui, lang: Lang) {
crate::i18n::set_lang(lang);
self.refresh_fonts(ui.ctx());
self.save_pref_str(KEY_LANGUAGE, lang.code());
}
}
fn appearance_checkbox(ui: &mut Ui, label: &str, value: &mut bool) {
ui.checkbox(value, RichText::new(label).color(TEXT_PRIMARY).size(12.5));
ui.add_space(4.0);
}
/// Выпадающий список из всех 12 языков — названия на них самих (Español,
/// 日本語...), чтобы найти свой язык, даже не понимая текущего. Возвращает
/// выбранный язык, если пользователь только что кликнул по другому пункту.
fn language_picker(ui: &mut Ui) -> Option<Lang> {
let current = crate::i18n::current();
let mut picked = None;
egui::ComboBox::from_id_salt("language_picker")
.width(200.0)
.selected_text(
RichText::new(current.native_name())
.color(TEXT_PRIMARY)
.size(12.0),
)
.show_ui(ui, |ui| {
for lang in Lang::ALL {
let selected = lang == current;
if ui.selectable_label(selected, lang.native_name()).clicked() && !selected {
picked = Some(lang);
}
}
});
picked
}

View file

@ -1,219 +0,0 @@
//! Диалог Settings — popup с меню разделов слева и содержимым справа
//! (General/Appearance/Connections/History/Security/Transfers/About).
use egui::{Align2, Context, RichText, Ui};
use super::FileManagerApp;
use crate::ui::state::SettingsSection;
use crate::ui::theme::*;
use crate::ui::widgets::{button, overlay};
mod about;
mod connections;
mod general;
mod nav;
pub(super) mod prefs;
mod transfers;
const NAV_HEIGHT: f32 = 420.0;
impl FileManagerApp {
pub(super) fn render_settings_dialog(&mut self, ctx: &Context, t: f32) {
if overlay::dim(ctx, "settings_overlay", t) {
self.state.show_settings_dialog = false;
}
egui::Window::new("settings_dialog")
.collapsible(false)
.title_bar(false)
.resizable(false)
.movable(false)
.anchor(Align2::CENTER_CENTER, overlay::slide_offset(t))
.interactable(t >= 1.0)
.frame(overlay::panel_frame())
.show(ctx, |ui| {
ui.set_opacity(overlay::ease_out(t));
ui.set_width(600.0);
header(ui, &mut self.state.show_settings_dialog);
ui.add_space(12.0);
let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0));
ui.painter().rect_filled(sep, 0.0, BORDER);
ui.add_space(14.0);
ui.horizontal_top(|ui| {
// ScrollArea наследует layout родителя, а здесь он
// горизонтальный — без ui.vertical() содержимое скроллов
// растекается в одну строку и окно вылезает за экран.
ui.vertical(|ui| {
egui::ScrollArea::vertical()
.id_salt("settings_nav_scroll")
.max_height(NAV_HEIGHT)
.show(ui, |ui| {
nav::render(ui, &mut self.state.settings_section);
});
});
let (_, vsep) = ui.allocate_space(egui::vec2(1.0, NAV_HEIGHT));
ui.painter().rect_filled(vsep, 0.0, BORDER);
ui.add_space(18.0);
ui.vertical(|ui| {
// Кросс-фейд содержимого при переключении раздела:
// фиксируем момент смены и плавно поднимаем прозрачность.
if self.state.settings_section != self.state.settings_prev_section {
self.state.settings_prev_section = self.state.settings_section;
self.state.settings_section_changed_at = ui.input(|i| i.time);
}
let fade = ((ui.input(|i| i.time) - self.state.settings_section_changed_at)
/ 0.18)
.clamp(0.0, 1.0) as f32;
if fade < 1.0 {
ui.ctx().request_repaint();
}
ui.multiply_opacity(overlay::ease_out(fade));
egui::ScrollArea::vertical()
.id_salt("settings_content_scroll")
.max_height(NAV_HEIGHT)
.show(ui, |ui| {
ui.set_width(370.0);
match self.state.settings_section {
SettingsSection::General => self.render_general(ui),
SettingsSection::Appearance => self.render_appearance(ui),
SettingsSection::Connections => self.render_connections(ui),
SettingsSection::History => self.render_history_section(ui),
SettingsSection::Security => self.render_security(ui),
SettingsSection::Transfers => self.render_transfers(ui),
SettingsSection::About => self.render_about(ui),
}
});
});
});
});
}
}
fn header(ui: &mut Ui, show: &mut bool) {
ui.horizontal(|ui| {
crate::ui::icons::icon(ui, crate::ui::icons::Icon::Settings, 16.0, ACCENT);
ui.add_space(8.0);
ui.label(
RichText::new(crate::i18n::t("settings.title"))
.color(TEXT_PRIMARY)
.size(15.0)
.strong(),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let close = egui::Button::new(RichText::new("✕").color(TEXT_DIM).size(12.0))
.rounding(RADIUS_SM)
.min_size(egui::vec2(24.0, 24.0));
if button::ghost(ui, close).clicked() {
*show = false;
}
});
});
}
/// Заголовок раздела ("General", "Transfers"...), одинаковый во всех секциях.
pub(super) fn section_title(ui: &mut Ui, text: &str) {
ui.label(RichText::new(text).color(TEXT_PRIMARY).size(15.0).strong());
ui.add_space(12.0);
}
/// Подсказка серым текстом под заголовком/контролом.
pub(super) fn hint(ui: &mut Ui, text: &str) {
ui.label(RichText::new(text).color(TEXT_HINT).size(11.0));
}
/// Строка "метка — значение" только для чтения (версия, путь к БД и т.п.).
/// Длинные значения (пути) усекаются с «…», полный текст — по наведению.
pub(super) fn value_row(ui: &mut Ui, label: &str, value: &str) {
ui.horizontal(|ui| {
ui.label(RichText::new(label).color(TEXT_DIM).size(11.5));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let text = RichText::new(value).color(TEXT_HINT).monospace().size(10.5);
let resp = ui.add(egui::Label::new(text).truncate());
if resp.hovered() && value.len() > 32 {
resp.on_hover_text(value);
}
});
});
ui.add_space(6.0);
}
/// Пустое состояние списка ("No saved sites yet." и т.п.).
pub(super) fn empty_state(ui: &mut Ui, text: &str) {
ui.label(RichText::new(text).color(TEXT_HINT).size(11.5));
}
/// Строка элемента списка с кнопкой-иконкой справа (удалить/забыть и т.п.).
/// Возвращает `true`, если по кнопке кликнули.
pub(super) fn list_row_with_action(
ui: &mut Ui,
label: &str,
sublabel: &str,
action_icon: crate::ui::icons::Icon,
action_hover: &str,
) -> bool {
let mut clicked = false;
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.label(RichText::new(label).color(TEXT_PRIMARY).size(12.5));
if !sublabel.is_empty() {
ui.label(RichText::new(sublabel).color(TEXT_HINT).size(10.5));
}
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let btn = egui::Button::image(crate::ui::icons::image(action_icon, 13.0, TEXT_DIM))
.rounding(RADIUS_SM)
.min_size(egui::vec2(26.0, 26.0));
if button::ghost(ui, btn).on_hover_text(action_hover).clicked() {
clicked = true;
}
});
});
ui.add_space(4.0);
clicked
}
/// Сегментированный ряд из равных по ширине кнопок (используется в Transfers
/// для выбора числа/интервала) — тот же паттерн, что и выбор протокола в
/// диалоге New Connection.
pub(super) fn segmented_row<T: Copy + PartialEq>(
ui: &mut Ui,
options: &[(&str, T)],
current: T,
) -> Option<T> {
let mut picked = None;
let gap = 4.0;
let n = options.len().max(1) as f32;
// На первом кадре свежего egui::Window ширина ещё не устоялась и может
// быть меньше суммарных отступов — clamp защищает от negative desired_size.
let seg_w = ((ui.available_width() - gap * (n - 1.0) - 6.0) / n).max(0.0);
egui::Frame::none()
.fill(BG_CONTENT)
.stroke(egui::Stroke::new(1.0, BORDER))
.rounding(RADIUS_MD)
.inner_margin(egui::Margin::same(3.0))
.show(ui, |ui| {
ui.spacing_mut().item_spacing.x = gap;
ui.horizontal(|ui| {
for (label, value) in options {
let active = *value == current;
let tc = if active { ON_ACCENT } else { TEXT_DIM };
let btn = egui::Button::new(RichText::new(*label).color(tc).size(11.5))
.rounding(RADIUS_SM)
.min_size(egui::vec2(seg_w, 26.0));
let resp = if active {
button::accent(ui, btn)
} else {
button::ghost(ui, btn)
};
if resp.clicked() {
picked = Some(*value);
}
}
});
});
picked
}

View file

@ -1,107 +0,0 @@
//! Левое меню диалога Settings: сгруппированные пункты разделов.
use egui::{RichText, Ui};
use crate::i18n::t;
use crate::ui::icons::{self, Icon};
use crate::ui::state::SettingsSection;
use crate::ui::theme::*;
use crate::ui::widgets::row::clickable_row;
struct NavItem {
section: SettingsSection,
label_key: &'static str,
icon: Icon,
}
struct NavGroup {
label_key: &'static str,
items: &'static [NavItem],
}
const GENERAL: &[NavItem] = &[
NavItem {
section: SettingsSection::General,
label_key: "settings.nav.general",
icon: Icon::Settings,
},
NavItem {
section: SettingsSection::Appearance,
label_key: "settings.nav.appearance",
icon: Icon::Monitor,
},
];
const CONNECTIONS: &[NavItem] = &[
NavItem {
section: SettingsSection::Connections,
label_key: "settings.nav.connections",
icon: Icon::ServerSquare,
},
NavItem {
section: SettingsSection::History,
label_key: "settings.nav.history",
icon: Icon::History,
},
NavItem {
section: SettingsSection::Security,
label_key: "settings.nav.security",
icon: Icon::ShieldKeyhole,
},
];
const TRANSFERS: &[NavItem] = &[NavItem {
section: SettingsSection::Transfers,
label_key: "settings.nav.transfers",
icon: Icon::Upload,
}];
const ABOUT: &[NavItem] = &[NavItem {
section: SettingsSection::About,
label_key: "settings.nav.about",
icon: Icon::Document,
}];
const GROUPS: &[NavGroup] = &[
NavGroup {
label_key: "settings.nav.group_general",
items: GENERAL,
},
NavGroup {
label_key: "settings.nav.group_connections",
items: CONNECTIONS,
},
NavGroup {
label_key: "settings.nav.group_transfers",
items: TRANSFERS,
},
NavGroup {
label_key: "settings.nav.group_about",
items: ABOUT,
},
];
pub(super) fn render(ui: &mut Ui, active: &mut SettingsSection) {
ui.set_width(168.0);
for group in GROUPS {
ui.label(
RichText::new(t(group.label_key))
.color(TEXT_HINT)
.size(10.0)
.strong(),
);
ui.add_space(4.0);
for item in group.items {
let is_active = *active == item.section;
let icon_col = if is_active { ACCENT } else { TEXT_DIM };
let resp = clickable_row(ui, is_active, 30.0, |ui| {
icons::icon(ui, item.icon, 14.0, icon_col);
ui.add_space(8.0);
ui.label(RichText::new(t(item.label_key)).color(TEXT_PRIMARY).size(12.5));
});
if resp.clicked() {
*active = item.section;
}
}
ui.add_space(10.0);
}
}

View file

@ -1,67 +0,0 @@
//! Ключи настроек в БД, значения по умолчанию и helpers сохранения —
//! общие для всех разделов Settings.
use super::FileManagerApp;
pub(super) const KEY_CONFIRM_DELETE: &str = "confirm_before_delete";
pub(super) const KEY_DEFAULT_LOCAL_FOLDER: &str = "default_local_folder";
pub(super) const KEY_AUTO_CLEAR_SECS: &str = "auto_clear_completed_secs";
pub(super) const KEY_MAX_CONCURRENT: &str = "max_concurrent_transfers";
pub(super) const KEY_LANGUAGE: &str = "language";
pub(super) const DEFAULT_MAX_CONCURRENT: u32 = 2;
/// Настройки, загруженные из БД при старте (см. `FileManagerApp::new`).
pub(in crate::ui::app) struct Prefs {
pub confirm_before_delete: bool,
pub default_local_folder: String,
pub auto_clear_completed_secs: u32,
pub max_concurrent: u32,
/// `None` — язык ещё ни разу не выбирался явно, стартуем от локали ОС.
pub language: Option<crate::i18n::Lang>,
}
pub(in crate::ui::app) fn load(
db: &std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>,
) -> Prefs {
let Ok(conn) = db.lock() else {
return Prefs {
confirm_before_delete: true,
default_local_folder: String::new(),
auto_clear_completed_secs: 0,
max_concurrent: DEFAULT_MAX_CONCURRENT,
language: None,
};
};
Prefs {
confirm_before_delete: crate::storage::db::get_bool(&conn, KEY_CONFIRM_DELETE, true),
default_local_folder: crate::storage::db::get_setting(&conn, KEY_DEFAULT_LOCAL_FOLDER)
.unwrap_or_default(),
auto_clear_completed_secs: crate::storage::db::get_u32(&conn, KEY_AUTO_CLEAR_SECS, 0),
max_concurrent: crate::storage::db::get_u32(
&conn,
KEY_MAX_CONCURRENT,
DEFAULT_MAX_CONCURRENT,
),
language: crate::storage::db::get_setting(&conn, KEY_LANGUAGE)
.and_then(|c| crate::i18n::Lang::from_code(&c)),
}
}
impl FileManagerApp {
pub(super) fn save_pref_bool(&self, key: &str, value: bool) {
if let Ok(conn) = self.db.lock() {
crate::storage::db::set_bool(&conn, key, value);
}
}
pub(super) fn save_pref_u32(&self, key: &str, value: u32) {
if let Ok(conn) = self.db.lock() {
crate::storage::db::set_u32(&conn, key, value);
}
}
pub(super) fn save_pref_str(&self, key: &str, value: &str) {
if let Ok(conn) = self.db.lock() {
let _ = crate::storage::db::set_setting(&conn, key, value);
}
}
}

View file

@ -1,59 +0,0 @@
//! Settings → Transfers: параллелизм передач и авто-очистка завершённых задач.
use std::sync::atomic::Ordering;
use egui::{RichText, Ui};
use super::prefs::{KEY_AUTO_CLEAR_SECS, KEY_MAX_CONCURRENT};
use super::{FileManagerApp, hint, section_title, segmented_row};
use crate::i18n::t;
use crate::ui::theme::TEXT_PRIMARY;
const CONCURRENCY_OPTIONS: &[(&str, u32)] =
&[("1", 1), ("2", 2), ("3", 3), ("4", 4), ("5", 5), ("6", 6)];
impl FileManagerApp {
pub(super) fn render_transfers(&mut self, ui: &mut Ui) {
section_title(ui, t("settings.nav.transfers"));
ui.label(
RichText::new(t("settings.transfers.concurrent_title"))
.color(TEXT_PRIMARY)
.size(12.5)
.strong(),
);
hint(ui, t("settings.transfers.concurrent_hint"));
ui.add_space(6.0);
let current = self
.state
.max_concurrent
.load(Ordering::Relaxed)
.clamp(1, 6);
if let Some(picked) = segmented_row(ui, CONCURRENCY_OPTIONS, current) {
self.state.max_concurrent.store(picked, Ordering::Relaxed);
self.save_pref_u32(KEY_MAX_CONCURRENT, picked);
}
ui.add_space(18.0);
ui.label(
RichText::new(t("settings.transfers.auto_remove_title"))
.color(TEXT_PRIMARY)
.size(12.5)
.strong(),
);
hint(ui, t("settings.transfers.auto_remove_hint"));
ui.add_space(6.0);
let auto_clear_options: &[(&str, u32)] = &[
(t("settings.transfers.never"), 0),
(t("settings.transfers.10s"), 10),
(t("settings.transfers.30s"), 30),
(t("settings.transfers.1min"), 60),
];
let current = self.state.auto_clear_completed_secs;
if let Some(picked) = segmented_row(ui, auto_clear_options, current) {
self.state.auto_clear_completed_secs = picked;
self.save_pref_u32(KEY_AUTO_CLEAR_SECS, picked);
}
}
}

View file

@ -1,101 +0,0 @@
//! Тема egui (цвета/отступы) и системный шрифт.
use egui::Visuals;
use super::FileManagerApp;
use crate::ui::theme::*;
impl FileManagerApp {
pub(super) fn apply_visuals(&self, ctx: &egui::Context) {
let mut vis = Visuals::dark();
vis.window_fill = BG_PANEL;
vis.panel_fill = BG_CONTENT;
vis.extreme_bg_color = BG_BASE;
vis.code_bg_color = BG_BASE;
vis.override_text_color = Some(TEXT_PRIMARY);
vis.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, BORDER);
vis.widgets.inactive.bg_fill = BG_CONTENT;
vis.widgets.hovered.bg_fill = BG_ROW_HOVER;
vis.widgets.active.bg_fill = ACCENT_DIM;
vis.selection.bg_fill = BG_ROW_SEL;
vis.selection.stroke = egui::Stroke::new(1.0, ACCENT);
vis.window_rounding = egui::Rounding::same(8.0);
ctx.set_visuals(vis);
self.refresh_fonts(ctx);
let mut style = ctx.style().as_ref().clone();
style.spacing.item_spacing = egui::vec2(4.0, 2.0);
style.spacing.button_padding = egui::vec2(8.0, 4.0);
ctx.set_style(style);
}
/// Перезагружает шрифты под текущий язык (см. [`crate::i18n::current`]) —
/// вызывается при старте и заново при смене языка в Settings, потому что
/// SF Pro не содержит глифов CJK (китайский/японский/корейский).
pub(super) fn refresh_fonts(&self, ctx: &egui::Context) {
ctx.set_fonts(system_fonts(crate::i18n::current()));
}
}
/// Загружает системный шрифт (SF Pro / SF Mono на macOS) вместо egui-дефолта,
/// чтобы интерфейс выглядел нативно, плюс CJK-шрифты фолбэком (SF Pro не
/// содержит китайских/японских/корейских глифов — без фолбэка был бы tofu).
/// На других ОС файлы просто отсутствуют — тогда остаются встроенные шрифты egui.
fn system_fonts(lang: crate::i18n::Lang) -> egui::FontDefinitions {
use crate::i18n::Lang;
let mut fonts = egui::FontDefinitions::default();
// Основной шрифт всегда первый в списке: даёт консистентную латиницу и
// кириллицу во всём интерфейсе. CJK-фонты идут строго ПОСЛЕ — иначе их
// собственное начертание латинских букв/цифр перекрасило бы весь текст,
// а не только иероглифы/кану/хангыль, которых нет в SF Pro.
let mut candidates: Vec<(&str, &str, egui::FontFamily)> = vec![
(
"system-sans",
"/System/Library/Fonts/SFNS.ttf",
egui::FontFamily::Proportional,
),
(
"system-mono",
"/System/Library/Fonts/SFNSMono.ttf",
egui::FontFamily::Monospace,
),
];
// CJK Unified Ideographs пересекаются между китайским/японским/корейским
// (ханьцзы/кандзи/ханча — одни кодпоинты), поэтому шрифт активного языка
// ставим первым среди CJK-фолбэков: он и определит начертание общих иероглифов.
let mut cjk: Vec<(&str, &str)> = vec![
("cjk-ja", "/System/Library/Fonts/ヒラギノ角ゴシック W4.ttc"),
("cjk-zh", "/System/Library/Fonts/Hiragino Sans GB.ttc"),
("cjk-ko", "/System/Library/Fonts/AppleSDGothicNeo.ttc"),
];
let active = match lang {
Lang::Ja => Some("cjk-ja"),
Lang::Zh => Some("cjk-zh"),
Lang::Ko => Some("cjk-ko"),
_ => None,
};
if let Some(active) = active
&& let Some(pos) = cjk.iter().position(|(name, _)| *name == active)
{
cjk.swap(0, pos);
}
candidates.extend(cjk.into_iter().map(|(n, p)| (n, p, egui::FontFamily::Proportional)));
for (name, path, family) in candidates {
if let Ok(bytes) = std::fs::read(path) {
fonts
.font_data
.entry(name.to_owned())
.or_insert_with(|| egui::FontData::from_owned(bytes));
let list = fonts.families.entry(family).or_default();
if !list.iter().any(|n| n == name) {
list.push(name.to_owned());
}
}
}
fonts
}

View file

@ -1,126 +0,0 @@
//! Стартовый экран — пока нет ни одного открытого соединения: логотип,
//! кнопка нового подключения, индикатор подключения и список недавних серверов.
use egui::{CentralPanel, RichText};
use super::FileManagerApp;
use crate::ui::dialogs::connection;
use crate::ui::icons::{self, Icon};
use crate::ui::theme::*;
use crate::ui::widgets::{button, row::clickable_row};
impl FileManagerApp {
pub(super) fn render_welcome_screen(&mut self, ctx: &egui::Context, fade: f32) {
CentralPanel::default()
.frame(egui::Frame::none().fill(BG_BASE))
.show(ctx, |ui| {
ui.multiply_opacity(crate::ui::widgets::overlay::ease_out(fade));
ui.vertical_centered(|ui| {
let avail_h = ui.available_height();
ui.add_space((avail_h * 0.16).max(24.0));
let icon_bytes: &[u8] = include_bytes!("../icons/app_icon.png");
let icon_img = egui::Image::from_bytes("bytes://welcome_app_icon", icon_bytes)
.rounding(RADIUS_LG)
.fit_to_exact_size(egui::vec2(72.0, 72.0));
ui.add(icon_img);
ui.add_space(14.0);
ui.label(
RichText::new("LoFlum")
.color(TEXT_PRIMARY)
.size(20.0)
.strong(),
);
ui.add_space(4.0);
ui.label(RichText::new("FTP/SFTP client").color(TEXT_HINT).size(12.5));
ui.add_space(20.0);
let btn = egui::Button::image_and_text(
icons::image(Icon::AddCircleBold, 15.0, ON_ACCENT),
RichText::new("New Connection")
.color(ON_ACCENT)
.size(12.5)
.strong(),
)
.rounding(RADIUS_MD)
.min_size(egui::vec2(170.0, 36.0));
if button::accent(ui, btn).clicked() {
self.state.show_connect_dialog = true;
}
self.connect_status(ui);
ui.add_space(28.0);
self.recent_connections(ui);
});
});
}
/// Мгновенная обратная связь при подключении из истории/приветствия: спиннер,
/// а при неудаче — текст ошибки (иначе провал был бы беззвучным, т.к. на
/// welcome-экране нет статус-бара).
///
/// Спиннер — единственный виджет, добавленный напрямую (без `ui.horizontal`):
/// `ui.horizontal` по умолчанию растягивает свой прямоугольник на всю
/// доступную ширину панели, и `vertical_centered` центрирует уже растянутый
/// на всю ширину блок — то есть фактически не центрирует ничего, и
/// содержимое просто прилипает к левому краю. Одиночный виджет всегда
/// имеет свой собственный размер, поэтому центрируется корректно.
fn connect_status(&self, ui: &mut egui::Ui) {
if self.state.connect_loading {
ui.add_space(14.0);
ui.spinner();
} else if !self.state.connect_error.is_empty() {
ui.add_space(14.0);
ui.label(
RichText::new(&self.state.connect_error)
.color(RED)
.size(11.5),
);
}
}
fn recent_connections(&mut self, ui: &mut egui::Ui) {
if self.state.history.is_empty() {
return;
}
let list_w = 320.0_f32.min(ui.available_width() - 40.0);
ui.label(
RichText::new("RECENT CONNECTIONS")
.color(TEXT_HINT)
.size(10.5)
.strong(),
);
ui.add_space(8.0);
egui::Frame::none().show(ui, |ui| {
ui.set_width(list_w);
let history: Vec<_> = self.state.history.iter().take(8).cloned().collect();
for entry in &history {
let label = format!("{}@{}:{}", entry.user, entry.host, entry.port);
let resp = clickable_row(ui, false, 34.0, |ui| {
icons::icon(ui, Icon::ServerSquare, 13.0, TEXT_DIM);
ui.add_space(8.0);
ui.label(RichText::new(&label).color(TEXT_PRIMARY).size(12.5));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(RichText::new(&entry.time).color(TEXT_HINT).size(10.5));
});
});
if resp.clicked() {
self.state.pending_history_reconnect = Some(entry.clone());
}
crate::ui::widgets::context_menu::context_menu(&resp, |ui| {
if ui.button("Edit").clicked() {
connection::edit_history_entry(&mut self.state, entry);
ui.close_menu();
}
if ui.button("Save").clicked() {
self.state.pending_history_save = Some(entry.clone());
ui.close_menu();
}
});
}
});
}
}

View file

@ -1,159 +0,0 @@
//! Логика подключения: сборка параметров и запуск async-коннекта.
use std::sync::Arc;
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};
pub(super) fn do_connect(
state: &mut AppState,
registry: &Arc<RemoteRegistry>,
rt_handle: &tokio::runtime::Handle,
) {
state.connect_error.clear();
if state.connect_host.trim().is_empty() {
state.connect_error = "Host is required.".into();
return;
}
if state.connect_user.trim().is_empty() {
state.connect_error = "Username is required.".into();
return;
}
if !state.connect_port.trim().is_empty() && state.connect_port.trim().parse::<u16>().is_err() {
state.connect_error = "Port must be a number between 1 and 65535.".into();
return;
}
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() {
format!(
"{} ({})",
state.connect_host,
match protocol {
Protocol::Sftp => "SFTP",
Protocol::Ftp => "FTP",
Protocol::Ftps => "FTPS",
}
)
} 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())
},
};
spawn_connect(state, registry, rt_handle, params);
}
/// Общий код запуска подключения — используется и диалогом New Connection,
/// и переподключением из истории (см. `reconnect_from_history`).
pub fn spawn_connect(
state: &mut AppState,
registry: &Arc<RemoteRegistry>,
rt_handle: &tokio::runtime::Handle,
params: ConnectionParams,
) {
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 = connect_async(&registry, &params_clone).await;
*result_clone.lock().unwrap() = Some(r);
});
state.pending_connect = Some(PendingConnect { result });
}
async fn connect_async(
registry: &RemoteRegistry,
params: &ConnectionParams,
) -> Result<(ConnectionParams, Vec<FileEntry>), String> {
// Пароль резолвим лениво по месту: для ключевой/автоматической SSH-аутен-
// тификации он не нужен, и обязательный поход в keychain раньше валил
// подключение по ключу с пустым паролем ("нет пароля в keychain").
let stored_password = || -> Option<String> {
params
.password
.clone()
.or_else(|| keychain::get_password(&params.id).ok())
};
let fs: Arc<dyn RemoteFs> = match params.protocol {
Protocol::Sftp => {
if let Some(key_path) = &params.key_path {
// Явно выбранный ключ; пароль из формы (если есть) — passphrase.
Arc::new(
SftpClient::connect_key(
&params.host,
params.port,
&params.username,
key_path,
params.password.as_deref(),
)
.map_err(|e| e.to_string())?,
)
} else if let Some(password) = stored_password() {
Arc::new(
SftpClient::connect_password(
&params.host,
params.port,
&params.username,
&password,
)
.map_err(|e| e.to_string())?,
)
} else {
// Пароль пуст и ключ не выбран — ищем ключ сами (агент, ~/.ssh).
Arc::new(
SftpClient::connect_auto(&params.host, params.port, &params.username)
.map_err(|e| e.to_string())?,
)
}
}
Protocol::Ftp => {
let password = stored_password().ok_or("Password is required for FTP connections.")?;
Arc::new(
FtpClient::connect(&params.host, params.port, &params.username, &password)
.await
.map_err(|e| e.to_string())?,
)
}
Protocol::Ftps => return Err("FTPS not yet implemented".into()),
};
registry.insert(params.id.clone(), fs.clone());
let entries = fs.list("/").await.map_err(|e| e.to_string())?;
Ok((params.clone(), entries))
}

View file

@ -1,173 +0,0 @@
//! Поля диалога подключения: сегментированный выбор протокола, текстовые
//! поля с рамкой и подсветкой фокуса, блок ошибки.
use egui::{Color32, RichText, Stroke, TextEdit, Ui};
use crate::ui::state::AppState;
use crate::ui::theme::*;
use crate::ui::widgets::button;
const FIELD_HEIGHT: f32 = 32.0;
/// Сегментированный переключатель протокола — три равные по ширине секции
/// в одной рамке, а не отдельные кнопки вразнобой (было похоже на кривой
/// список из-за неравных промежутков и незаполненного остатка ширины).
pub(super) fn protocol_row(
ui: &mut Ui,
state: &mut AppState,
protocols: &[&str; 3],
default_ports: &[u16; 3],
) {
let gap = 4.0;
let total_w = ui.available_width();
let seg_w = ((total_w - gap * 2.0 - 6.0) / 3.0).max(0.0);
egui::Frame::none()
.fill(BG_CONTENT)
.stroke(Stroke::new(1.0, BORDER))
.rounding(RADIUS_MD)
.inner_margin(egui::Margin::same(3.0))
.show(ui, |ui| {
ui.spacing_mut().item_spacing.x = gap;
ui.horizontal(|ui| {
for (i, p) in protocols.iter().enumerate() {
let active = state.connect_protocol == i;
let tc = if active { ON_ACCENT } else { TEXT_DIM };
let btn = egui::Button::new(RichText::new(*p).color(tc).size(11.5).strong())
.rounding(RADIUS_SM)
.min_size(egui::vec2(seg_w, 26.0));
let resp = if active {
button::accent(ui, btn)
} else {
button::ghost(ui, btn)
};
if resp.clicked() {
state.connect_protocol = i;
state.connect_port = default_ports[i].to_string();
}
}
});
});
}
pub(super) fn fields(ui: &mut Ui, state: &mut AppState) {
let field_w = ui.available_width();
field(ui, "Host", &mut state.connect_host, field_w, false);
ui.add_space(10.0);
ui.horizontal(|ui| {
let user_w = (field_w - 80.0 - 8.0).max(0.0);
field(ui, "Username", &mut state.connect_user, user_w, false);
ui.add_space(8.0);
field(ui, "Port", &mut state.connect_port, 80.0, false);
});
ui.add_space(10.0);
field(ui, "Password", &mut state.connect_pass, field_w, true);
if state.connect_protocol == 0 {
ui.add_space(3.0);
ui.label(
RichText::new("Leave empty to sign in with an SSH key (agent or ~/.ssh).")
.color(TEXT_HINT)
.size(10.0),
);
ui.add_space(10.0);
key_field(ui, state, field_w);
}
ui.add_space(10.0);
field(
ui,
"Label (optional)",
&mut state.connect_label,
field_w,
false,
);
}
pub(super) fn error_box(ui: &mut Ui, state: &AppState) {
if state.connect_error.is_empty() {
return;
}
let bg = Color32::from_rgba_unmultiplied(RED.r(), RED.g(), RED.b(), 26);
let border = Color32::from_rgba_unmultiplied(RED.r(), RED.g(), RED.b(), 100);
ui.add_space(12.0);
egui::Frame::none()
.fill(bg)
.stroke(Stroke::new(1.0, border))
.rounding(RADIUS_MD)
.inner_margin(egui::Margin::symmetric(10.0, 8.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
crate::ui::icons::icon(ui, crate::ui::icons::Icon::DangerTriangle, 14.0, RED);
ui.add_space(6.0);
ui.label(RichText::new(&state.connect_error).color(RED).size(11.0));
});
});
}
/// Поле пути к SSH-ключу с кнопкой Browse… (нативный диалог выбора файла).
fn key_field(ui: &mut Ui, state: &mut AppState, field_w: f32) {
const BROWSE_W: f32 = 72.0;
ui.label(
RichText::new("Key file (optional)")
.color(TEXT_DIM)
.size(11.0),
);
ui.add_space(4.0);
ui.horizontal(|ui| {
field_input(
ui,
&mut state.connect_key_path,
(field_w - BROWSE_W - 8.0).max(0.0),
false,
);
ui.add_space(8.0);
let browse = egui::Button::new(RichText::new("Browse…").color(TEXT_DIM).size(11.5))
.rounding(RADIUS_MD)
.min_size(egui::vec2(BROWSE_W, FIELD_HEIGHT));
if button::ghost(ui, browse).clicked() {
let ssh_dir = dirs::home_dir()
.map(|h| h.join(".ssh"))
.filter(|d| d.is_dir())
.or_else(dirs::home_dir);
let mut dlg = rfd::FileDialog::new().set_title("Choose SSH key");
if let Some(dir) = ssh_dir {
dlg = dlg.set_directory(dir);
}
if let Some(path) = dlg.pick_file() {
state.connect_key_path = path.display().to_string();
}
}
});
}
/// Текстовое поле с меткой, рамкой и подсветкой фокуса/hover в акцентном цвете —
/// раньше поля были обычными TextEdit без рамки, из-за чего вся форма выглядела
/// незакреплённой/неровной на фоне остального интерфейса с рамками у карточек.
fn field(ui: &mut Ui, label: &str, value: &mut String, width: f32, password: bool) {
ui.label(RichText::new(label).color(TEXT_DIM).size(11.0));
ui.add_space(4.0);
field_input(ui, value, width, password);
}
/// Само поле ввода без метки (для составных рядов вроде «путь + Browse»).
fn field_input(ui: &mut Ui, value: &mut String, width: f32, password: bool) {
ui.scope(|ui| {
let w = &mut ui.visuals_mut().widgets;
w.inactive.rounding = RADIUS_MD.into();
w.inactive.bg_stroke = Stroke::new(1.0, BORDER);
w.hovered.rounding = RADIUS_MD.into();
w.hovered.bg_stroke = Stroke::new(1.0, ACCENT_DIM);
ui.visuals_mut().selection.stroke = Stroke::new(1.0, ACCENT);
let te = TextEdit::singleline(value)
.password(password)
.desired_width(width)
.min_size(egui::vec2(width, FIELD_HEIGHT))
.margin(egui::Margin::symmetric(10.0, 8.0))
.font(egui::TextStyle::Body);
ui.add(te);
});
}

View file

@ -1,82 +0,0 @@
//! Действия над записями истории: переподключение, «Изменить», «Сохранить» как Site.
use std::sync::Arc;
use super::actions::spawn_connect;
use crate::domain::connection::{ConnectionParams, Protocol};
use crate::fs::remote::RemoteRegistry;
use crate::storage::keychain;
use crate::ui::state::AppState;
fn protocol_index(protocol: &Protocol) -> usize {
match protocol {
Protocol::Sftp => 0,
Protocol::Ftp => 1,
Protocol::Ftps => 2,
}
}
/// Переподключение по клику на запись истории — пароль (если есть) уже лежит
/// в keychain под `entry.conn_id`, поэтому диалог не нужен.
pub fn reconnect_from_history(
state: &mut AppState,
registry: &Arc<RemoteRegistry>,
rt_handle: &tokio::runtime::Handle,
entry: &crate::ui::state::HistoryEntry,
) {
state.connect_error.clear();
let params = ConnectionParams {
id: entry.conn_id.clone(),
label: format!("{}@{}", entry.user, entry.host),
protocol: entry.protocol.clone(),
host: entry.host.clone(),
port: entry.port,
username: entry.user.clone(),
password: None,
key_path: entry.key_path.clone(),
};
spawn_connect(state, registry, rt_handle, params);
}
/// «Изменить» в меню истории — открывает диалог New Connection, предзаполненный
/// этой записью (включая пароль из keychain, если он там есть).
pub fn edit_history_entry(state: &mut AppState, entry: &crate::ui::state::HistoryEntry) {
state.connect_error.clear();
state.connect_host = entry.host.clone();
state.connect_user = entry.user.clone();
state.connect_port = entry.port.to_string();
state.connect_protocol = protocol_index(&entry.protocol);
state.connect_key_path = entry.key_path.clone().unwrap_or_default();
state.connect_pass = keychain::get_password(&entry.conn_id).unwrap_or_default();
state.connect_label.clear();
state.show_connect_dialog = true;
}
/// «Сохранить» в меню истории — превращает разовое подключение в постоянный Site.
pub fn save_history_as_site(
db: &Arc<std::sync::Mutex<rusqlite::Connection>>,
sites: &mut Vec<crate::domain::site::Site>,
entry: &crate::ui::state::HistoryEntry,
) -> Result<(), String> {
let site = crate::domain::site::Site {
id: entry.conn_id.clone(),
name: format!("{}@{}", entry.user, entry.host),
protocol: entry.protocol.clone(),
host: entry.host.clone(),
port: entry.port,
username: entry.user.clone(),
key_path: entry.key_path.clone(),
folder: None,
note: None,
};
let conn = db
.lock()
.map_err(|_| "database lock poisoned".to_string())?;
crate::storage::db::save_site(&conn, &site).map_err(|e| e.to_string())?;
drop(conn);
if let Some(existing) = sites.iter_mut().find(|s| s.id == site.id) {
*existing = site;
} else {
sites.push(site);
}
Ok(())
}

View file

@ -1,9 +0,0 @@
//! Диалог «New Connection» и связанные действия (подключение, история → Site).
mod actions;
mod form;
mod history;
mod view;
pub use actions::spawn_connect;
pub use history::{edit_history_entry, reconnect_from_history, save_history_as_site};
pub use view::render;

View file

@ -1,137 +0,0 @@
//! Отрисовка диалога «New Connection» (оболочка окна; поля — в [`super::form`]).
use egui::{Align, Color32, Layout, RichText, Ui};
use std::sync::Arc;
use super::actions::do_connect;
use super::form;
// Алиас `tr`: параметр `t` в этой функции — анимационная «открытость» (f32).
use crate::i18n::t as tr;
use crate::fs::remote::RemoteRegistry;
use crate::ui::state::AppState;
use crate::ui::theme::*;
use crate::ui::widgets::{button, overlay};
const DIALOG_WIDTH: f32 = 332.0;
pub fn render(
ctx: &egui::Context,
state: &mut AppState,
registry: &Arc<RemoteRegistry>,
rt_handle: &tokio::runtime::Handle,
t: f32,
) {
let protocols = ["SFTP", "FTP", "FTPS"];
let default_ports: [u16; 3] = [22, 21, 990];
// Клик по затемнению закрывает диалог (кроме момента подключения).
if overlay::dim(ctx, "connect_overlay", t) && !state.connect_loading {
state.show_connect_dialog = false;
}
// `egui::Window` с anchor(CENTER_CENTER) авто-подгоняет размер под контент
// и всегда центрирует его заново — в отличие от Area с заранее посчитанным
// размером окна, где реальная высота полей могла разойтись с предположенной
// и диалог "уезжал" от центра.
egui::Window::new("connect_dialog")
.title_bar(false)
.collapsible(false)
.resizable(false)
.movable(false)
.anchor(egui::Align2::CENTER_CENTER, overlay::slide_offset(t))
.interactable(t >= 1.0)
.frame(
egui::Frame::none()
.fill(BG_PANEL)
.stroke(egui::Stroke::new(1.0, BORDER))
.rounding(RADIUS_LG)
.shadow(egui::Shadow {
offset: egui::vec2(0.0, 10.0),
blur: 28.0,
spread: 0.0,
color: Color32::from_black_alpha(130),
})
.inner_margin(egui::Margin::same(22.0)),
)
.show(ctx, |ui| {
ui.set_opacity(overlay::ease_out(t));
ui.set_width(DIALOG_WIDTH);
header(ui, state);
ui.add_space(18.0);
form::protocol_row(ui, state, &protocols, &default_ports);
ui.add_space(16.0);
form::fields(ui, state);
form::error_box(ui, state);
ui.add_space(18.0);
buttons(ui, state, registry, rt_handle);
// Enter из любого поля запускает подключение (submit формы).
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
if enter && t >= 1.0 && state.show_connect_dialog && !state.connect_loading {
do_connect(state, registry, rt_handle);
}
});
}
fn header(ui: &mut Ui, state: &mut AppState) {
ui.horizontal(|ui| {
crate::ui::icons::icon(ui, crate::ui::icons::Icon::ServerSquare, 15.0, ACCENT);
ui.add_space(8.0);
ui.label(
RichText::new("New Connection")
.color(TEXT_PRIMARY)
.size(15.5)
.strong(),
);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let close = egui::Button::new(RichText::new("✕").color(TEXT_DIM).size(12.0))
.rounding(RADIUS_SM)
.min_size(egui::vec2(24.0, 24.0));
if button::ghost(ui, close).clicked() {
state.show_connect_dialog = false;
}
});
});
}
fn buttons(
ui: &mut Ui,
state: &mut AppState,
registry: &Arc<RemoteRegistry>,
rt_handle: &tokio::runtime::Handle,
) {
ui.horizontal(|ui| {
let cancel = egui::Button::new(RichText::new("Cancel").color(TEXT_DIM).size(12.5))
.rounding(RADIUS_MD)
.min_size(egui::vec2(90.0, 32.0));
if button::ghost(ui, cancel).clicked() && !state.connect_loading {
state.show_connect_dialog = false;
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.add_enabled_ui(!state.connect_loading, |ui| {
let connect_label = if state.connect_loading {
"Connecting…"
} else {
"Connect"
};
let connect_btn = egui::Button::new(
RichText::new(connect_label)
.color(ON_ACCENT)
.size(12.5)
.strong(),
)
.rounding(RADIUS_MD)
.min_size(egui::vec2(114.0, 32.0));
if button::accent(ui, connect_btn).clicked() {
do_connect(state, registry, rt_handle);
}
});
if state.connect_loading {
ui.add_space(8.0);
ui.spinner();
}
});
});
}

View file

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

View file

@ -1,102 +0,0 @@
//! Модульная система панелей: Local и каждое подключение — перетаскиваемые,
//! разбиваемые и закрываемые вкладки внутри одной рабочей области (egui_dock).
use crate::domain::connection::ConnectionStatus;
use crate::fs::remote::RemoteRegistry;
use crate::transfer::queue::TransferQueue;
use crate::ui::panels::{local_pane, remote_pane};
use crate::ui::state::AppState;
use crate::ui::theme::*;
use egui::{RichText, Ui, WidgetText};
use std::sync::{Arc, Mutex};
/// Одна вкладка рабочей области — локальная ФС или конкретное удалённое соединение
/// (по стабильному id, а не индексу — индексы сдвигаются при закрытии других вкладок).
#[derive(Clone, PartialEq, Eq)]
pub enum PaneTab {
Local,
Remote(String),
}
pub struct AppTabViewer<'a> {
pub state: &'a mut AppState,
pub queue: &'a TransferQueue,
pub registry: &'a Arc<RemoteRegistry>,
pub rt_handle: &'a tokio::runtime::Handle,
pub db: &'a Arc<Mutex<rusqlite::Connection>>,
/// Если пользователь закрыл вкладку с удалённым соединением — сюда кладём его id,
/// вызывающий код (app.rs) разрывает соединение после `show_inside`.
pub closed_remote: &'a mut Option<String>,
}
impl egui_dock::TabViewer for AppTabViewer<'_> {
type Tab = PaneTab;
fn title(&mut self, tab: &mut PaneTab) -> WidgetText {
match tab {
PaneTab::Local => RichText::new("Local").into(),
PaneTab::Remote(id) => match self.state.tabs.iter().find(|t| &t.id == id) {
Some(t) => {
let (dot, col) = match t.status {
ConnectionStatus::Connected => ("● ", GREEN),
ConnectionStatus::Connecting => ("◐ ", YELLOW),
ConnectionStatus::Error(_) => ("× ", RED),
ConnectionStatus::Disconnected => ("○ ", TEXT_HINT),
};
RichText::new(format!("{dot}{}", t.label)).color(col).into()
}
None => RichText::new("Remote").color(TEXT_HINT).into(),
},
}
}
fn id(&mut self, tab: &mut PaneTab) -> egui::Id {
match tab {
PaneTab::Local => egui::Id::new("pane_tab_local"),
PaneTab::Remote(id) => egui::Id::new(("pane_tab_remote", id.clone())),
}
}
fn ui(&mut self, ui: &mut Ui, tab: &mut PaneTab) {
match tab {
PaneTab::Local => {
local_pane::render(
ui,
self.state,
self.queue,
self.registry,
self.rt_handle,
self.db,
);
}
PaneTab::Remote(id) => {
if let Some(idx) = self.state.tabs.iter().position(|t| &t.id == id) {
self.state.active_tab = idx;
remote_pane::render(
ui,
self.state,
idx,
self.registry,
self.rt_handle,
self.queue,
);
} else {
ui.vertical_centered(|ui| {
ui.add_space(24.0);
ui.label(RichText::new("Disconnected").color(TEXT_HINT));
});
}
}
}
}
fn closeable(&mut self, tab: &mut PaneTab) -> bool {
matches!(tab, PaneTab::Remote(_))
}
fn on_close(&mut self, tab: &mut PaneTab) -> bool {
if let PaneTab::Remote(id) = tab {
*self.closed_remote = Some(id.clone());
}
true
}
}

View file

@ -1,47 +0,0 @@
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(_, _, _))
}
}

View file

@ -1,99 +0,0 @@
//! Solar icon set (https://solar-icons.vercel.app) — встроенные SVG, красятся через tint()
use egui::{Color32, Ui};
macro_rules! icon_set {
($($key:ident => $file:literal),* $(,)?) => {
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Icon { $($key),* }
fn bytes(icon: &Icon) -> &'static [u8] {
match icon {
$(Icon::$key => include_bytes!(concat!("icons/svg/", $file, ".svg"))),*
}
}
fn uri(icon: &Icon) -> &'static str {
match icon {
$(Icon::$key => concat!("bytes://solar/", $file, ".svg")),*
}
}
};
}
icon_set! {
Folder => "folder-bold",
FolderWithFiles => "folder-with-files-linear",
AddCircleLinear => "add-circle-linear",
AddCircleBold => "add-circle-bold",
AddSquare => "add-square-linear",
Upload => "upload-linear",
Download => "download-linear",
DownloadSquare => "download-square-bold",
Clock => "clock-circle-linear",
Refresh => "refresh-linear",
Trash => "trash-bin-minimalistic-linear",
Pen => "pen-linear",
History => "history-linear",
ServerSquare => "server-square-linear",
Home => "home-2-bold",
Monitor => "monitor-bold",
Document => "document-bold",
DocumentText => "document-text-bold",
Ssd => "ssd-square-linear",
Database => "database-linear",
DangerTriangle => "danger-triangle-bold",
LockPassword => "lock-password-bold",
ArrowDown => "alt-arrow-down-linear",
ArrowUp => "alt-arrow-up-linear",
CheckCircle => "check-circle-bold",
PlayCircle => "play-circle-linear",
PauseCircle => "pause-circle-linear",
FileText => "file-text-bold",
Gallery => "gallery-bold",
Code => "code-bold",
Archive => "archive-down-minimlistic-bold",
ShieldKeyhole => "shield-keyhole-bold",
File => "file-bold",
Star => "star-bold",
Videocamera => "videocamera-record-bold",
MusicNote => "music-note-3-bold",
Link => "link-bold",
CloseCircle => "close-circle-linear",
Settings => "settings-linear",
}
/// Рисует иконку заданного размера и цвета (tint) в текущей позиции ui.
pub fn icon(ui: &mut Ui, icon: Icon, size: f32, color: Color32) -> egui::Response {
let image = egui::Image::from_bytes(uri(&icon), bytes(&icon))
.tint(color)
.fit_to_exact_size(egui::vec2(size, size));
ui.add(image)
}
/// Тот же рендер, но для использования внутри builder-паттернов (кнопки и т.д.) — возвращает Image.
pub fn image(icon: Icon, size: f32, color: Color32) -> egui::Image<'static> {
egui::Image::from_bytes(uri(&icon), bytes(&icon))
.tint(color)
.fit_to_exact_size(egui::vec2(size, size))
}
/// Цвет + иконка по расширению файла — палитра Clay
pub fn file_icon_for(ext: &str) -> (Icon, Color32) {
use crate::ui::theme::*;
match ext {
"pdf" => (Icon::FileText, RED),
"xlsx" | "xls" | "doc" | "docx" => (Icon::Document, GREEN),
"png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" => (Icon::Gallery, ACCENT),
"mp4" | "mkv" | "avi" | "mov" => (Icon::Videocamera, ACCENT),
"mp3" | "ogg" | "flac" | "wav" => (Icon::MusicNote, GREEN),
"zip" | "tar" | "gz" | "bz2" | "xz" | "7z" => (Icon::Archive, TEXT_DIM),
"yaml" | "yml" | "toml" | "json" => (Icon::Code, YELLOW),
"php" | "java" | "class" | "jar" => (Icon::Code, ACCENT_DIM),
"rs" | "py" | "sh" | "bash" | "fish" | "zsh" => (Icon::Code, GREEN),
"html" | "css" | "js" | "ts" => (Icon::Code, ACCENT),
"log" => (Icon::DocumentText, TEXT_HINT),
"htaccess" => (Icon::ShieldKeyhole, TEXT_DIM),
"txt" | "md" => (Icon::DocumentText, TEXT_DIM),
_ => (Icon::File, TEXT_DIM),
}
}

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" fill-rule="evenodd" d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2S2 6.477 2 12s4.477 10 10 10m.75-13a.75.75 0 0 0-1.5 0v2.25H9a.75.75 0 0 0 0 1.5h2.25V15a.75.75 0 0 0 1.5 0v-2.25H15a.75.75 0 0 0 0-1.5h-2.25z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 343 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="#ffffff" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path stroke-linecap="round" d="M15 12h-3m0 0H9m3 0V9m0 3v3"/></g></svg>

Before

Width:  |  Height:  |  Size: 243 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="#ffffff" stroke-width="1.5"><path d="M2 12c0-4.714 0-7.071 1.464-8.536C4.93 2 7.286 2 12 2s7.071 0 8.535 1.464C22 4.93 22 7.286 22 12s0 7.071-1.465 8.535C19.072 22 16.714 22 12 22s-7.071 0-8.536-1.465C2 19.072 2 16.714 2 12Z"/><path stroke-linecap="round" d="M15 12h-3m0 0H9m3 0V9m0 3v3"/></g></svg>

Before

Width:  |  Height:  |  Size: 410 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m19 9l-7 6l-7-6"/></svg>

Before

Width:  |  Height:  |  Size: 216 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m19 15l-7-6l-7 6"/></svg>

Before

Width:  |  Height:  |  Size: 217 B

View file

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<rect x="90" y="90" width="844" height="844" rx="188" fill="#191611"/>
<path fill="#C1673C" fill-rule="evenodd" transform="translate(262,262) scale(23)" d="M2.07 5.258C2 5.626 2 6.068 2 6.95V14c0 3.771 0 5.657 1.172 6.828S6.229 22 10 22h4c3.771 0 5.657 0 6.828-1.172S22 17.771 22 14v-2.202c0-2.632 0-3.949-.77-4.804a3 3 0 0 0-.224-.225C20.151 6 18.834 6 16.202 6h-.374c-1.153 0-1.73 0-2.268-.153a4 4 0 0 1-.848-.352C12.224 5.224 11.816 4.815 11 4l-.55-.55c-.274-.274-.41-.41-.554-.53a4 4 0 0 0-2.18-.903C7.53 2 7.336 2 6.95 2c-.883 0-1.324 0-1.692.07A4 4 0 0 0 2.07 5.257M12.25 10a.75.75 0 0 1 .75-.75h5a.75.75 0 0 1 0 1.5h-5a.75.75 0 0 1-.75-.75" clip-rule="evenodd"/>
</svg>

Before

Width:  |  Height:  |  Size: 769 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" d="M20.535 3.464C19.07 2 16.713 2 11.999 2S4.93 2 3.464 3.464c-.758.758-1.123 1.754-1.3 3.192a6.5 6.5 0 0 1 1.884-1.448c.782-.398 1.619-.56 2.545-.635C7.488 4.5 8.59 4.5 9.936 4.5h4.126c1.347 0 2.448 0 3.343.073c.927.076 1.764.237 2.545.635a6.5 6.5 0 0 1 1.884 1.448c-.176-1.438-.542-2.434-1.3-3.192"/><path fill="#ffffff" fill-rule="evenodd" d="M2 14c0-2.8 0-4.2.545-5.27A5 5 0 0 1 4.73 6.545C5.8 6 7.2 6 10 6h4c2.8 0 4.2 0 5.27.545a5 5 0 0 1 2.185 2.185C22 9.8 22 11.2 22 14s0 4.2-.545 5.27a5 5 0 0 1-2.185 2.185C18.2 22 16.8 22 14 22h-4c-2.8 0-4.2 0-5.27-.545a5 5 0 0 1-2.185-2.185C2 18.2 2 16.8 2 14m10.53 3.53a.75.75 0 0 1-1.06 0l-2.5-2.5a.75.75 0 1 1 1.06-1.06l1.22 1.22V11a.75.75 0 0 1 1.5 0v4.19l1.22-1.22a.75.75 0 1 1 1.06 1.06z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 872 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" fill-rule="evenodd" d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12S6.477 2 12 2s10 4.477 10 10m-5.97-3.03a.75.75 0 0 1 0 1.06l-5 5a.75.75 0 0 1-1.06 0l-2-2a.75.75 0 1 1 1.06-1.06l1.47 1.47l2.235-2.235L14.97 8.97a.75.75 0 0 1 1.06 0" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 366 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="#ffffff" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l2.5 2.5"/></g></svg>

Before

Width:  |  Height:  |  Size: 255 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="#ffffff" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path stroke-linecap="round" d="m14.5 9.5l-5 5m0-5l5 5"/></g></svg>

Before

Width:  |  Height:  |  Size: 238 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" d="M14.18 4.276a.75.75 0 0 1 .531.918l-3.973 14.83a.75.75 0 0 1-1.45-.389l3.974-14.83a.75.75 0 0 1 .919-.53m2.262 3.053a.75.75 0 0 1 1.059-.056l1.737 1.564c.737.662 1.347 1.212 1.767 1.71c.44.525.754 1.088.754 1.784c0 .695-.313 1.258-.754 1.782c-.42.499-1.03 1.049-1.767 1.711l-1.737 1.564a.75.75 0 0 1-1.004-1.115l1.697-1.527c.788-.709 1.319-1.19 1.663-1.598c.33-.393.402-.622.402-.818s-.072-.424-.402-.817c-.344-.409-.875-.89-1.663-1.598l-1.697-1.527a.75.75 0 0 1-.056-1.06m-8.94 1.06a.75.75 0 1 0-1.004-1.115L4.761 8.836c-.737.662-1.347 1.212-1.767 1.71c-.44.525-.754 1.088-.754 1.784c0 .695.313 1.258.754 1.782c.42.499 1.03 1.049 1.767 1.711l1.737 1.564a.75.75 0 0 0 1.004-1.115l-1.697-1.527c-.788-.709-1.319-1.19-1.663-1.598c-.33-.393-.402-.622-.402-.818s.072-.424.402-.817c.344-.409.875-.89 1.663-1.598z"/></svg>

Before

Width:  |  Height:  |  Size: 924 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" fill-rule="evenodd" d="M5.312 10.762C8.23 5.587 9.689 3 12 3s3.77 2.587 6.688 7.762l.364.644c2.425 4.3 3.638 6.45 2.542 8.022S17.786 21 12.364 21h-.728c-5.422 0-8.134 0-9.23-1.572s.117-3.722 2.542-8.022zM12 7.25a.75.75 0 0 1 .75.75v5a.75.75 0 0 1-1.5 0V8a.75.75 0 0 1 .75-.75M12 17a1 1 0 1 0 0-2a1 1 0 0 0 0 2" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 444 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="#ffffff" stroke-width="1.5"><path stroke-linecap="round" d="M4 18V6m16 0v12"/><path d="M12 10c4.418 0 8-1.79 8-4s-3.582-4-8-4s-8 1.79-8 4s3.582 4 8 4Zm8 2c0 2.21-3.582 4-8 4s-8-1.79-8-4m16 6c0 2.21-3.582 4-8 4s-8-1.79-8-4"/></g></svg>

Before

Width:  |  Height:  |  Size: 345 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" fill-rule="evenodd" d="M4.172 3.172C3 4.343 3 6.229 3 10v4c0 3.771 0 5.657 1.172 6.828S7.229 22 11 22h2c3.771 0 5.657 0 6.828-1.172S21 17.771 21 14v-4c0-3.771 0-5.657-1.172-6.828S16.771 2 13 2h-2C7.229 2 5.343 2 4.172 3.172M8 9.25a.75.75 0 0 0 0 1.5h8a.75.75 0 0 0 0-1.5zm0 4a.75.75 0 0 0 0 1.5h5a.75.75 0 0 0 0-1.5z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 451 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" fill-rule="evenodd" d="M4.172 3.172C3 4.343 3 6.229 3 10v4c0 3.771 0 5.657 1.172 6.828S7.229 22 11 22h2c3.771 0 5.657 0 6.828-1.172S21 17.771 21 14v-4c0-3.771 0-5.657-1.172-6.828S16.771 2 13 2h-2C7.229 2 5.343 2 4.172 3.172M7.25 8A.75.75 0 0 1 8 7.25h8a.75.75 0 0 1 0 1.5H8A.75.75 0 0 1 7.25 8m0 4a.75.75 0 0 1 .75-.75h8a.75.75 0 0 1 0 1.5H8a.75.75 0 0 1-.75-.75M8 15.25a.75.75 0 0 0 0 1.5h5a.75.75 0 0 0 0-1.5z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 546 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="#ffffff" stroke-linecap="round" stroke-width="1.5"><path d="M17 9.002c2.175.012 3.353.109 4.121.877C22 10.758 22 12.172 22 15v1c0 2.829 0 4.243-.879 5.122C20.243 22 18.828 22 16 22H8c-2.828 0-4.243 0-5.121-.878C2 20.242 2 18.829 2 16v-1c0-2.828 0-4.242.879-5.121c.768-.768 1.946-.865 4.121-.877"/><path stroke-linejoin="round" d="M12 2v13m0 0l-3-3.5m3 3.5l3-3.5"/></g></svg>

Before

Width:  |  Height:  |  Size: 485 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" fill-rule="evenodd" d="M2 12c0-4.714 0-7.071 1.464-8.536C4.93 2 7.286 2 12 2s7.071 0 8.535 1.464C22 4.93 22 7.286 22 12s0 7.071-1.465 8.535C19.072 22 16.714 22 12 22s-7.071 0-8.536-1.465C2 19.072 2 16.714 2 12m10-5.75a.75.75 0 0 1 .75.75v5.19l1.72-1.72a.75.75 0 1 1 1.06 1.06l-3 3a.75.75 0 0 1-1.06 0l-3-3a.75.75 0 1 1 1.06-1.06l1.72 1.72V7a.75.75 0 0 1 .75-.75m-4 10a.75.75 0 0 0 0 1.5h8a.75.75 0 0 0 0-1.5z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 543 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" fill-rule="evenodd" d="M14 22h-4c-3.771 0-5.657 0-6.828-1.172S2 17.771 2 14v-4c0-3.771 0-5.657 1.172-6.828S6.239 2 10.03 2c.606 0 1.091 0 1.5.017q-.02.12-.02.244l-.01 2.834c0 1.097 0 2.067.105 2.848c.114.847.375 1.694 1.067 2.386c.69.69 1.538.952 2.385 1.066c.781.105 1.751.105 2.848.105h4.052c.043.534.043 1.19.043 2.063V14c0 3.771 0 5.657-1.172 6.828S17.771 22 14 22" clip-rule="evenodd"/><path fill="#ffffff" d="m19.352 7.617l-3.96-3.563c-1.127-1.015-1.69-1.523-2.383-1.788L13 5c0 2.357 0 3.536.732 4.268S15.643 10 18 10h3.58c-.362-.704-1.012-1.288-2.228-2.383"/></svg>

Before

Width:  |  Height:  |  Size: 678 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" fill-rule="evenodd" d="M14 22h-4c-3.771 0-5.657 0-6.828-1.172S2 17.771 2 14v-4c0-3.771 0-5.657 1.172-6.828S6.239 2 10.03 2c.606 0 1.091 0 1.5.017q-.02.12-.02.244l-.01 2.834c0 1.097 0 2.067.105 2.848c.114.847.375 1.694 1.067 2.386c.69.69 1.538.952 2.385 1.066c.781.105 1.751.105 2.848.105h4.052c.043.534.043 1.19.043 2.063V14c0 3.771 0 5.657-1.172 6.828S17.771 22 14 22m-8.75-7.5a.75.75 0 0 1 .75-.75h8a.75.75 0 0 1 0 1.5H6a.75.75 0 0 1-.75-.75m0 3.5a.75.75 0 0 1 .75-.75h5.5a.75.75 0 0 1 0 1.5H6a.75.75 0 0 1-.75-.75" clip-rule="evenodd"/><path fill="#ffffff" d="m19.352 7.617l-3.96-3.563c-1.127-1.015-1.69-1.523-2.383-1.788L13 5c0 2.357 0 3.536.732 4.268S15.643 10 18 10h3.58c-.362-.704-1.012-1.288-2.228-2.383"/></svg>

Before

Width:  |  Height:  |  Size: 826 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" fill-rule="evenodd" d="M2.07 5.258C2 5.626 2 6.068 2 6.95V14c0 3.771 0 5.657 1.172 6.828S6.229 22 10 22h4c3.771 0 5.657 0 6.828-1.172S22 17.771 22 14v-2.202c0-2.632 0-3.949-.77-4.804a3 3 0 0 0-.224-.225C20.151 6 18.834 6 16.202 6h-.374c-1.153 0-1.73 0-2.268-.153a4 4 0 0 1-.848-.352C12.224 5.224 11.816 4.815 11 4l-.55-.55c-.274-.274-.41-.41-.554-.53a4 4 0 0 0-2.18-.903C7.53 2 7.336 2 6.95 2c-.883 0-1.324 0-1.692.07A4 4 0 0 0 2.07 5.257M12.25 10a.75.75 0 0 1 .75-.75h5a.75.75 0 0 1 0 1.5h-5a.75.75 0 0 1-.75-.75" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 648 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="#ffffff" stroke-width="1.5"><path stroke-linecap="round" d="M18 10h-5"/><path d="M10 3h6.5c.464 0 .697 0 .892.026a3 3 0 0 1 2.582 2.582c.026.195.026.428.026.892"/><path d="M2 6.95c0-.883 0-1.324.07-1.692A4 4 0 0 1 5.257 2.07C5.626 2 6.068 2 6.95 2c.386 0 .58 0 .766.017a4 4 0 0 1 2.18.904c.144.119.28.255.554.529L11 4c.816.816 1.224 1.224 1.712 1.495a4 4 0 0 0 .848.352C14.098 6 14.675 6 15.828 6h.374c2.632 0 3.949 0 4.804.77q.119.105.224.224c.77.855.77 2.172.77 4.804V14c0 3.771 0 5.657-1.172 6.828S17.771 22 14 22h-4c-3.771 0-5.657 0-6.828-1.172S2 17.771 2 14z"/></g></svg>

Before

Width:  |  Height:  |  Size: 687 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" d="M18 8a2 2 0 1 1-4 0a2 2 0 0 1 4 0"/><path fill="#ffffff" fill-rule="evenodd" d="M11.943 1.25h.114c2.309 0 4.118 0 5.53.19c1.444.194 2.584.6 3.479 1.494c.895.895 1.3 2.035 1.494 3.48c.19 1.411.19 3.22.19 5.529v.088c0 1.909 0 3.471-.104 4.743c-.104 1.28-.317 2.347-.795 3.235q-.314.586-.785 1.057c-.895.895-2.035 1.3-3.48 1.494c-1.411.19-3.22.19-5.529.19h-.114c-2.309 0-4.118 0-5.53-.19c-1.444-.194-2.584-.6-3.479-1.494c-.793-.793-1.203-1.78-1.42-3.006c-.215-1.203-.254-2.7-.262-4.558Q1.25 12.792 1.25 12v-.058c0-2.309 0-4.118.19-5.53c.194-1.444.6-2.584 1.494-3.479c.895-.895 2.035-1.3 3.48-1.494c1.411-.19 3.22-.19 5.529-.19m-5.33 1.676c-1.278.172-2.049.5-2.618 1.069c-.57.57-.897 1.34-1.069 2.619c-.174 1.3-.176 3.008-.176 5.386v.844l1.001-.876a2.3 2.3 0 0 1 3.141.104l4.29 4.29a2 2 0 0 0 2.564.222l.298-.21a3 3 0 0 1 3.732.225l2.83 2.547c.286-.598.455-1.384.545-2.493c.098-1.205.099-2.707.099-4.653c0-2.378-.002-4.086-.176-5.386c-.172-1.279-.5-2.05-1.069-2.62c-.57-.569-1.34-.896-2.619-1.068c-1.3-.174-3.008-.176-5.386-.176s-4.086.002-5.386.176" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff"><path stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l2.5 2.5"/><path fill="#ffffff" d="m5.604 5.604l-.53-.53zM4.338 6.871l-.75.003a.75.75 0 0 0 .746.747zm2.542.762a.75.75 0 1 0 .007-1.5zM5.075 4.321a.75.75 0 1 0-1.5.008zm-1.248 6.464a.75.75 0 1 0-1.486-.204zm15.035-5.647c-3.82-3.82-9.993-3.86-13.788-.064l1.06 1.06c3.2-3.199 8.423-3.18 11.668.064zM5.138 18.862c3.82 3.82 9.993 3.86 13.788.064l-1.06-1.06c-3.2 3.199-8.423 3.18-11.668-.064zm13.788.064c3.795-3.795 3.756-9.968-.064-13.788l-1.06 1.06c3.244 3.245 3.263 8.468.064 11.668zM5.074 5.074L3.807 6.34L4.868 7.4l1.266-1.266zm-.74 2.547l2.546.012l.007-1.5l-2.545-.012zm.754-.754L5.075 4.32l-1.5.008l.013 2.545zM2.34 10.58a9.81 9.81 0 0 0 2.797 8.281l1.06-1.06a8.31 8.31 0 0 1-2.371-7.017z"/></g></svg>

Before

Width:  |  Height:  |  Size: 905 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" fill-rule="evenodd" d="M2.52 7.823C2 8.77 2 9.915 2 12.203v1.522c0 3.9 0 5.851 1.172 7.063S6.229 22 10 22h4c3.771 0 5.657 0 6.828-1.212S22 17.626 22 13.725v-1.521c0-2.289 0-3.433-.52-4.381c-.518-.949-1.467-1.537-3.364-2.715l-2-1.241C14.111 2.622 13.108 2 12 2s-2.11.622-4.116 1.867l-2 1.241C3.987 6.286 3.038 6.874 2.519 7.823M11.25 18a.75.75 0 0 0 1.5 0v-3a.75.75 0 0 0-1.5 0z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 512 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" d="M15.729 3.884c1.434-1.44 3.532-1.47 4.693-.304c1.164 1.168 1.133 3.28-.303 4.72l-2.423 2.433a.75.75 0 0 0 1.062 1.059l2.424-2.433c1.911-1.919 2.151-4.982.303-6.838c-1.85-1.857-4.907-1.615-6.82.304L9.819 7.692c-1.911 1.919-2.151 4.982-.303 6.837a.75.75 0 1 0 1.063-1.058c-1.164-1.168-1.132-3.28.303-4.72z"/><path fill="#ffffff" d="M14.485 9.47a.75.75 0 0 0-1.063 1.06c1.164 1.168 1.133 3.279-.303 4.72l-4.847 4.866c-1.435 1.44-3.533 1.47-4.694.304c-1.164-1.168-1.132-3.28.303-4.72l2.424-2.433a.75.75 0 0 0-1.063-1.059l-2.424 2.433c-1.911 1.92-2.151 4.982-.303 6.838c1.85 1.858 4.907 1.615 6.82-.304l4.847-4.867c1.911-1.918 2.151-4.982.303-6.837"/></svg>

Before

Width:  |  Height:  |  Size: 761 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" fill-rule="evenodd" d="M5.25 10.055V8a6.75 6.75 0 0 1 13.5 0v2.055c1.115.083 1.84.293 2.371.824C22 11.757 22 13.172 22 16s0 4.243-.879 5.121C20.243 22 18.828 22 16 22H8c-2.828 0-4.243 0-5.121-.879C2 20.243 2 18.828 2 16s0-4.243.879-5.121c.53-.531 1.256-.741 2.371-.824M6.75 8a5.25 5.25 0 0 1 10.5 0v2.004Q16.676 9.999 16 10H8q-.677-.001-1.25.004zM8 17a1 1 0 1 0 0-2a1 1 0 0 0 0 2m4 0a1 1 0 1 0 0-2a1 1 0 0 0 0 2m5-1a1 1 0 1 1-2 0a1 1 0 0 1 2 0" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 578 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" d="M8 17c-2.828 0-4.243 0-5.121-.879c-.57-.569-.77-1.363-.84-2.621h19.923c-.07 1.258-.271 2.052-.84 2.621C20.241 17 18.827 17 16 17h-3.25v4H16a.75.75 0 0 1 0 1.5H8A.75.75 0 0 1 8 21h3.25v-4zm2-15h4c3.771 0 5.657 0 6.828 1.172S22 6.229 22 10v1q.002.827-.006 1.5H2.007Q1.998 11.827 2 11v-1c0-3.771 0-5.657 1.172-6.828S6.229 2 10 2"/></svg>

Before

Width:  |  Height:  |  Size: 443 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" d="M14.319 2.505A2.75 2.75 0 0 0 11.414 4.3c-.098.27-.132.563-.148.869A17 17 0 0 0 11.25 6v8.536A4.75 4.75 0 1 0 12.75 18V9.21q.156.083.343.175L15.8 10.74c.418.21.759.38 1.038.5c.281.123.558.223.843.257A2.75 2.75 0 0 0 20.586 9.7c.098-.27.132-.563.148-.87c.016-.303.016-.683.016-1.151v-.083c0-.348 0-.62-.049-.878a2.75 2.75 0 0 0-1.03-1.667c-.21-.16-.453-.281-.764-.436L16.2 3.262a22 22 0 0 0-1.038-.501c-.28-.123-.558-.223-.843-.256"/></svg>

Before

Width:  |  Height:  |  Size: 548 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="#ffffff" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path d="M8 9.5c0-.466 0-.699.076-.883a1 1 0 0 1 .541-.54C8.801 8 9.034 8 9.5 8s.699 0 .883.076a1 1 0 0 1 .54.541c.077.184.077.417.077.883v5c0 .466 0 .699-.076.883a1 1 0 0 1-.541.54C10.199 16 9.966 16 9.5 16s-.699 0-.883-.076a1 1 0 0 1-.54-.541C8 15.199 8 14.966 8 14.5zm5 0c0-.466 0-.699.076-.883a1 1 0 0 1 .541-.54C13.801 8 14.034 8 14.5 8s.699 0 .883.076a1 1 0 0 1 .54.541c.077.184.077.417.077.883v5c0 .466 0 .699-.076.883a1 1 0 0 1-.541.54c-.184.077-.417.077-.883.077s-.699 0-.883-.076a1 1 0 0 1-.54-.541C13 15.199 13 14.966 13 14.5z"/></g></svg>

Before

Width:  |  Height:  |  Size: 721 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" stroke="#ffffff" stroke-width="1.5" d="m14.36 4.079l.927-.927a3.932 3.932 0 0 1 5.561 5.561l-.927.927m-5.56-5.561s.115 1.97 1.853 3.707C17.952 9.524 19.92 9.64 19.92 9.64m-5.56-5.561l-8.522 8.52c-.577.578-.866.867-1.114 1.185a6.6 6.6 0 0 0-.749 1.211c-.173.364-.302.752-.56 1.526l-1.094 3.281m17.6-10.162L11.4 18.16c-.577.577-.866.866-1.184 1.114a6.6 6.6 0 0 1-1.211.749c-.364.173-.751.302-1.526.56l-3.281 1.094m0 0l-.802.268a1.06 1.06 0 0 1-1.342-1.342l.268-.802m1.876 1.876l-1.876-1.876"/></svg>

Before

Width:  |  Height:  |  Size: 603 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="#ffffff" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path d="M15.414 10.941c.781.462.781 1.656 0 2.118l-4.72 2.787C9.934 16.294 9 15.71 9 14.786V9.214c0-.924.934-1.507 1.694-1.059z"/></g></svg>

Before

Width:  |  Height:  |  Size: 312 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" d="M3.68 11.333h-.75zm0 1.667l-.528.532a.75.75 0 0 0 1.056 0zm2.208-1.134A.75.75 0 1 0 4.83 10.8zM2.528 10.8a.75.75 0 0 0-1.056 1.065zm16.088-3.408a.75.75 0 1 0 1.277-.786zM12.079 2.25c-5.047 0-9.15 4.061-9.15 9.083h1.5c0-4.182 3.42-7.583 7.65-7.583zm-9.15 9.083V13h1.5v-1.667zm1.28 2.2l1.679-1.667L4.83 10.8l-1.68 1.667zm0-1.065L2.528 10.8l-1.057 1.065l1.68 1.666zm15.684-5.86A9.16 9.16 0 0 0 12.08 2.25v1.5a7.66 7.66 0 0 1 6.537 3.643zM20.314 11l.527-.533a.75.75 0 0 0-1.054 0zM18.1 12.133a.75.75 0 0 0 1.055 1.067zm3.373 1.067a.75.75 0 1 0 1.054-1.067zM5.318 16.606a.75.75 0 1 0-1.277.788zm6.565 5.144c5.062 0 9.18-4.058 9.18-9.083h-1.5c0 4.18-3.43 7.583-7.68 7.583zm9.18-9.083V11h-1.5v1.667zm-1.276-2.2L18.1 12.133l1.055 1.067l1.686-1.667zm0 1.066l1.686 1.667l1.054-1.067l-1.686-1.666zM4.04 17.393a9.2 9.2 0 0 0 7.842 4.357v-1.5a7.7 7.7 0 0 1-6.565-3.644z"/></svg>

Before

Width:  |  Height:  |  Size: 974 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="#ffffff" stroke-width="1.5"><path d="M2 11c0-3.771 0-5.657 1.172-6.828S6.229 3 10 3h4c3.771 0 5.657 0 6.828 1.172S22 7.229 22 11v2c0 3.771 0 5.657-1.172 6.828S17.771 21 14 21h-4c-3.771 0-5.657 0-6.828-1.172S2 16.771 2 13zm0 1h20"/><path stroke-linecap="round" d="M13.5 16.5H18m-4.5-9H18m-12 10v-2m0-7v-2m3 11v-2m0-7v-2"/></g></svg>

Before

Width:  |  Height:  |  Size: 442 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="#ffffff" stroke-width="1.5"><circle cx="12" cy="12" r="3"/><path d="M13.765 2.152C13.398 2 12.932 2 12 2s-1.398 0-1.765.152a2 2 0 0 0-1.083 1.083c-.092.223-.129.484-.143.863a1.62 1.62 0 0 1-.79 1.353a1.62 1.62 0 0 1-1.567.008c-.336-.178-.579-.276-.82-.308a2 2 0 0 0-1.478.396C4.04 5.79 3.806 6.193 3.34 7s-.7 1.21-.751 1.605a2 2 0 0 0 .396 1.479c.148.192.355.353.676.555c.473.297.777.803.777 1.361s-.304 1.064-.777 1.36c-.321.203-.529.364-.676.556a2 2 0 0 0-.396 1.479c.052.394.285.798.75 1.605c.467.807.7 1.21 1.015 1.453a2 2 0 0 0 1.479.396c.24-.032.483-.13.819-.308a1.62 1.62 0 0 1 1.567.008c.483.28.77.795.79 1.353c.014.38.05.64.143.863a2 2 0 0 0 1.083 1.083C10.602 22 11.068 22 12 22s1.398 0 1.765-.152a2 2 0 0 0 1.083-1.083c.092-.223.129-.483.143-.863c.02-.558.307-1.074.79-1.353a1.62 1.62 0 0 1 1.567-.008c.336.178.579.276.819.308a2 2 0 0 0 1.479-.396c.315-.242.548-.646 1.014-1.453s.7-1.21.751-1.605a2 2 0 0 0-.396-1.479c-.148-.192-.355-.353-.676-.555A1.62 1.62 0 0 1 19.562 12c0-.558.304-1.064.777-1.36c.321-.203.529-.364.676-.556a2 2 0 0 0 .396-1.479c-.052-.394-.285-.798-.75-1.605c-.467-.807-.7-1.21-1.015-1.453a2 2 0 0 0-1.479-.396c-.24.032-.483.13-.82.308a1.62 1.62 0 0 1-1.566-.008a1.62 1.62 0 0 1-.79-1.353c-.014-.38-.05-.64-.143-.863a2 2 0 0 0-1.083-1.083Z"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" fill-rule="evenodd" d="M3.378 5.082C3 5.62 3 7.22 3 10.417v1.574c0 5.638 4.239 8.375 6.899 9.536c.721.315 1.082.473 2.101.473c1.02 0 1.38-.158 2.101-.473C16.761 20.365 21 17.63 21 11.991v-1.574c0-3.198 0-4.797-.378-5.335c-.377-.537-1.88-1.052-4.887-2.081l-.573-.196C13.595 2.268 12.812 2 12 2s-1.595.268-3.162.805L8.265 3c-3.007 1.03-4.51 1.545-4.887 2.082M13.5 15a1 1 0 0 1-1 1h-1a1 1 0 0 1-1-1v-1.401A2.999 2.999 0 0 1 12 8a3 3 0 0 1 1.5 5.599z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 581 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff"><path stroke="#ffffff" stroke-linecap="round" stroke-width="1.5" d="M19 17v1m-2.5-1v1M14 17v1m-2.5-1v1"/><path fill="#ffffff" d="m19 5.118l.72-.21l-.002-.007l-.003-.009zm-14 0l-.715-.226l-.003.009l-.002.008zM2.889 20.643l.433-.612zm-.552-.584l.634-.4zm19.326 0l-.634-.4zm-.552.584l-.433-.612zm0-6.698l-.433.612zm.552.584l-.634.4zm-18.774-.584l.433.612zm-.552.584l.634.4zM7.5 3.75h9v-1.5h-9zm9 0c.428 0 .753.1 1.016.305c.269.21.549.59.769 1.288l1.43-.45c-.28-.89-.698-1.57-1.277-2.021c-.587-.457-1.261-.622-1.938-.622zm-9-1.5c-.676 0-1.351.165-1.938.622c-.58.451-.997 1.13-1.277 2.02l1.43.45c.22-.697.5-1.077.77-1.287c.262-.205.587-.305 1.015-.305zm10.78 3.076l2.851 9.839l1.44-.418l-2.85-9.838zm-14-.417l-2.852 9.838l1.44.418L5.72 5.326zm1.22 9.43h13v-1.5h-13zm13 5.911h-13v1.5h13zm-13 0c-.719 0-1.198-.001-1.563-.04c-.35-.038-.509-.104-.615-.18l-.866 1.225c.398.282.842.395 1.32.446c.464.05 1.038.049 1.724.049zm-4.25-2.956c0 .729 0 1.329.046 1.812c.047.493.15.945.407 1.353l1.268-.8c-.08-.126-.145-.313-.182-.697c-.038-.394-.039-.91-.039-1.668zm2.072 2.737a1.3 1.3 0 0 1-.35-.372l-1.27.8c.198.313.453.584.754.796zm17.928-2.737c0 .758 0 1.274-.039 1.668c-.037.384-.103.57-.182.697l1.268.8c.258-.408.36-.86.407-1.353c.047-.483.046-1.083.046-1.812zM18.5 21.75c.686 0 1.26.001 1.723-.049c.479-.052.923-.164 1.321-.446l-.866-1.224c-.106.075-.265.141-.615.179c-.365.039-.844.04-1.563.04zm2.529-2.091a1.3 1.3 0 0 1-.351.372l.866 1.224c.3-.212.556-.483.753-.796zm-2.529-5.32c.719 0 1.198 0 1.563.04c.35.037.509.103.615.178l.866-1.224c-.398-.282-.842-.394-1.32-.446c-.464-.05-1.038-.049-1.724-.049zm4.25 2.955c0-.729 0-1.329-.046-1.811c-.047-.494-.15-.946-.407-1.354l-1.268.8c.08.127.145.313.182.697c.038.394.039.91.039 1.668zm-2.072-2.737c.136.097.256.223.35.373l1.27-.8a2.8 2.8 0 0 0-.754-.797zM5.5 12.838c-.686 0-1.26 0-1.723.05c-.479.05-.923.163-1.321.445l.866 1.224c.106-.075.265-.14.615-.178c.365-.04.844-.04 1.563-.04zm-2.75 4.456c0-.758 0-1.274.039-1.668c.037-.384.103-.57.182-.696l-1.268-.8c-.258.407-.36.86-.407 1.353c-.047.482-.046 1.082-.046 1.811zm-.294-3.961c-.3.212-.556.484-.753.796l1.268.8a1.3 1.3 0 0 1 .351-.372z"/></g></svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" d="M9.153 5.408C10.42 3.136 11.053 2 12 2s1.58 1.136 2.847 3.408l.328.588c.36.646.54.969.82 1.182s.63.292 1.33.45l.636.144c2.46.557 3.689.835 3.982 1.776c.292.94-.546 1.921-2.223 3.882l-.434.507c-.476.557-.715.836-.822 1.18c-.107.345-.071.717.001 1.46l.066.677c.253 2.617.38 3.925-.386 4.506s-1.918.051-4.22-1.009l-.597-.274c-.654-.302-.981-.452-1.328-.452s-.674.15-1.328.452l-.596.274c-2.303 1.06-3.455 1.59-4.22 1.01c-.767-.582-.64-1.89-.387-4.507l.066-.676c.072-.744.108-1.116 0-1.46c-.106-.345-.345-.624-.821-1.18l-.434-.508c-1.677-1.96-2.515-2.941-2.223-3.882S3.58 8.328 6.04 7.772l.636-.144c.699-.158 1.048-.237 1.329-.45s.46-.536.82-1.182z"/></svg>

Before

Width:  |  Height:  |  Size: 761 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" stroke="#ffffff" stroke-linecap="round" stroke-width="1.5" d="M9.17 4a3.001 3.001 0 0 1 5.66 0m5.67 2h-17m15.333 2.5l-.46 6.9c-.177 2.654-.265 3.981-1.13 4.79s-2.196.81-4.856.81h-.774c-2.66 0-3.991 0-4.856-.81c-.865-.809-.954-2.136-1.13-4.79l-.46-6.9M9.5 11l.5 5m4.5-5l-.5 5"/></svg>

Before

Width:  |  Height:  |  Size: 389 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="#ffffff" stroke-linecap="round" stroke-width="1.5"><path d="M17 9.002c2.175.012 3.353.109 4.121.877C22 10.758 22 12.172 22 15v1c0 2.829 0 4.243-.879 5.122C20.243 22 18.828 22 16 22H8c-2.828 0-4.243 0-5.121-.878C2 20.242 2 18.829 2 16v-1c0-2.828 0-4.242.879-5.121c.768-.768 1.946-.865 4.121-.877"/><path stroke-linejoin="round" d="M12 15V2m0 0l3 3.5M12 2L9 5.5"/></g></svg>

Before

Width:  |  Height:  |  Size: 483 B

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" fill-rule="evenodd" d="M2 12.5v-1c0-3.287 0-4.931.908-6.038a4 4 0 0 1 .554-.554C4.57 4 6.212 4 9.5 4c3.287 0 4.931 0 6.038.908a4 4 0 0 1 .554.554c.702.855.861 2.031.897 4.038l.67-.33c1.945-.972 2.918-1.459 3.63-1.019S22 9.68 22 11.854v.292c0 2.175 0 3.263-.711 3.703c-.712.44-1.685-.047-3.63-1.02l-.67-.329c-.036 2.007-.195 3.183-.897 4.038a4 4 0 0 1-.554.554C14.43 20 12.788 20 9.5 20c-3.287 0-4.931 0-6.038-.908a4 4 0 0 1-.554-.554C2 17.43 2 15.788 2 12.5m11.56-2.94a1.5 1.5 0 1 0-2.121-2.12a1.5 1.5 0 0 0 2.122 2.12" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 653 B

View file

@ -1,145 +0,0 @@
//! Нативное меню в верхней панели экрана (только macOS).
//! На остальных платформах кнопка настроек живёт в тулбаре — см. panels/toolbar.rs.
use crate::ui::state::AppState;
#[cfg(target_os = "macos")]
mod ids {
pub const SETTINGS: &str = "loflum-settings";
pub const NEW_CONNECTION: &str = "loflum-new-connection";
pub const SHOW_TOOLBAR: &str = "loflum-show-toolbar";
pub const SHOW_SIDEBAR: &str = "loflum-show-sidebar";
pub const SHOW_STATUS_BAR: &str = "loflum-show-status-bar";
pub const SHOW_QUEUE: &str = "loflum-show-queue";
}
// muda использует Rc внутри (не Send/Sync) — держим пункты меню, отражающие
// видимость панелей, в thread_local, чтобы синхронизировать галочки с AppState.
#[cfg(target_os = "macos")]
thread_local! {
static PANEL_CHECK_ITEMS: std::cell::RefCell<Option<[muda::CheckMenuItem; 4]>> =
const { std::cell::RefCell::new(None) };
}
/// Строит нативное меню macOS: App-меню (с "Settings…"), File, Edit, Window.
/// Вызывать один раз при старте, на главном потоке, уже после инициализации NSApplication.
#[cfg(target_os = "macos")]
pub fn setup_native_menu() {
use muda::{
Menu, PredefinedMenuItem, Submenu,
accelerator::{Accelerator, CMD_OR_CTRL, Code},
};
let menu = Menu::new();
let app_menu = Submenu::new("LoFlum", true);
let _ = app_menu.append_items(&[
&PredefinedMenuItem::about(Some("About LoFlum"), None),
&PredefinedMenuItem::separator(),
&muda::MenuItem::with_id(
ids::SETTINGS,
"Settings…",
true,
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::Comma)),
),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::hide(None),
&PredefinedMenuItem::hide_others(None),
&PredefinedMenuItem::show_all(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::quit(None),
]);
let file_menu = Submenu::new("File", true);
let _ = file_menu.append_items(&[
&muda::MenuItem::with_id(
ids::NEW_CONNECTION,
"New Connection…",
true,
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyN)),
),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::close_window(None),
]);
let edit_menu = Submenu::new("Edit", true);
let _ = edit_menu.append_items(&[
&PredefinedMenuItem::undo(None),
&PredefinedMenuItem::redo(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::cut(None),
&PredefinedMenuItem::copy(None),
&PredefinedMenuItem::paste(None),
&PredefinedMenuItem::select_all(None),
]);
let show_toolbar = muda::CheckMenuItem::with_id(ids::SHOW_TOOLBAR, "Toolbar", true, true, None);
let show_sidebar = muda::CheckMenuItem::with_id(ids::SHOW_SIDEBAR, "Sidebar", true, true, None);
let show_status_bar =
muda::CheckMenuItem::with_id(ids::SHOW_STATUS_BAR, "Status Bar", true, true, None);
let show_queue =
muda::CheckMenuItem::with_id(ids::SHOW_QUEUE, "Transfer Queue", true, true, None);
let window_menu = Submenu::new("Window", true);
let _ = window_menu.append_items(&[
&PredefinedMenuItem::minimize(None),
&PredefinedMenuItem::maximize(None),
&PredefinedMenuItem::separator(),
&show_toolbar,
&show_sidebar,
&show_status_bar,
&show_queue,
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::bring_all_to_front(None),
]);
PANEL_CHECK_ITEMS.with(|cell| {
*cell.borrow_mut() = Some([show_toolbar, show_sidebar, show_status_bar, show_queue]);
});
let _ = menu.append_items(&[&app_menu, &file_menu, &edit_menu, &window_menu]);
menu.init_for_nsapp();
// Держим меню живым на весь процесс — NSApp хранит на него ссылку,
// но Rust-сторона (Rc внутри muda) не должна быть уничтожена раньше.
Box::leak(Box::new(menu));
}
#[cfg(not(target_os = "macos"))]
pub fn setup_native_menu() {}
/// Забирает события из нативного меню (клики по кастомным пунктам) и применяет к состоянию.
/// На платформах без нативного меню — no-op.
#[cfg(target_os = "macos")]
pub fn poll_menu_events(state: &mut AppState) {
while let Ok(event) = muda::MenuEvent::receiver().try_recv() {
let id = event.id().0.as_str();
if id == ids::SETTINGS {
state.show_settings_dialog = true;
} else if id == ids::NEW_CONNECTION {
state.show_connect_dialog = true;
} else if id == ids::SHOW_TOOLBAR {
state.show_toolbar = !state.show_toolbar;
} else if id == ids::SHOW_SIDEBAR {
state.show_sidebar = !state.show_sidebar;
} else if id == ids::SHOW_STATUS_BAR {
state.show_status_bar = !state.show_status_bar;
} else if id == ids::SHOW_QUEUE {
state.show_queue_panel = !state.show_queue_panel;
}
}
// Держим галочки в меню в согласии с state — независимо от того, что
// именно их поменяло (клик по меню или чекбокс в Settings).
PANEL_CHECK_ITEMS.with(|cell| {
if let Some(items) = cell.borrow().as_ref() {
let [toolbar, sidebar, status_bar, queue] = items;
toolbar.set_checked(state.show_toolbar);
sidebar.set_checked(state.show_sidebar);
status_bar.set_checked(state.show_status_bar);
queue.set_checked(state.show_queue_panel);
}
});
}
#[cfg(not(target_os = "macos"))]
pub fn poll_menu_events(_state: &mut AppState) {}

View file

@ -1,10 +0,0 @@
pub mod app;
pub mod dialogs;
pub mod dock;
pub mod drag;
pub mod icons;
pub mod menu;
pub mod panels;
pub mod state;
pub mod theme;
pub mod widgets;

View file

@ -1,93 +0,0 @@
//! Переиспользуемая таблица файлов: публичный API, типы и форматтеры.
//! Рендер строк — в [`table`], сортировка — в [`sort`].
use crate::domain::file_entry::FileEntry;
use crate::ui::drag::DragPayload;
use crate::ui::icons::{self, Icon};
use crate::ui::theme::*;
use crate::ui::widgets::button;
use egui::{RichText, Ui};
mod row;
mod sort;
mod table;
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("%d.%m.%Y %H:%M").to_string(),
None => String::new(),
}
}
#[derive(Clone, Copy, PartialEq)]
pub enum SortCol {
Name,
Size,
Kind,
Modified,
}
#[derive(Clone, Copy, PartialEq)]
pub enum SortDir {
Asc,
Desc,
}
/// Действие, запрошенное через контекстное меню (ПКМ) на строке.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum RowAction {
Open,
Rename,
Delete,
Bookmark,
CopyPath,
/// Загрузка/выгрузка этой конкретной строки — направление решает вызывающая сторона.
Transfer,
}
pub struct FileTableResponse {
pub double_clicked: Option<String>,
pub clicked: Option<String>,
/// Payload, перетащенный на папку (или "..") в этой таблице — (целевая папка, что бросили).
pub dropped_on_dir: Option<(FileEntry, std::sync::Arc<DragPayload>)>,
/// Действие из контекстного меню (ПКМ) — (строка, действие).
pub context_action: Option<(FileEntry, RowAction)>,
}
pub fn file_table<F, C>(
ui: &mut Ui,
id: &str,
entries: &[FileEntry],
selected: &mut Option<String>,
make_drag_payload: F,
context_menu: C,
) -> FileTableResponse
where
F: Fn(&FileEntry) -> Option<DragPayload>,
C: FnMut(&mut Ui, &FileEntry) -> Option<RowAction>,
{
ui.push_id(id, |ui| {
table::file_table_inner(ui, id, entries, selected, make_drag_payload, context_menu)
})
.inner
}
/// Единообразный пункт для контекстных меню (ПКМ на строке) с подсветкой на hover.
pub fn context_menu_item(ui: &mut Ui, icon: Icon, label: &str, danger: bool) -> bool {
let color = if danger { RED } else { TEXT_PRIMARY };
let btn = egui::Button::image_and_text(
icons::image(icon, 13.0, color),
RichText::new(label).size(12.0).color(color),
)
.min_size(egui::vec2(170.0, 26.0));
button::ghost(ui, btn).clicked()
}

View file

@ -1,120 +0,0 @@
//! Отрисовка шапки колонок и содержимого одной строки таблицы файлов.
use egui::{Id, RichText, Ui};
use super::sort::{sort_header, toggle_sort};
use super::{SortCol, SortDir, format_size, format_time};
use crate::domain::file_entry::{EntryKind, FileEntry};
use crate::ui::drag::DragPayload;
use crate::ui::icons::{self, Icon, file_icon_for};
use crate::ui::theme::*;
/// Шапка с кликабельными заголовками колонок.
pub(super) fn render_header(
ui: &mut Ui,
sort_col: SortCol,
sort_dir: SortDir,
new_sort: &mut Option<(SortCol, SortDir)>,
) {
let header_frame = egui::Frame::none()
.fill(BG_HEADER)
.inner_margin(egui::Margin::symmetric(12.0, 0.0));
header_frame.show(ui, |ui| {
let width = ui.available_width();
ui.allocate_ui_with_layout(
egui::vec2(width, 24.0),
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
use crate::i18n::t;
if sort_header(ui, t("panels.col_name"), 0.0, sort_col == SortCol::Name, sort_dir)
{
*new_sort = Some(toggle_sort(SortCol::Name, sort_col, sort_dir));
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if sort_header(
ui,
t("panels.col_modified"),
140.0,
sort_col == SortCol::Modified,
sort_dir,
) {
*new_sort = Some(toggle_sort(SortCol::Modified, sort_col, sort_dir));
}
if sort_header(ui, t("panels.col_type"), 40.0, sort_col == SortCol::Kind, sort_dir) {
*new_sort = Some(toggle_sort(SortCol::Kind, sort_col, sort_dir));
}
if sort_header(ui, t("panels.col_size"), 70.0, sort_col == SortCol::Size, sort_dir) {
*new_sort = Some(toggle_sort(SortCol::Size, sort_col, sort_dir));
}
});
},
);
});
}
/// Содержимое одной строки: иконка+имя (drag source) слева, метаданные справа.
pub(super) fn draw_row_content<F>(ui: &mut Ui, entry: &FileEntry, row_id: Id, make_drag_payload: &F)
where
F: Fn(&FileEntry) -> Option<DragPayload>,
{
let (icon, icon_col) = if entry.name == ".." {
(Icon::ArrowUp, TEXT_HINT)
} else {
match entry.kind {
EntryKind::Dir => (Icon::Folder, YELLOW),
EntryKind::Symlink => (Icon::Link, YELLOW),
EntryKind::File => {
let ext = entry.name.rsplit('.').next().unwrap_or("").to_lowercase();
file_icon_for(&ext)
}
}
};
let name_col = if entry.name == ".." {
TEXT_HINT
} else {
TEXT_PRIMARY
};
let draw = |ui: &mut Ui| {
icons::icon(ui, icon, 14.0, icon_col);
ui.add_space(6.0);
ui.label(RichText::new(&entry.name).color(name_col).size(12.5));
};
if let Some(payload) = make_drag_payload(entry) {
ui.dnd_drag_source(row_id, payload, |ui| {
ui.horizontal(draw);
});
} else {
draw(ui);
}
// правые колонки
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(
RichText::new(format_time(entry.modified))
.color(TEXT_HINT)
.monospace()
.size(10.5),
);
ui.add_space(6.0);
let (type_str, type_col) = match entry.kind {
EntryKind::Dir => (crate::i18n::t("panels.type_dir"), TEXT_DIM),
EntryKind::File => (crate::i18n::t("panels.type_file"), TEXT_HINT),
EntryKind::Symlink => (crate::i18n::t("panels.type_link"), YELLOW),
};
let type_label =
egui::Label::new(RichText::new(type_str).color(type_col).size(11.0)).truncate();
ui.add_sized([38.0, ROW_H], type_label);
ui.label(
RichText::new(format_size(entry.size))
.color(TEXT_DIM)
.monospace()
.size(11.0),
);
ui.add_space(4.0);
});
}

View file

@ -1,81 +0,0 @@
//! Сортировка таблицы файлов: заголовки-переключатели и сам компаратор.
use egui::{RichText, Ui};
use super::{SortCol, SortDir};
use crate::domain::file_entry::{EntryKind, FileEntry};
use crate::ui::theme::*;
use crate::ui::widgets::button;
/// Сортирует записи по колонке/направлению, держа папки выше файлов, а ".." — первым.
pub(super) fn sort_entries(
entries: &[FileEntry],
sort_col: SortCol,
sort_dir: SortDir,
) -> Vec<FileEntry> {
let mut sorted = entries.to_vec();
let dotdot_pos = sorted.iter().position(|e| e.name == "..");
let dotdot = dotdot_pos.map(|i| sorted.remove(i));
sorted.sort_by(|a, b| {
// папки всегда выше
let dir_cmp = match (&b.kind, &a.kind) {
(EntryKind::Dir, EntryKind::Dir) => std::cmp::Ordering::Equal,
(EntryKind::Dir, _) => std::cmp::Ordering::Greater,
(_, EntryKind::Dir) => std::cmp::Ordering::Less,
_ => std::cmp::Ordering::Equal,
};
if dir_cmp != std::cmp::Ordering::Equal {
return dir_cmp;
}
let ord = match sort_col {
SortCol::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
SortCol::Size => a.size.unwrap_or(0).cmp(&b.size.unwrap_or(0)),
SortCol::Kind => format!("{:?}", a.kind).cmp(&format!("{:?}", b.kind)),
SortCol::Modified => a.modified.unwrap_or(0).cmp(&b.modified.unwrap_or(0)),
};
if sort_dir == SortDir::Desc {
ord.reverse()
} else {
ord
}
});
if let Some(dd) = dotdot {
sorted.insert(0, dd);
}
sorted
}
/// Кликабельный заголовок колонки со стрелкой активной сортировки.
pub(super) fn sort_header(
ui: &mut Ui,
label: &str,
min_w: f32,
active: bool,
dir: SortDir,
) -> bool {
let arrow = if active {
if dir == SortDir::Asc { " ↑" } else { " ↓" }
} else {
""
};
let full = format!("{}{}", label, arrow);
let col = if active { ACCENT } else { TEXT_DIM };
let btn = egui::Button::new(RichText::new(full).color(col).size(10.5).strong())
.min_size(egui::vec2(min_w, 20.0));
button::ghost(ui, btn).clicked()
}
pub(super) fn toggle_sort(col: SortCol, cur_col: SortCol, cur_dir: SortDir) -> (SortCol, SortDir) {
if col == cur_col {
let new_dir = if cur_dir == SortDir::Asc {
SortDir::Desc
} else {
SortDir::Asc
};
(col, new_dir)
} else {
(col, SortDir::Asc)
}
}

View file

@ -1,150 +0,0 @@
//! Рендер строк таблицы файлов (шапка, список, drag&drop, контекстное меню).
use egui::{Color32, Id, Ui};
use super::row::{draw_row_content, render_header};
use super::sort::sort_entries;
use super::{FileTableResponse, RowAction, SortCol, SortDir};
use crate::domain::file_entry::{EntryKind, FileEntry};
use crate::ui::drag::DragPayload;
use crate::ui::theme::*;
pub(super) fn file_table_inner<F, C>(
ui: &mut Ui,
id: &str,
entries: &[FileEntry],
selected: &mut Option<String>,
make_drag_payload: F,
mut context_menu: C,
) -> FileTableResponse
where
F: Fn(&FileEntry) -> Option<DragPayload>,
C: FnMut(&mut Ui, &FileEntry) -> Option<RowAction>,
{
// Сортировка — читаем из egui memory
let sort_id = Id::new((id, "sort"));
let (sort_col, sort_dir) = ui
.ctx()
.data_mut(|d| *d.get_temp_mut_or(sort_id, (SortCol::Name, SortDir::Asc)));
let sorted = sort_entries(entries, sort_col, sort_dir);
let mut double_clicked: Option<String> = None;
let mut clicked: Option<String> = None;
let mut new_sort: Option<(SortCol, SortDir)> = None;
let mut dropped_on_dir: Option<(FileEntry, std::sync::Arc<DragPayload>)> = None;
let mut context_action: Option<(FileEntry, RowAction)> = None;
render_header(ui, sort_col, sort_dir, &mut new_sort);
if let Some(s) = new_sort {
ui.ctx().data_mut(|d| {
d.insert_temp(sort_id, s);
});
}
// разделитель
let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0));
ui.painter().rect_filled(sep, 0.0, BORDER);
// Список файлов
egui::ScrollArea::vertical()
.id_salt((id, "scroll"))
.auto_shrink([false; 2])
.show(ui, |ui| {
for entry in &sorted {
let is_selected = selected.as_deref() == Some(&entry.name);
let row_id = Id::new((id, "row", &entry.name));
// Строка, которую сейчас тащат — её содержимое рисуется в
// плавающем слое поверх курсора (см. dnd_drag_source), поэтому
// сама она не может быть целью для дропа, а на исходном месте
// не должно оставаться подсветки выбора/ховера — там пусто.
let is_being_dragged = ui.ctx().is_being_dragged(row_id);
let row_rect = egui::Rect::from_min_size(
ui.cursor().min,
egui::vec2(ui.available_width(), ROW_H),
);
let is_hovered = !is_being_dragged && ui.rect_contains_pointer(row_rect);
let is_drop_target = !is_being_dragged
&& entry.kind == EntryKind::Dir
&& egui::DragAndDrop::has_payload_of_type::<DragPayload>(ui.ctx());
let is_drop_hover = is_drop_target && is_hovered;
let bg = if is_being_dragged {
Color32::TRANSPARENT
} else if is_drop_hover {
ACCENT_DIM
} else if is_selected {
BG_ROW_SEL
} else if is_hovered {
BG_ROW_HOVER
} else {
Color32::TRANSPARENT
};
let stroke = if is_drop_hover {
egui::Stroke::new(1.5, ACCENT)
} else {
egui::Stroke::NONE
};
let row_width = ui.available_width();
let response = egui::Frame::none()
.fill(bg)
.stroke(stroke)
.rounding(RADIUS_SM)
.inner_margin(egui::Margin::symmetric(12.0, 0.0))
.show(ui, |ui| {
ui.allocate_ui_with_layout(
egui::vec2(row_width - 24.0, ROW_H),
egui::Layout::left_to_right(egui::Align::Center),
|ui| draw_row_content(ui, entry, row_id, &make_drag_payload),
);
})
.response;
// Frame::show() only senses hover, not clicks — add a real click
// sense on the full row rect (separate id from the drag source,
// which only senses drag over the icon+name sub-area). Without
// this, every press was interpreted as a drag start and plain
// clicks/double-clicks never fired.
let click_response =
ui.interact(response.rect, row_id.with("click"), egui::Sense::click());
let response = response | click_response;
if response.clicked() {
*selected = Some(entry.name.clone());
clicked = Some(entry.name.clone());
}
if response.double_clicked() {
double_clicked = Some(entry.name.clone());
}
if is_drop_target
&& let Some(payload) = response.dnd_release_payload::<DragPayload>()
{
dropped_on_dir = Some((entry.clone(), payload));
}
if entry.name != ".." {
// Правый клик выбирает строку — иначе меню открывается
// над предыдущим выделением, что сбивает с толку.
if response.secondary_clicked() {
*selected = Some(entry.name.clone());
}
crate::ui::widgets::context_menu::context_menu(&response, |ui| {
if let Some(action) = context_menu(ui, entry) {
context_action = Some((entry.clone(), action));
ui.close_menu();
}
});
}
}
});
FileTableResponse {
double_clicked,
clicked,
dropped_on_dir,
context_action,
}
}

View file

@ -1,199 +0,0 @@
//! Действия над локальной ФС: drop, открытие, контекстное меню, закладки.
use std::sync::{Arc, Mutex};
use super::{parent_path, refresh_local};
use crate::domain::file_entry::EntryKind;
use crate::domain::file_entry::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::DragPayload;
use crate::ui::icons::Icon;
use crate::ui::panels::file_pane::{RowAction, context_menu_item};
use crate::ui::state::{AppState, Pane};
/// Обрабатывает бросок файла в директорию `dest_dir` — общий код для дропа
/// на весь пейн и дропа на конкретную папку в списке.
pub(super) fn handle_drop(
state: &mut AppState,
queue: &TransferQueue,
registry: &Arc<RemoteRegistry>,
payload: &DragPayload,
dest_dir: &str,
) {
match payload {
DragPayload::RemoteFile(remote_path, file_name, conn_id) => {
if registry.get(conn_id).is_some() {
let local_path_str = format!("{}/{}", dest_dir.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 =
crate::i18n::tf("toolbar.download_queued", &[("{name}", file_name)]);
} else {
state.status_message = crate::i18n::t("panels.local.conn_inactive").into();
}
}
DragPayload::LocalFile(src_path, file_name) => {
if std::path::Path::new(dest_dir)
== std::path::Path::new(src_path)
.parent()
.unwrap_or(std::path::Path::new(""))
{
return; // бросили туда же, откуда взяли
}
match local::move_into(src_path, dest_dir) {
Ok(()) => {
state.status_message =
crate::i18n::tf("panels.local.moved", &[("{name}", file_name)]);
refresh_local(state);
}
Err(e) => {
state.status_message =
crate::i18n::tf("panels.local.move_failed", &[("{err}", &e.to_string())])
}
}
}
}
}
pub(super) fn build_context_menu(
ui: &mut egui::Ui,
entry: &FileEntry,
has_connection: bool,
) -> Option<RowAction> {
use crate::i18n::t;
if context_menu_item(ui, Icon::Folder, t("common.open"), false) {
return Some(RowAction::Open);
}
if entry.name != ".." {
if context_menu_item(ui, Icon::Pen, t("common.rename"), false) {
return Some(RowAction::Rename);
}
if context_menu_item(ui, Icon::Trash, t("common.delete"), true) {
return Some(RowAction::Delete);
}
ui.separator();
if entry.kind == EntryKind::Dir
&& context_menu_item(ui, Icon::Star, t("panels.local.add_to_bookmarks"), false)
{
return Some(RowAction::Bookmark);
}
if context_menu_item(ui, Icon::Document, t("common.copy_path"), false) {
return Some(RowAction::CopyPath);
}
if entry.kind == EntryKind::File && has_connection {
ui.separator();
if context_menu_item(ui, Icon::Upload, t("panels.local.upload_to_server"), false) {
return Some(RowAction::Transfer);
}
}
}
None
}
pub(super) fn open_entry(state: &mut AppState, name: &str) {
if name == ".." {
state.local_path = parent_path(&state.local_path);
refresh_local(state);
} else if let Some(entry) = state.local_entries.iter().find(|e| e.name == name) {
if entry.kind == EntryKind::Dir {
state.local_path = entry.path.clone();
refresh_local(state);
} else {
match local::open(&entry.path) {
Ok(()) => {
state.status_message =
crate::i18n::tf("panels.local.opening", &[("{name}", &entry.name)])
}
Err(e) => {
state.status_message =
crate::i18n::tf("panels.local.open_failed", &[("{err}", &e.to_string())])
}
}
}
}
}
pub(super) fn handle_row_action(
ctx: &egui::Context,
state: &mut AppState,
queue: &TransferQueue,
db: &Arc<Mutex<rusqlite::Connection>>,
entry: &FileEntry,
action: RowAction,
) {
match action {
RowAction::Open => open_entry(state, &entry.name),
RowAction::Rename => {
state.op_target = Pane::Local;
state.show_rename_dialog = true;
state.rename_old_name = entry.name.clone();
state.rename_new_name = entry.name.clone();
}
RowAction::Delete => {
state.op_target = Pane::Local;
state.show_delete_dialog = true;
state.delete_name = entry.name.clone();
}
RowAction::Bookmark => add_bookmark(state, db, &entry.name, &entry.path),
RowAction::CopyPath => {
ctx.copy_text(entry.path.clone());
state.status_message = crate::i18n::t("panels.path_copied").into();
}
RowAction::Transfer => {
if let Some(tab) = state.active_tab_ref() {
let connection_id = tab.id.clone();
let remote_path =
format!("{}/{}", tab.remote_path.trim_end_matches('/'), entry.name);
let task = TransferTask::new(
TransferKind::Upload,
connection_id,
entry.path.clone(),
remote_path,
entry.name.clone(),
0,
);
queue.push(task);
state.status_message =
crate::i18n::tf("toolbar.upload_queued", &[("{name}", &entry.name)]);
}
}
}
}
pub(super) fn add_bookmark(
state: &mut AppState,
db: &Arc<Mutex<rusqlite::Connection>>,
name: &str,
path: &str,
) {
if state.bookmarks.iter().any(|b| b.path == path) {
state.status_message = crate::i18n::t("panels.local.already_bookmarked").into();
return;
}
let Ok(conn) = db.lock() else {
return;
};
match crate::storage::db::add_bookmark(&conn, name, path) {
Ok(id) => {
state.bookmarks.push(crate::ui::state::Bookmark {
id,
name: name.to_string(),
path: path.to_string(),
});
state.status_message = crate::i18n::t("panels.local.bookmark_added").into();
}
Err(e) => {
state.status_message =
crate::i18n::tf("panels.local.bookmark_failed", &[("{err}", &e.to_string())])
}
}
}

View file

@ -1,137 +0,0 @@
//! Верхняя (путь + home) и нижняя (счётчик + закладка) полоски локального пейна.
use egui::{RichText, Ui};
use std::sync::{Arc, Mutex};
use super::actions::add_bookmark;
use super::refresh_local;
use crate::ui::icons::{self, Icon};
use crate::ui::panels::file_pane::format_size;
use crate::ui::state::AppState;
use crate::ui::theme::*;
use crate::ui::widgets::button;
pub(super) fn render_path_bar(ui: &mut Ui, state: &mut AppState) {
let frame = egui::Frame::none()
.fill(BG_BASE)
.inner_margin(egui::Margin::symmetric(12.0, 0.0));
frame.show(ui, |ui| {
let width = ui.available_width();
ui.allocate_ui_with_layout(
egui::vec2(width, 30.0),
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
ui.label(
RichText::new(crate::i18n::t("panels.local_label"))
.color(TEXT_DIM)
.size(10.5)
.strong(),
);
ui.add_space(10.0);
// иконка home
let home = dirs::home_dir()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let is_home = state.local_path == home;
let home_col = if is_home { ACCENT } else { TEXT_DIM };
let home_btn = egui::Button::image(icons::image(Icon::Home, 13.0, home_col))
.rounding(RADIUS_SM)
.min_size(egui::vec2(22.0, 22.0));
if button::toggle(ui, home_btn, is_home).clicked() {
state.local_path = home;
refresh_local(state);
}
ui.add_space(4.0);
breadcrumbs(ui, state);
},
);
});
}
/// Хлебные крошки локального пути — каждый сегмент ghost-кнопка (hover + курсор-рука).
fn breadcrumbs(ui: &mut Ui, state: &mut AppState) {
let path_clone = state.local_path.clone();
let parts: Vec<&str> = path_clone.split('/').filter(|s| !s.is_empty()).collect();
let mut acc = String::new();
for (i, part) in parts.iter().enumerate() {
acc.push('/');
acc.push_str(part);
let is_last = i == parts.len() - 1;
if is_last {
ui.label(RichText::new(*part).color(TEXT_PRIMARY).size(12.0).strong());
} else {
let path_snap = acc.clone();
let seg = egui::Button::new(RichText::new(*part).color(TEXT_DIM).size(12.0))
.min_size(egui::vec2(0.0, 22.0));
if button::ghost(ui, seg).clicked() {
state.local_path = path_snap;
refresh_local(state);
}
ui.label(RichText::new("/").color(TEXT_HINT).size(12.0));
}
}
}
pub(super) fn render_footer(
ui: &mut Ui,
state: &mut AppState,
db: &Arc<Mutex<rusqlite::Connection>>,
) {
let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0));
ui.painter().rect_filled(sep, 0.0, BORDER);
let frame = egui::Frame::none()
.fill(BG_PANEL)
.inner_margin(egui::Margin::symmetric(8.0, 3.0));
frame.show(ui, |ui| {
ui.horizontal(|ui| {
let count = state
.local_entries
.iter()
.filter(|e| e.name != "..")
.count();
let total_size: u64 = state.local_entries.iter().filter_map(|e| e.size).sum();
ui.label(
RichText::new(crate::i18n::tf(
"panels.count_items",
&[("{n}", &count.to_string())],
))
.color(TEXT_DIM)
.size(11.0),
);
if total_size > 0 {
ui.label(
RichText::new(format!("({})", format_size(Some(total_size))))
.color(TEXT_HINT)
.size(11.0),
);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
// кнопка добавить закладку
let star = egui::Button::image(icons::image(Icon::Star, 12.0, YELLOW))
.min_size(egui::vec2(22.0, 22.0));
if button::ghost(ui, star)
.on_hover_text(crate::i18n::t("panels.sidebar.bookmark_this_folder"))
.clicked()
{
let name = std::path::Path::new(&state.local_path)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "Bookmark".into());
let path = state.local_path.clone();
add_bookmark(state, db, &name, &path);
}
});
});
});
}

View file

@ -1,131 +0,0 @@
use crate::domain::connection::ConnectionStatus;
use crate::domain::file_entry::{EntryKind, FileEntry};
use crate::fs::local;
use crate::fs::remote::RemoteRegistry;
use crate::transfer::queue::TransferQueue;
use crate::ui::drag::{DragPayload, make_local_payload};
use crate::ui::panels::file_pane::{FileTableResponse, file_table};
use crate::ui::state::{AppState, Pane};
use crate::ui::theme::*;
use egui::Ui;
use std::sync::{Arc, Mutex};
mod actions;
mod bars;
pub(super) fn parent_path(path: &str) -> String {
std::path::Path::new(path)
.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 Ui,
state: &mut AppState,
queue: &TransferQueue,
registry: &Arc<RemoteRegistry>,
_rt_handle: &tokio::runtime::Handle,
db: &Arc<Mutex<rusqlite::Connection>>,
) {
let frame = egui::Frame::none()
.fill(BG_CONTENT)
.inner_margin(egui::Margin::same(0.0));
let (_inner, dropped) = ui.dnd_drop_zone::<DragPayload, _>(frame, |ui| {
render_inner(ui, state, queue, registry, db);
});
if let Some(payload_arc) = dropped {
let dest_dir = state.local_path.clone();
actions::handle_drop(state, queue, registry, &payload_arc, &dest_dir);
}
}
fn render_inner(
ui: &mut Ui,
state: &mut AppState,
queue: &TransferQueue,
registry: &Arc<RemoteRegistry>,
db: &Arc<Mutex<rusqlite::Connection>>,
) {
bars::render_path_bar(ui, state);
let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0));
ui.painter().rect_filled(sep, 0.0, BORDER);
let entries = state.local_entries.clone();
let mut selected = state.local_selected.clone();
let has_connection = state
.active_tab_ref()
.map(|t| t.status == ConnectionStatus::Connected)
.unwrap_or(false);
let FileTableResponse {
double_clicked,
clicked,
dropped_on_dir,
context_action,
} = file_table(
ui,
"local_table",
&entries,
&mut selected,
|entry| {
if has_connection {
make_local_payload(entry)
} else {
None
}
},
|ui, entry| actions::build_context_menu(ui, entry, has_connection),
);
state.local_selected = selected;
if clicked.is_some() {
state.active_pane = Pane::Local;
}
if let Some((target_entry, payload)) = dropped_on_dir {
actions::handle_drop(state, queue, registry, &payload, &target_entry.path);
}
if let Some(name) = double_clicked {
actions::open_entry(state, &name);
}
if let Some((entry, action)) = context_action {
actions::handle_row_action(ui.ctx(), state, queue, db, &entry, action);
}
bars::render_footer(ui, state, db);
}
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 =
crate::i18n::tf("panels.local.error", &[("{err}", &e.to_string())]);
}
}
}

View file

@ -1,7 +0,0 @@
pub mod file_pane;
pub mod local_pane;
pub mod queue;
pub mod remote_pane;
pub mod sidebar;
pub mod status_bar;
pub mod toolbar;

View file

@ -1,203 +0,0 @@
use crate::domain::transfer::TransferTask;
use crate::domain::transfer::{TaskState, TransferKind};
use crate::ui::icons::{self, Icon};
use crate::ui::panels::file_pane::format_size;
use crate::ui::state::AppState;
use crate::ui::theme::*;
use crate::ui::widgets::button;
use egui::{RichText, Ui};
pub fn render(ui: &mut Ui, state: &mut AppState, tasks: &[TransferTask]) {
// Заголовок очереди (всегда виден)
let header_frame = egui::Frame::none()
.fill(BG_QUEUE)
.inner_margin(egui::Margin::symmetric(12.0, 0.0));
header_frame.show(ui, |ui| {
let width = ui.available_width();
ui.allocate_ui_with_layout(
egui::vec2(width, QUEUE_COLLAPSED_H),
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
let chevron = if state.show_queue {
Icon::ArrowDown
} else {
Icon::ArrowUp
};
let n_active = tasks
.iter()
.filter(|t| matches!(t.state, TaskState::Running | TaskState::Queued))
.count();
let n = if n_active > 0 { n_active } else { tasks.len() };
let title = crate::i18n::tf("queue.title", &[("{n}", &n.to_string())]);
let btn = egui::Button::image_and_text(
icons::image(chevron, 13.0, TEXT_DIM),
RichText::new(&title)
.color(TEXT_PRIMARY)
.size(11.5)
.strong(),
)
.min_size(egui::vec2(0.0, QUEUE_COLLAPSED_H));
if button::ghost(ui, btn).clicked() {
state.show_queue = !state.show_queue;
}
// Аггрегированная скорость справа
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let agg_speed: u64 = tasks
.iter()
.filter(|t| t.state == TaskState::Running)
.filter_map(|t| t.speed)
.sum();
if agg_speed > 0 {
ui.label(
RichText::new(format!("{}/s", format_size(Some(agg_speed))))
.color(GREEN)
.size(11.0),
);
} else if !tasks.is_empty() {
let done = tasks
.iter()
.filter(|t| t.state == TaskState::Completed)
.count();
let failed = tasks
.iter()
.filter(|t| matches!(t.state, TaskState::Failed(_)))
.count();
ui.label(
RichText::new(crate::i18n::tf(
"queue.done_suffix",
&[("{done}", &done.to_string()), ("{total}", &tasks.len().to_string())],
))
.color(TEXT_DIM)
.size(11.0),
);
if failed > 0 {
ui.add_space(8.0);
ui.label(
RichText::new(crate::i18n::tf(
"queue.failed_suffix",
&[("{n}", &failed.to_string())],
))
.color(RED)
.size(11.0),
);
}
}
});
},
);
});
if !state.show_queue || tasks.is_empty() {
return;
}
let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0));
ui.painter().rect_filled(sep, 0.0, BORDER);
egui::ScrollArea::vertical()
.auto_shrink([false; 2])
.max_height(QUEUE_EXPANDED_H - QUEUE_COLLAPSED_H - 4.0)
.show(ui, |ui| {
ui.add_space(2.0);
for (i, task) in tasks.iter().enumerate() {
if i > 0 {
ui.add_space(6.0);
}
egui::Frame::none()
.inner_margin(egui::Margin::symmetric(12.0, 0.0))
.show(ui, |ui| render_task(ui, task));
}
});
}
fn render_task(ui: &mut Ui, task: &TransferTask) {
// Плоская однострочная карточка, как в макете — без цветной подложки
ui.horizontal(|ui| {
ui.set_min_height(34.0);
let (arrow, arrow_col) = match task.kind {
TransferKind::Upload => (Icon::Upload, ACCENT),
TransferKind::Download => (Icon::Download, GREEN),
};
icons::icon(ui, arrow, 15.0, arrow_col);
ui.add_space(10.0);
// имя файла — фиксированная ширина, как в спеке
let name_label = egui::Label::new(
RichText::new(&task.file_name)
.color(TEXT_PRIMARY)
.size(12.0),
)
.truncate();
let name_resp = ui.add_sized([150.0, 18.0], name_label);
if let TaskState::Failed(err) = &task.state {
name_resp.on_hover_text(err);
}
ui.add_space(10.0);
let pct = task.progress_pct() as f32 / 100.0;
let bar_col = match &task.state {
TaskState::Completed => GREEN,
TaskState::Failed(_) => RED,
TaskState::Paused => TEXT_HINT,
_ => ACCENT,
};
let bar = egui::ProgressBar::new(pct)
.desired_width(ui.available_width() - 130.0)
.animate(matches!(task.state, TaskState::Running))
.fill(bar_col);
ui.add(bar);
ui.add_space(10.0);
ui.label(
RichText::new(format!("{:.0}%", task.progress_pct()))
.color(TEXT_DIM)
.monospace()
.size(11.0),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let (state_str, state_col, state_icon) = match &task.state {
TaskState::Queued => (crate::i18n::t("queue.state_queued").to_string(), TEXT_HINT, None),
TaskState::Running => match task.speed {
Some(s) if s > 0 => (format!("{}/s", format_size(Some(s))), GREEN, None),
_ => {
let verb = match task.kind {
TransferKind::Upload => "queue.state_uploading",
TransferKind::Download => "queue.state_downloading",
};
(crate::i18n::t(verb).to_string(), ACCENT, None)
}
},
TaskState::Completed => (
crate::i18n::t("queue.state_complete").to_string(),
GREEN,
Some(Icon::CheckCircle),
),
TaskState::Failed(_) => (crate::i18n::t("queue.state_failed").to_string(), RED, None),
TaskState::Paused => (
crate::i18n::t("queue.state_paused").to_string(),
YELLOW,
Some(Icon::PlayCircle),
),
TaskState::Cancelled => {
(crate::i18n::t("queue.state_cancelled").to_string(), TEXT_HINT, None)
}
TaskState::Retrying(_) => {
(crate::i18n::t("queue.state_retrying").to_string(), YELLOW, None)
}
};
if let Some(ic) = state_icon {
icons::icon(ui, ic, 13.0, state_col);
ui.add_space(4.0);
}
ui.label(RichText::new(state_str).color(state_col).size(10.5));
});
});
}

Some files were not shown because too many files have changed in this diff Show more