From 04a0dc21b9b5454672a96b5c75e8aed349d5d4ac Mon Sep 17 00:00:00 2001 From: Kirill Date: Sun, 29 Mar 2026 14:40:15 +0300 Subject: [PATCH] fix(Ui): fix err with deleting copy and replace more than 1 file --- src/ui.rs | 454 +++++++++++++++++++++++++++--------------------------- 1 file changed, 228 insertions(+), 226 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index 586fb18..953f2ee 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,14 +1,12 @@ //! Графический интерфейс для операций rclone. -use crate::operations::{ - self, CopyOptions, DeleteOptions, FileInfo, FindOptions, MoveOptions, Remote, -}; -use crate::rclone_install::RcloneApp; use eframe::egui; -use egui::{Align2, CentralPanel, Color32, ScrollArea, Spinner, Window}; +use egui::{CentralPanel, ScrollArea, Window, Align2, Color32, Spinner}; use std::sync::{Arc, Mutex}; use std::time::Duration; use tokio::runtime::Runtime; +use crate::rclone_install::RcloneApp; +use crate::operations::{self, FileInfo, Remote, CopyOptions, MoveOptions, DeleteOptions, FindOptions}; /// Состояние приложения. #[derive(Clone)] @@ -44,28 +42,28 @@ pub struct RcloneUI { rclone: Option>, state: AppState, error_message: Option, - + current_path: String, remote_list: Vec, current_files: Vec, selected_paths: Vec, - - transfer_source: String, + + transfer_source_list: Vec, // список выбранных источников для копирования/перемещения transfer_dest: String, transfer_progress: Option, - + search_pattern: String, search_results: Vec, - + show_transfer_dialog: bool, show_new_remote_dialog: bool, new_remote_name: String, new_remote_type: String, available_remote_types: Vec, new_remote_config: std::collections::HashMap, - + settings: AppSettings, - + background_working: bool, operation_result: Arc>, operation_id: u32, @@ -73,7 +71,7 @@ pub struct RcloneUI { remote_to_delete: Option, show_browser_warning: bool, pending_remote_creation: Option<(String, String)>, - + // Отложенные действия pending_load_path: Option, pending_search_pattern: Option, @@ -108,7 +106,7 @@ impl RcloneUI { remote_list: Vec::new(), current_files: Vec::new(), selected_paths: Vec::new(), - transfer_source: String::new(), + transfer_source_list: Vec::new(), transfer_dest: String::new(), transfer_progress: None, search_pattern: String::new(), @@ -131,7 +129,7 @@ impl RcloneUI { pending_search_pattern: None, } } - + fn init_rclone(&mut self) { let rt = Runtime::new().unwrap(); let rclone_result = rt.block_on(async { RcloneApp::new().await }); @@ -148,25 +146,21 @@ impl RcloneUI { } } } - + fn load_remote_types(&mut self) { if let Some(rclone) = &self.rclone { match operations::info::backends(rclone) { Ok(types) => self.available_remote_types = types, Err(_) => { self.available_remote_types = vec![ - "drive".to_string(), - "s3".to_string(), - "dropbox".to_string(), - "local".to_string(), - "onedrive".to_string(), - "webdav".to_string(), + "drive".to_string(), "s3".to_string(), "dropbox".to_string(), + "local".to_string(), "onedrive".to_string(), "webdav".to_string(), ]; } } } } - + fn load_remotes(&mut self) { if let Some(rclone) = &self.rclone { match operations::remotes::list(rclone) { @@ -181,7 +175,7 @@ impl RcloneUI { } } } - + fn load_files(&mut self, path: &str) { if let Some(rclone) = &self.rclone { match operations::files::list(rclone, path) { @@ -197,7 +191,7 @@ impl RcloneUI { } } } - + fn start_background_operation(&mut self, f: F) -> u32 where F: FnOnce() -> Result + Send + 'static, @@ -216,7 +210,7 @@ impl RcloneUI { }); op_id } - + fn poll_background_operation(&mut self) { if !self.background_working { return; @@ -247,7 +241,7 @@ impl RcloneUI { } } } - + fn delete_remote(&mut self, name: &str) { if let Some(rclone) = self.rclone.clone() { let name = name.to_string(); @@ -258,8 +252,9 @@ impl RcloneUI { }); } } - - fn perform_move(&mut self, source: &str, dest: &str) { + + /// Массовое перемещение выбранных файлов/папок + fn perform_move_multiple(&mut self, sources: Vec, dest: &str) { if let Some(rclone) = self.rclone.clone() { self.state = AppState::Moving; let options = MoveOptions { @@ -267,25 +262,36 @@ impl RcloneUI { dry_run: false, delete_empty_src_dirs: true, }; - let source = source.to_string(); let dest = dest.to_string(); self.start_background_operation(move || { - let rt = Runtime::new().unwrap(); - rt.block_on(async { - operations::sync::move_files(&rclone, &source, &dest, &options) - }) - .map(|stats| { - format!( - "Перемещено {} файлов, {} байт", - stats.files, stats.transferred - ) - }) - .map_err(|e| format!("Ошибка перемещения: {}", e)) + let _rt = Runtime::new().unwrap(); + let mut errors = Vec::new(); + let mut moved_count = 0; + let mut total_bytes = 0; + + for source in sources { + match operations::sync::move_files(&rclone, &source, &dest, &options) { + Ok(stats) => { + moved_count += 1; + total_bytes += stats.transferred; + } + Err(e) => { + errors.push(format!("{}: {}", source, e)); + } + } + } + + if errors.is_empty() { + Ok(format!("Перемещено {} элементов, {} байт", moved_count, total_bytes)) + } else { + Err(format!("Перемещено {}, ошибки:\n{}", moved_count, errors.join("\n"))) + } }); } } - - fn perform_copy(&mut self, source: &str, dest: &str) { + + /// Массовое копирование выбранных файлов/папок + fn perform_copy_multiple(&mut self, sources: Vec, dest: &str) { if let Some(rclone) = self.rclone.clone() { self.state = AppState::Copying; let options = CopyOptions { @@ -294,22 +300,82 @@ impl RcloneUI { bandwidth_limit: self.settings.bandwidth_limit.clone(), no_traverse: false, }; - let source = source.to_string(); let dest = dest.to_string(); self.start_background_operation(move || { - let rt = Runtime::new().unwrap(); - rt.block_on(async { operations::sync::copy(&rclone, &source, &dest, &options) }) - .map(|stats| format!("Скопировано {} файлов", stats.files)) - .map_err(|e| format!("Ошибка копирования: {}", e)) + let _rt = Runtime::new().unwrap(); + let mut errors = Vec::new(); + let mut copied_count = 0; + + for source in sources { + match operations::sync::copy(&rclone, &source, &dest, &options) { + Ok(_stats) => { + copied_count += 1; + } + Err(e) => { + errors.push(format!("{}: {}", source, e)); + } + } + } + + if errors.is_empty() { + Ok(format!("Скопировано {} элементов", copied_count)) + } else { + Err(format!("Скопировано {}, ошибки:\n{}", copied_count, errors.join("\n"))) + } }); } } - + + /// Удаление нескольких выбранных путей + fn delete_selected_paths(&mut self, paths: Vec) { + if let Some(rclone) = self.rclone.clone() { + // Создаём карту: путь -> является ли директорией + let mut is_dir_map = std::collections::HashMap::new(); + for file in &self.current_files { + let full_path = if self.current_path.ends_with(':') { + format!("{}{}", self.current_path, file.name) + } else { + format!("{}/{}", self.current_path, file.name) + }; + is_dir_map.insert(full_path, file.is_dir); + } + + self.start_background_operation(move || { + let mut errors = Vec::new(); + let mut deleted_count = 0; + + for path in paths { + let is_dir = is_dir_map.get(&path).cloned().unwrap_or(false); + let options = DeleteOptions { + recursive: is_dir, + verbose: false, + dry_run: false, + }; + match operations::sync::delete(&rclone, &path, &options) { + Ok(stats) => { + deleted_count += 1; + println!("Удалено: {} ({} элементов)", path, stats.files); + } + Err(e) => { + errors.push(format!("{}: {}", path, e)); + } + } + } + + if errors.is_empty() { + Ok(format!("Успешно удалено {} элементов", deleted_count)) + } else { + Err(format!("Удалено {} элементов, но возникли ошибки:\n{}", deleted_count, errors.join("\n"))) + } + }); + } + } + fn create_remote_with_warning(&mut self, name: &str, r#type: &str) { self.pending_remote_creation = Some((name.to_string(), r#type.to_string())); self.show_browser_warning = true; } - + fn execute_remote_creation(&mut self, name: &str, r#type: &str) { if let Some(rclone) = self.rclone.clone() { let name = name.to_string(); @@ -322,7 +388,7 @@ impl RcloneUI { }); } } - + fn search_files(&mut self, pattern: &str) { if let Some(rclone) = &self.rclone { let options = FindOptions { @@ -335,66 +401,45 @@ impl RcloneUI { } } } - + fn validate_remote_name(name: &str) -> bool { - if name.is_empty() || name.starts_with(' ') || name.starts_with('-') || name.ends_with(' ') - { + if name.is_empty() || name.starts_with(' ') || name.starts_with('-') || name.ends_with(' ') { return false; } - name.chars().all(|ch| { - ch.is_ascii_alphanumeric() - || ch == '_' - || ch == '-' - || ch == '.' - || ch == '+' - || ch == '@' - || ch == ' ' - }) + name.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' || ch == '.' || ch == '+' || ch == '@' || ch == ' ') } - + fn format_size(bytes: u64) -> String { const KB: u64 = 1024; const MB: u64 = KB * 1024; const GB: u64 = MB * 1024; - if bytes >= GB { - format!("{:.2} ГБ", bytes as f64 / GB as f64) - } else if bytes >= MB { - format!("{:.2} МБ", bytes as f64 / MB as f64) - } else if bytes >= KB { - format!("{:.2} КБ", bytes as f64 / KB as f64) - } else { - format!("{} Б", bytes) - } + if bytes >= GB { format!("{:.2} ГБ", bytes as f64 / GB as f64) } + else if bytes >= MB { format!("{:.2} МБ", bytes as f64 / MB as f64) } + else if bytes >= KB { format!("{:.2} КБ", bytes as f64 / KB as f64) } + else { format!("{} Б", bytes) } } - + fn format_speed(speed: f64) -> String { - if speed < 1024.0 { - format!("{:.0} Б/с", speed) - } else if speed < 1024.0 * 1024.0 { - format!("{:.1} КБ/с", speed / 1024.0) - } else if speed < 1024.0 * 1024.0 * 1024.0 { - format!("{:.1} МБ/с", speed / (1024.0 * 1024.0)) - } else { - format!("{:.2} ГБ/с", speed / (1024.0 * 1024.0 * 1024.0)) - } + if speed < 1024.0 { format!("{:.0} Б/с", speed) } + else if speed < 1024.0 * 1024.0 { format!("{:.1} КБ/с", speed / 1024.0) } + else if speed < 1024.0 * 1024.0 * 1024.0 { format!("{:.1} МБ/с", speed / (1024.0 * 1024.0)) } + else { format!("{:.2} ГБ/с", speed / (1024.0 * 1024.0 * 1024.0)) } } } impl eframe::App for RcloneUI { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.poll_background_operation(); - + if matches!(self.state, AppState::Initializing) && self.rclone.is_none() { self.init_rclone(); } - + // Верхняя панель меню egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| { egui::menu::bar(ui, |ui| { ui.menu_button("Файл", |ui| { - if ui.button("Выход").clicked() { - std::process::exit(0); - } + if ui.button("Выход").clicked() { std::process::exit(0); } }); ui.menu_button("Хранилища", |ui| { if ui.button("Добавить новое хранилище").clicked() { @@ -406,24 +451,13 @@ impl eframe::App for RcloneUI { }); ui.menu_button("Настройки", |ui| { ui.checkbox(&mut self.settings.show_hidden, "Показывать скрытые файлы"); - ui.checkbox( - &mut self.settings.confirm_before_transfer, - "Подтверждать перед передачей", - ); - ui.add( - egui::Slider::new(&mut self.settings.max_concurrent_transfers, 1..=10) - .text("Макс. одновременных передач"), - ); + ui.checkbox(&mut self.settings.confirm_before_transfer, "Подтверждать перед передачей"); + ui.add(egui::Slider::new(&mut self.settings.max_concurrent_transfers, 1..=10).text("Макс. одновременных передач")); ui.horizontal(|ui| { ui.label("Ограничение скорости:"); - let mut limit_str = - self.settings.bandwidth_limit.clone().unwrap_or_default(); + let mut limit_str = self.settings.bandwidth_limit.clone().unwrap_or_default(); if ui.text_edit_singleline(&mut limit_str).changed() { - self.settings.bandwidth_limit = if limit_str.is_empty() { - None - } else { - Some(limit_str) - }; + self.settings.bandwidth_limit = if limit_str.is_empty() { None } else { Some(limit_str) }; } }); }); @@ -436,18 +470,10 @@ impl eframe::App for RcloneUI { }); } else { match &self.state { - AppState::Ready => { - ui.colored_label(Color32::GREEN, "● Готов"); - } - AppState::Initializing => { - ui.colored_label(Color32::YELLOW, "● Инициализация..."); - } - AppState::Error(_) => { - ui.colored_label(Color32::RED, "● Ошибка"); - } - _ => { - ui.colored_label(Color32::BLUE, "● Занят"); - } + AppState::Ready => { ui.colored_label(Color32::GREEN, "● Готов"); } + AppState::Initializing => { ui.colored_label(Color32::YELLOW, "● Инициализация..."); } + AppState::Error(_) => { ui.colored_label(Color32::RED, "● Ошибка"); } + _ => { ui.colored_label(Color32::BLUE, "● Занят"); } } }; if let Some(rclone) = &self.rclone { @@ -456,7 +482,7 @@ impl eframe::App for RcloneUI { } }); }); - + // Левая панель со списком хранилищ egui::SidePanel::left("remotes_panel") .default_width(220.0) @@ -476,8 +502,7 @@ impl eframe::App for RcloneUI { if ui.button(format!("📡 {}", remote.name)).clicked() { self.pending_load_path = Some(format!("{}:", remote.name)); } - if ui.button("🗑️").on_hover_text("Удалить хранилище").clicked() - { + if ui.button("🗑️").on_hover_text("Удалить хранилище").clicked() { self.remote_to_delete = Some(remote.name.clone()); self.show_delete_remote_dialog = true; } @@ -487,7 +512,7 @@ impl eframe::App for RcloneUI { } }); }); - + // Центральная панель CentralPanel::default().show(ctx, |ui| { let current_path = self.current_path.clone(); @@ -495,11 +520,7 @@ impl eframe::App for RcloneUI { let mut new_search = search_pattern.clone(); ui.horizontal(|ui| { ui.label("📍 Путь:"); - ui.label(if current_path.is_empty() { - "Выберите хранилище" - } else { - ¤t_path - }); + ui.label(if current_path.is_empty() { "Выберите хранилище" } else { ¤t_path }); ui.separator(); ui.label("🔍 Поиск:"); if ui.text_edit_singleline(&mut new_search).changed() { @@ -515,7 +536,7 @@ impl eframe::App for RcloneUI { } }); ui.separator(); - + // Анимированная загрузка в центральной панели if self.background_working { ui.centered_and_justified(|ui| { @@ -523,20 +544,20 @@ impl eframe::App for RcloneUI { ui.add_space(10.0); ui.colored_label( egui::Color32::LIGHT_BLUE, - "⟳ Выполняется операция... Пожалуйста, подождите.", + "⟳ Выполняется операция... Пожалуйста, подождите." ); }); ctx.request_repaint_after(Duration::from_millis(16)); return; } - + if let AppState::Error(msg) = &self.state { ui.centered_and_justified(|ui| { ui.colored_label(Color32::RED, format!("Ошибка: {}", msg)); }); return; } - + if !self.search_results.is_empty() { let search_results = self.search_results.clone(); let current_path = self.current_path.clone(); @@ -561,9 +582,7 @@ impl eframe::App for RcloneUI { let current_files = self.current_files.clone(); let current_path = self.current_path.clone(); ScrollArea::vertical().show(ui, |ui| { - if current_path.contains('/') - && ui.button("📁 .. (Родительская папка)").clicked() - { + if current_path.contains('/') && ui.button("📁 .. (Родительская папка)").clicked() { let parent = current_path.rsplit_once('/').map(|(p, _)| p).unwrap_or(""); self.pending_load_path = Some(parent.to_string()); } @@ -609,48 +628,28 @@ impl eframe::App for RcloneUI { } }); } - + ui.separator(); if !self.selected_paths.is_empty() { ui.horizontal(|ui| { ui.label(format!("Выбрано: {}", self.selected_paths.len())); if ui.button("📋 Копировать").clicked() { - self.transfer_source = self.selected_paths[0].clone(); + self.transfer_source_list = self.selected_paths.clone(); self.show_transfer_dialog = true; } if ui.button("✂️ Переместить").clicked() { - self.transfer_source = self.selected_paths[0].clone(); + self.transfer_source_list = self.selected_paths.clone(); self.show_transfer_dialog = true; } if ui.button("🗑️ Удалить").clicked() { - if let Some(rclone) = self.rclone.clone() { - let path = self.selected_paths[0].clone(); - // Определяем, является ли выбранный путь директорией - let is_dir = self.current_files.iter().any(|f| { - let full_path = if self.current_path.ends_with(':') { - format!("{}{}", self.current_path, f.name) - } else { - format!("{}/{}", self.current_path, f.name) - }; - full_path == path && f.is_dir - }); - let options = DeleteOptions { - recursive: is_dir, - verbose: true, - dry_run: false, - }; - self.start_background_operation(move || { - operations::sync::delete(&rclone, &path, &options) - .map(|_| format!("Удалено: {}", path)) - .map_err(|e| format!("Ошибка удаления: {}", e)) - }); - self.selected_paths.clear(); - } + let paths_to_delete = self.selected_paths.clone(); + self.delete_selected_paths(paths_to_delete); + self.selected_paths.clear(); } }); } }); - + // Обработка отложенных действий if let Some(path) = self.pending_load_path.take() { self.load_files(&path); @@ -658,42 +657,57 @@ impl eframe::App for RcloneUI { if let Some(pattern) = self.pending_search_pattern.take() { self.search_files(&pattern); } - - // Диалог передачи файлов + + // Диалог передачи файлов (поддерживает несколько источников) if self.show_transfer_dialog { - let source = self.transfer_source.clone(); + let source_count = self.transfer_source_list.len(); let dest = self.transfer_dest.clone(); Window::new("Передача файлов") .collapsible(false) .resizable(false) .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) .show(ctx, |ui| { - ui.label(format!("Источник: {}", source)); - ui.label("Назначение:"); + ui.label(format!("Выбрано элементов для передачи: {}", source_count)); + if source_count <= 3 { + for src in &self.transfer_source_list { + ui.label(format!("📄 {}", src)); + } + } else { + ui.label(format!("и ещё {} элементов", source_count - 3)); + } + ui.separator(); + ui.label("Назначение (директория):"); let mut dest_edit = dest.clone(); if ui.text_edit_singleline(&mut dest_edit).changed() { self.transfer_dest = dest_edit; } ui.separator(); let current_dest = self.transfer_dest.clone(); + let sources = self.transfer_source_list.clone(); ui.horizontal(|ui| { if ui.button("✅ Копировать").clicked() { - self.perform_copy(&source, ¤t_dest); + self.perform_copy_multiple(sources.clone(), ¤t_dest); self.show_transfer_dialog = false; self.selected_paths.clear(); + self.transfer_source_list.clear(); + self.transfer_dest.clear(); } if ui.button("✂️ Переместить").clicked() { - self.perform_move(&source, ¤t_dest); + self.perform_move_multiple(sources.clone(), ¤t_dest); self.show_transfer_dialog = false; self.selected_paths.clear(); + self.transfer_source_list.clear(); + self.transfer_dest.clear(); } if ui.button("❌ Отмена").clicked() { self.show_transfer_dialog = false; + self.transfer_source_list.clear(); + self.transfer_dest.clear(); } }); }); } - + // Диалог добавления хранилища if self.show_new_remote_dialog { let mut name = self.new_remote_name.clone(); @@ -734,79 +748,67 @@ impl eframe::App for RcloneUI { }); }); } - + // Диалог предупреждения о браузере if self.show_browser_warning { - Window::new("Внимание") - .collapsible(false) - .resizable(false) - .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) - .show(ctx, |ui| { - ui.colored_label(Color32::YELLOW, "🌐 Будет открыт браузер для авторизации"); - ui.label("После завершения авторизации в браузере вернитесь в приложение."); - ui.label("Операция выполняется в фоне, интерфейс не зависнет."); - ui.separator(); - if ui.button("Продолжить").clicked() { - if let Some((name, typ)) = self.pending_remote_creation.take() { - self.execute_remote_creation(&name, &typ); - } - self.show_browser_warning = false; + Window::new("Внимание").collapsible(false).resizable(false).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { + ui.colored_label(Color32::YELLOW, "🌐 Будет открыт браузер для авторизации"); + ui.label("После завершения авторизации в браузере вернитесь в приложение."); + ui.label("Операция выполняется в фоне, интерфейс не зависнет."); + ui.separator(); + if ui.button("Продолжить").clicked() { + if let Some((name, typ)) = self.pending_remote_creation.take() { + self.execute_remote_creation(&name, &typ); } - if ui.button("Отмена").clicked() { - self.pending_remote_creation = None; - self.show_browser_warning = false; - } - }); + self.show_browser_warning = false; + } + if ui.button("Отмена").clicked() { + self.pending_remote_creation = None; + self.show_browser_warning = false; + } + }); } - + // Диалог подтверждения удаления хранилища if self.show_delete_remote_dialog { let remote_name = self.remote_to_delete.clone(); - Window::new("Удаление хранилища") - .collapsible(false) - .resizable(false) - .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) - .show(ctx, |ui| { - if let Some(ref name) = remote_name { - ui.label(format!("Удалить хранилище '{}'?", name)); - ui.label("Это действие не удаляет данные, только конфигурацию."); - let name_clone = name.clone(); - ui.horizontal(|ui| { - if ui.button("✅ Удалить").clicked() { - self.delete_remote(&name_clone); - self.show_delete_remote_dialog = false; - self.remote_to_delete = None; - } - if ui.button("❌ Отмена").clicked() { - self.show_delete_remote_dialog = false; - self.remote_to_delete = None; - } - }); - } else { - self.show_delete_remote_dialog = false; - } - }); + Window::new("Удаление хранилища").collapsible(false).resizable(false).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { + if let Some(ref name) = remote_name { + ui.label(format!("Удалить хранилище '{}'?", name)); + ui.label("Это действие не удаляет данные, только конфигурацию."); + let name_clone = name.clone(); + ui.horizontal(|ui| { + if ui.button("✅ Удалить").clicked() { + self.delete_remote(&name_clone); + self.show_delete_remote_dialog = false; + self.remote_to_delete = None; + } + if ui.button("❌ Отмена").clicked() { + self.show_delete_remote_dialog = false; + self.remote_to_delete = None; + } + }); + } else { + self.show_delete_remote_dialog = false; + } + }); } - + // Диалог ошибок if let Some(error) = &self.error_message { let error_clone = error.clone(); - Window::new("Ошибка") - .collapsible(false) - .resizable(false) - .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) - .show(ctx, |ui| { - ui.colored_label(Color32::RED, "⚠️ Произошла ошибка:"); - ui.label(&error_clone); - if ui.button("OK").clicked() { - self.error_message = None; - if let AppState::Error(_) = &self.state { - self.state = AppState::Ready; - } + Window::new("Ошибка").collapsible(false).resizable(false).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { + ui.colored_label(Color32::RED, "⚠️ Произошла ошибка:"); + ui.label(&error_clone); + if ui.button("OK").clicked() { + self.error_message = None; + if let AppState::Error(_) = &self.state { + self.state = AppState::Ready; } - }); + } + }); } - + ctx.request_repaint(); } }