Gui
1734
Cargo.lock
generated
179
LoFlum - Standalone.html
Normal file
|
|
@ -14,9 +14,9 @@ name = "loflum"
|
|||
path = "main.rs"
|
||||
|
||||
[dependencies]
|
||||
eframe = { version = "0.28", features = ["default"] }
|
||||
egui = "0.28"
|
||||
egui_extras = "0.28"
|
||||
eframe = { version = "0.29", features = ["default"] }
|
||||
egui = "0.29"
|
||||
egui_extras = { version = "0.29", features = ["svg", "image"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
|
@ -34,4 +34,5 @@ tracing-subscriber = "0.3"
|
|||
chrono = "0.4"
|
||||
futures-lite = "2"
|
||||
image = "0.25"
|
||||
muda = "0.19.3"
|
||||
|
||||
|
|
|
|||
|
|
@ -58,3 +58,46 @@ pub fn home_dir() -> String {
|
|||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn rename(from: &str, to: &str) -> Result<()> {
|
||||
fs::rename(from, to)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete(path: &str) -> Result<()> {
|
||||
let meta = fs::symlink_metadata(path)?;
|
||||
if meta.is_dir() {
|
||||
fs::remove_dir_all(path)?;
|
||||
} else {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn mkdir(path: &str) -> Result<()> {
|
||||
fs::create_dir(path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Открывает файл/папку в системном приложении по умолчанию.
|
||||
pub fn open(path: &str) -> Result<()> {
|
||||
#[cfg(target_os = "macos")]
|
||||
std::process::Command::new("open").arg(path).spawn()?;
|
||||
#[cfg(target_os = "linux")]
|
||||
std::process::Command::new("xdg-open").arg(path).spawn()?;
|
||||
#[cfg(target_os = "windows")]
|
||||
std::process::Command::new("cmd")
|
||||
.args(["/C", "start", "", path])
|
||||
.spawn()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Перемещает файл/папку в целевую директорию (для drag & drop).
|
||||
pub fn move_into(src_path: &str, dest_dir: &str) -> Result<()> {
|
||||
let name = std::path::Path::new(src_path)
|
||||
.file_name()
|
||||
.ok_or_else(|| anyhow::anyhow!("invalid source path"))?;
|
||||
let dest = std::path::Path::new(dest_dir).join(name);
|
||||
fs::rename(src_path, dest)?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
29
src/lib.rs
|
|
@ -39,14 +39,37 @@ pub fn run() {
|
|||
|
||||
let app = FileManagerApp::new(registry, queue, rt, db, sites);
|
||||
|
||||
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_min_inner_size([900.0, 600.0])
|
||||
.with_icon(icon),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
eframe::run_native("LoFlum", native_options, Box::new(|_cc| Ok(Box::new(app))))
|
||||
.expect("failed to start LoFlum");
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,7 +115,11 @@ impl RemoteFs for SftpClient {
|
|||
let session = self.session.clone();
|
||||
let path = path.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let sftp = session.lock().unwrap().sftp().context("SFTP subsystem failed")?;
|
||||
let sftp = session
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sftp()
|
||||
.context("SFTP subsystem failed")?;
|
||||
let entries = sftp.readdir(Path::new(&path)).context("readdir failed")?;
|
||||
|
||||
let result = entries
|
||||
|
|
@ -159,7 +163,11 @@ impl RemoteFs for SftpClient {
|
|||
let local = local.to_string();
|
||||
let remote = remote.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let sftp = session.lock().unwrap().sftp().context("SFTP subsystem failed")?;
|
||||
let sftp = session
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sftp()
|
||||
.context("SFTP subsystem failed")?;
|
||||
let mut local_file = std::fs::File::open(&local).context("open local file failed")?;
|
||||
let mut remote_file = sftp
|
||||
.create(Path::new(&remote))
|
||||
|
|
@ -168,7 +176,9 @@ impl RemoteFs for SftpClient {
|
|||
let mut buf = vec![0u8; CHUNK_SIZE];
|
||||
let mut total = 0u64;
|
||||
loop {
|
||||
let n = local_file.read(&mut buf).context("read local file failed")?;
|
||||
let n = local_file
|
||||
.read(&mut buf)
|
||||
.context("read local file failed")?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
|
|
@ -204,15 +214,23 @@ impl RemoteFs for SftpClient {
|
|||
let remote = remote.to_string();
|
||||
let local = local.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let sftp = session.lock().unwrap().sftp().context("SFTP subsystem failed")?;
|
||||
let mut remote_file = sftp.open(Path::new(&remote)).context("open remote file failed")?;
|
||||
let sftp = session
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sftp()
|
||||
.context("SFTP subsystem failed")?;
|
||||
let mut remote_file = sftp
|
||||
.open(Path::new(&remote))
|
||||
.context("open remote file failed")?;
|
||||
let mut local_file =
|
||||
std::fs::File::create(&local).context("create local file failed")?;
|
||||
|
||||
let mut buf = vec![0u8; CHUNK_SIZE];
|
||||
let mut total = 0u64;
|
||||
loop {
|
||||
let n = remote_file.read(&mut buf).context("read remote file failed")?;
|
||||
let n = remote_file
|
||||
.read(&mut buf)
|
||||
.context("read remote file failed")?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
|
|
@ -242,8 +260,13 @@ impl RemoteFs for SftpClient {
|
|||
let session = self.session.clone();
|
||||
let path = path.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let sftp = session.lock().unwrap().sftp().context("SFTP subsystem failed")?;
|
||||
sftp.mkdir(Path::new(&path), 0o755).context("mkdir failed")?;
|
||||
let sftp = session
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sftp()
|
||||
.context("SFTP subsystem failed")?;
|
||||
sftp.mkdir(Path::new(&path), 0o755)
|
||||
.context("mkdir failed")?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
|
|
@ -255,7 +278,11 @@ impl RemoteFs for SftpClient {
|
|||
let from = from.to_string();
|
||||
let to = to.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let sftp = session.lock().unwrap().sftp().context("SFTP subsystem failed")?;
|
||||
let sftp = session
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sftp()
|
||||
.context("SFTP subsystem failed")?;
|
||||
sftp.rename(Path::new(&from), Path::new(&to), None)
|
||||
.context("rename failed")?;
|
||||
Ok(())
|
||||
|
|
@ -268,7 +295,11 @@ impl RemoteFs for SftpClient {
|
|||
let session = self.session.clone();
|
||||
let path = path.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let sftp = session.lock().unwrap().sftp().context("SFTP subsystem failed")?;
|
||||
let sftp = session
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sftp()
|
||||
.context("SFTP subsystem failed")?;
|
||||
// пробуем как файл, потом как директорию
|
||||
if sftp.unlink(Path::new(&path)).is_err() {
|
||||
sftp.rmdir(Path::new(&path)).context("delete failed")?;
|
||||
|
|
@ -283,14 +314,18 @@ impl RemoteFs for SftpClient {
|
|||
let session = self.session.clone();
|
||||
let path = path.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let sftp = session.lock().unwrap().sftp().context("SFTP subsystem failed")?;
|
||||
let sftp = session
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sftp()
|
||||
.context("SFTP subsystem failed")?;
|
||||
let stat = sftp.stat(Path::new(&path)).context("stat failed")?;
|
||||
Ok(FileEntry {
|
||||
name: Path::new(&path)
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
.to_string(),
|
||||
path: path.to_string(),
|
||||
kind: if stat.is_dir() {
|
||||
EntryKind::Dir
|
||||
|
|
|
|||
|
|
@ -3,6 +3,22 @@ use crate::domain::site::Site;
|
|||
use anyhow::Result;
|
||||
use rusqlite::{Connection, params};
|
||||
|
||||
fn protocol_to_str(protocol: &Protocol) -> &'static str {
|
||||
match protocol {
|
||||
Protocol::Sftp => "sftp",
|
||||
Protocol::Ftp => "ftp",
|
||||
Protocol::Ftps => "ftps",
|
||||
}
|
||||
}
|
||||
|
||||
fn protocol_from_str(s: &str) -> Protocol {
|
||||
match s {
|
||||
"sftp" => Protocol::Sftp,
|
||||
"ftps" => Protocol::Ftps,
|
||||
_ => Protocol::Ftp,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_tables(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
"
|
||||
|
|
@ -19,11 +35,159 @@ pub fn init_tables(conn: &Connection) -> Result<()> {
|
|||
folder TEXT,
|
||||
note TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS connection_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
connected_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bookmarks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL
|
||||
);
|
||||
",
|
||||
)?;
|
||||
|
||||
// Миграция: старые базы созданы без 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'",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE connection_history ADD COLUMN key_path TEXT",
|
||||
[],
|
||||
);
|
||||
// На случай старых записей без уникальности (host,port,username) — оставляем
|
||||
// только самую свежую строку на каждую цель перед созданием индекса.
|
||||
conn.execute(
|
||||
"DELETE FROM connection_history WHERE id NOT IN (
|
||||
SELECT MAX(id) FROM connection_history GROUP BY host, port, username
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_history_target
|
||||
ON connection_history(host, port, username)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_bookmarks(conn: &Connection) -> Result<Vec<(i64, String, String)>> {
|
||||
let mut stmt = conn.prepare("SELECT id, name, path FROM bookmarks ORDER BY id")?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub fn add_bookmark(conn: &Connection, name: &str, path: &str) -> Result<i64> {
|
||||
conn.execute(
|
||||
"INSERT INTO bookmarks (name, path) VALUES (?1, ?2)",
|
||||
params![name, path],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub fn remove_bookmark(conn: &Connection, id: i64) -> Result<()> {
|
||||
conn.execute("DELETE FROM bookmarks WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Одна запись истории подключений: host, port, username, время, conn_id
|
||||
/// (стабильный id — под ним же лежит пароль в keychain), протокол, key_path.
|
||||
pub type HistoryRow = (
|
||||
String,
|
||||
u16,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Protocol,
|
||||
Option<String>,
|
||||
);
|
||||
|
||||
/// Ищет уже существующий conn_id для этой цели (host,port,username), чтобы
|
||||
/// повторные подключения писали пароль в keychain под тем же id, а не заводили новый.
|
||||
pub fn find_history_conn_id(
|
||||
conn: &Connection,
|
||||
host: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
) -> Result<Option<String>> {
|
||||
let id: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT conn_id FROM connection_history WHERE host = ?1 AND port = ?2 AND username = ?3",
|
||||
params![host, port, username],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.ok();
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn add_history_entry(
|
||||
conn: &Connection,
|
||||
host: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
conn_id: &str,
|
||||
protocol: &Protocol,
|
||||
key_path: Option<&str>,
|
||||
) -> Result<()> {
|
||||
use chrono::Local;
|
||||
let connected_at = Local::now().format("%H:%M %d.%m").to_string();
|
||||
let proto = protocol_to_str(protocol);
|
||||
conn.execute(
|
||||
"INSERT INTO connection_history (host, port, username, connected_at, conn_id, protocol, key_path)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7)
|
||||
ON CONFLICT(host, port, username) DO UPDATE SET
|
||||
connected_at = excluded.connected_at,
|
||||
protocol = excluded.protocol,
|
||||
key_path = excluded.key_path,
|
||||
conn_id = COALESCE(connection_history.conn_id, excluded.conn_id)",
|
||||
params![host, port, username, connected_at, conn_id, proto, key_path],
|
||||
)?;
|
||||
// Храним только последние 20 записей
|
||||
conn.execute(
|
||||
"DELETE FROM connection_history WHERE id NOT IN (
|
||||
SELECT id FROM connection_history ORDER BY id DESC LIMIT 20
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_history(conn: &Connection) -> Result<Vec<HistoryRow>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT host, port, username, connected_at, conn_id, protocol, key_path
|
||||
FROM connection_history ORDER BY id DESC LIMIT 20",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
let proto_str: String = row.get(5)?;
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
row.get(1)?,
|
||||
row.get(2)?,
|
||||
row.get(3)?,
|
||||
row.get(4)?,
|
||||
protocol_from_str(&proto_str),
|
||||
row.get(6)?,
|
||||
))
|
||||
})?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
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",
|
||||
|
|
@ -31,11 +195,7 @@ pub fn get_sites(conn: &Connection) -> Result<Vec<Site>> {
|
|||
let sites = stmt
|
||||
.query_map([], |row| {
|
||||
let proto_str: String = row.get(2)?;
|
||||
let protocol = match proto_str.as_str() {
|
||||
"sftp" => Protocol::Sftp,
|
||||
"ftps" => Protocol::Ftps,
|
||||
_ => Protocol::Ftp,
|
||||
};
|
||||
let protocol = protocol_from_str(&proto_str);
|
||||
Ok(Site {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
|
|
@ -54,11 +214,7 @@ pub fn get_sites(conn: &Connection) -> Result<Vec<Site>> {
|
|||
}
|
||||
|
||||
pub fn save_site(conn: &Connection, site: &Site) -> Result<()> {
|
||||
let proto = match site.protocol {
|
||||
Protocol::Sftp => "sftp",
|
||||
Protocol::Ftp => "ftp",
|
||||
Protocol::Ftps => "ftps",
|
||||
};
|
||||
let proto = protocol_to_str(&site.protocol);
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO sites
|
||||
(id, name, protocol, host, port, username, key_path, folder, note)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,12 @@ impl TransferQueue {
|
|||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<TransferTask> {
|
||||
self.inner.lock().unwrap().iter().find(|t| t.id == id).cloned()
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|t| t.id == id)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub fn all(&self) -> Vec<TransferTask> {
|
||||
|
|
|
|||
|
|
@ -94,8 +94,8 @@ pub fn spawn_worker(
|
|||
let task_id_for_progress = task.id.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>> = Some(Box::new(
|
||||
move |transferred: u64| {
|
||||
let on_progress: Option<Box<dyn Fn(u64) -> ProgressAction + Send>> =
|
||||
Some(Box::new(move |transferred: u64| {
|
||||
// Check for user-initiated cancel/pause first.
|
||||
if let Some(t) = queue_for_progress.get(&task_id_for_progress) {
|
||||
match t.state {
|
||||
|
|
@ -119,18 +119,23 @@ pub fn spawn_worker(
|
|||
speed
|
||||
};
|
||||
if throttle.lock().unwrap().should_emit() {
|
||||
queue_for_progress.update_progress(&task_id_for_progress, transferred, speed);
|
||||
queue_for_progress.update_progress(
|
||||
&task_id_for_progress,
|
||||
transferred,
|
||||
speed,
|
||||
);
|
||||
}
|
||||
ProgressAction::Continue
|
||||
},
|
||||
));
|
||||
}));
|
||||
|
||||
let result = match task.kind {
|
||||
TransferKind::Upload => {
|
||||
fs.upload_with_progress(&task.local_path, &task.remote_path, on_progress).await
|
||||
fs.upload_with_progress(&task.local_path, &task.remote_path, on_progress)
|
||||
.await
|
||||
}
|
||||
TransferKind::Download => {
|
||||
fs.download_with_progress(&task.remote_path, &task.local_path, on_progress).await
|
||||
fs.download_with_progress(&task.remote_path, &task.local_path, on_progress)
|
||||
.await
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
595
src/ui/app.rs
|
|
@ -1,6 +1,6 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use egui::{CentralPanel, Color32, RichText, SidePanel, TopBottomPanel, Visuals};
|
||||
use egui::{CentralPanel, RichText, SidePanel, TopBottomPanel, Visuals};
|
||||
|
||||
use crate::domain::connection::ConnectionStatus;
|
||||
use crate::domain::file_entry::{EntryKind, FileEntry};
|
||||
|
|
@ -35,6 +35,15 @@ impl FileManagerApp {
|
|||
};
|
||||
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,
|
||||
|
|
@ -81,8 +90,39 @@ impl FileManagerApp {
|
|||
self.state.status_message = format!("Connected to {}", params.host);
|
||||
self.state.connect_loading = false;
|
||||
self.state.show_connect_dialog = false;
|
||||
self.state
|
||||
.add_history(¶ms.host, params.port, ¶ms.username);
|
||||
|
||||
// Один и тот же host/port/username всегда должен использовать
|
||||
// один conn_id — под ним лежит пароль в keychain, иначе повторное
|
||||
// подключение через "New Connection" развело бы историю и keychain.
|
||||
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(),
|
||||
);
|
||||
}
|
||||
self.state.reload_history(&self.db);
|
||||
}
|
||||
Err(e) => {
|
||||
self.state.status_message = format!("Connection failed: {}", e);
|
||||
|
|
@ -149,6 +189,24 @@ impl FileManagerApp {
|
|||
}
|
||||
}
|
||||
|
||||
// --- История: переподключение по клику ---
|
||||
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 result ---
|
||||
let opt = self.state.pending_mkdir_result.take();
|
||||
if let Some(res) = opt.as_ref() {
|
||||
|
|
@ -346,6 +404,55 @@ impl FileManagerApp {
|
|||
self.state.pending_rename_result = Some(result);
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_visuals(&self, ctx: &egui::Context) {
|
||||
let mut vis = Visuals::dark();
|
||||
vis.window_fill = BG_PANEL;
|
||||
|
|
@ -354,7 +461,7 @@ impl FileManagerApp {
|
|||
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 = Color32::from_rgb(36, 36, 40);
|
||||
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;
|
||||
|
|
@ -362,8 +469,7 @@ impl FileManagerApp {
|
|||
vis.window_rounding = egui::Rounding::same(8.0);
|
||||
ctx.set_visuals(vis);
|
||||
|
||||
let fonts = egui::FontDefinitions::default();
|
||||
ctx.set_fonts(fonts);
|
||||
ctx.set_fonts(system_fonts());
|
||||
|
||||
let mut style = ctx.style().as_ref().clone();
|
||||
style.spacing.item_spacing = egui::vec2(4.0, 2.0);
|
||||
|
|
@ -376,11 +482,13 @@ 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.state.connected_count = self
|
||||
.state
|
||||
|
|
@ -389,11 +497,168 @@ impl eframe::App for FileManagerApp {
|
|||
.filter(|t| t.status == ConnectionStatus::Connected)
|
||||
.count();
|
||||
|
||||
if self.state.tabs.is_empty() {
|
||||
self.render_welcome_screen(ctx);
|
||||
} else {
|
||||
self.render_main_ui(ctx);
|
||||
}
|
||||
|
||||
// ── Connection dialog ────────────────────────────────────────────────
|
||||
if self.state.show_connect_dialog {
|
||||
connection::render(ctx, &mut self.state, &self.registry, self.rt.handle());
|
||||
}
|
||||
|
||||
// ── Remote operation dialogs ─────────────────────────────────────────
|
||||
self.render_remote_op_dialogs(ctx);
|
||||
|
||||
// ── Settings ─────────────────────────────────────────────────────────
|
||||
if self.state.show_settings_dialog {
|
||||
self.render_settings_dialog(ctx);
|
||||
}
|
||||
|
||||
// 200ms repaint для обновления прогресса
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(200));
|
||||
}
|
||||
}
|
||||
|
||||
impl FileManagerApp {
|
||||
/// Стартовый экран — показывается, пока нет ни одного открытого соединения.
|
||||
/// Только фон, логотип, кнопка нового подключения и список недавних серверов.
|
||||
fn render_welcome_screen(&mut self, ctx: &egui::Context) {
|
||||
CentralPanel::default()
|
||||
.frame(egui::Frame::none().fill(BG_BASE))
|
||||
.show(ctx, |ui| {
|
||||
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(
|
||||
crate::ui::icons::image(
|
||||
crate::ui::icons::Icon::AddCircleBold,
|
||||
15.0,
|
||||
ON_ACCENT,
|
||||
),
|
||||
RichText::new("New Connection")
|
||||
.color(ON_ACCENT)
|
||||
.size(12.5)
|
||||
.strong(),
|
||||
)
|
||||
.fill(ACCENT)
|
||||
.rounding(RADIUS_MD)
|
||||
.min_size(egui::vec2(170.0, 36.0));
|
||||
if ui.add(btn).clicked() {
|
||||
self.state.show_connect_dialog = true;
|
||||
}
|
||||
|
||||
ui.add_space(28.0);
|
||||
|
||||
let list_w = 320.0_f32.min(ui.available_width() - 40.0);
|
||||
if !self.state.history.is_empty() {
|
||||
ui.label(
|
||||
RichText::new("RECENT CONNECTIONS")
|
||||
.color(TEXT_HINT)
|
||||
.size(10.5)
|
||||
.strong(),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
|
||||
const ROW_H: f32 = 34.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 row_rect = egui::Rect::from_min_size(
|
||||
ui.cursor().min,
|
||||
egui::vec2(list_w, ROW_H),
|
||||
);
|
||||
let is_hovered = ui.rect_contains_pointer(row_rect);
|
||||
let bg = if is_hovered {
|
||||
BG_ROW_HOVER
|
||||
} else {
|
||||
egui::Color32::TRANSPARENT
|
||||
};
|
||||
|
||||
let label = format!("{}@{}:{}", entry.user, entry.host, entry.port);
|
||||
let row = egui::Frame::none()
|
||||
.fill(bg)
|
||||
.rounding(RADIUS_MD)
|
||||
.inner_margin(egui::Margin::symmetric(10.0, 7.0))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(list_w - 20.0);
|
||||
ui.horizontal(|ui| {
|
||||
crate::ui::icons::icon(
|
||||
ui,
|
||||
crate::ui::icons::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),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
let resp = row.response.interact(egui::Sense::click());
|
||||
if is_hovered {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
}
|
||||
if resp.clicked() {
|
||||
self.state.pending_history_reconnect = Some(entry.clone());
|
||||
}
|
||||
resp.context_menu(|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();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn render_main_ui(&mut self, ctx: &egui::Context) {
|
||||
// ── Toolbar ──────────────────────────────────────────────────────────
|
||||
TopBottomPanel::top("toolbar")
|
||||
.frame(egui::Frame::none().fill(BG_TOOLBAR))
|
||||
.show(ctx, |ui| {
|
||||
toolbar::render(ui, &mut self.state);
|
||||
toolbar::render(ui, &mut self.state, &self.queue);
|
||||
});
|
||||
|
||||
// ── Tab bar ──────────────────────────────────────────────────────────
|
||||
|
|
@ -441,7 +706,7 @@ impl eframe::App for FileManagerApp {
|
|||
.default_width(SIDEBAR_W)
|
||||
.width_range(120.0..=260.0)
|
||||
.show(ctx, |ui| {
|
||||
sidebar::render(ui, &mut self.state);
|
||||
sidebar::render(ui, &mut self.state, &self.db);
|
||||
});
|
||||
|
||||
// ── Main content: Local | Remote ─────────────────────────────────────
|
||||
|
|
@ -460,15 +725,13 @@ impl eframe::App for FileManagerApp {
|
|||
egui::vec2(half, total_h),
|
||||
egui::Layout::top_down(egui::Align::LEFT),
|
||||
|ui| {
|
||||
// Хедер панели
|
||||
pane_header(ui, "LOCAL", half);
|
||||
|
||||
local_pane::render(
|
||||
ui,
|
||||
&mut self.state,
|
||||
&self.queue,
|
||||
&self.registry,
|
||||
self.rt.handle(),
|
||||
&self.db,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -485,10 +748,6 @@ impl eframe::App for FileManagerApp {
|
|||
if has_connection {
|
||||
let idx = self.state.active_tab.min(self.state.tabs.len() - 1);
|
||||
|
||||
// Хедер панели
|
||||
let label = self.state.tabs[idx].params.host.clone();
|
||||
pane_header(ui, &format!("REMOTE · {}", label), half);
|
||||
|
||||
remote_pane::render(
|
||||
ui,
|
||||
&mut self.state,
|
||||
|
|
@ -504,10 +763,35 @@ impl eframe::App for FileManagerApp {
|
|||
egui::Layout::centered_and_justified(egui::Direction::TopDown),
|
||||
|ui| {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.add_space(ui.available_height() * 0.3);
|
||||
ui.add_space(ui.available_height() * 0.28);
|
||||
|
||||
let circle_d = 56.0;
|
||||
let (rect, _) = ui.allocate_exact_size(
|
||||
egui::vec2(circle_d, circle_d),
|
||||
egui::Sense::hover(),
|
||||
);
|
||||
ui.painter().circle_filled(
|
||||
rect.center(),
|
||||
circle_d / 2.0,
|
||||
BG_TAB_ACTIVE,
|
||||
);
|
||||
let icon_img = crate::ui::icons::image(
|
||||
crate::ui::icons::Icon::ServerSquare,
|
||||
26.0,
|
||||
TEXT_HINT,
|
||||
);
|
||||
icon_img.paint_at(
|
||||
ui,
|
||||
egui::Rect::from_center_size(
|
||||
rect.center(),
|
||||
egui::vec2(26.0, 26.0),
|
||||
),
|
||||
);
|
||||
|
||||
ui.add_space(10.0);
|
||||
ui.label(
|
||||
RichText::new("Remote")
|
||||
.color(TEXT_HINT)
|
||||
.color(TEXT_DIM)
|
||||
.size(14.0)
|
||||
.strong(),
|
||||
);
|
||||
|
|
@ -520,13 +804,19 @@ impl eframe::App for FileManagerApp {
|
|||
.size(12.0),
|
||||
);
|
||||
ui.add_space(16.0);
|
||||
let btn = egui::Button::new(
|
||||
RichText::new("+ New Connection")
|
||||
.color(Color32::WHITE)
|
||||
.size(12.0),
|
||||
let btn = egui::Button::image_and_text(
|
||||
crate::ui::icons::image(
|
||||
crate::ui::icons::Icon::AddCircleBold,
|
||||
15.0,
|
||||
ON_ACCENT,
|
||||
),
|
||||
RichText::new("New Connection")
|
||||
.color(ON_ACCENT)
|
||||
.size(12.5)
|
||||
.strong(),
|
||||
)
|
||||
.fill(ACCENT)
|
||||
.rounding(6.0)
|
||||
.rounding(RADIUS_MD)
|
||||
.min_size(egui::vec2(150.0, 34.0));
|
||||
if ui.add(btn).clicked() {
|
||||
self.state.show_connect_dialog = true;
|
||||
|
|
@ -539,17 +829,6 @@ impl eframe::App for FileManagerApp {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Connection dialog ────────────────────────────────────────────────
|
||||
if self.state.show_connect_dialog {
|
||||
connection::render(ctx, &mut self.state, &self.registry, self.rt.handle());
|
||||
}
|
||||
|
||||
// ── Remote operation dialogs ─────────────────────────────────────────
|
||||
self.render_remote_op_dialogs(ctx);
|
||||
|
||||
// 200ms repaint для обновления прогресса
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(200));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -588,7 +867,7 @@ impl FileManagerApp {
|
|||
egui::Frame::none()
|
||||
.fill(BG_PANEL)
|
||||
.stroke(egui::Stroke::new(1.0, BORDER))
|
||||
.rounding(8.0)
|
||||
.rounding(RADIUS_LG)
|
||||
.inner_margin(egui::Margin::same(16.0)),
|
||||
)
|
||||
.open(&mut open)
|
||||
|
|
@ -600,19 +879,20 @@ impl FileManagerApp {
|
|||
ui.add_space(16.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let ok = egui::Button::new(
|
||||
RichText::new("OK").color(Color32::WHITE).size(12.0),
|
||||
)
|
||||
.fill(ACCENT)
|
||||
.rounding(4.0);
|
||||
let ok =
|
||||
egui::Button::new(RichText::new("OK").color(ON_ACCENT).size(12.5))
|
||||
.fill(ACCENT)
|
||||
.rounding(RADIUS_MD)
|
||||
.min_size(egui::vec2(70.0, 30.0));
|
||||
if ui.add(ok).clicked() {
|
||||
clicked_ok = true;
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
let cancel = egui::Button::new(
|
||||
RichText::new("Cancel").color(TEXT_DIM).size(12.0),
|
||||
RichText::new("Cancel").color(TEXT_DIM).size(12.5),
|
||||
)
|
||||
.fill(egui::Color32::TRANSPARENT);
|
||||
.fill(egui::Color32::TRANSPARENT)
|
||||
.min_size(egui::vec2(70.0, 30.0));
|
||||
if ui.add(cancel).clicked() {
|
||||
self.state.show_mkdir_dialog = false;
|
||||
self.state.mkdir_name.clear();
|
||||
|
|
@ -622,9 +902,14 @@ impl FileManagerApp {
|
|||
});
|
||||
|
||||
if clicked_ok && !self.state.mkdir_name.is_empty() {
|
||||
if let Some(idx) = self.active_tab_idx() {
|
||||
let name = self.state.mkdir_name.clone();
|
||||
self.start_mkdir(idx, name);
|
||||
let name = self.state.mkdir_name.clone();
|
||||
match self.state.op_target {
|
||||
crate::ui::state::Pane::Local => self.start_local_mkdir(name),
|
||||
crate::ui::state::Pane::Remote => {
|
||||
if let Some(idx) = self.active_tab_idx() {
|
||||
self.start_mkdir(idx, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.state.show_mkdir_dialog = false;
|
||||
}
|
||||
|
|
@ -649,33 +934,50 @@ impl FileManagerApp {
|
|||
egui::Frame::none()
|
||||
.fill(BG_PANEL)
|
||||
.stroke(egui::Stroke::new(1.0, BORDER))
|
||||
.rounding(8.0)
|
||||
.rounding(RADIUS_LG)
|
||||
.inner_margin(egui::Margin::same(16.0)),
|
||||
)
|
||||
.open(&mut open)
|
||||
.show(ctx, |ui| {
|
||||
ui.set_width(260.0);
|
||||
ui.label(
|
||||
RichText::new(format!("Delete '{}' ?", name))
|
||||
.color(TEXT_PRIMARY)
|
||||
.strong(),
|
||||
);
|
||||
ui.horizontal(|ui| {
|
||||
crate::ui::icons::icon(
|
||||
ui,
|
||||
crate::ui::icons::Icon::DangerTriangle,
|
||||
18.0,
|
||||
RED,
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.label(
|
||||
RichText::new(format!("Delete \"{}\"?", name))
|
||||
.color(TEXT_PRIMARY)
|
||||
.strong(),
|
||||
);
|
||||
ui.label(
|
||||
RichText::new("This action cannot be undone.")
|
||||
.color(TEXT_DIM)
|
||||
.size(11.0),
|
||||
);
|
||||
});
|
||||
});
|
||||
ui.add_space(16.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let ok = egui::Button::new(
|
||||
RichText::new("OK").color(Color32::WHITE).size(12.0),
|
||||
)
|
||||
.fill(RED)
|
||||
.rounding(4.0);
|
||||
let ok =
|
||||
egui::Button::new(RichText::new("OK").color(ON_ACCENT).size(12.5))
|
||||
.fill(RED)
|
||||
.rounding(RADIUS_MD)
|
||||
.min_size(egui::vec2(70.0, 30.0));
|
||||
if ui.add(ok).clicked() {
|
||||
clicked_ok = true;
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
let cancel = egui::Button::new(
|
||||
RichText::new("Cancel").color(TEXT_DIM).size(12.0),
|
||||
RichText::new("Cancel").color(TEXT_DIM).size(12.5),
|
||||
)
|
||||
.fill(egui::Color32::TRANSPARENT);
|
||||
.fill(egui::Color32::TRANSPARENT)
|
||||
.min_size(egui::vec2(70.0, 30.0));
|
||||
if ui.add(cancel).clicked() {
|
||||
self.state.show_delete_dialog = false;
|
||||
self.state.delete_name.clear();
|
||||
|
|
@ -685,8 +987,13 @@ impl FileManagerApp {
|
|||
});
|
||||
|
||||
if clicked_ok {
|
||||
if let Some(idx) = self.active_tab_idx() {
|
||||
self.start_delete(idx, name);
|
||||
match self.state.op_target {
|
||||
crate::ui::state::Pane::Local => self.start_local_delete(name),
|
||||
crate::ui::state::Pane::Remote => {
|
||||
if let Some(idx) = self.active_tab_idx() {
|
||||
self.start_delete(idx, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.state.show_delete_dialog = false;
|
||||
}
|
||||
|
|
@ -710,7 +1017,7 @@ impl FileManagerApp {
|
|||
egui::Frame::none()
|
||||
.fill(BG_PANEL)
|
||||
.stroke(egui::Stroke::new(1.0, BORDER))
|
||||
.rounding(8.0)
|
||||
.rounding(RADIUS_LG)
|
||||
.inner_margin(egui::Margin::same(16.0)),
|
||||
)
|
||||
.open(&mut open)
|
||||
|
|
@ -722,19 +1029,20 @@ impl FileManagerApp {
|
|||
ui.add_space(16.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let ok = egui::Button::new(
|
||||
RichText::new("OK").color(Color32::WHITE).size(12.0),
|
||||
)
|
||||
.fill(ACCENT)
|
||||
.rounding(4.0);
|
||||
let ok =
|
||||
egui::Button::new(RichText::new("OK").color(ON_ACCENT).size(12.5))
|
||||
.fill(ACCENT)
|
||||
.rounding(RADIUS_MD)
|
||||
.min_size(egui::vec2(70.0, 30.0));
|
||||
if ui.add(ok).clicked() {
|
||||
clicked_ok = true;
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
let cancel = egui::Button::new(
|
||||
RichText::new("Cancel").color(TEXT_DIM).size(12.0),
|
||||
RichText::new("Cancel").color(TEXT_DIM).size(12.5),
|
||||
)
|
||||
.fill(egui::Color32::TRANSPARENT);
|
||||
.fill(egui::Color32::TRANSPARENT)
|
||||
.min_size(egui::vec2(70.0, 30.0));
|
||||
if ui.add(cancel).clicked() {
|
||||
self.state.show_rename_dialog = false;
|
||||
self.state.rename_new_name.clear();
|
||||
|
|
@ -745,10 +1053,15 @@ impl FileManagerApp {
|
|||
});
|
||||
|
||||
if clicked_ok && !self.state.rename_new_name.is_empty() {
|
||||
if let Some(idx) = self.active_tab_idx() {
|
||||
let old_name = self.state.rename_old_name.clone();
|
||||
let new_name = self.state.rename_new_name.clone();
|
||||
self.start_rename(idx, old_name, new_name);
|
||||
let old_name = self.state.rename_old_name.clone();
|
||||
let new_name = self.state.rename_new_name.clone();
|
||||
match self.state.op_target {
|
||||
crate::ui::state::Pane::Local => self.start_local_rename(old_name, new_name),
|
||||
crate::ui::state::Pane::Remote => {
|
||||
if let Some(idx) = self.active_tab_idx() {
|
||||
self.start_rename(idx, old_name, new_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.state.show_rename_dialog = false;
|
||||
}
|
||||
|
|
@ -759,22 +1072,142 @@ impl FileManagerApp {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_settings_dialog(&mut self, ctx: &egui::Context) {
|
||||
let screen = ctx.screen_rect();
|
||||
let center = screen.center();
|
||||
|
||||
egui::Area::new(egui::Id::new("settings_overlay"))
|
||||
.fixed_pos(egui::Pos2::ZERO)
|
||||
.order(egui::Order::Background)
|
||||
.show(ctx, |ui| {
|
||||
let r = ui.allocate_rect(screen, egui::Sense::click());
|
||||
ui.painter()
|
||||
.rect_filled(screen, 0.0, egui::Color32::from_black_alpha(160));
|
||||
if r.clicked() {
|
||||
self.state.show_settings_dialog = false;
|
||||
}
|
||||
});
|
||||
|
||||
let mut open = true;
|
||||
egui::Window::new("settings_dialog")
|
||||
.collapsible(false)
|
||||
.title_bar(false)
|
||||
.resizable(false)
|
||||
.default_pos(center)
|
||||
.pivot(egui::Align2::CENTER_CENTER)
|
||||
.frame(
|
||||
egui::Frame::none()
|
||||
.fill(BG_PANEL)
|
||||
.stroke(egui::Stroke::new(1.0, BORDER))
|
||||
.rounding(RADIUS_LG)
|
||||
.inner_margin(egui::Margin::same(20.0)),
|
||||
)
|
||||
.open(&mut open)
|
||||
.show(ctx, |ui| {
|
||||
ui.set_width(340.0);
|
||||
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("Settings")
|
||||
.color(TEXT_PRIMARY)
|
||||
.size(15.0)
|
||||
.strong(),
|
||||
);
|
||||
});
|
||||
ui.add_space(16.0);
|
||||
|
||||
settings_row(ui, "Version", env!("CARGO_PKG_VERSION"));
|
||||
let db_path = dirs::data_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join("loflum")
|
||||
.join("loflum.db");
|
||||
settings_row(ui, "Sites database", &db_path.to_string_lossy());
|
||||
|
||||
ui.add_space(16.0);
|
||||
ui.label(
|
||||
RichText::new("More settings are on the way.")
|
||||
.color(TEXT_HINT)
|
||||
.size(11.0),
|
||||
);
|
||||
|
||||
ui.add_space(16.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let close =
|
||||
egui::Button::new(RichText::new("Close").color(ON_ACCENT).size(12.5))
|
||||
.fill(ACCENT)
|
||||
.rounding(RADIUS_MD)
|
||||
.min_size(egui::vec2(80.0, 30.0));
|
||||
if ui.add(close).clicked() {
|
||||
self.state.show_settings_dialog = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if !open {
|
||||
self.state.show_settings_dialog = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn settings_row(ui: &mut egui::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| {
|
||||
ui.label(RichText::new(value).color(TEXT_HINT).monospace().size(10.5));
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
|
||||
/// Загружает системный шрифт (SF Pro / SF Mono на macOS) вместо egui-дефолта,
|
||||
/// чтобы интерфейс выглядел нативно, как в исходном макете. На других ОС
|
||||
/// файлы просто отсутствуют — тогда остаются встроенные шрифты egui.
|
||||
fn system_fonts() -> egui::FontDefinitions {
|
||||
let mut fonts = egui::FontDefinitions::default();
|
||||
|
||||
let candidates: &[(&str, &str, egui::FontFamily)] = &[
|
||||
(
|
||||
"system-sans",
|
||||
"/System/Library/Fonts/SFNS.ttf",
|
||||
egui::FontFamily::Proportional,
|
||||
),
|
||||
(
|
||||
"system-mono",
|
||||
"/System/Library/Fonts/SFNSMono.ttf",
|
||||
egui::FontFamily::Monospace,
|
||||
),
|
||||
];
|
||||
|
||||
for (name, path, family) in candidates {
|
||||
if let Ok(bytes) = std::fs::read(path) {
|
||||
fonts
|
||||
.font_data
|
||||
.insert((*name).to_owned(), egui::FontData::from_owned(bytes));
|
||||
fonts
|
||||
.families
|
||||
.entry(family.clone())
|
||||
.or_default()
|
||||
.insert(0, (*name).to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
fonts
|
||||
}
|
||||
|
||||
fn pane_header(ui: &mut egui::Ui, label: &str, width: f32) {
|
||||
egui::Frame::none()
|
||||
.fill(BG_BASE)
|
||||
.inner_margin(egui::Margin {
|
||||
left: 10.0,
|
||||
right: 6.0,
|
||||
top: 4.0,
|
||||
bottom: 4.0,
|
||||
})
|
||||
.inner_margin(egui::Margin::symmetric(12.0, 0.0))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(width);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(label).color(TEXT_DIM).size(10.0).strong());
|
||||
});
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(width - 24.0, 30.0),
|
||||
egui::Layout::left_to_right(egui::Align::Center),
|
||||
|ui| {
|
||||
ui.label(RichText::new(label).color(TEXT_DIM).size(10.5).strong());
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
let (_, sep) = ui.allocate_space(egui::vec2(width, 1.0));
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ pub fn render(
|
|||
|
||||
// Центрируем окно
|
||||
let screen = ctx.screen_rect();
|
||||
let win_size = egui::vec2(420.0, 340.0);
|
||||
let win_size = egui::vec2(380.0, 340.0);
|
||||
let win_pos = egui::pos2(
|
||||
(screen.width() - win_size.x) * 0.5,
|
||||
(screen.height() - win_size.y) * 0.4,
|
||||
|
|
@ -43,9 +43,9 @@ pub fn render(
|
|||
.order(egui::Order::Foreground)
|
||||
.show(ctx, |ui| {
|
||||
egui::Frame::none()
|
||||
.fill(Color32::from_rgb(30, 30, 34))
|
||||
.fill(BG_PANEL)
|
||||
.stroke(egui::Stroke::new(1.0, BORDER))
|
||||
.rounding(10.0)
|
||||
.rounding(RADIUS_LG)
|
||||
.shadow(egui::Shadow {
|
||||
offset: egui::vec2(0.0, 8.0),
|
||||
blur: 24.0,
|
||||
|
|
@ -80,15 +80,11 @@ pub fn render(
|
|||
ui.horizontal(|ui| {
|
||||
for (i, p) in protocols.iter().enumerate() {
|
||||
let active = state.connect_protocol == i;
|
||||
let bg = if active {
|
||||
ACCENT
|
||||
} else {
|
||||
Color32::from_rgb(40, 40, 44)
|
||||
};
|
||||
let tc = if active { Color32::WHITE } else { TEXT_DIM };
|
||||
let btn = egui::Button::new(RichText::new(*p).color(tc).size(12.0))
|
||||
let bg = if active { ACCENT_DIM } else { BG_CONTENT };
|
||||
let tc = if active { TEXT_PRIMARY } else { TEXT_DIM };
|
||||
let btn = egui::Button::new(RichText::new(*p).color(tc).size(11.5))
|
||||
.fill(bg)
|
||||
.rounding(4.0)
|
||||
.rounding(RADIUS_MD)
|
||||
.min_size(egui::vec2(64.0, 28.0));
|
||||
if ui.add(btn).clicked() {
|
||||
state.connect_protocol = i;
|
||||
|
|
@ -99,27 +95,20 @@ pub fn render(
|
|||
|
||||
ui.add_space(14.0);
|
||||
|
||||
// Поля ввода
|
||||
// Поля ввода — Host / Username+Port / Password, как в макете
|
||||
let field_w = ui.available_width();
|
||||
field(
|
||||
ui,
|
||||
"Label (optional)",
|
||||
&mut state.connect_label,
|
||||
field_w,
|
||||
false,
|
||||
);
|
||||
|
||||
field(ui, "Host", &mut state.connect_host, field_w, false);
|
||||
ui.add_space(8.0);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
let host_w = field_w - 80.0 - 8.0;
|
||||
field(ui, "Host", &mut state.connect_host, host_w, false);
|
||||
let user_w = field_w - 80.0 - 8.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(8.0);
|
||||
|
||||
field(ui, "Username", &mut state.connect_user, field_w, false);
|
||||
ui.add_space(8.0);
|
||||
field(ui, "Password", &mut state.connect_pass, field_w, true);
|
||||
|
||||
if state.connect_protocol == 0 {
|
||||
|
|
@ -127,7 +116,7 @@ pub fn render(
|
|||
ui.horizontal(|ui| {
|
||||
field(
|
||||
ui,
|
||||
"Key path",
|
||||
"Key path (optional)",
|
||||
&mut state.connect_key_path,
|
||||
field_w - 80.0 - 8.0,
|
||||
false,
|
||||
|
|
@ -137,22 +126,43 @@ pub fn render(
|
|||
let browse = egui::Button::new(
|
||||
RichText::new("Browse").color(TEXT_DIM).size(11.0),
|
||||
)
|
||||
.fill(Color32::from_rgb(40, 40, 44))
|
||||
.fill(BG_CONTENT)
|
||||
.min_size(egui::vec2(72.0, 32.0))
|
||||
.rounding(4.0);
|
||||
.rounding(RADIUS_MD);
|
||||
ui.add_enabled(false, browse);
|
||||
});
|
||||
}
|
||||
|
||||
ui.add_space(8.0);
|
||||
field(
|
||||
ui,
|
||||
"Label (optional)",
|
||||
&mut state.connect_label,
|
||||
field_w,
|
||||
false,
|
||||
);
|
||||
|
||||
// Ошибка
|
||||
if !state.connect_error.is_empty() {
|
||||
ui.add_space(10.0);
|
||||
egui::Frame::none()
|
||||
.fill(Color32::from_rgb(60, 25, 25))
|
||||
.rounding(4.0)
|
||||
.inner_margin(egui::Margin::symmetric(8.0, 6.0))
|
||||
.fill(Color32::from_rgb(42, 27, 22))
|
||||
.stroke(egui::Stroke::new(1.0, Color32::from_rgb(70, 40, 34)))
|
||||
.rounding(RADIUS_MD)
|
||||
.inner_margin(egui::Margin::symmetric(10.0, 8.0))
|
||||
.show(ui, |ui| {
|
||||
ui.label(RichText::new(&state.connect_error).color(RED).size(11.0));
|
||||
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),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -161,10 +171,10 @@ pub fn render(
|
|||
// Кнопки
|
||||
ui.horizontal(|ui| {
|
||||
let cancel =
|
||||
egui::Button::new(RichText::new("Cancel").color(TEXT_DIM).size(12.0))
|
||||
.fill(Color32::from_rgb(40, 40, 44))
|
||||
.rounding(6.0)
|
||||
.min_size(egui::vec2(90.0, 34.0));
|
||||
egui::Button::new(RichText::new("Cancel").color(TEXT_DIM).size(12.5))
|
||||
.fill(Color32::TRANSPARENT)
|
||||
.rounding(RADIUS_MD)
|
||||
.min_size(egui::vec2(90.0, 30.0));
|
||||
if ui.add(cancel).clicked() && !state.connect_loading {
|
||||
state.show_connect_dialog = false;
|
||||
}
|
||||
|
|
@ -178,13 +188,13 @@ pub fn render(
|
|||
};
|
||||
let connect_btn = egui::Button::new(
|
||||
RichText::new(connect_label)
|
||||
.color(Color32::WHITE)
|
||||
.size(12.0)
|
||||
.color(ON_ACCENT)
|
||||
.size(12.5)
|
||||
.strong(),
|
||||
)
|
||||
.fill(ACCENT)
|
||||
.rounding(6.0)
|
||||
.min_size(egui::vec2(110.0, 34.0));
|
||||
.rounding(RADIUS_MD)
|
||||
.min_size(egui::vec2(110.0, 30.0));
|
||||
|
||||
if ui.add(connect_btn).clicked() {
|
||||
do_connect(state, registry, rt_handle);
|
||||
|
|
@ -218,6 +228,19 @@ fn do_connect(
|
|||
) {
|
||||
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,
|
||||
|
|
@ -261,6 +284,17 @@ fn do_connect(
|
|||
},
|
||||
};
|
||||
|
||||
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));
|
||||
|
|
@ -275,6 +309,80 @@ fn do_connect(
|
|||
state.pending_connect = Some(PendingConnect { result });
|
||||
}
|
||||
|
||||
/// Переподключение по клику на запись истории — пароль (если есть) уже лежит
|
||||
/// в 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);
|
||||
}
|
||||
|
||||
fn protocol_index(protocol: &Protocol) -> usize {
|
||||
match protocol {
|
||||
Protocol::Sftp => 0,
|
||||
Protocol::Ftp => 1,
|
||||
Protocol::Ftps => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// "Изменить" в меню истории — открывает диалог 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(())
|
||||
}
|
||||
|
||||
async fn connect_async(
|
||||
registry: &RemoteRegistry,
|
||||
params: &ConnectionParams,
|
||||
|
|
|
|||
98
src/ui/icons.rs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
//! Solar icon set (https://solar-icons.vercel.app) — встроенные SVG, красятся через tint()
|
||||
use egui::{Color32, Ui};
|
||||
|
||||
macro_rules! icon_set {
|
||||
($($key:ident => $file:literal),* $(,)?) => {
|
||||
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),
|
||||
}
|
||||
}
|
||||
BIN
src/ui/icons/app_icon.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
1
src/ui/icons/svg/add-circle-bold.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 343 B |
1
src/ui/icons/svg/add-circle-linear.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path stroke-linecap="round" d="M15 12h-3m0 0H9m3 0V9m0 3v3"/></g></svg>
|
||||
|
After Width: | Height: | Size: 248 B |
1
src/ui/icons/svg/add-square-linear.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="currentColor" 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>
|
||||
|
After Width: | Height: | Size: 415 B |
1
src/ui/icons/svg/alt-arrow-down-linear.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m19 9l-7 6l-7-6"/></svg>
|
||||
|
After Width: | Height: | Size: 221 B |
1
src/ui/icons/svg/alt-arrow-up-linear.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m19 15l-7-6l-7 6"/></svg>
|
||||
|
After Width: | Height: | Size: 222 B |
4
src/ui/icons/svg/app_icon_source.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 769 B |
1
src/ui/icons/svg/archive-down-minimlistic-bold.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 872 B |
1
src/ui/icons/svg/check-circle-bold.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 366 B |
1
src/ui/icons/svg/clock-circle-linear.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="currentColor" 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>
|
||||
|
After Width: | Height: | Size: 260 B |
1
src/ui/icons/svg/close-circle-linear.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="currentColor" 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>
|
||||
|
After Width: | Height: | Size: 243 B |
1
src/ui/icons/svg/code-bold.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 924 B |
1
src/ui/icons/svg/danger-triangle-bold.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 444 B |
1
src/ui/icons/svg/database-linear.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="currentColor" 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>
|
||||
|
After Width: | Height: | Size: 350 B |
1
src/ui/icons/svg/document-bold.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 451 B |
1
src/ui/icons/svg/document-text-bold.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 546 B |
1
src/ui/icons/svg/download-linear.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="currentColor" 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>
|
||||
|
After Width: | Height: | Size: 490 B |
1
src/ui/icons/svg/download-square-bold.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 543 B |
1
src/ui/icons/svg/file-bold.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 678 B |
1
src/ui/icons/svg/file-text-bold.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 826 B |
1
src/ui/icons/svg/folder-bold.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 648 B |
1
src/ui/icons/svg/folder-with-files-linear.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="currentColor" 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>
|
||||
|
After Width: | Height: | Size: 692 B |
1
src/ui/icons/svg/gallery-bold.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
1
src/ui/icons/svg/history-linear.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff"><path stroke="currentColor" 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>
|
||||
|
After Width: | Height: | Size: 910 B |
1
src/ui/icons/svg/home-2-bold.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 512 B |
1
src/ui/icons/svg/link-bold.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 761 B |
1
src/ui/icons/svg/lock-password-bold.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 578 B |
1
src/ui/icons/svg/monitor-bold.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 443 B |
1
src/ui/icons/svg/music-note-3-bold.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 548 B |
1
src/ui/icons/svg/pause-circle-linear.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="currentColor" 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>
|
||||
|
After Width: | Height: | Size: 726 B |
1
src/ui/icons/svg/pen-linear.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" stroke="currentColor" 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>
|
||||
|
After Width: | Height: | Size: 608 B |
1
src/ui/icons/svg/play-circle-linear.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="currentColor" 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>
|
||||
|
After Width: | Height: | Size: 317 B |
1
src/ui/icons/svg/refresh-linear.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 974 B |
1
src/ui/icons/svg/server-square-linear.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="currentColor" 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>
|
||||
|
After Width: | Height: | Size: 447 B |
1
src/ui/icons/svg/settings-linear.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
1
src/ui/icons/svg/shield-keyhole-bold.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 581 B |
1
src/ui/icons/svg/ssd-square-linear.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff"><path stroke="currentColor" 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>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
1
src/ui/icons/svg/star-bold.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 761 B |
1
src/ui/icons/svg/trash-bin-minimalistic-linear.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="#ffffff" stroke="currentColor" 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>
|
||||
|
After Width: | Height: | Size: 394 B |
1
src/ui/icons/svg/upload-linear.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="#ffffff" stroke="currentColor" 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>
|
||||
|
After Width: | Height: | Size: 488 B |
1
src/ui/icons/svg/videocamera-record-bold.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 653 B |
97
src/ui/menu.rs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
//! Нативное меню в верхней панели экрана (только 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";
|
||||
}
|
||||
|
||||
/// Строит нативное меню 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 window_menu = Submenu::new("Window", true);
|
||||
let _ = window_menu.append_items(&[
|
||||
&PredefinedMenuItem::minimize(None),
|
||||
&PredefinedMenuItem::maximize(None),
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::bring_all_to_front(None),
|
||||
]);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn poll_menu_events(_state: &mut AppState) {}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
pub mod app;
|
||||
pub mod dialogs;
|
||||
pub mod drag;
|
||||
pub mod icons;
|
||||
pub mod menu;
|
||||
pub mod panels;
|
||||
pub mod state;
|
||||
pub mod theme;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! Переиспользуемая таблица файлов с нормальными колонками
|
||||
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::*;
|
||||
use egui::{Color32, Id, RichText, Ui};
|
||||
|
||||
|
|
@ -35,20 +36,56 @@ pub enum SortDir {
|
|||
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>(
|
||||
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| {
|
||||
file_table_inner(ui, id, entries, selected, make_drag_payload, context_menu)
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
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"));
|
||||
|
|
@ -92,48 +129,49 @@ where
|
|||
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;
|
||||
|
||||
// Заголовки колонок
|
||||
let header_frame = egui::Frame::none()
|
||||
.fill(BG_HEADER)
|
||||
.inner_margin(egui::Margin {
|
||||
left: 6.0,
|
||||
right: 6.0,
|
||||
top: 0.0,
|
||||
bottom: 0.0,
|
||||
});
|
||||
.inner_margin(egui::Margin::symmetric(12.0, 0.0));
|
||||
|
||||
header_frame.show(ui, |ui| {
|
||||
ui.set_min_height(22.0);
|
||||
ui.horizontal(|ui| {
|
||||
let name_r = sort_header(ui, "Name", 0.0, sort_col == SortCol::Name, sort_dir);
|
||||
if name_r {
|
||||
new_sort = Some(toggle_sort(SortCol::Name, sort_col, sort_dir));
|
||||
}
|
||||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let mod_r = sort_header(
|
||||
ui,
|
||||
"Modified",
|
||||
140.0,
|
||||
sort_col == SortCol::Modified,
|
||||
sort_dir,
|
||||
);
|
||||
if mod_r {
|
||||
new_sort = Some(toggle_sort(SortCol::Modified, sort_col, sort_dir));
|
||||
let width = ui.available_width();
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(width, 24.0),
|
||||
egui::Layout::left_to_right(egui::Align::Center),
|
||||
|ui| {
|
||||
let name_r = sort_header(ui, "Name", 0.0, sort_col == SortCol::Name, sort_dir);
|
||||
if name_r {
|
||||
new_sort = Some(toggle_sort(SortCol::Name, sort_col, sort_dir));
|
||||
}
|
||||
|
||||
let type_r = sort_header(ui, "Type", 40.0, sort_col == SortCol::Kind, sort_dir);
|
||||
if type_r {
|
||||
new_sort = Some(toggle_sort(SortCol::Kind, sort_col, sort_dir));
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let mod_r = sort_header(
|
||||
ui,
|
||||
"Modified",
|
||||
140.0,
|
||||
sort_col == SortCol::Modified,
|
||||
sort_dir,
|
||||
);
|
||||
if mod_r {
|
||||
new_sort = Some(toggle_sort(SortCol::Modified, sort_col, sort_dir));
|
||||
}
|
||||
|
||||
let size_r = sort_header(ui, "Size", 70.0, sort_col == SortCol::Size, sort_dir);
|
||||
if size_r {
|
||||
new_sort = Some(toggle_sort(SortCol::Size, sort_col, sort_dir));
|
||||
}
|
||||
});
|
||||
});
|
||||
let type_r = sort_header(ui, "Type", 40.0, sort_col == SortCol::Kind, sort_dir);
|
||||
if type_r {
|
||||
new_sort = Some(toggle_sort(SortCol::Kind, sort_col, sort_dir));
|
||||
}
|
||||
|
||||
let size_r = sort_header(ui, "Size", 70.0, sort_col == SortCol::Size, sort_dir);
|
||||
if size_r {
|
||||
new_sort = Some(toggle_sort(SortCol::Size, sort_col, sort_dir));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if let Some(s) = new_sort {
|
||||
|
|
@ -148,101 +186,141 @@ where
|
|||
|
||||
// Список файлов
|
||||
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 = ui.rect_contains_pointer(row_rect);
|
||||
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_selected {
|
||||
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)
|
||||
.inner_margin(egui::Margin {
|
||||
left: 6.0,
|
||||
right: 6.0,
|
||||
top: 0.0,
|
||||
bottom: 0.0,
|
||||
})
|
||||
.stroke(stroke)
|
||||
.rounding(RADIUS_SM)
|
||||
.inner_margin(egui::Margin::symmetric(12.0, 0.0))
|
||||
.show(ui, |ui| {
|
||||
ui.set_min_height(ROW_H);
|
||||
ui.horizontal_centered(|ui| {
|
||||
// иконка + имя
|
||||
let name_text = if entry.name == ".." {
|
||||
"⬆ ..".to_string()
|
||||
} else {
|
||||
match entry.kind {
|
||||
EntryKind::Dir => format!("📁 {}", entry.name),
|
||||
EntryKind::Symlink => format!("🔗 {}", entry.name),
|
||||
EntryKind::File => {
|
||||
let icon = file_icon(&entry.name);
|
||||
format!("{} {}", icon, entry.name)
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(row_width - 24.0, ROW_H),
|
||||
egui::Layout::left_to_right(egui::Align::Center),
|
||||
|ui| {
|
||||
// иконка (цвет по типу) + имя (единый цвет, как в спеке)
|
||||
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 name_col = if entry.name == ".." {
|
||||
TEXT_HINT
|
||||
} else if entry.kind == EntryKind::Dir {
|
||||
ACCENT
|
||||
} else {
|
||||
TEXT_PRIMARY
|
||||
};
|
||||
|
||||
if let Some(payload) = make_drag_payload(entry) {
|
||||
ui.dnd_drag_source(row_id, payload, |ui| {
|
||||
ui.label(RichText::new(&name_text).color(name_col).size(12.0));
|
||||
});
|
||||
} else {
|
||||
ui.label(RichText::new(&name_text).color(name_col).size(12.0));
|
||||
}
|
||||
|
||||
// правые колонки
|
||||
ui.with_layout(
|
||||
egui::Layout::right_to_left(egui::Align::Center),
|
||||
|ui| {
|
||||
ui.label(
|
||||
RichText::new(format_time(entry.modified))
|
||||
.color(TEXT_DIM)
|
||||
.size(11.0),
|
||||
);
|
||||
let draw_row = |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));
|
||||
};
|
||||
|
||||
let (type_str, type_col) = match entry.kind {
|
||||
EntryKind::Dir => ("Dir", TEXT_DIM),
|
||||
EntryKind::File => ("File", TEXT_HINT),
|
||||
EntryKind::Symlink => ("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);
|
||||
if let Some(payload) = make_drag_payload(entry) {
|
||||
ui.dnd_drag_source(row_id, payload, |ui| {
|
||||
ui.horizontal(draw_row);
|
||||
});
|
||||
} else {
|
||||
draw_row(ui);
|
||||
}
|
||||
|
||||
ui.label(
|
||||
RichText::new(format_size(entry.size))
|
||||
.color(TEXT_DIM)
|
||||
.size(11.0),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
},
|
||||
);
|
||||
});
|
||||
// правые колонки
|
||||
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 => ("Dir", TEXT_DIM),
|
||||
EntryKind::File => ("File", TEXT_HINT),
|
||||
EntryKind::Symlink => ("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);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
})
|
||||
.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());
|
||||
|
|
@ -250,15 +328,48 @@ where
|
|||
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());
|
||||
}
|
||||
response.context_menu(|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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Единообразная кнопка-пункт для контекстных меню (ПКМ на строке).
|
||||
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),
|
||||
)
|
||||
.fill(Color32::TRANSPARENT)
|
||||
.min_size(egui::vec2(170.0, 26.0));
|
||||
ui.add(btn).clicked()
|
||||
}
|
||||
|
||||
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 { " ↓" }
|
||||
|
|
@ -267,7 +378,7 @@ fn sort_header(ui: &mut Ui, label: &str, min_w: f32, active: bool, dir: SortDir)
|
|||
};
|
||||
let full = format!("{}{}", label, arrow);
|
||||
let col = if active { ACCENT } else { TEXT_DIM };
|
||||
let btn = egui::Button::new(RichText::new(full).color(col).size(11.0).strong())
|
||||
let btn = egui::Button::new(RichText::new(full).color(col).size(10.5).strong())
|
||||
.fill(Color32::TRANSPARENT)
|
||||
.min_size(egui::vec2(min_w, 20.0));
|
||||
ui.add(btn).clicked()
|
||||
|
|
@ -285,22 +396,3 @@ fn toggle_sort(col: SortCol, cur_col: SortCol, cur_dir: SortDir) -> (SortCol, So
|
|||
(col, SortDir::Asc)
|
||||
}
|
||||
}
|
||||
|
||||
fn file_icon(name: &str) -> &'static str {
|
||||
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
|
||||
match ext.as_str() {
|
||||
"rs" => "🦀",
|
||||
"toml" | "yaml" | "yml" | "json" => "⚙",
|
||||
"md" | "txt" | "log" => "📝",
|
||||
"png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" => "🖼",
|
||||
"mp4" | "mkv" | "avi" | "mov" => "🎬",
|
||||
"mp3" | "ogg" | "flac" | "wav" => "🎵",
|
||||
"zip" | "tar" | "gz" | "bz2" | "xz" | "7z" => "📦",
|
||||
"pdf" => "📕",
|
||||
"sh" | "bash" | "fish" | "zsh" => "⚡",
|
||||
"html" | "css" | "js" | "ts" => "🌐",
|
||||
"py" => "🐍",
|
||||
"java" | "class" | "jar" => "☕",
|
||||
_ => "📄",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,11 +5,14 @@ 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, format_size};
|
||||
use crate::ui::state::AppState;
|
||||
use crate::ui::icons::{self, Icon};
|
||||
use crate::ui::panels::file_pane::{
|
||||
FileTableResponse, RowAction, context_menu_item, file_table, format_size,
|
||||
};
|
||||
use crate::ui::state::{AppState, Pane};
|
||||
use crate::ui::theme::*;
|
||||
use egui::{RichText, Ui};
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
fn parent_path(path: &str) -> String {
|
||||
std::path::Path::new(path)
|
||||
|
|
@ -35,43 +38,75 @@ pub fn render(
|
|||
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);
|
||||
render_inner(ui, state, queue, registry, db);
|
||||
});
|
||||
|
||||
if let Some(payload_arc) = dropped {
|
||||
match &*payload_arc {
|
||||
DragPayload::RemoteFile(remote_path, file_name, conn_id) => {
|
||||
if registry.get(conn_id).is_some() {
|
||||
let local_path_str =
|
||||
format!("{}/{}", state.local_path.trim_end_matches('/'), file_name);
|
||||
let task = TransferTask::new(
|
||||
TransferKind::Download,
|
||||
conn_id.clone(),
|
||||
local_path_str,
|
||||
remote_path.clone(),
|
||||
file_name.clone(),
|
||||
0,
|
||||
);
|
||||
queue.push(task);
|
||||
state.status_message = format!("Download queued: {}", file_name);
|
||||
} else {
|
||||
state.status_message = "Connection no longer active".into();
|
||||
}
|
||||
let dest_dir = state.local_path.clone();
|
||||
handle_drop(state, queue, registry, &payload_arc, &dest_dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Обрабатывает бросок файла в текущую директорию (dest_dir) — общий код
|
||||
/// для дропа на весь пейн и дропа на конкретную папку в списке.
|
||||
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 = format!("Download queued: {}", file_name);
|
||||
} else {
|
||||
state.status_message = "Connection no longer active".into();
|
||||
}
|
||||
DragPayload::LocalFile(_, _) => {
|
||||
state.status_message = "Cannot drop local onto local".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 = format!("Moved {}", file_name);
|
||||
refresh_local(state);
|
||||
}
|
||||
Err(e) => state.status_message = format!("Move failed: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_inner(ui: &mut Ui, state: &mut AppState) {
|
||||
fn render_inner(
|
||||
ui: &mut Ui,
|
||||
state: &mut AppState,
|
||||
queue: &TransferQueue,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
db: &Arc<Mutex<rusqlite::Connection>>,
|
||||
) {
|
||||
// Хлебные крошки + путь
|
||||
render_path_bar(ui, state);
|
||||
|
||||
|
|
@ -88,93 +123,232 @@ fn render_inner(ui: &mut Ui, state: &mut AppState) {
|
|||
.map(|t| t.status == ConnectionStatus::Connected)
|
||||
.unwrap_or(false);
|
||||
|
||||
let FileTableResponse { double_clicked, .. } =
|
||||
file_table(ui, "local_table", &entries, &mut selected, |entry| {
|
||||
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| 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 {
|
||||
handle_drop(state, queue, registry, &payload, &target_entry.path);
|
||||
}
|
||||
|
||||
if let Some(name) = double_clicked {
|
||||
if name == ".." {
|
||||
state.local_path = parent_path(&state.local_path);
|
||||
} else if let Some(entry) = state.local_entries.iter().find(|e| e.name == name)
|
||||
&& entry.kind == EntryKind::Dir
|
||||
{
|
||||
state.local_path = entry.path.clone();
|
||||
}
|
||||
refresh_local(state);
|
||||
open_entry(state, &name);
|
||||
}
|
||||
|
||||
if let Some((entry, action)) = context_action {
|
||||
handle_row_action(ui.ctx(), state, queue, db, &entry, action);
|
||||
}
|
||||
|
||||
// Нижняя полоска
|
||||
render_footer(ui, state);
|
||||
render_footer(ui, state, db);
|
||||
}
|
||||
|
||||
fn build_context_menu(ui: &mut Ui, entry: &FileEntry, has_connection: bool) -> Option<RowAction> {
|
||||
if context_menu_item(ui, Icon::Folder, "Open", false) {
|
||||
return Some(RowAction::Open);
|
||||
}
|
||||
if entry.name != ".." {
|
||||
if context_menu_item(ui, Icon::Pen, "Rename", false) {
|
||||
return Some(RowAction::Rename);
|
||||
}
|
||||
if context_menu_item(ui, Icon::Trash, "Delete", true) {
|
||||
return Some(RowAction::Delete);
|
||||
}
|
||||
ui.separator();
|
||||
if entry.kind == EntryKind::Dir
|
||||
&& context_menu_item(ui, Icon::Star, "Add to Bookmarks", false)
|
||||
{
|
||||
return Some(RowAction::Bookmark);
|
||||
}
|
||||
if context_menu_item(ui, Icon::Document, "Copy Path", false) {
|
||||
return Some(RowAction::CopyPath);
|
||||
}
|
||||
if entry.kind == EntryKind::File && has_connection {
|
||||
ui.separator();
|
||||
if context_menu_item(ui, Icon::Upload, "Upload to Server", false) {
|
||||
return Some(RowAction::Transfer);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
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 = format!("Opening {}", entry.name),
|
||||
Err(e) => state.status_message = format!("Open failed: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = "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 = format!("Upload queued: {}", entry.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = "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 = "Bookmark added".into();
|
||||
}
|
||||
Err(e) => state.status_message = format!("Bookmark failed: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_path_bar(ui: &mut Ui, state: &mut AppState) {
|
||||
let frame = egui::Frame::none()
|
||||
.fill(BG_PANEL)
|
||||
.inner_margin(egui::Margin {
|
||||
left: 8.0,
|
||||
right: 8.0,
|
||||
top: 4.0,
|
||||
bottom: 4.0,
|
||||
});
|
||||
.fill(BG_BASE)
|
||||
.inner_margin(egui::Margin::symmetric(12.0, 0.0));
|
||||
|
||||
frame.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
// иконка home
|
||||
let home = dirs::home_dir()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let is_home = state.local_path == home;
|
||||
let home_btn = egui::Button::new(RichText::new("🏠").size(13.0))
|
||||
.fill(if is_home {
|
||||
BG_ROW_SEL
|
||||
} else {
|
||||
egui::Color32::TRANSPARENT
|
||||
})
|
||||
.rounding(4.0)
|
||||
.min_size(egui::vec2(22.0, 22.0));
|
||||
if ui.add(home_btn).clicked() {
|
||||
state.local_path = home;
|
||||
refresh_local(state);
|
||||
}
|
||||
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("LOCAL").color(TEXT_DIM).size(10.5).strong());
|
||||
ui.add_space(10.0);
|
||||
|
||||
ui.add_space(4.0);
|
||||
|
||||
// Крошки
|
||||
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 link = egui::Label::new(RichText::new(*part).color(TEXT_DIM).size(12.0))
|
||||
.sense(egui::Sense::click());
|
||||
if ui.add(link).clicked() {
|
||||
state.local_path = path_snap;
|
||||
refresh_local(state);
|
||||
}
|
||||
ui.label(RichText::new("/").color(TEXT_HINT).size(12.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))
|
||||
.fill(if is_home {
|
||||
BG_ROW_SEL
|
||||
} else {
|
||||
egui::Color32::TRANSPARENT
|
||||
})
|
||||
.rounding(RADIUS_SM)
|
||||
.min_size(egui::vec2(22.0, 22.0));
|
||||
if ui.add(home_btn).clicked() {
|
||||
state.local_path = home;
|
||||
refresh_local(state);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(4.0);
|
||||
|
||||
// Крошки
|
||||
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 link =
|
||||
egui::Label::new(RichText::new(*part).color(TEXT_DIM).size(12.0))
|
||||
.sense(egui::Sense::click());
|
||||
if ui.add(link).clicked() {
|
||||
state.local_path = path_snap;
|
||||
refresh_local(state);
|
||||
}
|
||||
ui.label(RichText::new("/").color(TEXT_HINT).size(12.0));
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn render_footer(ui: &mut Ui, state: &mut AppState) {
|
||||
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);
|
||||
|
||||
|
|
@ -207,7 +381,7 @@ fn render_footer(ui: &mut Ui, state: &mut AppState) {
|
|||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
// кнопка добавить закладку
|
||||
let star = egui::Button::new(RichText::new("★").color(YELLOW).size(12.0))
|
||||
let star = egui::Button::image(icons::image(Icon::Star, 12.0, YELLOW))
|
||||
.fill(egui::Color32::TRANSPARENT)
|
||||
.min_size(egui::vec2(20.0, 20.0));
|
||||
if ui.add(star).on_hover_text("Bookmark this folder").clicked() {
|
||||
|
|
@ -215,11 +389,8 @@ fn render_footer(ui: &mut Ui, state: &mut AppState) {
|
|||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "Bookmark".into());
|
||||
state.bookmarks.push(crate::ui::state::Bookmark {
|
||||
name,
|
||||
path: state.local_path.clone(),
|
||||
});
|
||||
state.status_message = "Bookmark added".into();
|
||||
let path = state.local_path.clone();
|
||||
add_bookmark(state, db, &name, &path);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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::*;
|
||||
|
|
@ -9,75 +10,84 @@ 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(8.0, 0.0));
|
||||
.inner_margin(egui::Margin::symmetric(12.0, 0.0));
|
||||
|
||||
header_frame.show(ui, |ui| {
|
||||
ui.set_min_height(QUEUE_COLLAPSED_H);
|
||||
ui.horizontal_centered(|ui| {
|
||||
let arrow = if state.show_queue { "▼" } else { "▲" };
|
||||
let n_active = tasks
|
||||
.iter()
|
||||
.filter(|t| matches!(t.state, TaskState::Running | TaskState::Queued))
|
||||
.count();
|
||||
|
||||
let title = if n_active > 0 {
|
||||
format!("{} Transfer Queue ({})", arrow, n_active)
|
||||
} else {
|
||||
format!("{} Transfer Queue ({})", arrow, tasks.len())
|
||||
};
|
||||
|
||||
let btn = egui::Button::new(
|
||||
RichText::new(&title)
|
||||
.color(TEXT_PRIMARY)
|
||||
.size(12.0)
|
||||
.strong(),
|
||||
)
|
||||
.fill(Color32::TRANSPARENT)
|
||||
.min_size(egui::vec2(0.0, QUEUE_COLLAPSED_H));
|
||||
|
||||
if ui.add(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
|
||||
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| t.state == TaskState::Running)
|
||||
.filter_map(|t| t.speed)
|
||||
.sum();
|
||||
.filter(|t| matches!(t.state, TaskState::Running | TaskState::Queued))
|
||||
.count();
|
||||
|
||||
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
|
||||
let title = if n_active > 0 {
|
||||
format!("Transfer Queue ({})", n_active)
|
||||
} else {
|
||||
format!("Transfer Queue ({})", tasks.len())
|
||||
};
|
||||
|
||||
let btn = egui::Button::image_and_text(
|
||||
icons::image(chevron, 13.0, TEXT_DIM),
|
||||
RichText::new(&title)
|
||||
.color(TEXT_PRIMARY)
|
||||
.size(11.5)
|
||||
.strong(),
|
||||
)
|
||||
.fill(Color32::TRANSPARENT)
|
||||
.min_size(egui::vec2(0.0, QUEUE_COLLAPSED_H));
|
||||
|
||||
if ui.add(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::Completed)
|
||||
.count();
|
||||
let failed = tasks
|
||||
.iter()
|
||||
.filter(|t| matches!(t.state, TaskState::Failed(_)))
|
||||
.count();
|
||||
ui.label(
|
||||
RichText::new(format!("{}/{} done", done, tasks.len()))
|
||||
.color(TEXT_DIM)
|
||||
.size(11.0),
|
||||
);
|
||||
if failed > 0 {
|
||||
ui.add_space(8.0);
|
||||
.filter(|t| t.state == TaskState::Running)
|
||||
.filter_map(|t| t.speed)
|
||||
.sum();
|
||||
|
||||
if agg_speed > 0 {
|
||||
ui.label(
|
||||
RichText::new(format!("{} failed", failed))
|
||||
.color(RED)
|
||||
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(format!("{}/{} done", done, tasks.len()))
|
||||
.color(TEXT_DIM)
|
||||
.size(11.0),
|
||||
);
|
||||
if failed > 0 {
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
RichText::new(format!("{} failed", failed))
|
||||
.color(RED)
|
||||
.size(11.0),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if !state.show_queue || tasks.is_empty() {
|
||||
|
|
@ -91,113 +101,81 @@ pub fn render(ui: &mut Ui, state: &mut AppState, tasks: &[TransferTask]) {
|
|||
.auto_shrink([false; 2])
|
||||
.max_height(QUEUE_EXPANDED_H - QUEUE_COLLAPSED_H - 4.0)
|
||||
.show(ui, |ui| {
|
||||
for task in tasks {
|
||||
render_task(ui, task);
|
||||
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) {
|
||||
let bg = match &task.state {
|
||||
TaskState::Completed => Color32::from_rgb(22, 38, 24),
|
||||
TaskState::Failed(_) => Color32::from_rgb(38, 22, 22),
|
||||
TaskState::Running => Color32::from_rgb(22, 28, 42),
|
||||
TaskState::Queued => BG_QUEUE,
|
||||
_ => BG_QUEUE,
|
||||
};
|
||||
// Плоская однострочная карточка, как в макете — без цветной подложки
|
||||
ui.horizontal(|ui| {
|
||||
ui.set_min_height(34.0);
|
||||
|
||||
egui::Frame::none()
|
||||
.fill(bg)
|
||||
.inner_margin(egui::Margin {
|
||||
left: 10.0,
|
||||
right: 10.0,
|
||||
top: 4.0,
|
||||
bottom: 4.0,
|
||||
})
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
// стрелка вида
|
||||
let (arrow, arrow_col) = match task.kind {
|
||||
TransferKind::Upload => ("↑", ACCENT),
|
||||
TransferKind::Download => ("↓", GREEN),
|
||||
};
|
||||
ui.label(RichText::new(arrow).color(arrow_col).size(13.0).strong());
|
||||
ui.add_space(6.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);
|
||||
|
||||
// имя файла
|
||||
ui.label(
|
||||
RichText::new(&task.file_name)
|
||||
.color(TEXT_PRIMARY)
|
||||
.size(12.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);
|
||||
|
||||
// статус справа
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let (state_str, state_col) = match &task.state {
|
||||
TaskState::Queued => ("Queued", TEXT_HINT),
|
||||
TaskState::Running => ("Uploading", ACCENT),
|
||||
TaskState::Completed => ("✓ Done", GREEN),
|
||||
TaskState::Failed(_) => ("× Failed", RED),
|
||||
TaskState::Paused => ("Paused", YELLOW),
|
||||
TaskState::Cancelled => ("Cancelled", TEXT_HINT),
|
||||
TaskState::Retrying(_) => ("Retrying", YELLOW),
|
||||
};
|
||||
ui.label(RichText::new(state_str).color(state_col).size(11.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)
|
||||
.fill(bar_col);
|
||||
ui.add(bar);
|
||||
ui.add_space(10.0);
|
||||
|
||||
// прогресс
|
||||
ui.horizontal(|ui| {
|
||||
let pct = task.progress_pct() as f32 / 100.0;
|
||||
let bar_col = match &task.state {
|
||||
TaskState::Completed => GREEN,
|
||||
TaskState::Failed(_) => RED,
|
||||
_ => ACCENT,
|
||||
};
|
||||
ui.label(
|
||||
RichText::new(format!("{:.0}%", task.progress_pct()))
|
||||
.color(TEXT_DIM)
|
||||
.monospace()
|
||||
.size(11.0),
|
||||
);
|
||||
|
||||
let bar = egui::ProgressBar::new(pct)
|
||||
.text(format!("{:.0}%", task.progress_pct()))
|
||||
.desired_width(ui.available_width() * 0.55)
|
||||
.fill(bar_col);
|
||||
ui.add(bar);
|
||||
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
" {}/{}",
|
||||
format_size(Some(task.transferred_bytes)),
|
||||
format_size(Some(task.total_bytes))
|
||||
))
|
||||
.color(TEXT_DIM)
|
||||
.size(11.0),
|
||||
);
|
||||
|
||||
if let Some(speed) = task.speed
|
||||
&& speed > 0
|
||||
{
|
||||
ui.label(
|
||||
RichText::new(format!(" {}/s", format_size(Some(speed))))
|
||||
.color(GREEN)
|
||||
.size(11.0),
|
||||
);
|
||||
}
|
||||
if let Some(eta) = task.eta_secs
|
||||
&& eta > 0
|
||||
{
|
||||
ui.label(
|
||||
RichText::new(format!(" ETA {}s", eta))
|
||||
.color(TEXT_HINT)
|
||||
.size(10.0),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ошибка если есть
|
||||
if let TaskState::Failed(err) = &task.state {
|
||||
ui.label(RichText::new(err).color(RED).size(10.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 => ("Queued".to_string(), TEXT_HINT, None),
|
||||
TaskState::Running => match task.speed {
|
||||
Some(s) if s > 0 => (format!("{}/s", format_size(Some(s))), GREEN, None),
|
||||
_ => ("Uploading".to_string(), ACCENT, None),
|
||||
},
|
||||
TaskState::Completed => ("Complete".to_string(), GREEN, Some(Icon::CheckCircle)),
|
||||
TaskState::Failed(_) => ("Failed".to_string(), RED, None),
|
||||
TaskState::Paused => ("Paused".to_string(), YELLOW, Some(Icon::PlayCircle)),
|
||||
TaskState::Cancelled => ("Cancelled".to_string(), TEXT_HINT, None),
|
||||
TaskState::Retrying(_) => ("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));
|
||||
});
|
||||
|
||||
// разделитель
|
||||
let (_, sep) = ui.allocate_space(egui::vec2(ui.available_width(), 1.0));
|
||||
ui.painter().rect_filled(sep, 0.0, SEPARATOR);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,11 @@ use crate::domain::transfer::{TransferKind, TransferTask};
|
|||
use crate::fs::remote::RemoteRegistry;
|
||||
use crate::transfer::queue::TransferQueue;
|
||||
use crate::ui::drag::{DragPayload, make_remote_payload};
|
||||
use crate::ui::panels::file_pane::{FileTableResponse, file_table, format_size};
|
||||
use crate::ui::state::{AppState, PendingRemoteList};
|
||||
use crate::ui::icons::{self, Icon};
|
||||
use crate::ui::panels::file_pane::{
|
||||
FileTableResponse, RowAction, context_menu_item, file_table, format_size,
|
||||
};
|
||||
use crate::ui::state::{AppState, Pane, PendingRemoteList};
|
||||
use crate::ui::theme::*;
|
||||
|
||||
pub fn remote_parent(path: &str) -> String {
|
||||
|
|
@ -41,35 +44,83 @@ pub fn render(
|
|||
.inner_margin(egui::Margin::same(0.0));
|
||||
|
||||
let (_inner, dropped) = ui.dnd_drop_zone::<DragPayload, _>(frame, |ui| {
|
||||
render_content(ui, state, tab_idx, registry, rt_handle);
|
||||
render_content(ui, state, tab_idx, registry, rt_handle, queue);
|
||||
});
|
||||
|
||||
let is_connected = state.tabs[tab_idx].status == ConnectionStatus::Connected;
|
||||
let connection_id = state.tabs[tab_idx].id.clone();
|
||||
let remote_path = state.tabs[tab_idx].remote_path.clone();
|
||||
|
||||
if let Some(payload_arc) = dropped {
|
||||
match &*payload_arc {
|
||||
DragPayload::LocalFile(local_path, file_name) => {
|
||||
if is_connected {
|
||||
let dest = format!("{}/{}", remote_path.trim_end_matches('/'), file_name);
|
||||
let task = TransferTask::new(
|
||||
TransferKind::Upload,
|
||||
connection_id,
|
||||
local_path.clone(),
|
||||
dest,
|
||||
file_name.clone(),
|
||||
0,
|
||||
);
|
||||
queue.push(task);
|
||||
state.status_message = format!("Upload queued: {}", file_name);
|
||||
} else {
|
||||
state.status_message = "No active connection".into();
|
||||
}
|
||||
handle_drop(
|
||||
state,
|
||||
tab_idx,
|
||||
queue,
|
||||
registry,
|
||||
rt_handle,
|
||||
&payload_arc,
|
||||
&remote_path,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Обрабатывает бросок файла в директорию dest_dir на удалённой стороне —
|
||||
/// общий код для дропа на весь пейн и дропа на конкретную папку в списке.
|
||||
fn handle_drop(
|
||||
state: &mut AppState,
|
||||
tab_idx: usize,
|
||||
queue: &TransferQueue,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
rt_handle: &tokio::runtime::Handle,
|
||||
payload: &DragPayload,
|
||||
dest_dir: &str,
|
||||
) {
|
||||
let is_connected = state.tabs[tab_idx].status == ConnectionStatus::Connected;
|
||||
let connection_id = state.tabs[tab_idx].id.clone();
|
||||
|
||||
match payload {
|
||||
DragPayload::LocalFile(local_path, file_name) => {
|
||||
if is_connected {
|
||||
let dest = format!("{}/{}", dest_dir.trim_end_matches('/'), file_name);
|
||||
let task = TransferTask::new(
|
||||
TransferKind::Upload,
|
||||
connection_id,
|
||||
local_path.clone(),
|
||||
dest,
|
||||
file_name.clone(),
|
||||
0,
|
||||
);
|
||||
queue.push(task);
|
||||
state.status_message = format!("Upload queued: {}", file_name);
|
||||
} else {
|
||||
state.status_message = "No active connection".into();
|
||||
}
|
||||
DragPayload::RemoteFile(_, _, _) => {
|
||||
state.status_message = "Cannot drop remote onto remote".into();
|
||||
}
|
||||
DragPayload::RemoteFile(remote_path, file_name, conn_id) => {
|
||||
if *conn_id != connection_id {
|
||||
state.status_message = "Cannot move between different connections".into();
|
||||
return;
|
||||
}
|
||||
let dest = format!("{}/{}", dest_dir.trim_end_matches('/'), file_name);
|
||||
if dest == *remote_path {
|
||||
return;
|
||||
}
|
||||
let registry = registry.clone();
|
||||
let from = remote_path.clone();
|
||||
let conn_id_clone = conn_id.clone();
|
||||
let result = Arc::new(std::sync::Mutex::new(None::<Result<(), String>>));
|
||||
let result_clone = result.clone();
|
||||
rt_handle.spawn(async move {
|
||||
let fs = match registry.get(&conn_id_clone) {
|
||||
Some(fs) => fs,
|
||||
None => {
|
||||
*result_clone.lock().unwrap() = Some(Err("connection not found".into()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let r = fs.rename(&from, &dest).await.map_err(|e| e.to_string());
|
||||
*result_clone.lock().unwrap() = Some(r);
|
||||
});
|
||||
state.pending_rename_result = Some(result);
|
||||
state.status_message = format!("Moving {}…", file_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -80,6 +131,7 @@ fn render_content(
|
|||
tab_idx: usize,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
rt_handle: &tokio::runtime::Handle,
|
||||
queue: &TransferQueue,
|
||||
) {
|
||||
// Path bar
|
||||
render_path_bar(ui, state, tab_idx, registry, rt_handle);
|
||||
|
|
@ -109,44 +161,173 @@ fn render_content(
|
|||
let conn_id = state.tabs[tab_idx].id.clone();
|
||||
|
||||
let table_id = format!("remote_table_{}", tab_idx);
|
||||
let FileTableResponse { double_clicked, .. } =
|
||||
file_table(ui, &table_id, &entries, &mut selected, move |entry| {
|
||||
let conn_id_for_drag = conn_id.clone();
|
||||
let FileTableResponse {
|
||||
double_clicked,
|
||||
clicked,
|
||||
dropped_on_dir,
|
||||
context_action,
|
||||
} = file_table(
|
||||
ui,
|
||||
&table_id,
|
||||
&entries,
|
||||
&mut selected,
|
||||
move |entry| {
|
||||
if is_connected {
|
||||
make_remote_payload(entry, &conn_id)
|
||||
make_remote_payload(entry, &conn_id_for_drag)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
},
|
||||
build_context_menu,
|
||||
);
|
||||
|
||||
state.tabs[tab_idx].remote_selected = selected;
|
||||
if clicked.is_some() {
|
||||
state.active_pane = Pane::Remote;
|
||||
}
|
||||
|
||||
if let Some((target_entry, payload)) = dropped_on_dir {
|
||||
handle_drop(
|
||||
state,
|
||||
tab_idx,
|
||||
queue,
|
||||
registry,
|
||||
rt_handle,
|
||||
&payload,
|
||||
&target_entry.path,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(name) = double_clicked {
|
||||
let (nav_path, is_dir) = if name == ".." {
|
||||
let parent = remote_parent(&state.tabs[tab_idx].remote_path);
|
||||
(parent, true)
|
||||
} else {
|
||||
let entry = state.tabs[tab_idx]
|
||||
.remote_entries
|
||||
.iter()
|
||||
.find(|e| e.name == name);
|
||||
match entry {
|
||||
Some(e) if e.kind == EntryKind::Dir => (e.path.clone(), true),
|
||||
_ => (state.tabs[tab_idx].remote_path.clone(), false),
|
||||
}
|
||||
};
|
||||
open_entry(state, tab_idx, registry, rt_handle, queue, &name);
|
||||
}
|
||||
|
||||
if is_dir {
|
||||
state.tabs[tab_idx].remote_path = nav_path;
|
||||
if !state.tabs[tab_idx].loading {
|
||||
trigger_list(state, tab_idx, registry, rt_handle);
|
||||
}
|
||||
}
|
||||
if let Some((entry, action)) = context_action {
|
||||
handle_row_action(
|
||||
ui.ctx(),
|
||||
state,
|
||||
tab_idx,
|
||||
registry,
|
||||
rt_handle,
|
||||
queue,
|
||||
&entry,
|
||||
action,
|
||||
);
|
||||
}
|
||||
|
||||
// Footer
|
||||
render_footer(ui, state, tab_idx, registry, rt_handle);
|
||||
}
|
||||
|
||||
fn build_context_menu(ui: &mut Ui, entry: &FileEntry) -> Option<RowAction> {
|
||||
if context_menu_item(ui, Icon::Folder, "Open", false) {
|
||||
return Some(RowAction::Open);
|
||||
}
|
||||
if entry.name != ".." {
|
||||
if context_menu_item(ui, Icon::Pen, "Rename", false) {
|
||||
return Some(RowAction::Rename);
|
||||
}
|
||||
if context_menu_item(ui, Icon::Trash, "Delete", true) {
|
||||
return Some(RowAction::Delete);
|
||||
}
|
||||
ui.separator();
|
||||
if context_menu_item(ui, Icon::Document, "Copy Path", false) {
|
||||
return Some(RowAction::CopyPath);
|
||||
}
|
||||
if entry.kind == EntryKind::File
|
||||
&& context_menu_item(ui, Icon::Download, "Download to Local", false)
|
||||
{
|
||||
return Some(RowAction::Transfer);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn open_entry(
|
||||
state: &mut AppState,
|
||||
tab_idx: usize,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
rt_handle: &tokio::runtime::Handle,
|
||||
queue: &TransferQueue,
|
||||
name: &str,
|
||||
) {
|
||||
let (nav_path, is_dir) = if name == ".." {
|
||||
let parent = remote_parent(&state.tabs[tab_idx].remote_path);
|
||||
(parent, true)
|
||||
} else {
|
||||
let entry = state.tabs[tab_idx]
|
||||
.remote_entries
|
||||
.iter()
|
||||
.find(|e| e.name == name);
|
||||
match entry {
|
||||
Some(e) if e.kind == EntryKind::Dir => (e.path.clone(), true),
|
||||
_ => (state.tabs[tab_idx].remote_path.clone(), false),
|
||||
}
|
||||
};
|
||||
|
||||
if is_dir {
|
||||
state.tabs[tab_idx].remote_path = nav_path;
|
||||
if !state.tabs[tab_idx].loading {
|
||||
trigger_list(state, tab_idx, registry, rt_handle);
|
||||
}
|
||||
} else if let Some(entry) = state.tabs[tab_idx]
|
||||
.remote_entries
|
||||
.iter()
|
||||
.find(|e| e.name == name)
|
||||
.cloned()
|
||||
{
|
||||
queue_download(state, tab_idx, queue, &entry);
|
||||
}
|
||||
}
|
||||
|
||||
fn queue_download(state: &mut AppState, tab_idx: usize, queue: &TransferQueue, entry: &FileEntry) {
|
||||
let connection_id = state.tabs[tab_idx].id.clone();
|
||||
let task = TransferTask::new(
|
||||
TransferKind::Download,
|
||||
connection_id,
|
||||
format!("{}/{}", state.local_path.trim_end_matches('/'), entry.name),
|
||||
entry.path.clone(),
|
||||
entry.name.clone(),
|
||||
0,
|
||||
);
|
||||
queue.push(task);
|
||||
state.status_message = format!("Download queued: {}", entry.name);
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn handle_row_action(
|
||||
ctx: &egui::Context,
|
||||
state: &mut AppState,
|
||||
tab_idx: usize,
|
||||
registry: &Arc<RemoteRegistry>,
|
||||
rt_handle: &tokio::runtime::Handle,
|
||||
queue: &TransferQueue,
|
||||
entry: &FileEntry,
|
||||
action: RowAction,
|
||||
) {
|
||||
match action {
|
||||
RowAction::Open => open_entry(state, tab_idx, registry, rt_handle, queue, &entry.name),
|
||||
RowAction::Rename => {
|
||||
state.op_target = Pane::Remote;
|
||||
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::Remote;
|
||||
state.show_delete_dialog = true;
|
||||
state.delete_name = entry.name.clone();
|
||||
}
|
||||
RowAction::CopyPath => {
|
||||
ctx.copy_text(entry.path.clone());
|
||||
state.status_message = "Path copied".into();
|
||||
}
|
||||
RowAction::Transfer => queue_download(state, tab_idx, queue, entry),
|
||||
RowAction::Bookmark => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_path_bar(
|
||||
ui: &mut Ui,
|
||||
state: &mut AppState,
|
||||
|
|
@ -155,63 +336,73 @@ fn render_path_bar(
|
|||
rt_handle: &tokio::runtime::Handle,
|
||||
) {
|
||||
let frame = egui::Frame::none()
|
||||
.fill(BG_PANEL)
|
||||
.inner_margin(egui::Margin {
|
||||
left: 8.0,
|
||||
right: 8.0,
|
||||
top: 4.0,
|
||||
bottom: 4.0,
|
||||
});
|
||||
.fill(BG_BASE)
|
||||
.inner_margin(egui::Margin::symmetric(12.0, 0.0));
|
||||
|
||||
frame.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
// статус точка
|
||||
let (dot, dot_col) = match state.tabs[tab_idx].status {
|
||||
ConnectionStatus::Connected => ("●", GREEN),
|
||||
ConnectionStatus::Disconnected => ("○", TEXT_HINT),
|
||||
ConnectionStatus::Connecting => ("◐", YELLOW),
|
||||
ConnectionStatus::Error(_) => ("×", RED),
|
||||
};
|
||||
ui.label(RichText::new(dot).color(dot_col).size(10.0));
|
||||
ui.add_space(4.0);
|
||||
let width = ui.available_width();
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(width, 30.0),
|
||||
egui::Layout::left_to_right(egui::Align::Center),
|
||||
|ui| {
|
||||
let host = state.tabs[tab_idx].params.host.clone();
|
||||
ui.label(
|
||||
RichText::new(format!("REMOTE · {}", host))
|
||||
.color(TEXT_DIM)
|
||||
.size(10.5)
|
||||
.strong(),
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
|
||||
// Хлебные крошки для remote path
|
||||
let path_clone = state.tabs[tab_idx].remote_path.clone();
|
||||
let parts: Vec<&str> = path_clone.split('/').filter(|s| !s.is_empty()).collect();
|
||||
let mut acc = String::new();
|
||||
// статус точка
|
||||
let (dot, dot_col) = match state.tabs[tab_idx].status {
|
||||
ConnectionStatus::Connected => ("●", GREEN),
|
||||
ConnectionStatus::Disconnected => ("○", TEXT_HINT),
|
||||
ConnectionStatus::Connecting => ("◐", YELLOW),
|
||||
ConnectionStatus::Error(_) => ("×", RED),
|
||||
};
|
||||
ui.label(RichText::new(dot).color(dot_col).size(10.0));
|
||||
ui.add_space(4.0);
|
||||
|
||||
// Root
|
||||
let root_btn = egui::Button::new(RichText::new("/").color(TEXT_DIM).size(12.0))
|
||||
.fill(egui::Color32::TRANSPARENT)
|
||||
.min_size(egui::vec2(12.0, 22.0));
|
||||
if ui.add(root_btn).clicked() {
|
||||
state.tabs[tab_idx].remote_path = "/".into();
|
||||
if !state.tabs[tab_idx].loading {
|
||||
trigger_list(state, tab_idx, registry, rt_handle);
|
||||
}
|
||||
}
|
||||
// Хлебные крошки для remote path
|
||||
let path_clone = state.tabs[tab_idx].remote_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;
|
||||
let path_snap = acc.clone();
|
||||
|
||||
if is_last {
|
||||
ui.label(RichText::new(*part).color(TEXT_PRIMARY).size(12.0).strong());
|
||||
} else {
|
||||
let link = egui::Label::new(RichText::new(*part).color(TEXT_DIM).size(12.0))
|
||||
.sense(egui::Sense::click());
|
||||
if ui.add(link).clicked() {
|
||||
state.tabs[tab_idx].remote_path = path_snap;
|
||||
if !state.tabs[tab_idx].loading {
|
||||
trigger_list(state, tab_idx, registry, rt_handle);
|
||||
}
|
||||
// Root
|
||||
let root_btn = egui::Button::new(RichText::new("/").color(TEXT_DIM).size(12.0))
|
||||
.fill(egui::Color32::TRANSPARENT)
|
||||
.min_size(egui::vec2(12.0, 22.0));
|
||||
if ui.add(root_btn).clicked() {
|
||||
state.tabs[tab_idx].remote_path = "/".into();
|
||||
if !state.tabs[tab_idx].loading {
|
||||
trigger_list(state, tab_idx, registry, rt_handle);
|
||||
}
|
||||
ui.label(RichText::new("/").color(TEXT_HINT).size(12.0));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
acc.push('/');
|
||||
acc.push_str(part);
|
||||
let is_last = i == parts.len() - 1;
|
||||
let path_snap = acc.clone();
|
||||
|
||||
if is_last {
|
||||
ui.label(RichText::new(*part).color(TEXT_PRIMARY).size(12.0).strong());
|
||||
} else {
|
||||
let link =
|
||||
egui::Label::new(RichText::new(*part).color(TEXT_DIM).size(12.0))
|
||||
.sense(egui::Sense::click());
|
||||
if ui.add(link).clicked() {
|
||||
state.tabs[tab_idx].remote_path = path_snap;
|
||||
if !state.tabs[tab_idx].loading {
|
||||
trigger_list(state, tab_idx, registry, rt_handle);
|
||||
}
|
||||
}
|
||||
ui.label(RichText::new("/").color(TEXT_HINT).size(12.0));
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -253,7 +444,7 @@ fn render_footer(
|
|||
|
||||
// Refresh кнопка
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let refresh = egui::Button::new(RichText::new("⟳").color(ACCENT).size(14.0))
|
||||
let refresh = egui::Button::image(icons::image(Icon::Refresh, 13.0, ACCENT))
|
||||
.fill(egui::Color32::TRANSPARENT)
|
||||
.min_size(egui::vec2(20.0, 20.0));
|
||||
if ui.add(refresh).on_hover_text("Refresh").clicked()
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
//! Левая боковая панель — bookmarks + места
|
||||
use crate::ui::icons::{self, Icon};
|
||||
use crate::ui::panels::local_pane::refresh_local;
|
||||
use crate::ui::state::AppState;
|
||||
use crate::ui::theme::*;
|
||||
use egui::{RichText, Ui};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub fn render(ui: &mut Ui, state: &mut AppState) {
|
||||
pub fn render(ui: &mut Ui, state: &mut AppState, db: &Arc<Mutex<rusqlite::Connection>>) {
|
||||
let frame = egui::Frame::none()
|
||||
.fill(BG_PANEL)
|
||||
.inner_margin(egui::Margin::symmetric(0.0, 0.0));
|
||||
|
|
@ -20,11 +22,11 @@ pub fn render(ui: &mut Ui, state: &mut AppState) {
|
|||
section_header(ui, "LOCAL");
|
||||
ui.add_space(4.0);
|
||||
|
||||
for bm in &state.bookmarks.clone() {
|
||||
let is_active = state.local_path == bm.path;
|
||||
let icon = bookmark_icon(&bm.name);
|
||||
sidebar_item(ui, icon, &bm.name, is_active, || {
|
||||
state.local_path = bm.path.clone();
|
||||
for qa in &state.quick_access.clone() {
|
||||
let is_active = state.local_path == qa.path;
|
||||
let icon = quick_access_icon(&qa.name);
|
||||
sidebar_item(ui, icon, &qa.name, is_active, || {
|
||||
state.local_path = qa.path.clone();
|
||||
refresh_local(state);
|
||||
});
|
||||
}
|
||||
|
|
@ -38,7 +40,7 @@ pub fn render(ui: &mut Ui, state: &mut AppState) {
|
|||
|
||||
// Root + data drives
|
||||
let root_active = state.local_path == "/";
|
||||
sidebar_item(ui, "🖥", "/ (root)", root_active, || {
|
||||
sidebar_item(ui, Icon::Ssd, "/ (root)", root_active, || {
|
||||
state.local_path = "/".into();
|
||||
refresh_local(state);
|
||||
});
|
||||
|
|
@ -50,7 +52,7 @@ pub fn render(ui: &mut Ui, state: &mut AppState) {
|
|||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| mount_path.clone());
|
||||
let is_active = state.local_path == mount_path;
|
||||
sidebar_item(ui, "💾", &label, is_active, || {
|
||||
sidebar_item(ui, Icon::Database, &label, is_active, || {
|
||||
state.local_path = mount_path.clone();
|
||||
refresh_local(state);
|
||||
});
|
||||
|
|
@ -63,9 +65,7 @@ pub fn render(ui: &mut Ui, state: &mut AppState) {
|
|||
section_header(ui, "BOOKMARKS");
|
||||
ui.add_space(4.0);
|
||||
|
||||
// Пользовательские закладки (после 3 дефолтных)
|
||||
let custom: Vec<_> = state.bookmarks.iter().skip(3).cloned().collect();
|
||||
if custom.is_empty() {
|
||||
if state.bookmarks.is_empty() {
|
||||
ui.label(
|
||||
RichText::new(" No bookmarks yet")
|
||||
.color(TEXT_HINT)
|
||||
|
|
@ -77,13 +77,26 @@ pub fn render(ui: &mut Ui, state: &mut AppState) {
|
|||
.size(10.0),
|
||||
);
|
||||
} else {
|
||||
for bm in &custom {
|
||||
let mut to_remove: Option<i64> = None;
|
||||
for bm in &state.bookmarks.clone() {
|
||||
let is_active = state.local_path == bm.path;
|
||||
sidebar_item(ui, "📌", &bm.name, is_active, || {
|
||||
sidebar_item(ui, Icon::Star, &bm.name, is_active, || {
|
||||
state.local_path = bm.path.clone();
|
||||
refresh_local(state);
|
||||
})
|
||||
.context_menu(|ui| {
|
||||
if ui.button("Remove bookmark").clicked() {
|
||||
to_remove = Some(bm.id);
|
||||
ui.close_menu();
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(id) = to_remove {
|
||||
state.bookmarks.retain(|b| b.id != id);
|
||||
if let Ok(conn) = db.lock() {
|
||||
let _ = crate::storage::db::remove_bookmark(&conn, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
|
@ -93,8 +106,8 @@ pub fn render(ui: &mut Ui, state: &mut AppState) {
|
|||
|
||||
fn section_header(ui: &mut Ui, text: &str) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(10.0);
|
||||
ui.label(RichText::new(text).color(TEXT_HINT).size(10.0).strong());
|
||||
ui.add_space(14.0);
|
||||
ui.label(RichText::new(text).color(TEXT_HINT).size(10.5).strong());
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -103,51 +116,54 @@ fn section_divider(ui: &mut Ui) {
|
|||
ui.painter().rect_filled(rect, 0.0, SEPARATOR);
|
||||
}
|
||||
|
||||
fn sidebar_item(ui: &mut Ui, icon: &str, label: &str, active: bool, mut on_click: impl FnMut()) {
|
||||
fn sidebar_item(
|
||||
ui: &mut Ui,
|
||||
icon: Icon,
|
||||
label: &str,
|
||||
active: bool,
|
||||
mut on_click: impl FnMut(),
|
||||
) -> egui::Response {
|
||||
let bg = if active {
|
||||
BG_ROW_SEL
|
||||
} else {
|
||||
egui::Color32::TRANSPARENT
|
||||
};
|
||||
let text_col = if active {
|
||||
egui::Color32::WHITE
|
||||
} else {
|
||||
TEXT_PRIMARY
|
||||
};
|
||||
let text_col = TEXT_PRIMARY;
|
||||
let icon_col = if active { ACCENT } else { TEXT_DIM };
|
||||
|
||||
let resp = egui::Frame::none()
|
||||
.fill(bg)
|
||||
.rounding(4.0)
|
||||
.inner_margin(egui::Margin {
|
||||
left: 10.0,
|
||||
right: 6.0,
|
||||
top: 2.0,
|
||||
bottom: 2.0,
|
||||
})
|
||||
.rounding(RADIUS_SM)
|
||||
.inner_margin(egui::Margin::symmetric(8.0, 0.0))
|
||||
.show(ui, |ui| {
|
||||
ui.set_min_width(SIDEBAR_W - 8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(icon).size(12.0));
|
||||
ui.add_space(6.0);
|
||||
ui.label(RichText::new(label).color(text_col).size(12.0));
|
||||
});
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(SIDEBAR_W - 32.0, 28.0),
|
||||
egui::Layout::left_to_right(egui::Align::Center),
|
||||
|ui| {
|
||||
icons::icon(ui, icon, 14.0, icon_col);
|
||||
ui.add_space(8.0);
|
||||
ui.label(RichText::new(label).color(text_col).size(12.5));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if resp.response.interact(egui::Sense::click()).clicked() {
|
||||
let response = resp.response.interact(egui::Sense::click());
|
||||
if response.clicked() {
|
||||
on_click();
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
fn bookmark_icon(name: &str) -> &'static str {
|
||||
fn quick_access_icon(name: &str) -> Icon {
|
||||
match name {
|
||||
"Home" => "🏠",
|
||||
"Desktop" => "🖥",
|
||||
"Documents" => "📄",
|
||||
"Downloads" => "⬇",
|
||||
"Pictures" => "🖼",
|
||||
"Music" => "🎵",
|
||||
"Videos" => "🎬",
|
||||
_ => "📁",
|
||||
"Home" => Icon::Home,
|
||||
"Desktop" => Icon::Monitor,
|
||||
"Documents" => Icon::Document,
|
||||
"Downloads" => Icon::DownloadSquare,
|
||||
"Pictures" => Icon::Gallery,
|
||||
"Music" => Icon::MusicNote,
|
||||
"Videos" => Icon::Videocamera,
|
||||
_ => Icon::Folder,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use crate::domain::connection::ConnectionStatus;
|
||||
use crate::ui::icons::{self, Icon};
|
||||
use crate::ui::panels::file_pane::format_size;
|
||||
use crate::ui::state::AppState;
|
||||
use crate::ui::theme::*;
|
||||
|
|
@ -10,56 +11,61 @@ pub fn render(ui: &mut egui::Ui, state: &AppState) {
|
|||
.inner_margin(egui::Margin::symmetric(10.0, 2.0));
|
||||
|
||||
frame.show(ui, |ui| {
|
||||
ui.set_min_height(STATUS_H);
|
||||
ui.horizontal_centered(|ui| {
|
||||
// статус сообщение
|
||||
ui.label(
|
||||
RichText::new(&state.status_message)
|
||||
.color(TEXT_DIM)
|
||||
.size(11.0),
|
||||
);
|
||||
let width = ui.available_width();
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(width, STATUS_H),
|
||||
egui::Layout::left_to_right(egui::Align::Center),
|
||||
|ui| {
|
||||
// статус сообщение
|
||||
ui.label(
|
||||
RichText::new(&state.status_message)
|
||||
.color(TEXT_DIM)
|
||||
.size(11.0),
|
||||
);
|
||||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
// аггрегированная скорость
|
||||
let agg: u64 = state
|
||||
.queue_tasks
|
||||
.iter()
|
||||
.filter(|t| t.state == crate::domain::transfer::TaskState::Running)
|
||||
.filter_map(|t| t.speed)
|
||||
.sum();
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
// аггрегированная скорость
|
||||
let agg: u64 = state
|
||||
.queue_tasks
|
||||
.iter()
|
||||
.filter(|t| t.state == crate::domain::transfer::TaskState::Running)
|
||||
.filter_map(|t| t.speed)
|
||||
.sum();
|
||||
|
||||
if agg > 0 {
|
||||
ui.label(
|
||||
RichText::new(format!("⬆⬇ {}/s", format_size(Some(agg))))
|
||||
.color(GREEN)
|
||||
.size(11.0),
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
if agg > 0 {
|
||||
ui.label(
|
||||
RichText::new(format!("{}/s", format_size(Some(agg))))
|
||||
.color(GREEN)
|
||||
.monospace()
|
||||
.size(11.0),
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
|
||||
// соединения
|
||||
let connected = state
|
||||
.tabs
|
||||
.iter()
|
||||
.filter(|t| t.status == ConnectionStatus::Connected)
|
||||
.count();
|
||||
let total = state.tabs.len();
|
||||
// соединения
|
||||
let connected = state
|
||||
.tabs
|
||||
.iter()
|
||||
.filter(|t| t.status == ConnectionStatus::Connected)
|
||||
.count();
|
||||
let total = state.tabs.len();
|
||||
|
||||
if total > 0 {
|
||||
let dot_col = if connected > 0 { GREEN } else { TEXT_HINT };
|
||||
ui.label(
|
||||
RichText::new(format!("● {}/{}", connected, total))
|
||||
.color(dot_col)
|
||||
.size(11.0),
|
||||
);
|
||||
} else {
|
||||
ui.label(
|
||||
RichText::new("○ Not connected")
|
||||
.color(TEXT_HINT)
|
||||
.size(11.0),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
if total > 0 {
|
||||
let dot_col = if connected > 0 { GREEN } else { TEXT_HINT };
|
||||
if connected > 0 {
|
||||
icons::icon(ui, Icon::LockPassword, 11.0, GREEN);
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
ui.label(
|
||||
RichText::new(format!("{}/{}", connected, total))
|
||||
.color(dot_col)
|
||||
.size(11.0),
|
||||
);
|
||||
} else {
|
||||
ui.label(RichText::new("Not connected").color(TEXT_HINT).size(11.0));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,92 +14,96 @@ pub fn render(ui: &mut Ui, state: &mut AppState) {
|
|||
});
|
||||
|
||||
frame.show(ui, |ui| {
|
||||
ui.set_min_height(TABBAR_H);
|
||||
ui.horizontal(|ui| {
|
||||
let mut to_close: Option<usize> = None;
|
||||
let width = ui.available_width();
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(width, TABBAR_H),
|
||||
egui::Layout::left_to_right(egui::Align::Center),
|
||||
|ui| {
|
||||
let mut to_close: Option<usize> = None;
|
||||
|
||||
if state.tabs.is_empty() {
|
||||
ui.label(RichText::new("No connections").color(TEXT_HINT).size(11.0));
|
||||
}
|
||||
if state.tabs.is_empty() {
|
||||
ui.label(RichText::new("No connections").color(TEXT_HINT).size(11.0));
|
||||
}
|
||||
|
||||
for (i, tab) in state.tabs.iter().enumerate() {
|
||||
let is_active = i == state.active_tab;
|
||||
for (i, tab) in state.tabs.iter().enumerate() {
|
||||
let is_active = i == state.active_tab;
|
||||
|
||||
let (dot_col, dot) = match &tab.status {
|
||||
ConnectionStatus::Connected => (GREEN, "●"),
|
||||
ConnectionStatus::Disconnected => (TEXT_HINT, "○"),
|
||||
ConnectionStatus::Connecting => (YELLOW, "◐"),
|
||||
ConnectionStatus::Error(_) => (RED, "×"),
|
||||
};
|
||||
let (dot_col, dot) = match &tab.status {
|
||||
ConnectionStatus::Connected => (GREEN, "●"),
|
||||
ConnectionStatus::Disconnected => (TEXT_HINT, "○"),
|
||||
ConnectionStatus::Connecting => (YELLOW, "◐"),
|
||||
ConnectionStatus::Error(_) => (RED, "×"),
|
||||
};
|
||||
|
||||
let bg = if is_active {
|
||||
BG_TAB_ACTIVE
|
||||
} else {
|
||||
BG_TAB_IDLE
|
||||
};
|
||||
let tcol = if is_active { TEXT_PRIMARY } else { TEXT_DIM };
|
||||
let bg = if is_active {
|
||||
BG_TAB_ACTIVE
|
||||
} else {
|
||||
BG_TAB_IDLE
|
||||
};
|
||||
let tcol = if is_active { TEXT_PRIMARY } else { TEXT_DIM };
|
||||
|
||||
let tab_response = egui::Frame::none()
|
||||
.fill(bg)
|
||||
.rounding(egui::Rounding {
|
||||
nw: 4.0,
|
||||
ne: 4.0,
|
||||
sw: 0.0,
|
||||
se: 0.0,
|
||||
})
|
||||
.inner_margin(egui::Margin {
|
||||
left: 10.0,
|
||||
right: 4.0,
|
||||
top: 0.0,
|
||||
bottom: 0.0,
|
||||
})
|
||||
.show(ui, |ui| {
|
||||
ui.set_min_height(TABBAR_H);
|
||||
ui.horizontal_centered(|ui| {
|
||||
// цветная точка статуса
|
||||
ui.label(RichText::new(dot).color(dot_col).size(9.0));
|
||||
ui.add_space(4.0);
|
||||
// название вкладки
|
||||
let lbl =
|
||||
egui::Label::new(RichText::new(&tab.label).color(tcol).size(12.0))
|
||||
let tab_response = egui::Frame::none()
|
||||
.fill(bg)
|
||||
.rounding(RADIUS_MD)
|
||||
.inner_margin(egui::Margin {
|
||||
left: 10.0,
|
||||
right: 4.0,
|
||||
top: 0.0,
|
||||
bottom: 0.0,
|
||||
})
|
||||
.show(ui, |ui| {
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(ui.available_width().min(220.0), 24.0),
|
||||
egui::Layout::left_to_right(egui::Align::Center),
|
||||
|ui| {
|
||||
// цветная точка статуса
|
||||
ui.label(RichText::new(dot).color(dot_col).size(9.0));
|
||||
ui.add_space(4.0);
|
||||
// название вкладки
|
||||
let lbl = egui::Label::new(
|
||||
RichText::new(&tab.label).color(tcol).size(12.0),
|
||||
)
|
||||
.sense(egui::Sense::click());
|
||||
if ui.add(lbl).clicked() {
|
||||
// обрабатывается ниже
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
// кнопка закрытия
|
||||
let close =
|
||||
egui::Label::new(RichText::new("×").color(TEXT_HINT).size(14.0))
|
||||
if ui.add(lbl).clicked() {
|
||||
// обрабатывается ниже
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
// кнопка закрытия
|
||||
let close = egui::Label::new(
|
||||
RichText::new("×").color(TEXT_HINT).size(14.0),
|
||||
)
|
||||
.sense(egui::Sense::click());
|
||||
if ui.add(close).clicked() {
|
||||
to_close = Some(i);
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
if ui.add(close).clicked() {
|
||||
to_close = Some(i);
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
if tab_response.response.clicked() {
|
||||
state.active_tab = i;
|
||||
if tab_response.response.clicked() {
|
||||
state.active_tab = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// кнопка "+"
|
||||
ui.add_space(4.0);
|
||||
let plus = egui::Button::new(RichText::new("+").color(TEXT_DIM).size(16.0))
|
||||
.fill(Color32::TRANSPARENT)
|
||||
.rounding(4.0)
|
||||
.min_size(egui::vec2(28.0, TABBAR_H));
|
||||
if ui.add(plus).clicked() {
|
||||
state.show_connect_dialog = true;
|
||||
}
|
||||
|
||||
if let Some(idx) = to_close {
|
||||
state.tabs.remove(idx);
|
||||
if !state.tabs.is_empty() && state.active_tab >= state.tabs.len() {
|
||||
state.active_tab = state.tabs.len() - 1;
|
||||
// кнопка "+"
|
||||
ui.add_space(4.0);
|
||||
let plus = egui::Button::new(RichText::new("+").color(TEXT_DIM).size(15.0))
|
||||
.fill(Color32::TRANSPARENT)
|
||||
.rounding(RADIUS_MD)
|
||||
.min_size(egui::vec2(24.0, 24.0));
|
||||
if ui.add(plus).clicked() {
|
||||
state.show_connect_dialog = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(idx) = to_close {
|
||||
state.tabs.remove(idx);
|
||||
if !state.tabs.is_empty() && state.active_tab >= state.tabs.len() {
|
||||
state.active_tab = state.tabs.len() - 1;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// нижняя граница таббара
|
||||
|
|
|
|||
|
|
@ -1,105 +1,166 @@
|
|||
use crate::ui::state::AppState;
|
||||
use crate::domain::transfer::{TransferKind, TransferTask};
|
||||
use crate::transfer::queue::TransferQueue;
|
||||
use crate::ui::icons::{self, Icon};
|
||||
use crate::ui::state::{AppState, Pane};
|
||||
use crate::ui::theme::*;
|
||||
use egui::{Align, Layout, RichText, Ui};
|
||||
|
||||
pub fn render(ui: &mut Ui, state: &mut AppState) {
|
||||
pub fn render(ui: &mut Ui, state: &mut AppState, queue: &TransferQueue) {
|
||||
let frame = egui::Frame::none()
|
||||
.fill(BG_TOOLBAR)
|
||||
.inner_margin(egui::Margin::symmetric(8.0, 0.0));
|
||||
|
||||
frame.show(ui, |ui| {
|
||||
ui.set_min_height(TOOLBAR_H);
|
||||
ui.horizontal_centered(|ui| {
|
||||
// Логотип
|
||||
ui.label(
|
||||
RichText::new("LoFlum")
|
||||
.color(TEXT_PRIMARY)
|
||||
.size(14.0)
|
||||
.strong(),
|
||||
);
|
||||
let width = ui.available_width();
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(width, TOOLBAR_H),
|
||||
Layout::left_to_right(Align::Center),
|
||||
|ui| {
|
||||
// Логотип
|
||||
icons::icon(ui, Icon::Folder, 17.0, ACCENT);
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new("LoFlum")
|
||||
.color(TEXT_PRIMARY)
|
||||
.size(14.0)
|
||||
.strong(),
|
||||
);
|
||||
|
||||
ui.add_space(8.0);
|
||||
separator_v(ui);
|
||||
ui.add_space(8.0);
|
||||
ui.add_space(8.0);
|
||||
separator_v(ui);
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Новое подключение
|
||||
toolbar_btn(ui, "+ New Connection", false, || {
|
||||
state.show_connect_dialog = true;
|
||||
});
|
||||
|
||||
separator_v(ui);
|
||||
|
||||
// Кнопки работы с файлами
|
||||
let has_conn = state
|
||||
.active_tab_ref()
|
||||
.map(|t| t.status == crate::domain::connection::ConnectionStatus::Connected)
|
||||
.unwrap_or(false);
|
||||
|
||||
let has_local_sel = state.local_selected.is_some();
|
||||
let has_remote_sel = state
|
||||
.active_tab_ref()
|
||||
.and_then(|t| t.remote_selected.as_ref())
|
||||
.is_some();
|
||||
|
||||
toolbar_btn_enabled(ui, "⬆ Upload", has_conn && has_local_sel, || {
|
||||
state.status_message = "Upload: select file and use drag & drop".into();
|
||||
});
|
||||
toolbar_btn_enabled(ui, "⬇ Download", has_conn && has_remote_sel, || {
|
||||
state.status_message = "Download: select file and use drag & drop".into();
|
||||
});
|
||||
|
||||
separator_v(ui);
|
||||
|
||||
// Очередь — с счётчиком
|
||||
let q_count = state.queue_tasks.len();
|
||||
let q_label = if q_count > 0 {
|
||||
format!("⏳ Queue {}", q_count)
|
||||
} else {
|
||||
"⏳ Queue".to_string()
|
||||
};
|
||||
let q_active = state.show_queue;
|
||||
toolbar_btn(ui, &q_label, q_active, || {
|
||||
state.show_queue = !state.show_queue;
|
||||
});
|
||||
|
||||
separator_v(ui);
|
||||
|
||||
toolbar_btn_enabled(ui, "⟳ Refresh", has_conn, || {
|
||||
// refresh remote — вызывается снаружи через state флаг
|
||||
state.pending_refresh = true;
|
||||
});
|
||||
toolbar_btn_enabled(ui, "📁 New Folder", has_conn, || {
|
||||
state.show_mkdir_dialog = true;
|
||||
state.mkdir_name.clear();
|
||||
});
|
||||
toolbar_btn_enabled(ui, "× Delete", has_conn && has_remote_sel, || {
|
||||
if let Some(name) = state
|
||||
.active_tab_ref()
|
||||
.and_then(|t| t.remote_selected.clone())
|
||||
{
|
||||
state.show_delete_dialog = true;
|
||||
state.delete_name = name;
|
||||
// Новое подключение — единственная акцентная кнопка в тулбаре
|
||||
let connect_btn = egui::Button::image_and_text(
|
||||
icons::image(Icon::AddCircleLinear, 15.0, TEXT_PRIMARY),
|
||||
RichText::new("New Connection")
|
||||
.color(TEXT_PRIMARY)
|
||||
.size(12.5),
|
||||
)
|
||||
.fill(ACCENT_DIM)
|
||||
.rounding(RADIUS_MD)
|
||||
.min_size(egui::vec2(0.0, 30.0));
|
||||
if ui.add(connect_btn).clicked() {
|
||||
state.show_connect_dialog = true;
|
||||
}
|
||||
});
|
||||
toolbar_btn_enabled(ui, "✎ Rename", has_conn && has_remote_sel, || {
|
||||
if let Some(name) = state
|
||||
.active_tab_ref()
|
||||
.and_then(|t| t.remote_selected.clone())
|
||||
{
|
||||
state.show_rename_dialog = true;
|
||||
state.rename_old_name = name.clone();
|
||||
state.rename_new_name = name;
|
||||
}
|
||||
});
|
||||
|
||||
// Правая часть — History / Bookmarks
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
toolbar_btn(ui, "⏱ History", state.show_history, || {
|
||||
state.show_history = !state.show_history;
|
||||
state.show_bookmarks = false;
|
||||
separator_v(ui);
|
||||
|
||||
// Кнопки работы с файлами
|
||||
let has_conn = state
|
||||
.active_tab_ref()
|
||||
.map(|t| t.status == crate::domain::connection::ConnectionStatus::Connected)
|
||||
.unwrap_or(false);
|
||||
|
||||
let has_local_sel = state.local_selected.is_some();
|
||||
let has_remote_sel = state
|
||||
.active_tab_ref()
|
||||
.and_then(|t| t.remote_selected.as_ref())
|
||||
.is_some();
|
||||
|
||||
toolbar_btn_enabled(
|
||||
ui,
|
||||
Icon::Upload,
|
||||
"Upload",
|
||||
has_conn && has_local_sel,
|
||||
|| {
|
||||
queue_selected_upload(state, queue);
|
||||
},
|
||||
);
|
||||
toolbar_btn_enabled(
|
||||
ui,
|
||||
Icon::Download,
|
||||
"Download",
|
||||
has_conn && has_remote_sel,
|
||||
|| {
|
||||
queue_selected_download(state, queue);
|
||||
},
|
||||
);
|
||||
|
||||
separator_v(ui);
|
||||
|
||||
// Очередь — с счётчиком
|
||||
let q_count = state.queue_tasks.len();
|
||||
let q_label = if q_count > 0 {
|
||||
format!("Queue {}", q_count)
|
||||
} else {
|
||||
"Queue".to_string()
|
||||
};
|
||||
let q_active = state.show_queue;
|
||||
toolbar_btn(ui, Icon::Clock, &q_label, q_active, || {
|
||||
state.show_queue = !state.show_queue;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
separator_v(ui);
|
||||
|
||||
let on_remote = state.active_pane == Pane::Remote;
|
||||
let can_new_folder = if on_remote { has_conn } else { true };
|
||||
let can_delete_rename = if on_remote {
|
||||
has_conn && has_remote_sel
|
||||
} else {
|
||||
has_local_sel
|
||||
};
|
||||
|
||||
toolbar_icon_btn(ui, Icon::Refresh, has_conn, "Refresh", || {
|
||||
// refresh remote — вызывается снаружи через state флаг
|
||||
state.pending_refresh = true;
|
||||
});
|
||||
toolbar_icon_btn(
|
||||
ui,
|
||||
Icon::FolderWithFiles,
|
||||
can_new_folder,
|
||||
"New Folder",
|
||||
|| {
|
||||
state.op_target = state.active_pane;
|
||||
state.show_mkdir_dialog = true;
|
||||
state.mkdir_name.clear();
|
||||
},
|
||||
);
|
||||
toolbar_icon_btn(ui, Icon::Trash, can_delete_rename, "Delete", || {
|
||||
let name = if on_remote {
|
||||
state
|
||||
.active_tab_ref()
|
||||
.and_then(|t| t.remote_selected.clone())
|
||||
} else {
|
||||
state.local_selected.clone()
|
||||
};
|
||||
if let Some(name) = name {
|
||||
state.op_target = state.active_pane;
|
||||
state.show_delete_dialog = true;
|
||||
state.delete_name = name;
|
||||
}
|
||||
});
|
||||
toolbar_icon_btn(ui, Icon::Pen, can_delete_rename, "Rename", || {
|
||||
let name = if on_remote {
|
||||
state
|
||||
.active_tab_ref()
|
||||
.and_then(|t| t.remote_selected.clone())
|
||||
} else {
|
||||
state.local_selected.clone()
|
||||
};
|
||||
if let Some(name) = name {
|
||||
state.op_target = state.active_pane;
|
||||
state.show_rename_dialog = true;
|
||||
state.rename_old_name = name.clone();
|
||||
state.rename_new_name = name;
|
||||
}
|
||||
});
|
||||
|
||||
// Правая часть — History / Bookmarks / Settings
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
// На macOS кнопка настроек живёт в нативном верхнем меню
|
||||
// (LoFlum → Settings…), здесь она не нужна.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
toolbar_icon_btn(ui, Icon::Settings, true, "Settings", || {
|
||||
state.show_settings_dialog = true;
|
||||
});
|
||||
toolbar_btn(ui, Icon::History, "History", state.show_history, || {
|
||||
state.show_history = !state.show_history;
|
||||
state.show_bookmarks = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// История поверх
|
||||
|
|
@ -120,17 +181,22 @@ fn render_history_popup(ui: &mut Ui, state: &mut AppState) {
|
|||
.order(egui::Order::Foreground)
|
||||
.show(ui.ctx(), |ui| {
|
||||
egui::Frame::none()
|
||||
.fill(egui::Color32::from_rgb(32, 32, 36))
|
||||
.fill(BG_PANEL)
|
||||
.stroke(egui::Stroke::new(1.0, BORDER))
|
||||
.rounding(6.0)
|
||||
.inner_margin(egui::Margin::same(8.0))
|
||||
.rounding(RADIUS_LG)
|
||||
.inner_margin(egui::Margin::same(10.0))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(260.0);
|
||||
ui.label(
|
||||
RichText::new("Recent Connections")
|
||||
.color(TEXT_DIM)
|
||||
.size(11.0),
|
||||
);
|
||||
ui.horizontal(|ui| {
|
||||
icons::icon(ui, Icon::ServerSquare, 12.0, TEXT_DIM);
|
||||
ui.add_space(6.0);
|
||||
ui.label(
|
||||
RichText::new("Recent connections")
|
||||
.color(TEXT_DIM)
|
||||
.size(11.0)
|
||||
.strong(),
|
||||
);
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
if state.history.is_empty() {
|
||||
ui.label(RichText::new("No history yet").color(TEXT_HINT));
|
||||
|
|
@ -143,24 +209,30 @@ fn render_history_popup(ui: &mut Ui, state: &mut AppState) {
|
|||
format!("{}@{}:{}", entry.user, entry.host, entry.port);
|
||||
let time =
|
||||
RichText::new(&entry.time).color(TEXT_HINT).size(10.0);
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(
|
||||
RichText::new(&label)
|
||||
.color(TEXT_PRIMARY)
|
||||
.size(12.0),
|
||||
)
|
||||
.fill(egui::Color32::TRANSPARENT)
|
||||
.min_size(egui::vec2(240.0, 24.0)),
|
||||
let btn = ui.add(
|
||||
egui::Button::new(
|
||||
RichText::new(&label).color(TEXT_PRIMARY).size(12.0),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
state.connect_host = entry.host.clone();
|
||||
state.connect_user = entry.user.clone();
|
||||
state.connect_port = entry.port.to_string();
|
||||
state.show_connect_dialog = true;
|
||||
.fill(egui::Color32::TRANSPARENT)
|
||||
.min_size(egui::vec2(240.0, 24.0)),
|
||||
);
|
||||
if btn.clicked() {
|
||||
state.pending_history_reconnect = Some(entry.clone());
|
||||
state.show_history = false;
|
||||
}
|
||||
btn.context_menu(|ui| {
|
||||
if ui.button("Edit").clicked() {
|
||||
crate::ui::dialogs::connection::edit_history_entry(
|
||||
state, entry,
|
||||
);
|
||||
state.show_history = false;
|
||||
ui.close_menu();
|
||||
}
|
||||
if ui.button("Save").clicked() {
|
||||
state.pending_history_save = Some(entry.clone());
|
||||
ui.close_menu();
|
||||
}
|
||||
});
|
||||
ui.label(time);
|
||||
}
|
||||
});
|
||||
|
|
@ -179,25 +251,53 @@ fn render_history_popup(ui: &mut Ui, state: &mut AppState) {
|
|||
});
|
||||
}
|
||||
|
||||
fn toolbar_btn(ui: &mut Ui, label: &str, active: bool, mut on_click: impl FnMut()) {
|
||||
fn toolbar_btn(ui: &mut Ui, icon: Icon, label: &str, active: bool, mut on_click: impl FnMut()) {
|
||||
let fill = if active {
|
||||
BG_TAB_ACTIVE
|
||||
} else {
|
||||
egui::Color32::TRANSPARENT
|
||||
};
|
||||
let text_col = if active { ACCENT } else { TEXT_PRIMARY };
|
||||
let btn = egui::Button::new(RichText::new(label).color(text_col).size(12.0))
|
||||
.fill(fill)
|
||||
.rounding(4.0)
|
||||
.min_size(egui::vec2(0.0, 26.0));
|
||||
let btn = egui::Button::image_and_text(
|
||||
icons::image(icon, 15.0, text_col),
|
||||
RichText::new(label).color(text_col).size(12.5),
|
||||
)
|
||||
.fill(fill)
|
||||
.rounding(RADIUS_MD)
|
||||
.min_size(egui::vec2(0.0, 30.0));
|
||||
if ui.add(btn).clicked() {
|
||||
on_click();
|
||||
}
|
||||
}
|
||||
|
||||
fn toolbar_btn_enabled(ui: &mut Ui, label: &str, enabled: bool, on_click: impl FnMut()) {
|
||||
fn toolbar_btn_enabled(
|
||||
ui: &mut Ui,
|
||||
icon: Icon,
|
||||
label: &str,
|
||||
enabled: bool,
|
||||
on_click: impl FnMut(),
|
||||
) {
|
||||
ui.add_enabled_ui(enabled, |ui| {
|
||||
toolbar_btn(ui, label, false, on_click);
|
||||
toolbar_btn(ui, icon, label, false, on_click);
|
||||
});
|
||||
}
|
||||
|
||||
fn toolbar_icon_btn(
|
||||
ui: &mut Ui,
|
||||
icon: Icon,
|
||||
enabled: bool,
|
||||
hover: &str,
|
||||
mut on_click: impl FnMut(),
|
||||
) {
|
||||
ui.add_enabled_ui(enabled, |ui| {
|
||||
let col = if enabled { TEXT_DIM } else { TEXT_HINT };
|
||||
let btn = egui::Button::image(icons::image(icon, 16.0, col))
|
||||
.fill(egui::Color32::TRANSPARENT)
|
||||
.rounding(RADIUS_MD)
|
||||
.min_size(egui::vec2(30.0, 30.0));
|
||||
if ui.add(btn).on_hover_text(hover).clicked() {
|
||||
on_click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -205,3 +305,51 @@ fn separator_v(ui: &mut Ui) {
|
|||
let (_, rect) = ui.allocate_space(egui::vec2(1.0, 20.0));
|
||||
ui.painter().rect_filled(rect, 0.0, BORDER);
|
||||
}
|
||||
|
||||
fn queue_selected_upload(state: &mut AppState, queue: &TransferQueue) {
|
||||
let Some(name) = state.local_selected.clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(tab) = state.active_tab_ref() else {
|
||||
return;
|
||||
};
|
||||
let connection_id = tab.id.clone();
|
||||
let remote_dir = tab.remote_path.clone();
|
||||
let local_path = format!("{}/{}", state.local_path.trim_end_matches('/'), name);
|
||||
let remote_path = format!("{}/{}", remote_dir.trim_end_matches('/'), name);
|
||||
let task = TransferTask::new(
|
||||
TransferKind::Upload,
|
||||
connection_id,
|
||||
local_path,
|
||||
remote_path,
|
||||
name.clone(),
|
||||
0,
|
||||
);
|
||||
queue.push(task);
|
||||
state.status_message = format!("Upload queued: {}", name);
|
||||
}
|
||||
|
||||
fn queue_selected_download(state: &mut AppState, queue: &TransferQueue) {
|
||||
let Some(tab) = state.active_tab_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(name) = tab.remote_selected.clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(entry) = tab.remote_entries.iter().find(|e| e.name == name) else {
|
||||
return;
|
||||
};
|
||||
let connection_id = tab.id.clone();
|
||||
let remote_path = entry.path.clone();
|
||||
let local_path = format!("{}/{}", state.local_path.trim_end_matches('/'), name);
|
||||
let task = TransferTask::new(
|
||||
TransferKind::Download,
|
||||
connection_id,
|
||||
local_path,
|
||||
remote_path,
|
||||
name.clone(),
|
||||
0,
|
||||
);
|
||||
queue.push(task);
|
||||
state.status_message = format!("Download queued: {}", name);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use crate::domain::connection::{ConnectionParams, ConnectionStatus};
|
||||
use crate::domain::connection::{ConnectionParams, ConnectionStatus, Protocol};
|
||||
use crate::domain::file_entry::FileEntry;
|
||||
use crate::domain::transfer::TransferTask;
|
||||
|
||||
|
|
@ -16,6 +16,14 @@ pub struct ConnectionTab {
|
|||
pub loading: bool,
|
||||
}
|
||||
|
||||
/// Какая панель последней принимала клик — используется тулбаром,
|
||||
/// чтобы New Folder/Rename/Delete применялись к нужной стороне (local/remote).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Pane {
|
||||
Local,
|
||||
Remote,
|
||||
}
|
||||
|
||||
type PendingResult<T> = Arc<std::sync::Mutex<Option<Result<T, String>>>>;
|
||||
|
||||
pub struct PendingConnect {
|
||||
|
|
@ -29,6 +37,14 @@ pub struct PendingRemoteList {
|
|||
|
||||
#[derive(Clone)]
|
||||
pub struct Bookmark {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Фиксированный пункт быстрого доступа (Home/Desktop/...) — не удаляется, не хранится в БД.
|
||||
#[derive(Clone)]
|
||||
pub struct QuickAccess {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
}
|
||||
|
|
@ -39,6 +55,10 @@ pub struct HistoryEntry {
|
|||
pub port: u16,
|
||||
pub user: String,
|
||||
pub time: String,
|
||||
/// Стабильный id этой цели — под ним же лежит пароль в keychain.
|
||||
pub conn_id: String,
|
||||
pub protocol: Protocol,
|
||||
pub key_path: Option<String>,
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
|
|
@ -49,10 +69,20 @@ pub struct AppState {
|
|||
pub local_entries: Vec<FileEntry>,
|
||||
pub local_selected: Option<String>,
|
||||
|
||||
/// Панель, в которой был последний клик — New Folder/Rename/Delete в тулбаре
|
||||
/// применяются к ней.
|
||||
pub active_pane: Pane,
|
||||
/// К какой стороне относится открытый сейчас диалог mkdir/rename/delete.
|
||||
pub op_target: Pane,
|
||||
|
||||
pub show_connect_dialog: bool,
|
||||
pub show_bookmarks: bool,
|
||||
pub show_history: bool,
|
||||
pub show_settings_dialog: bool,
|
||||
|
||||
/// Фиксированные пункты быстрого доступа (Home/Desktop/...) — не удаляются, не хранятся в БД.
|
||||
pub quick_access: Vec<QuickAccess>,
|
||||
/// Закладки, добавленные пользователем — хранятся в БД, можно удалять.
|
||||
pub bookmarks: Vec<Bookmark>,
|
||||
pub history: Vec<HistoryEntry>,
|
||||
|
||||
|
|
@ -74,6 +104,10 @@ pub struct AppState {
|
|||
|
||||
// action flags — выставляются тулбаром, обрабатываются в app.rs
|
||||
pub pending_refresh: bool,
|
||||
/// Клик по записи истории — переподключиться (новая вкладка, без диалога).
|
||||
pub pending_history_reconnect: Option<HistoryEntry>,
|
||||
/// ПКМ → "Save" на записи истории — сохранить как постоянный Site.
|
||||
pub pending_history_save: Option<HistoryEntry>,
|
||||
|
||||
// диалоги операций над удалённой ФС
|
||||
pub show_mkdir_dialog: bool,
|
||||
|
|
@ -105,41 +139,45 @@ impl Default for AppState {
|
|||
local_path: home.clone(),
|
||||
local_entries: Vec::new(),
|
||||
local_selected: None,
|
||||
active_pane: Pane::Local,
|
||||
op_target: Pane::Local,
|
||||
show_connect_dialog: false,
|
||||
show_bookmarks: false,
|
||||
show_history: false,
|
||||
bookmarks: vec![
|
||||
Bookmark {
|
||||
show_settings_dialog: false,
|
||||
quick_access: vec![
|
||||
QuickAccess {
|
||||
name: "Home".into(),
|
||||
path: home.clone(),
|
||||
},
|
||||
Bookmark {
|
||||
QuickAccess {
|
||||
name: "Desktop".into(),
|
||||
path: format!("{}/Desktop", home),
|
||||
},
|
||||
Bookmark {
|
||||
QuickAccess {
|
||||
name: "Documents".into(),
|
||||
path: format!("{}/Documents", home),
|
||||
},
|
||||
Bookmark {
|
||||
QuickAccess {
|
||||
name: "Downloads".into(),
|
||||
path: dirs::download_dir()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| format!("{}/Downloads", home)),
|
||||
},
|
||||
Bookmark {
|
||||
QuickAccess {
|
||||
name: "Pictures".into(),
|
||||
path: format!("{}/Pictures", home),
|
||||
},
|
||||
Bookmark {
|
||||
QuickAccess {
|
||||
name: "Music".into(),
|
||||
path: format!("{}/Music", home),
|
||||
},
|
||||
Bookmark {
|
||||
QuickAccess {
|
||||
name: "Videos".into(),
|
||||
path: format!("{}/Videos", home),
|
||||
},
|
||||
],
|
||||
bookmarks: Vec::new(),
|
||||
history: Vec::new(),
|
||||
connect_label: String::new(),
|
||||
connect_host: String::new(),
|
||||
|
|
@ -155,6 +193,8 @@ impl Default for AppState {
|
|||
status_message: "Ready".into(),
|
||||
connected_count: 0,
|
||||
pending_refresh: false,
|
||||
pending_history_reconnect: None,
|
||||
pending_history_save: None,
|
||||
show_mkdir_dialog: false,
|
||||
show_delete_dialog: false,
|
||||
show_rename_dialog: false,
|
||||
|
|
@ -188,20 +228,25 @@ impl AppState {
|
|||
Some(&self.tabs[idx])
|
||||
}
|
||||
|
||||
pub fn add_history(&mut self, host: &str, port: u16, user: &str) {
|
||||
use chrono::Local;
|
||||
let now = Local::now().format("%H:%M %d.%m").to_string();
|
||||
self.history.insert(
|
||||
0,
|
||||
HistoryEntry {
|
||||
host: host.into(),
|
||||
port,
|
||||
user: user.into(),
|
||||
time: now,
|
||||
},
|
||||
);
|
||||
if self.history.len() > 20 {
|
||||
self.history.truncate(20);
|
||||
/// Перечитывает историю подключений из БД (после записи новой попытки).
|
||||
pub fn reload_history(&mut self, db: &Arc<std::sync::Mutex<rusqlite::Connection>>) {
|
||||
if let Ok(conn) = db.lock()
|
||||
&& let Ok(rows) = crate::storage::db::get_history(&conn)
|
||||
{
|
||||
self.history = rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(host, port, user, time, conn_id, protocol, key_path)| HistoryEntry {
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
time,
|
||||
conn_id,
|
||||
protocol,
|
||||
key_path,
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,40 +1,46 @@
|
|||
//! Цвета и константы темы LoFlum
|
||||
//! Цвета и константы темы LoFlum — "Clay"
|
||||
use egui::Color32;
|
||||
|
||||
// Backgrounds
|
||||
pub const BG_BASE: Color32 = Color32::from_rgb(18, 18, 18); // самый тёмный фон
|
||||
pub const BG_PANEL: Color32 = Color32::from_rgb(24, 24, 24); // боковая панель
|
||||
pub const BG_CONTENT: Color32 = Color32::from_rgb(28, 28, 28); // основная область
|
||||
pub const BG_TOOLBAR: Color32 = Color32::from_rgb(22, 22, 22); // тулбар
|
||||
pub const BG_TAB_ACTIVE: Color32 = Color32::from_rgb(38, 38, 42); // активная вкладка
|
||||
pub const BG_TAB_IDLE: Color32 = Color32::from_rgb(28, 28, 30); // неактивная
|
||||
pub const BG_ROW_HOVER: Color32 = Color32::from_rgb(35, 35, 38);
|
||||
pub const BG_ROW_SEL: Color32 = Color32::from_rgb(42, 64, 95); // выделение — синеватое
|
||||
pub const BG_QUEUE: Color32 = Color32::from_rgb(20, 20, 22);
|
||||
pub const BG_HEADER: Color32 = Color32::from_rgb(22, 22, 24);
|
||||
// Backgrounds — тёплый почти-чёрный, а не нейтрально-синий
|
||||
pub const BG_BASE: Color32 = Color32::from_rgb(23, 20, 15); // #17140F — самый тёмный фон
|
||||
pub const BG_PANEL: Color32 = Color32::from_rgb(25, 22, 17); // #19160F — тулбар/боковая панель
|
||||
pub const BG_CONTENT: Color32 = Color32::from_rgb(32, 29, 25); // #201D19 — основная область
|
||||
pub const BG_TOOLBAR: Color32 = Color32::from_rgb(25, 22, 17); // #19160F
|
||||
pub const BG_TAB_ACTIVE: Color32 = Color32::from_rgb(36, 29, 20); // #241D14 — активная вкладка (clay-tint)
|
||||
pub const BG_TAB_IDLE: Color32 = Color32::from_rgb(23, 20, 15);
|
||||
pub const BG_ROW_HOVER: Color32 = Color32::from_rgb(38, 32, 25); // #262019
|
||||
pub const BG_ROW_SEL: Color32 = Color32::from_rgb(42, 32, 24); // #2A2018 — тёплое выделение
|
||||
pub const BG_QUEUE: Color32 = Color32::from_rgb(22, 19, 16); // #161310
|
||||
pub const BG_HEADER: Color32 = Color32::from_rgb(23, 20, 15);
|
||||
|
||||
// Accents
|
||||
pub const ACCENT: Color32 = Color32::from_rgb(64, 130, 220); // синий акцент
|
||||
pub const ACCENT_DIM: Color32 = Color32::from_rgb(45, 95, 165);
|
||||
pub const GREEN: Color32 = Color32::from_rgb(60, 180, 100);
|
||||
pub const RED: Color32 = Color32::from_rgb(210, 70, 70);
|
||||
pub const YELLOW: Color32 = Color32::from_rgb(210, 160, 50);
|
||||
pub const ORANGE: Color32 = Color32::from_rgb(210, 120, 50);
|
||||
// Accents — clay/терракота вместо синего, один акцент на экран
|
||||
pub const ACCENT: Color32 = Color32::from_rgb(193, 103, 60); // #C1673C — clay
|
||||
pub const ACCENT_DIM: Color32 = Color32::from_rgb(139, 74, 44); // #8B4A2C — приглушённый clay
|
||||
pub const ON_ACCENT: Color32 = Color32::from_rgb(26, 22, 17); // #1A1611 — текст на clay-заливке
|
||||
pub const GREEN: Color32 = Color32::from_rgb(122, 148, 113); // #7A9471 — sage (успех/подключено)
|
||||
pub const RED: Color32 = Color32::from_rgb(179, 82, 62); // #B3523E — brick (ошибка)
|
||||
pub const YELLOW: Color32 = Color32::from_rgb(217, 164, 65); // #D9A441 — amber (папки/предупреждение)
|
||||
pub const ORANGE: Color32 = Color32::from_rgb(193, 103, 60);
|
||||
|
||||
// Text
|
||||
pub const TEXT_PRIMARY: Color32 = Color32::from_rgb(220, 220, 220);
|
||||
pub const TEXT_DIM: Color32 = Color32::from_rgb(130, 130, 135);
|
||||
pub const TEXT_HINT: Color32 = Color32::from_rgb(80, 80, 85);
|
||||
pub const TEXT_PRIMARY: Color32 = Color32::from_rgb(237, 232, 224); // #EDE8E0
|
||||
pub const TEXT_DIM: Color32 = Color32::from_rgb(166, 156, 142); // #A69C8E
|
||||
pub const TEXT_HINT: Color32 = Color32::from_rgb(110, 101, 90); // #6E655A
|
||||
|
||||
// Borders / separators
|
||||
pub const BORDER: Color32 = Color32::from_rgb(40, 40, 44);
|
||||
pub const SEPARATOR: Color32 = Color32::from_rgb(35, 35, 38);
|
||||
pub const BORDER: Color32 = Color32::from_rgb(46, 42, 36); // #2E2A24
|
||||
pub const SEPARATOR: Color32 = Color32::from_rgb(40, 36, 30); // #28241E
|
||||
|
||||
// Size constants
|
||||
pub const SIDEBAR_W: f32 = 180.0;
|
||||
pub const TOOLBAR_H: f32 = 40.0;
|
||||
pub const TABBAR_H: f32 = 32.0;
|
||||
pub const QUEUE_COLLAPSED_H: f32 = 32.0;
|
||||
pub const QUEUE_EXPANDED_H: f32 = 160.0;
|
||||
pub const STATUS_H: f32 = 24.0;
|
||||
pub const ROW_H: f32 = 22.0;
|
||||
pub const SIDEBAR_W: f32 = 200.0;
|
||||
pub const TOOLBAR_H: f32 = 48.0;
|
||||
pub const TABBAR_H: f32 = 36.0;
|
||||
pub const QUEUE_COLLAPSED_H: f32 = 34.0;
|
||||
pub const QUEUE_EXPANDED_H: f32 = 190.0;
|
||||
pub const STATUS_H: f32 = 26.0;
|
||||
pub const ROW_H: f32 = 28.0;
|
||||
|
||||
// Radius
|
||||
pub const RADIUS_SM: f32 = 5.0;
|
||||
pub const RADIUS_MD: f32 = 6.0;
|
||||
pub const RADIUS_LG: f32 = 10.0;
|
||||
|
|
|
|||