Дезайн ыыыы
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
7
src/capabilities/default.json
Normal 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
|
|
@ -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, ¶ms.host, params.port, ¶ms.username)
|
||||
.ok()
|
||||
.flatten()
|
||||
})
|
||||
.unwrap_or_else(|| params.id.clone());
|
||||
|
||||
let fs: Arc<dyn RemoteFs> = match params.protocol {
|
||||
Protocol::Sftp => {
|
||||
if let Some(key_path) = ¶ms.key_path {
|
||||
Arc::new(
|
||||
SftpClient::connect_key(
|
||||
¶ms.host,
|
||||
params.port,
|
||||
¶ms.username,
|
||||
key_path,
|
||||
params.password.as_deref(),
|
||||
)
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
} else if let Some(password) = ¶ms.password {
|
||||
Arc::new(
|
||||
SftpClient::connect_password(¶ms.host, params.port, ¶ms.username, password)
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
} else {
|
||||
Arc::new(
|
||||
SftpClient::connect_auto(¶ms.host, params.port, ¶ms.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(¶ms.host, params.port, ¶ms.username, &password)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
}
|
||||
Protocol::Ftps => return Err("FTPS not yet implemented".into()),
|
||||
};
|
||||
|
||||
registry.insert(params.id.clone(), fs);
|
||||
|
||||
if let Ok(conn) = db.lock() {
|
||||
let _ = db::add_history_entry(
|
||||
&conn,
|
||||
¶ms.host,
|
||||
params.port,
|
||||
¶ms.username,
|
||||
&canonical_id,
|
||||
¶ms.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 }
|
||||
}
|
||||
|
|
@ -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>,
|
||||
|
|
|
|||
1
src/gen/schemas/acl-manifests.json
Normal file
1
src/gen/schemas/capabilities.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"default":{"identifier":"default","description":"Capabilities for the main Wherry window","local":true,"windows":["main"],"permissions":["core:default","dialog:default"]}}
|
||||
2358
src/gen/schemas/desktop-schema.json
Normal file
2358
src/gen/schemas/macOS-schema.json
Normal 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
|
After Width: | Height: | Size: 3.7 KiB |
BIN
src/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
src/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 988 B |
BIN
src/icons/icon.icns
Normal file
BIN
src/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
126
src/lib.rs
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
loflum_lib::run();
|
||||
wherry_lib::run();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -1,2 +1 @@
|
|||
pub mod db;
|
||||
pub mod keychain;
|
||||
|
|
|
|||
37
src/tauri.conf.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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(¶ms);
|
||||
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,
|
||||
¶ms.host,
|
||||
params.port,
|
||||
¶ms.username,
|
||||
)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| params.id.clone());
|
||||
|
||||
if let Some(password) = ¶ms.password {
|
||||
let _ = crate::storage::keychain::store_password(&canonical_id, password);
|
||||
}
|
||||
|
||||
let _ = crate::storage::db::add_history_entry(
|
||||
&conn,
|
||||
¶ms.host,
|
||||
params.port,
|
||||
¶ms.username,
|
||||
&canonical_id,
|
||||
¶ms.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(¤t)
|
||||
.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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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(®istry, ¶ms_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(¶ms.id).ok())
|
||||
};
|
||||
|
||||
let fs: Arc<dyn RemoteFs> = match params.protocol {
|
||||
Protocol::Sftp => {
|
||||
if let Some(key_path) = ¶ms.key_path {
|
||||
// Явно выбранный ключ; пароль из формы (если есть) — passphrase.
|
||||
Arc::new(
|
||||
SftpClient::connect_key(
|
||||
¶ms.host,
|
||||
params.port,
|
||||
¶ms.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(
|
||||
¶ms.host,
|
||||
params.port,
|
||||
¶ms.username,
|
||||
&password,
|
||||
)
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
} else {
|
||||
// Пароль пуст и ключ не выбран — ищем ключ сами (агент, ~/.ssh).
|
||||
Arc::new(
|
||||
SftpClient::connect_auto(¶ms.host, params.port, ¶ms.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(¶ms.host, params.port, ¶ms.username, &password)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
}
|
||||
Protocol::Ftps => return Err("FTPS not yet implemented".into()),
|
||||
};
|
||||
|
||||
registry.insert(params.id.clone(), fs.clone());
|
||||
let entries = fs.list("/").await.map_err(|e| e.to_string())?;
|
||||
Ok((params.clone(), entries))
|
||||
}
|
||||
|
|
@ -1,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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
pub mod connection;
|
||||
102
src/ui/dock.rs
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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(_, _, _))
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
145
src/ui/menu.rs
|
|
@ -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) {}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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())])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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())]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||