From 25080e3cca15a45dfe241cfb7d6d21c64efe623d Mon Sep 17 00:00:00 2001 From: Kirill Date: Mon, 30 Mar 2026 13:57:34 +0300 Subject: [PATCH] feat(Ui): restrict UI by models --- Cargo.lock | 32 ++++ Cargo.toml | 4 + src/ui.rs | 469 +-------------------------------------------- src/ui/app.rs | 356 ++++++++++++++++++++++++++++++++++ src/ui/rcloneui.rs | 420 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 818 insertions(+), 463 deletions(-) create mode 100644 src/ui/app.rs create mode 100644 src/ui/rcloneui.rs diff --git a/Cargo.lock b/Cargo.lock index 6731dd0..09bd83a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,8 +11,10 @@ dependencies = [ "eframe", "egui", "egui_extras", + "env_logger", "flate2", "futures", + "log", "mockito", "reqwest", "rfd", @@ -1362,6 +1364,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "env_logger" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" +dependencies = [ + "humantime", + "is-terminal", + "log", + "regex", + "termcolor", +] + [[package]] name = "epaint" version = "0.27.2" @@ -2009,6 +2024,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + [[package]] name = "hyper" version = "0.14.32" @@ -2278,6 +2299,17 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi 0.5.2", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "itoa" version = "1.0.17" diff --git a/Cargo.toml b/Cargo.toml index 1a0f012..58ab050 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,10 @@ eframe = { version = "0.27", features = ["default"] } egui = "0.27" egui_extras = { version = "0.27" } +# Логирование +log = "0.4" +env_logger = "0.10" + [dev-dependencies] tempfile = "3.8" serial_test = "2.0" diff --git a/src/ui.rs b/src/ui.rs index af23452..76a014d 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,19 +1,9 @@ //! Графический интерфейс для операций rclone с интегрированным Local FS и поддержкой кастомных шрифтов. - -use crate::operations::{ - self, CopyOptions, DeleteOptions, FileInfo, MoveOptions, Remote, -}; +use crate::operations::{FileInfo, Remote}; use crate::rclone_install::RcloneApp; -use eframe::egui; -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::{Receiver, Sender, channel}; -use std::time::{Duration, Instant}; -use tokio::runtime::Runtime; +use std::sync::mpsc::{Receiver, Sender}; +use std::time::Instant; #[derive(Clone, PartialEq)] pub enum AppState { @@ -82,458 +72,11 @@ pub struct RcloneUI { next_op_id: u32, } +pub mod app; +pub mod rcloneui; + #[derive(Clone, Default)] pub struct AppSettings { pub show_hidden: bool, pub bandwidth_limit: Option, } - -impl RcloneUI { - 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(); - - std::thread::spawn(move || { - let rt = Runtime::new().unwrap(); - let result = rt.block_on(async { RcloneApp::new().await }); - let res = match result { - Ok(app) => Ok(Arc::new(app)), - Err(e) => Err(e.to_string()), - }; - let _ = init_tx.send(res); - }); - - Self { - rclone: None, - state: AppState::Initializing, - error_message: None, - current_path: String::new(), - history_back: Vec::new(), - history_forward: Vec::new(), - remote_list: Vec::new(), - current_files: Vec::new(), - 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, - operation_tx: op_tx, - operation_rx: op_rx, - pending_load_path: None, - rclone_init_receiver: Some(init_rx), - active_operations: Vec::new(), - next_op_id: 1, - } - } - - // --- Валидация --- - 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()); } - self.history_forward.clear(); - self.pending_load_path = Some(new_path); - } - - fn go_back(&mut self) { - if let Some(prev) = self.history_back.pop() { - self.history_forward.push(self.current_path.clone()); - self.pending_load_path = Some(prev); - } - } - - fn go_forward(&mut self) { - if let Some(next) = self.history_forward.pop() { - self.history_back.push(self.current_path.clone()); - 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(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(e.clone()); - self.state = AppState::Error(e); - } - } - self.rclone_init_receiver = None; - } - } - } - - fn load_remotes(&mut self) { - if let Some(rclone) = self.rclone.clone() { - self.active_task_count += 1; - let tx = self.operation_tx.clone(); - std::thread::spawn(move || { - let _ = tx.send(match operations::remotes::list(&rclone) { - Ok(list) => OperationResult::RemoteList(list), - Err(e) => OperationResult::Failure(0, e.to_string()), - }); - }); - } - } - - fn load_files(&mut self, path: &str) { - if let Some(rclone) = self.rclone.clone() { - self.active_task_count += 1; - let path = path.to_string(); - let tx = self.operation_tx.clone(); - std::thread::spawn(move || { - let _ = tx.send(match operations::files::list(&rclone, &path) { - Ok(list) => OperationResult::FileList(list), - Err(e) => OperationResult::Failure(0, e.to_string()), - }); - }); - } - } - - fn poll_background_operation(&mut self) { - while let Ok(result) = self.operation_rx.try_recv() { - match result { - OperationResult::Success(op_id, _) => { - self.active_task_count = self.active_task_count.saturating_sub(1); - self.active_operations.retain(|op| op.id != op_id); - self.pending_load_path = Some(self.current_path.clone()); - } - OperationResult::Failure(op_id, e) => { - self.active_task_count = self.active_task_count.saturating_sub(1); - self.active_operations.retain(|op| op.id != op_id); - self.error_message = Some(e); - } - OperationResult::FileList(files) => { - self.current_files = files; - self.active_task_count = self.active_task_count.saturating_sub(1); - } - OperationResult::RemoteList(remotes) => { - self.remote_list = remotes; - self.active_task_count = self.active_task_count.saturating_sub(1); - } - OperationResult::ProgressUpdate(op_id, progress, status) => { - if let Some(op) = self.active_operations.iter_mut().find(|op| op.id == op_id) { - op.progress = progress; - op.status = status; - } - } - } - } - } - - fn perform_transfer(&mut self, is_move: bool) { - if self.active_task_count > 0 { return; } - if let Some(rclone) = self.rclone.clone() { - 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 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())); - }); - } - } - - fn delete_selected(&mut self) { - if self.active_task_count > 0 { return; } - if let Some(rclone) = self.rclone.clone() { - 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 || { - 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, e.to_string())); return; - } - } - let _ = tx.send(OperationResult::Success(op_id, "Ок".into())); - }); - } - } - - fn add_operation(&mut self, description: String) -> u32 { - let id = self.next_op_id; - self.next_op_id += 1; - self.active_operations.push(Operation { id, description, progress: 0.0, status: "В очереди".into(), start_time: Instant::now() }); - id - } - - 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) }; - &full == path - }).map(|f| f.is_dir).unwrap_or(false); - (path.clone(), is_dir) - }).collect() - } -} - -impl eframe::App for RcloneUI { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - self.poll_rclone_init(); - self.poll_background_operation(); - - egui::TopBottomPanel::top("top").show(ctx, |ui| { - egui::menu::bar(ui, |ui| { - 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(180.0).show(ctx, |ui| { - ui.add_space(10.0); - ui.heading("Хранилища"); - ui.separator(); - ScrollArea::vertical().show(ui, |ui| { - for remote in self.remote_list.clone() { - if ui.selectable_label(self.current_path.starts_with(&remote.name), format!("📡 {}", remote.name)).clicked() { - self.navigate_to(format!("{}:", remote.name)); - } - } - }); - }); - - 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 { - ui.group(|ui| { - ui.label(&op.description); - ui.add(ProgressBar::new(op.progress).text(format!("{:.0}%", op.progress * 100.0))); - 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)); - 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(); - - ScrollArea::vertical().id_source("files").show(ui, |ui| { - 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 sel = self.selected_paths.contains(&full); - - ui.horizontal(|ui| { - if ui.selectable_label(sel, file.icon()).clicked() { - if sel { self.selected_paths.retain(|p| p != &full); } - else { self.selected_paths.push(full.clone()); } - } - if file.is_dir { - 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(file.format_size()); - }); - }); - } - } else { - ui.centered_and_justified(|ui| { ui.label("Выберите хранилище слева"); }); - } - }); - - if !self.selected_paths.is_empty() { - ui.separator(); - ui.horizontal(|ui| { - ui.label(format!("Выбрано: {}", self.selected_paths.len())); - 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; - self.show_transfer_dialog = true; - } - if ui.button("✂ Переместить").clicked() { - self.transfer_source_info = self.get_selected_info(); - self.is_move_mode = true; - self.show_transfer_dialog = true; - } - if ui.button("🗑 Удалить").clicked() { - self.delete_selected(); - self.selected_paths.clear(); - } - }); - }); - } - }); - - // Модальное окно трансфера - if self.show_transfer_dialog { - 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.label(msg); - 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(); - self.load_files(&path); - } - - ctx.request_repaint_after(Duration::from_millis(100)); - } -} diff --git a/src/ui/app.rs b/src/ui/app.rs new file mode 100644 index 0000000..4195f02 --- /dev/null +++ b/src/ui/app.rs @@ -0,0 +1,356 @@ +use super::*; +use eframe::egui; +use egui::{ + Align2, CentralPanel, Color32, ProgressBar, ScrollArea, + SidePanel, Spinner, Window, +}; +use std::path::Path; +use std::time::Duration; + +impl eframe::App for RcloneUI { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.poll_rclone_init(); + self.poll_background_operation(); + + egui::TopBottomPanel::top("top").show(ctx, |ui| { + egui::menu::bar(ui, |ui| { + ui.label("🚀 Rclone GUI"); + ui.separator(); + ui.add_enabled_ui(!self.history_back.is_empty(), |ui| { + if ui.button("⬅").clicked() { + log::debug!("Кнопка 'Назад' нажата"); + self.go_back(); + } + }); + ui.add_enabled_ui(!self.history_forward.is_empty(), |ui| { + if ui.button("➡").clicked() { + log::debug!("Кнопка 'Вперед' нажата"); + self.go_forward(); + } + }); + if self.active_task_count > 0 { + ui.add(Spinner::new().size(16.0)); + } + }); + }); + + SidePanel::left("left") + .default_width(180.0) + .show(ctx, |ui| { + ui.add_space(10.0); + ui.heading("Хранилища"); + ui.separator(); + ScrollArea::vertical().show(ui, |ui| { + for remote in self.remote_list.clone() { + if ui + .selectable_label( + self.current_path.starts_with(&remote.name), + format!("📡 {}", remote.name), + ) + .clicked() + { + log::debug!("Выбрано удаленное хранилище: {}", remote.name); + self.navigate_to(format!("{}:", remote.name)); + } + } + }); + }); + + 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 { + ui.group(|ui| { + ui.label(&op.description); + ui.add( + ProgressBar::new(op.progress) + .text(format!("{:.0}%", op.progress * 100.0)), + ); + 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)); + ui.add_enabled_ui(self.active_task_count == 0, |ui| { + if ui.button("🔄").clicked() { + log::debug!("Обновление текущего пути: {}", self.current_path); + self.pending_load_path = Some(self.current_path.clone()); + } + }); + }); + ui.separator(); + + ScrollArea::vertical().id_source("files").show(ui, |ui| { + if !self.current_path.is_empty() { + if self.current_path.contains('/') && ui.button("📁 .. (Вверх)").clicked() + { + log::debug!("Переход вверх по директории"); + 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 sel = self.selected_paths.contains(&full); + + ui.horizontal(|ui| { + if ui.selectable_label(sel, file.icon()).clicked() { + if sel { + log::debug!("Снят выбор с {}", full); + self.selected_paths.retain(|p| p != &full); + } else { + log::debug!("Выбран {}", full); + self.selected_paths.push(full.clone()); + } + } + if file.is_dir { + if ui.link(&file.name).clicked() { + log::debug!("Открыта директория: {}", full); + self.navigate_to(full); + } + } else { + ui.label(&file.name); + } + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + ui.small(file.format_size()); + }, + ); + }); + } + } else { + ui.centered_and_justified(|ui| { + ui.label("Выберите хранилище слева"); + }); + } + }); + + if !self.selected_paths.is_empty() { + ui.separator(); + ui.horizontal(|ui| { + ui.label(format!("Выбрано: {}", self.selected_paths.len())); + ui.add_enabled_ui(self.active_task_count == 0, |ui| { + if ui.button("📋 Копировать").clicked() { + log::info!("Начало операции копирования выбранных элементов"); + self.transfer_source_info = self.get_selected_info(); + self.is_move_mode = false; + self.show_transfer_dialog = true; + } + if ui.button("✂ Переместить").clicked() { + log::info!("Начало операции перемещения выбранных элементов"); + self.transfer_source_info = self.get_selected_info(); + self.is_move_mode = true; + self.show_transfer_dialog = true; + } + if ui.button("🗑 Удалить").clicked() { + log::info!("Начало операции удаления выбранных элементов"); + self.delete_selected(); + self.selected_paths.clear(); + } + }); + }); + } + }); + + // Модальное окно трансфера + if self.show_transfer_dialog { + 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() + { + log::debug!("Открыт локальный браузер файлов"); + 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() + { + log::info!("Запуск трансфера в {}", self.transfer_dest); + self.perform_transfer(self.is_move_mode); + self.show_transfer_dialog = false; + } + if ui.button("Отмена").clicked() { + log::debug!("Отмена трансфера"); + 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)); + ui.horizontal(|ui| { + 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(); + } + } + if ui.button("🔄").clicked() { + self.refresh_local_list(); + } + }); + ui.separator(); + + // Проверяем, пуста ли папка + let is_empty = self.local_browser_files.is_empty(); + + // Создаем рамку для контента + egui::Frame::group(ui.style()) + .inner_margin(egui::Margin::same(8.0)) + .show(ui, |ui| { + if is_empty { + // Если папка пуста, показываем сообщение по центру с иконкой + let available_height = ui.available_height(); + ui.centered_and_justified(|ui| { + ui.add_space(available_height / 3.0); + ui.vertical(|ui| { + ui.label("📂"); + ui.add_space(8.0); + ui.colored_label( + egui::Color32::GRAY, + "Эта папка пуста" + ); + }); + }); + } else { + // Если есть файлы, показываем список + ScrollArea::vertical() + .max_height(200.0) + .auto_shrink([false; 2]) + .show(ui, |ui| { + let files = self.local_browser_files.clone(); + for file in files { + if file.is_dir { + ui.horizontal(|ui| { + let response = ui.button("📁"); + ui.add_space(4.0); + let link_response = ui.link(&file.name); + + if response.clicked() || link_response.clicked() { + self.local_browser_path = file.path; + self.refresh_local_list(); + } + }); + } else { + ui.horizontal(|ui| { + ui.label("📄"); + ui.add_space(4.0); + ui.label(&file.name); + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + ui.small(file.format_size()); + }, + ); + }); + } + } + }); + } + }); + + ui.add_space(8.0); + ui.separator(); + ui.horizontal(|ui| { + if ui.button("✅ Выбрать").clicked() { + log::debug!("Выбрана локальная папка: {}", self.local_browser_path); + 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.label(msg); + if ui.button("ОК").clicked() { + close = true; + } + }); + if close { + log::debug!("Ошибка закрыта: {}", msg); + self.error_message = None; + } + } + + if let Some(path) = self.pending_load_path.take() { + self.current_path = path.clone(); + self.selected_paths.clear(); + self.load_files(&path); + } + + ctx.request_repaint_after(Duration::from_millis(100)); + } +} diff --git a/src/ui/rcloneui.rs b/src/ui/rcloneui.rs new file mode 100644 index 0000000..a0920e3 --- /dev/null +++ b/src/ui/rcloneui.rs @@ -0,0 +1,420 @@ +use super::*; +use crate::operations::{self, CopyOptions, DeleteOptions, MoveOptions}; +use crate::rclone_install::RcloneApp; +use eframe::egui; +use egui::{FontData, FontDefinitions, FontFamily}; +use std::path::Path; +use std::sync::Arc; +use std::sync::mpsc::channel; +use std::time::Instant; +use tokio::runtime::Runtime; + +impl RcloneUI { + pub fn new(cc: &eframe::CreationContext<'_>) -> Self { + log::debug!("Инициализация RcloneUI"); + + // --- ВОССТАНОВЛЕНИЕ ШРИФТОВ --- + 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()); + log::debug!("Шрифт Sans.otf загружен"); + } else { + log::warn!("Файл шрифта Sans.otf не найден"); + } + + 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()); + } + log::debug!("Шрифт NotoColorEmoji.ttf загружен"); + } else { + log::warn!("Файл шрифта NotoColorEmoji.ttf не найден"); + } + cc.egui_ctx.set_fonts(fonts); + + let (init_tx, init_rx) = channel(); + let (op_tx, op_rx) = channel(); + + log::info!("Запуск фоновой инициализации rclone"); + std::thread::spawn(move || { + let rt = Runtime::new().unwrap(); + let result = rt.block_on(async { RcloneApp::new().await }); + let res = match result { + Ok(app) => { + log::info!("Rclone успешно инициализирован"); + Ok(Arc::new(app)) + } + Err(e) => { + log::error!("Ошибка инициализации rclone: {}", e); + Err(e.to_string()) + } + }; + let _ = init_tx.send(res); + }); + + Self { + rclone: None, + state: AppState::Initializing, + error_message: None, + current_path: String::new(), + history_back: Vec::new(), + history_forward: Vec::new(), + remote_list: Vec::new(), + current_files: Vec::new(), + 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, + operation_tx: op_tx, + operation_rx: op_rx, + pending_load_path: None, + rclone_init_receiver: Some(init_rx), + active_operations: Vec::new(), + next_op_id: 1, + } + } + + // --- Валидация --- + pub 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() + } + } + } + + // --- Навигация --- + pub fn navigate_to(&mut self, new_path: String) { + log::debug!("Навигация: {} -> {}", self.current_path, new_path); + if self.current_path == new_path { + return; + } + 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); + } + + pub fn go_back(&mut self) { + if let Some(prev) = self.history_back.pop() { + log::debug!("Назад: {} -> {}", self.current_path, prev); + self.history_forward.push(self.current_path.clone()); + self.pending_load_path = Some(prev); + } + } + + pub fn go_forward(&mut self) { + if let Some(next) = self.history_forward.pop() { + log::debug!("Вперед: {} -> {}", self.current_path, next); + self.history_back.push(self.current_path.clone()); + self.pending_load_path = Some(next); + } + } + + pub fn refresh_local_list(&mut self) { + log::debug!("Обновление списка локальной директории: {}", self.local_browser_path); + if let Ok(files) = operations::local_fs::list_directory(&self.local_browser_path) { + let count = files.len(); + self.local_browser_files = files; + log::debug!("Найдено {} элементов в локальной директории", count); + } else { + log::warn!("Не удалось прочитать локальную директорию: {}", self.local_browser_path); + } + } + + // --- Работа с Rclone --- + pub fn poll_rclone_init(&mut self) { + if let Some(rx) = &self.rclone_init_receiver { + if let Ok(res) = rx.try_recv() { + match res { + Ok(rclone) => { + log::info!("Rclone инициализирован, загрузка удаленных хранилищ"); + self.rclone = Some(rclone); + self.load_remotes(); + self.state = AppState::Ready; + } + Err(e) => { + log::error!("Ошибка инициализации: {}", e); + self.error_message = Some(e.clone()); + self.state = AppState::Error(e); + } + } + self.rclone_init_receiver = None; + } + } + } + + pub fn load_remotes(&mut self) { + if let Some(rclone) = self.rclone.clone() { + log::info!("Загрузка списка удаленных хранилищ"); + self.active_task_count += 1; + let tx = self.operation_tx.clone(); + std::thread::spawn(move || { + log::debug!("Фоновый поток: получение списка remotes"); + let _ = tx.send(match operations::remotes::list(&rclone) { + Ok(list) => { + log::info!("Загружено {} удаленных хранилищ", list.len()); + OperationResult::RemoteList(list) + } + Err(e) => { + log::error!("Ошибка загрузки remotes: {}", e); + OperationResult::Failure(0, e.to_string()) + } + }); + }); + } + } + + pub fn load_files(&mut self, path: &str) { + if let Some(rclone) = self.rclone.clone() { + log::info!("Загрузка файлов из пути: {}", path); + self.active_task_count += 1; + let path = path.to_string(); + let tx = self.operation_tx.clone(); + std::thread::spawn(move || { + log::debug!("Фоновый поток: загрузка файлов из {}", path); + let _ = tx.send(match operations::files::list(&rclone, &path) { + Ok(list) => { + log::info!("Загружено {} файлов из {}", list.len(), path); + OperationResult::FileList(list) + } + Err(e) => { + log::error!("Ошибка загрузки файлов из {}: {}", path, e); + OperationResult::Failure(0, e.to_string()) + } + }); + }); + } + } + + pub fn poll_background_operation(&mut self) { + while let Ok(result) = self.operation_rx.try_recv() { + match result { + OperationResult::Success(op_id, _) => { + log::debug!("Операция {} успешно завершена", op_id); + self.active_task_count = self.active_task_count.saturating_sub(1); + self.active_operations.retain(|op| op.id != op_id); + self.pending_load_path = Some(self.current_path.clone()); + } + OperationResult::Failure(op_id, e) => { + log::error!("Операция {} завершена с ошибкой: {}", op_id, e); + self.active_task_count = self.active_task_count.saturating_sub(1); + self.active_operations.retain(|op| op.id != op_id); + self.error_message = Some(e); + } + OperationResult::FileList(files) => { + let count = files.len(); + log::debug!("Получен список файлов: {} элементов", count); + self.current_files = files; + self.active_task_count = self.active_task_count.saturating_sub(1); + } + OperationResult::RemoteList(remotes) => { + let count = remotes.len(); + log::debug!("Получен список remotes: {} элементов", count); + self.remote_list = remotes; + self.active_task_count = self.active_task_count.saturating_sub(1); + } + OperationResult::ProgressUpdate(op_id, progress, status) => { + if let Some(op) = self.active_operations.iter_mut().find(|op| op.id == op_id) { + op.progress = progress; + op.status = status.clone(); + log::trace!("Операция {}: прогресс {:.1}%, статус: {}", op_id, progress * 100.0, status); + } + } + } + } + } + + pub fn perform_transfer(&mut self, is_move: bool) { + if self.active_task_count > 0 { + log::warn!("Не удалось начать трансфер: активны другие операции ({})", self.active_task_count); + return; + } + if let Some(rclone) = self.rclone.clone() { + 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() + )); + + log::info!("Начало операции {}: {} элементов в {}", + if is_move { "перемещения" } else { "копирования" }, + sources.len(), + dest_base + ); + + 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 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() + }; + + log::debug!("Операция {}: обработка {}/{}: {} -> {}", op_id, idx + 1, total, source, final_dest); + + 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 { + log::error!("Операция {}: ошибка при обработке {}: {}", op_id, name, e); + 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), + )); + } + log::info!("Операция {} успешно завершена", op_id); + let _ = tx.send(OperationResult::Success(op_id, "Ок".into())); + }); + } + } + + pub fn delete_selected(&mut self) { + if self.active_task_count > 0 { + log::warn!("Не удалось начать удаление: активны другие операции ({})", self.active_task_count); + return; + } + if let Some(rclone) = self.rclone.clone() { + let paths = self.get_selected_info(); + let op_id = self.add_operation(format!("Удаление {} эл.", paths.len())); + + log::info!("Начало операции удаления {} элементов", paths.len()); + + self.active_task_count += 1; + let tx = self.operation_tx.clone(); + std::thread::spawn(move || { + for (p, is_dir) in paths.iter() { + log::debug!("Операция {}: удаление {}", op_id, p); + let opts = DeleteOptions { + recursive: *is_dir, + ..Default::default() + }; + if let Err(e) = operations::sync::delete(&rclone, p, &opts) { + log::error!("Операция {}: ошибка при удалении {}: {}", op_id, p, e); + let _ = tx.send(OperationResult::Failure(op_id, e.to_string())); + return; + } + } + log::info!("Операция удаления {} успешно завершена", op_id); + let _ = tx.send(OperationResult::Success(op_id, "Ок".into())); + }); + } + } + + pub fn add_operation(&mut self, description: String) -> u32 { + let id = self.next_op_id; + self.next_op_id += 1; + log::debug!("Добавлена операция {}: {}", id, description); + self.active_operations.push(Operation { + id, + description, + progress: 0.0, + status: "В очереди".into(), + start_time: Instant::now(), + }); + id + } + + pub 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) + }; + &full == path + }) + .map(|f| f.is_dir) + .unwrap_or(false); + (path.clone(), is_dir) + }) + .collect() + } +}