diff --git a/shell.nix b/shell.nix index 33722b9..a8a213e 100644 --- a/shell.nix +++ b/shell.nix @@ -35,6 +35,8 @@ pkgs.mkShell { pkgs.xorg.libX11 pkgs.mesa pkgs.libglvnd + pkgs.gtk3 + pkgs.glib ]; # (Необязательно) принудительно использовать X11 backend diff --git a/src/operations.rs b/src/operations.rs index 6532ede..d9fbfe0 100644 --- a/src/operations.rs +++ b/src/operations.rs @@ -1,6 +1,7 @@ +use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use serde::{Serialize, Deserialize}; +pub mod local_fs; /// === Модуль для работы с удаленными хранилищами === #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/operations/local_fs.rs b/src/operations/local_fs.rs new file mode 100644 index 0000000..a7f8ab9 --- /dev/null +++ b/src/operations/local_fs.rs @@ -0,0 +1,30 @@ + +use std::fs; +use std::path::{Path, PathBuf}; +use super::*; + +pub fn list_directory(path: &str) -> Result, String> { + let p = if path.is_empty() { + if cfg!(windows) { PathBuf::from("C:\\") } else { PathBuf::from("/") } + } else { + PathBuf::from(path) + }; + + let entries = fs::read_dir(&p).map_err(|e| e.to_string())?; + let mut files = Vec::new(); + + for entry in entries.flatten() { + if let Ok(meta) = entry.metadata() { + files.push(FileInfo { + name: entry.file_name().to_string_lossy().to_string(), + path: entry.path().to_string_lossy().to_string(), + size: meta.len(), + is_dir: meta.is_dir(), + modified: None, + }); + } + } + // Сначала папки, потом файлы, по алфавиту + files.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then(a.name.cmp(&b.name))); + Ok(files) +} diff --git a/src/ui.rs b/src/ui.rs index 6287c73..af23452 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,13 +1,19 @@ -//! Графический интерфейс для операций rclone. +//! Графический интерфейс для операций rclone с интегрированным Local FS и поддержкой кастомных шрифтов. +use crate::operations::{ + self, CopyOptions, DeleteOptions, FileInfo, MoveOptions, Remote, +}; +use crate::rclone_install::RcloneApp; use eframe::egui; -use egui::{CentralPanel, ScrollArea, Window, Align2, Color32, Spinner, ProgressBar, SidePanel}; +use egui::{ + Align2, CentralPanel, Color32, ProgressBar, ScrollArea, SidePanel, Spinner, Window, + FontDefinitions, FontData, FontFamily, +}; +use std::path::Path; use std::sync::Arc; -use std::sync::mpsc::{channel, Sender, Receiver}; +use std::sync::mpsc::{Receiver, Sender, channel}; use std::time::{Duration, Instant}; use tokio::runtime::Runtime; -use crate::rclone_install::RcloneApp; -use crate::operations::{self, FileInfo, Remote, CopyOptions, MoveOptions, DeleteOptions}; #[derive(Clone, PartialEq)] pub enum AppState { @@ -16,6 +22,12 @@ pub enum AppState { Error(String), } +#[derive(Debug, PartialEq, Clone, Copy)] +enum TransferTab { + Remote, + Local, +} + #[derive(Clone)] pub struct Operation { pub id: u32, @@ -26,8 +38,8 @@ pub struct Operation { } enum OperationResult { - Success(u32, String), - Failure(u32, String), + Success(u32, String), + Failure(u32, String), FileList(Vec), RemoteList(Vec), ProgressUpdate(u32, f32, String), @@ -39,7 +51,6 @@ pub struct RcloneUI { error_message: Option, current_path: String, - // История переходов history_back: Vec, history_forward: Vec, @@ -49,18 +60,24 @@ pub struct RcloneUI { transfer_source_info: Vec<(String, bool)>, transfer_dest: String, + active_transfer_tab: TransferTab, show_transfer_dialog: bool, - is_move_mode: bool, + show_local_browser: bool, + local_browser_path: String, + local_browser_files: Vec, + + is_move_mode: bool, + #[allow(dead_code)] settings: AppSettings, active_task_count: u32, operation_tx: Sender, operation_rx: Receiver, - + pending_load_path: Option, rclone_init_receiver: Option, String>>>, - + active_operations: Vec, next_op_id: u32, } @@ -72,7 +89,25 @@ pub struct AppSettings { } impl RcloneUI { - pub fn new(_cc: &eframe::CreationContext<'_>) -> Self { + pub fn new(cc: &eframe::CreationContext<'_>) -> Self { + // --- ВОССТАНОВЛЕНИЕ ШРИФТОВ --- + let mut fonts = FontDefinitions::default(); + + if let Ok(font_data) = std::fs::read("assets/fonts/Sans.otf") { + fonts.font_data.insert("sans".to_owned(), FontData::from_owned(font_data)); + fonts.families.entry(FontFamily::Proportional).or_default().insert(0, "sans".to_owned()); + fonts.families.entry(FontFamily::Monospace).or_default().push("sans".to_owned()); + } + + if let Ok(emoji_data) = std::fs::read("assets/fonts/NotoColorEmoji.ttf") { + fonts.font_data.insert("emoji".to_owned(), FontData::from_owned(emoji_data)); + let prop = fonts.families.entry(FontFamily::Proportional).or_default(); + if !prop.contains(&"emoji".to_owned()) { + prop.push("emoji".to_owned()); + } + } + cc.egui_ctx.set_fonts(fonts); + let (init_tx, init_rx) = channel(); let (op_tx, op_rx) = channel(); @@ -98,7 +133,11 @@ impl RcloneUI { selected_paths: Vec::new(), transfer_source_info: Vec::new(), transfer_dest: String::new(), + active_transfer_tab: TransferTab::Remote, show_transfer_dialog: false, + show_local_browser: false, + local_browser_path: std::env::var("HOME").unwrap_or_else(|_| "/".into()), + local_browser_files: Vec::new(), is_move_mode: false, settings: AppSettings::default(), active_task_count: 0, @@ -111,43 +150,56 @@ impl RcloneUI { } } - // --- Логика навигации --- + // --- Валидация --- + fn is_path_valid(&self) -> bool { + if self.transfer_dest.is_empty() { return false; } + match self.active_transfer_tab { + TransferTab::Remote => self.transfer_dest.contains(':'), + TransferTab::Local => !self.transfer_dest.contains(':') || Path::new(&self.transfer_dest).is_absolute(), + } + } + + // --- Навигация --- fn navigate_to(&mut self, new_path: String) { if self.current_path == new_path { return; } - if !self.current_path.is_empty() { - self.history_back.push(self.current_path.clone()); - } + if !self.current_path.is_empty() { self.history_back.push(self.current_path.clone()); } self.history_forward.clear(); self.pending_load_path = Some(new_path); } fn go_back(&mut self) { - if let Some(prev_path) = self.history_back.pop() { + if let Some(prev) = self.history_back.pop() { self.history_forward.push(self.current_path.clone()); - self.pending_load_path = Some(prev_path); + self.pending_load_path = Some(prev); } } fn go_forward(&mut self) { - if let Some(next_path) = self.history_forward.pop() { + if let Some(next) = self.history_forward.pop() { self.history_back.push(self.current_path.clone()); - self.pending_load_path = Some(next_path); + self.pending_load_path = Some(next); } } - // --- Фоновые процессы --- + fn refresh_local_list(&mut self) { + if let Ok(files) = operations::local_fs::list_directory(&self.local_browser_path) { + self.local_browser_files = files; + } + } + + // --- Работа с Rclone --- fn poll_rclone_init(&mut self) { - if let Some(receiver) = &self.rclone_init_receiver { - if let Ok(result) = receiver.try_recv() { - match result { - Ok(rclone) => { - self.rclone = Some(rclone); - self.load_remotes(); - self.state = AppState::Ready; + if let Some(rx) = &self.rclone_init_receiver { + if let Ok(res) = rx.try_recv() { + match res { + Ok(rclone) => { + self.rclone = Some(rclone); + self.load_remotes(); + self.state = AppState::Ready; } - Err(e) => { - self.error_message = Some(format!("Критическая ошибка: {}", e)); - self.state = AppState::Error(e); + Err(e) => { + self.error_message = Some(e.clone()); + self.state = AppState::Error(e); } } self.rclone_init_receiver = None; @@ -162,7 +214,7 @@ impl RcloneUI { std::thread::spawn(move || { let _ = tx.send(match operations::remotes::list(&rclone) { Ok(list) => OperationResult::RemoteList(list), - Err(e) => OperationResult::Failure(0, format!("Ошибка списка хранилищ: {}", e)), + Err(e) => OperationResult::Failure(0, e.to_string()), }); }); } @@ -176,7 +228,7 @@ impl RcloneUI { std::thread::spawn(move || { let _ = tx.send(match operations::files::list(&rclone, &path) { Ok(list) => OperationResult::FileList(list), - Err(e) => OperationResult::Failure(0, format!("Ошибка доступа к папке: {}", e)), + Err(e) => OperationResult::Failure(0, e.to_string()), }); }); } @@ -213,75 +265,59 @@ impl RcloneUI { } } - // --- Операции --- - fn perform_copy_multiple(&mut self, sources: Vec<(String, bool)>, dest: &str) { + fn perform_transfer(&mut self, is_move: bool) { + if self.active_task_count > 0 { return; } if let Some(rclone) = self.rclone.clone() { - let options = CopyOptions { verbose: true, dry_run: false, bandwidth_limit: self.settings.bandwidth_limit.clone(), no_traverse: false }; - let dest_base = dest.to_string(); - let op_id = self.add_operation(format!("Копирование {} эл.", sources.len())); + let sources = self.transfer_source_info.clone(); + let dest_base = self.transfer_dest.clone(); + let op_id = self.add_operation(format!("{} {} эл.", if is_move { "Перенос" } else { "Копия" }, sources.len())); self.active_task_count += 1; let tx = self.operation_tx.clone(); + std::thread::spawn(move || { let total = sources.len() as f32; for (idx, (source, is_dir)) in sources.iter().enumerate() { - let name = source.split([':', '/']).last().unwrap_or(""); - let final_dest = if *is_dir { format!("{}/{}", dest_base.trim_end_matches('/'), name) } else { dest_base.clone() }; - if let Err(e) = operations::sync::copy(&rclone, source, &final_dest, &options) { - let _ = tx.send(OperationResult::Failure(op_id, format!("Ошибка копирования {}: {}", source, e))); return; + let name = source.split([':', '/', '\\']).last().unwrap_or(""); + let separator = if dest_base.contains(':') && !dest_base.ends_with(':') && !dest_base.ends_with('/') { "/" } else { "" }; + + let final_dest = if *is_dir { + format!("{}{}{}", dest_base.trim_end_matches(['/', '\\']), separator, name) + } else { + dest_base.clone() + }; + + let res = if is_move { + operations::sync::move_files(&rclone, source, &final_dest, &MoveOptions { verbose: true, ..Default::default() }) + } else { + operations::sync::copy(&rclone, source, &final_dest, &CopyOptions { verbose: true, ..Default::default() }) + }; + + if let Err(e) = res { + let _ = tx.send(OperationResult::Failure(op_id, format!("Ошибка на {}: {}", name, e))); + return; } let _ = tx.send(OperationResult::ProgressUpdate(op_id, (idx + 1) as f32 / total, format!("{}/{}", idx+1, total))); } - let _ = tx.send(OperationResult::Success(op_id, "Завершено".into())); + let _ = tx.send(OperationResult::Success(op_id, "Ок".into())); }); } } - fn perform_move_multiple(&mut self, sources: Vec<(String, bool)>, dest: &str) { + fn delete_selected(&mut self) { + if self.active_task_count > 0 { return; } if let Some(rclone) = self.rclone.clone() { - let options = MoveOptions { verbose: true, dry_run: false, delete_empty_src_dirs: true }; - let dest_base = dest.to_string(); - let op_id = self.add_operation(format!("Перемещение {} эл.", sources.len())); - self.active_task_count += 1; - let tx = self.operation_tx.clone(); - std::thread::spawn(move || { - let total = sources.len() as f32; - for (idx, (source, is_dir)) in sources.iter().enumerate() { - let name = source.split([':', '/']).last().unwrap_or(""); - let final_dest = if *is_dir { format!("{}/{}", dest_base.trim_end_matches('/'), name) } else { dest_base.clone() }; - if let Err(e) = operations::sync::move_files(&rclone, source, &final_dest, &options) { - let _ = tx.send(OperationResult::Failure(op_id, format!("Ошибка перемещения {}: {}", source, e))); return; - } - let _ = tx.send(OperationResult::ProgressUpdate(op_id, (idx + 1) as f32 / total, format!("{}/{}", idx+1, total))); - } - let _ = tx.send(OperationResult::Success(op_id, "Завершено".into())); - }); - } - } - - fn delete_selected_paths(&mut self, paths: Vec) { - if let Some(rclone) = self.rclone.clone() { - let mut path_info = Vec::new(); - for path in &paths { - let is_dir = self.current_files.iter().find(|f| { - let full = if self.current_path.ends_with(':') { format!("{}{}", self.current_path, f.name) } - else { format!("{}/{}", self.current_path, f.name) }; - &full == path - }).map(|f| f.is_dir).unwrap_or(false); - path_info.push((path.clone(), is_dir)); - } + let paths = self.get_selected_info(); let op_id = self.add_operation(format!("Удаление {} эл.", paths.len())); self.active_task_count += 1; let tx = self.operation_tx.clone(); std::thread::spawn(move || { - let total = path_info.len() as f32; - for (idx, (p, is_dir)) in path_info.iter().enumerate() { - let opts = DeleteOptions { recursive: *is_dir, verbose: false, dry_run: false }; + for (p, is_dir) in paths.iter() { + let opts = DeleteOptions { recursive: *is_dir, ..Default::default() }; if let Err(e) = operations::sync::delete(&rclone, p, &opts) { - let _ = tx.send(OperationResult::Failure(op_id, format!("Ошибка удаления {}: {}", p, e))); return; + let _ = tx.send(OperationResult::Failure(op_id, e.to_string())); return; } - let _ = tx.send(OperationResult::ProgressUpdate(op_id, (idx + 1) as f32 / total, format!("{}/{}", idx+1, total))); } - let _ = tx.send(OperationResult::Success(op_id, "Удалено".into())); + let _ = tx.send(OperationResult::Success(op_id, "Ок".into())); }); } } @@ -293,18 +329,10 @@ impl RcloneUI { id } - fn format_size(bytes: u64) -> String { - if bytes >= 1073741824 { format!("{:.2} ГБ", bytes as f64 / 1073741824.0) } - else if bytes >= 1048576 { format!("{:.2} МБ", bytes as f64 / 1048576.0) } - else if bytes >= 1024 { format!("{:.2} КБ", bytes as f64 / 1024.0) } - else { format!("{} Б", bytes) } - } - fn get_selected_info(&self) -> Vec<(String, bool)> { self.selected_paths.iter().map(|path| { let is_dir = self.current_files.iter().find(|f| { - let full = if self.current_path.ends_with(':') { format!("{}{}", self.current_path, f.name) } - else { format!("{}/{}", self.current_path, f.name) }; + let full = if self.current_path.ends_with(':') { format!("{}{}", self.current_path, f.name) } else { format!("{}/{}", self.current_path, f.name) }; &full == path }).map(|f| f.is_dir).unwrap_or(false); (path.clone(), is_dir) @@ -317,26 +345,21 @@ impl eframe::App for RcloneUI { self.poll_rclone_init(); self.poll_background_operation(); - let is_busy = !self.active_operations.is_empty(); - egui::TopBottomPanel::top("top").show(ctx, |ui| { egui::menu::bar(ui, |ui| { - ui.label("🚀 UI rclone"); + ui.label("🚀 Rclone GUI"); ui.separator(); - - // Навигация ui.add_enabled_ui(!self.history_back.is_empty(), |ui| { if ui.button("⬅").clicked() { self.go_back(); } }); ui.add_enabled_ui(!self.history_forward.is_empty(), |ui| { if ui.button("➡").clicked() { self.go_forward(); } }); - if self.active_task_count > 0 { ui.add(Spinner::new().size(16.0)); } }); }); - SidePanel::left("left").default_width(200.0).show(ctx, |ui| { + SidePanel::left("left").default_width(180.0).show(ctx, |ui| { ui.add_space(10.0); ui.heading("Хранилища"); ui.separator(); @@ -349,8 +372,8 @@ impl eframe::App for RcloneUI { }); }); - SidePanel::right("right").default_width(220.0).show(ctx, |ui| { - ui.heading("Активность"); + SidePanel::right("right").default_width(200.0).show(ctx, |ui| { + ui.heading("Задачи"); ui.separator(); ScrollArea::vertical().show(ui, |ui| { for op in &self.active_operations { @@ -360,13 +383,18 @@ impl eframe::App for RcloneUI { ui.small(&op.status); }); } + if self.active_operations.is_empty() { + ui.weak("Нет активных задач"); + } }); }); CentralPanel::default().show(ctx, |ui| { ui.horizontal(|ui| { ui.label(format!("📍 {}", self.current_path)); - if ui.button("🔄").clicked() { self.pending_load_path = Some(self.current_path.clone()); } + ui.add_enabled_ui(self.active_task_count == 0, |ui| { + if ui.button("🔄").clicked() { self.pending_load_path = Some(self.current_path.clone()); } + }); }); ui.separator(); @@ -374,11 +402,11 @@ impl eframe::App for RcloneUI { if !self.current_path.is_empty() { if self.current_path.contains('/') && ui.button("📁 .. (Вверх)").clicked() { if let Some((p, _)) = self.current_path.rsplit_once('/') { self.navigate_to(p.into()); } + else if let Some((p, _)) = self.current_path.rsplit_once(':') { self.navigate_to(format!("{}:", p)); } } for file in self.current_files.clone() { - let full = if self.current_path.ends_with(':') { format!("{}{}", self.current_path, file.name) } - else { format!("{}/{}", self.current_path, file.name) }; + let full = if self.current_path.ends_with(':') { format!("{}{}", self.current_path, file.name) } else { format!("{}/{}", self.current_path, file.name) }; let sel = self.selected_paths.contains(&full); ui.horizontal(|ui| { @@ -390,12 +418,12 @@ impl eframe::App for RcloneUI { if ui.link(&file.name).clicked() { self.navigate_to(full); } } else { ui.label(&file.name); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.small(Self::format_size(file.size)); + ui.small(file.format_size()); }); }); } } else { - ui.centered_and_justified(|ui| { ui.label("Выберите диск в левой панели"); }); + ui.centered_and_justified(|ui| { ui.label("Выберите хранилище слева"); }); } }); @@ -403,7 +431,7 @@ impl eframe::App for RcloneUI { ui.separator(); ui.horizontal(|ui| { ui.label(format!("Выбрано: {}", self.selected_paths.len())); - ui.add_enabled_ui(!is_busy, |ui| { + ui.add_enabled_ui(self.active_task_count == 0, |ui| { if ui.button("📋 Копировать").clicked() { self.transfer_source_info = self.get_selected_info(); self.is_move_mode = false; @@ -415,7 +443,7 @@ impl eframe::App for RcloneUI { self.show_transfer_dialog = true; } if ui.button("🗑 Удалить").clicked() { - self.delete_selected_paths(self.selected_paths.clone()); + self.delete_selected(); self.selected_paths.clear(); } }); @@ -423,38 +451,83 @@ impl eframe::App for RcloneUI { } }); - // Модальные окна + // Модальное окно трансфера if self.show_transfer_dialog { - Window::new(if self.is_move_mode { "Перемещение" } else { "Копирование" }) - .anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { - ui.label("Куда отправить:"); - ui.text_edit_singleline(&mut self.transfer_dest); - if !self.transfer_dest.contains(':') && !self.transfer_dest.is_empty() { - ui.colored_label(Color32::YELLOW, "⚠ Путь rclone должен содержать ':'"); - } - ui.horizontal(|ui| { - if ui.button("ОК").clicked() && !self.transfer_dest.is_empty() { - let (s, d) = (self.transfer_source_info.clone(), self.transfer_dest.clone()); - if self.is_move_mode { self.perform_move_multiple(s, &d); } - else { self.perform_copy_multiple(s, &d); } - self.show_transfer_dialog = false; - } - if ui.button("Отмена").clicked() { self.show_transfer_dialog = false; } - }); + Window::new("Трансфер").anchor(Align2::CENTER_CENTER, [0.0, 0.0]).collapsible(false).show(ctx, |ui| { + ui.horizontal(|ui| { + ui.selectable_value(&mut self.active_transfer_tab, TransferTab::Remote, "☁ Облако"); + ui.selectable_value(&mut self.active_transfer_tab, TransferTab::Local, "💻 ПК"); }); + ui.separator(); + + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut self.transfer_dest).hint_text("Путь назначения...")); + if self.active_transfer_tab == TransferTab::Local && ui.button("📂 Обзор").clicked() { + self.refresh_local_list(); + self.show_local_browser = true; + } + }); + + if !self.transfer_dest.is_empty() && !self.is_path_valid() { + ui.colored_label(Color32::KHAKI, + if self.active_transfer_tab == TransferTab::Remote { "⚠ Путь облака должен содержать ':'" } + else { "⚠ Укажите локальный путь без ':'" }); + } + }); + + ui.add_space(10.0); + ui.horizontal(|ui| { + let can_start = self.is_path_valid() && self.active_task_count == 0; + if ui.add_enabled(can_start, egui::Button::new("🚀 Начать")).clicked() { + self.perform_transfer(self.is_move_mode); + self.show_transfer_dialog = false; + } + if ui.button("Отмена").clicked() { self.show_transfer_dialog = false; } + }); + }); + } + + // Внутренний браузер папок + if self.show_local_browser { + Window::new("Выбор локальной папки").anchor(Align2::CENTER_CENTER, [0.0, 0.0]).fixed_size([400.0, 300.0]).show(ctx, |ui| { + ui.label(format!("📍 {}", self.local_browser_path)); + if ui.button("⬅ Наверх").clicked() { + if let Some(parent) = Path::new(&self.local_browser_path).parent() { + self.local_browser_path = parent.to_string_lossy().into(); + self.refresh_local_list(); + } + } + ui.separator(); + ScrollArea::vertical().max_height(200.0).show(ui, |ui| { + let files = self.local_browser_files.clone(); + for file in files { + if file.is_dir && ui.button(format!("📁 {}", file.name)).clicked() { + self.local_browser_path = file.path; + self.refresh_local_list(); + } + } + }); + ui.separator(); + ui.horizontal(|ui| { + if ui.button("✅ Выбрать").clicked() { + self.transfer_dest = self.local_browser_path.clone(); + self.show_local_browser = false; + } + if ui.button("Отмена").clicked() { self.show_local_browser = false; } + }); + }); } if let Some(msg) = &self.error_message { let mut close = false; - Window::new("Сообщение").anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { - ui.colored_label(Color32::LIGHT_RED, "⚠️ Событие:"); + Window::new("Внимание").anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { ui.label(msg); - if ui.button("Закрыть").clicked() { close = true; } + if ui.button("ОК").clicked() { close = true; } }); if close { self.error_message = None; } } - // Выполнение отложенной загрузки if let Some(path) = self.pending_load_path.take() { self.current_path = path.clone(); self.selected_paths.clear();