feat(ui): Added paralell instructions and window with operations progress

This commit is contained in:
Black_Raven committed 2026-03-29 16:11:50 +03:00
1 parent 04a0dc21b9
commit 5878a6ec4a
4 files changed
+346 -154

No files matched your search

Generated
+1
View File
@@ -15,6 +15,7 @@ dependencies = [
"mockito", "mockito",
"reqwest", "reqwest",
"rfd", "rfd",
"serde",
"serde_json", "serde_json",
"serial_test", "serial_test",
"tar", "tar",
+1
View File
@@ -6,6 +6,7 @@ edition = "2024"
[dependencies] [dependencies]
tokio = { version = "1.0", features = ["full"] } tokio = { version = "1.0", features = ["full"] }
reqwest = { version = "0.11", default-features = false, features = ["rustls-tls", "json", "stream"] } reqwest = { version = "0.11", default-features = false, features = ["rustls-tls", "json", "stream"] }
serde = { version = "1.0", features = ["derive"] }
futures = "0.3" futures = "0.3"
flate2 = "1.0" flate2 = "1.0"
tar = "0.4" tar = "0.4"
+3 -2
View File
@@ -1,8 +1,9 @@
use std::collections::HashMap; use std::collections::HashMap;
use serde::{Serialize, Deserialize};
/// === Модуль для работы с удаленными хранилищами === /// === Модуль для работы с удаленными хранилищами ===
#[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Remote { pub struct Remote {
pub name: String, pub name: String,
pub r#type: String, pub r#type: String,
@@ -13,7 +14,7 @@ pub mod remotes;
/// === Модуль для работы с файлами и директориями === /// === Модуль для работы с файлами и директориями ===
#[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileInfo { pub struct FileInfo {
pub name: String, pub name: String,
pub path: String, pub path: String,
+341 -152
View File
@@ -1,9 +1,9 @@
//! Графический интерфейс для операций rclone. //! Графический интерфейс для операций rclone.
use eframe::egui; use eframe::egui;
use egui::{CentralPanel, ScrollArea, Window, Align2, Color32, Spinner}; use egui::{CentralPanel, ScrollArea, Window, Align2, Color32, Spinner, ProgressBar, SidePanel};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::{Duration, Instant};
use tokio::runtime::Runtime; use tokio::runtime::Runtime;
use crate::rclone_install::RcloneApp; use crate::rclone_install::RcloneApp;
use crate::operations::{self, FileInfo, Remote, CopyOptions, MoveOptions, DeleteOptions, FindOptions}; use crate::operations::{self, FileInfo, Remote, CopyOptions, MoveOptions, DeleteOptions, FindOptions};
@@ -30,11 +30,25 @@ pub struct TransferProgress {
pub file_name: String, pub file_name: String,
} }
/// Операция для отображения в правой панели.
#[derive(Clone)]
pub struct Operation {
pub id: u32,
pub description: String,
pub progress: f32,
pub status: String,
pub start_time: Instant,
}
/// Результат длительной операции. /// Результат длительной операции.
enum OperationResult { enum OperationResult {
None, None,
Success(String), Success(u32, String), // (operation_id, message)
Failure(String), Failure(u32, String), // (operation_id, error)
FileList(Vec<FileInfo>),
RemoteList(Vec<Remote>),
SearchResults(Vec<FileInfo>),
ProgressUpdate(u32, f32, String),
} }
/// Главная структура UI. /// Главная структура UI.
@@ -48,7 +62,7 @@ pub struct RcloneUI {
current_files: Vec<FileInfo>, current_files: Vec<FileInfo>,
selected_paths: Vec<String>, selected_paths: Vec<String>,
transfer_source_list: Vec<String>, // список выбранных источников для копирования/перемещения transfer_source_list: Vec<String>,
transfer_dest: String, transfer_dest: String,
transfer_progress: Option<TransferProgress>, transfer_progress: Option<TransferProgress>,
@@ -64,17 +78,18 @@ pub struct RcloneUI {
settings: AppSettings, settings: AppSettings,
background_working: bool, active_task_count: u32,
operation_result: Arc<Mutex<OperationResult>>, operation_result: Arc<Mutex<OperationResult>>,
operation_id: u32,
show_delete_remote_dialog: bool, show_delete_remote_dialog: bool,
remote_to_delete: Option<String>, remote_to_delete: Option<String>,
show_browser_warning: bool, show_browser_warning: bool,
pending_remote_creation: Option<(String, String)>, pending_remote_creation: Option<(String, String)>,
// Отложенные действия
pending_load_path: Option<String>, pending_load_path: Option<String>,
pending_search_pattern: Option<String>, pending_search_pattern: Option<String>,
rclone_init_receiver: Option<std::sync::mpsc::Receiver<Result<Arc<RcloneApp>, String>>>,
active_operations: Vec<Operation>,
next_op_id: u32,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -98,6 +113,17 @@ impl Default for AppSettings {
impl RcloneUI { impl RcloneUI {
pub fn new(_cc: &eframe::CreationContext<'_>) -> Self { pub fn new(_cc: &eframe::CreationContext<'_>) -> Self {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let rt = Runtime::new().unwrap();
let result = rt.block_on(async { RcloneApp::new().await });
let result_string = match result {
Ok(app) => Ok(Arc::new(app)),
Err(e) => Err(e.to_string()),
};
let _ = tx.send(result_string);
});
Self { Self {
rclone: None, rclone: None,
state: AppState::Initializing, state: AppState::Initializing,
@@ -118,26 +144,29 @@ impl RcloneUI {
available_remote_types: Vec::new(), available_remote_types: Vec::new(),
new_remote_config: std::collections::HashMap::new(), new_remote_config: std::collections::HashMap::new(),
settings: AppSettings::default(), settings: AppSettings::default(),
background_working: false, active_task_count: 0,
operation_result: Arc::new(Mutex::new(OperationResult::None)), operation_result: Arc::new(Mutex::new(OperationResult::None)),
operation_id: 0,
show_delete_remote_dialog: false, show_delete_remote_dialog: false,
remote_to_delete: None, remote_to_delete: None,
show_browser_warning: false, show_browser_warning: false,
pending_remote_creation: None, pending_remote_creation: None,
pending_load_path: None, pending_load_path: None,
pending_search_pattern: None, pending_search_pattern: None,
rclone_init_receiver: Some(rx),
active_operations: Vec::new(),
next_op_id: 1,
} }
} }
fn init_rclone(&mut self) { fn poll_rclone_init(&mut self) {
let rt = Runtime::new().unwrap(); if let Some(receiver) = &self.rclone_init_receiver {
let rclone_result = rt.block_on(async { RcloneApp::new().await }); if let Ok(result) = receiver.try_recv() {
match rclone_result { match result {
Ok(rclone) => { Ok(rclone) => {
self.rclone = Some(Arc::new(rclone)); self.rclone = Some(rclone);
self.load_remote_types(); self.load_remote_types();
self.load_remotes(); self.load_remotes();
self.state = AppState::Ready;
} }
Err(e) => { Err(e) => {
let error_msg = format!("Не удалось инициализировать rclone: {}", e); let error_msg = format!("Не удалось инициализировать rclone: {}", e);
@@ -145,6 +174,9 @@ impl RcloneUI {
self.state = AppState::Error(error_msg); self.state = AppState::Error(error_msg);
} }
} }
self.rclone_init_receiver = None;
}
}
} }
fn load_remote_types(&mut self) { fn load_remote_types(&mut self) {
@@ -162,99 +194,136 @@ impl RcloneUI {
} }
fn load_remotes(&mut self) { fn load_remotes(&mut self) {
if let Some(rclone) = &self.rclone { if self.active_task_count > 0 || self.rclone.is_none() {
match operations::remotes::list(rclone) { return;
Ok(remotes) => {
self.remote_list = remotes;
self.state = AppState::Ready;
}
Err(e) => {
self.error_message = Some(e.clone());
self.state = AppState::Error(e);
}
} }
if let Some(rclone) = self.rclone.clone() {
self.active_task_count += 1;
let result_holder = self.operation_result.clone();
std::thread::spawn(move || {
let remotes = operations::remotes::list(&rclone);
let mut holder = result_holder.lock().unwrap();
*holder = match remotes {
Ok(list) => OperationResult::RemoteList(list),
Err(e) => OperationResult::Failure(0, e),
};
});
} }
} }
fn load_files(&mut self, path: &str) { fn load_files(&mut self, path: &str) {
if let Some(rclone) = &self.rclone { if self.active_task_count > 0 || self.rclone.is_none() {
match operations::files::list(rclone, path) { return;
Ok(files) => {
self.current_files = files;
self.current_path = path.to_string();
self.search_results.clear();
self.search_pattern.clear();
// Очищаем выбранные файлы при смене директории
self.selected_paths.clear();
}
Err(e) => self.error_message = Some(e),
} }
if let Some(rclone) = self.rclone.clone() {
self.active_task_count += 1;
let path = path.to_string();
let result_holder = self.operation_result.clone();
std::thread::spawn(move || {
let files = operations::files::list(&rclone, &path);
let mut holder = result_holder.lock().unwrap();
*holder = match files {
Ok(list) => OperationResult::FileList(list),
Err(e) => OperationResult::Failure(0, e),
};
});
} }
} }
fn start_background_operation<F>(&mut self, f: F) -> u32 fn search_files(&mut self, pattern: &str) {
where if self.active_task_count > 0 || self.rclone.is_none() {
F: FnOnce() -> Result<String, String> + Send + 'static, return;
{ }
self.background_working = true; if let Some(rclone) = self.rclone.clone() {
self.operation_id = self.operation_id.wrapping_add(1); self.active_task_count += 1;
let op_id = self.operation_id; let pattern = pattern.to_string();
let current_path = self.current_path.clone();
let result_holder = self.operation_result.clone(); let result_holder = self.operation_result.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
let result = f(); let options = FindOptions {
recursive: true,
max_results: 100,
};
let results = operations::search::by_name(&rclone, &current_path, &pattern, &options);
let mut holder = result_holder.lock().unwrap(); let mut holder = result_holder.lock().unwrap();
*holder = match result { *holder = match results {
Ok(msg) => OperationResult::Success(msg), Ok(list) => OperationResult::SearchResults(list),
Err(e) => OperationResult::Failure(e), Err(e) => OperationResult::Failure(0, e),
}; };
}); });
op_id }
} }
fn poll_background_operation(&mut self) { fn poll_background_operation(&mut self) {
if !self.background_working { if self.active_task_count == 0 {
return; return;
} }
let result = { let result = {
let mut holder = self.operation_result.lock().unwrap(); let mut holder = self.operation_result.lock().unwrap();
match std::mem::replace(&mut *holder, OperationResult::None) { std::mem::replace(&mut *holder, OperationResult::None)
OperationResult::Success(msg) => Some(Ok(msg)),
OperationResult::Failure(e) => Some(Err(e)),
_ => None,
}
}; };
if let Some(res) = result {
self.background_working = false; match result {
match res { OperationResult::Success(op_id, msg) => {
Ok(msg) => { println!("✅ Операция {} успешна: {}", op_id, msg);
println!("Операция успешна: {}", msg); self.active_task_count = self.active_task_count.saturating_sub(1);
self.load_remotes(); self.active_operations.retain(|op| op.id != op_id);
let current_path = self.current_path.clone(); self.error_message = Some(msg);
if !current_path.is_empty() {
self.load_files(&current_path);
} }
self.error_message = None; OperationResult::Failure(op_id, e) => {
} eprintln!("❌ Операция {} ошибка: {}", op_id, e);
Err(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); 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::SearchResults(results) => {
self.search_results = results;
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;
}
}
OperationResult::None => {}
} }
} }
fn delete_remote(&mut self, name: &str) { fn delete_remote(&mut self, name: &str) {
if self.active_task_count > 0 || self.rclone.is_none() {
return;
}
if let Some(rclone) = self.rclone.clone() { if let Some(rclone) = self.rclone.clone() {
let name = name.to_string(); let name = name.to_string();
self.start_background_operation(move || { let op_id = self.add_operation(format!("Удаление хранилища '{}'", name));
operations::remotes::delete(&rclone, &name) self.active_task_count += 1;
let result_holder = self.operation_result.clone();
std::thread::spawn(move || {
let result = operations::remotes::delete(&rclone, &name)
.map(|_| format!("Хранилище '{}' удалено", name)) .map(|_| format!("Хранилище '{}' удалено", name))
.map_err(|e| format!("Ошибка удаления: {}", e)) .map_err(|e| format!("Ошибка удаления: {}", e));
let mut holder = result_holder.lock().unwrap();
*holder = match result {
Ok(msg) => OperationResult::Success(op_id, msg),
Err(e) => OperationResult::Failure(op_id, e),
};
}); });
} }
} }
/// Массовое перемещение выбранных файлов/папок
fn perform_move_multiple(&mut self, sources: Vec<String>, dest: &str) { fn perform_move_multiple(&mut self, sources: Vec<String>, dest: &str) {
if self.active_task_count > 0 || self.rclone.is_none() {
return;
}
if let Some(rclone) = self.rclone.clone() { if let Some(rclone) = self.rclone.clone() {
self.state = AppState::Moving; self.state = AppState::Moving;
let options = MoveOptions { let options = MoveOptions {
@@ -263,14 +332,18 @@ impl RcloneUI {
delete_empty_src_dirs: true, delete_empty_src_dirs: true,
}; };
let dest = dest.to_string(); let dest = dest.to_string();
self.start_background_operation(move || { let op_id = self.add_operation(format!("Перемещение {} элементов", sources.len()));
self.active_task_count += 1;
let result_holder = self.operation_result.clone();
std::thread::spawn(move || {
let _rt = Runtime::new().unwrap(); let _rt = Runtime::new().unwrap();
let mut errors = Vec::new(); let mut errors = Vec::new();
let mut moved_count = 0; let mut moved_count = 0;
let mut total_bytes = 0; let mut total_bytes = 0;
let total = sources.len() as f32;
for source in sources { for (idx, source) in sources.iter().enumerate() {
match operations::sync::move_files(&rclone, &source, &dest, &options) { match operations::sync::move_files(&rclone, source, &dest, &options) {
Ok(stats) => { Ok(stats) => {
moved_count += 1; moved_count += 1;
total_bytes += stats.transferred; total_bytes += stats.transferred;
@@ -279,19 +352,31 @@ impl RcloneUI {
errors.push(format!("{}: {}", source, e)); errors.push(format!("{}: {}", source, e));
} }
} }
let progress = (idx + 1) as f32 / total;
let _ = result_holder.lock().map(|mut holder| {
*holder = OperationResult::ProgressUpdate(op_id, progress, format!("Перемещено {} из {}", idx + 1, total));
});
std::thread::sleep(std::time::Duration::from_millis(10)); // небольшая пауза для UI
} }
if errors.is_empty() { let result = if errors.is_empty() {
Ok(format!("Перемещено {} элементов, {} байт", moved_count, total_bytes)) Ok(format!("Перемещено {} элементов, {} байт", moved_count, total_bytes))
} else { } else {
Err(format!("Перемещено {}, ошибки:\n{}", moved_count, errors.join("\n"))) Err(format!("Перемещено {}, ошибки:\n{}", moved_count, errors.join("\n")))
} };
let mut holder = result_holder.lock().unwrap();
*holder = match result {
Ok(msg) => OperationResult::Success(op_id, msg),
Err(e) => OperationResult::Failure(op_id, e),
};
}); });
} }
} }
/// Массовое копирование выбранных файлов/папок
fn perform_copy_multiple(&mut self, sources: Vec<String>, dest: &str) { fn perform_copy_multiple(&mut self, sources: Vec<String>, dest: &str) {
if self.active_task_count > 0 || self.rclone.is_none() {
return;
}
if let Some(rclone) = self.rclone.clone() { if let Some(rclone) = self.rclone.clone() {
self.state = AppState::Copying; self.state = AppState::Copying;
let options = CopyOptions { let options = CopyOptions {
@@ -301,13 +386,17 @@ impl RcloneUI {
no_traverse: false, no_traverse: false,
}; };
let dest = dest.to_string(); let dest = dest.to_string();
self.start_background_operation(move || { let op_id = self.add_operation(format!("Копирование {} элементов", sources.len()));
self.active_task_count += 1;
let result_holder = self.operation_result.clone();
std::thread::spawn(move || {
let _rt = Runtime::new().unwrap(); let _rt = Runtime::new().unwrap();
let mut errors = Vec::new(); let mut errors = Vec::new();
let mut copied_count = 0; let mut copied_count = 0;
let total = sources.len() as f32;
for source in sources { for (idx, source) in sources.iter().enumerate() {
match operations::sync::copy(&rclone, &source, &dest, &options) { match operations::sync::copy(&rclone, source, &dest, &options) {
Ok(_stats) => { Ok(_stats) => {
copied_count += 1; copied_count += 1;
} }
@@ -315,21 +404,32 @@ impl RcloneUI {
errors.push(format!("{}: {}", source, e)); errors.push(format!("{}: {}", source, e));
} }
} }
let progress = (idx + 1) as f32 / total;
let _ = result_holder.lock().map(|mut holder| {
*holder = OperationResult::ProgressUpdate(op_id, progress, format!("Скопировано {} из {}", idx + 1, total));
});
std::thread::sleep(std::time::Duration::from_millis(10));
} }
if errors.is_empty() { let result = if errors.is_empty() {
Ok(format!("Скопировано {} элементов", copied_count)) Ok(format!("Скопировано {} элементов", copied_count))
} else { } else {
Err(format!("Скопировано {}, ошибки:\n{}", copied_count, errors.join("\n"))) Err(format!("Скопировано {}, ошибки:\n{}", copied_count, errors.join("\n")))
} };
let mut holder = result_holder.lock().unwrap();
*holder = match result {
Ok(msg) => OperationResult::Success(op_id, msg),
Err(e) => OperationResult::Failure(op_id, e),
};
}); });
} }
} }
/// Удаление нескольких выбранных путей
fn delete_selected_paths(&mut self, paths: Vec<String>) { fn delete_selected_paths(&mut self, paths: Vec<String>) {
if self.active_task_count > 0 || self.rclone.is_none() {
return;
}
if let Some(rclone) = self.rclone.clone() { if let Some(rclone) = self.rclone.clone() {
// Создаём карту: путь -> является ли директорией
let mut is_dir_map = std::collections::HashMap::new(); let mut is_dir_map = std::collections::HashMap::new();
for file in &self.current_files { for file in &self.current_files {
let full_path = if self.current_path.ends_with(':') { let full_path = if self.current_path.ends_with(':') {
@@ -339,19 +439,22 @@ impl RcloneUI {
}; };
is_dir_map.insert(full_path, file.is_dir); is_dir_map.insert(full_path, file.is_dir);
} }
let op_id = self.add_operation(format!("Удаление {} элементов", paths.len()));
self.start_background_operation(move || { self.active_task_count += 1;
let result_holder = self.operation_result.clone();
std::thread::spawn(move || {
let mut errors = Vec::new(); let mut errors = Vec::new();
let mut deleted_count = 0; let mut deleted_count = 0;
let total = paths.len() as f32;
for path in paths { for (idx, path) in paths.iter().enumerate() {
let is_dir = is_dir_map.get(&path).cloned().unwrap_or(false); let is_dir = is_dir_map.get(path).cloned().unwrap_or(false);
let options = DeleteOptions { let options = DeleteOptions {
recursive: is_dir, recursive: is_dir,
verbose: false, verbose: false,
dry_run: false, dry_run: false,
}; };
match operations::sync::delete(&rclone, &path, &options) { match operations::sync::delete(&rclone, path, &options) {
Ok(stats) => { Ok(stats) => {
deleted_count += 1; deleted_count += 1;
println!("Удалено: {} ({} элементов)", path, stats.files); println!("Удалено: {} ({} элементов)", path, stats.files);
@@ -360,45 +463,69 @@ impl RcloneUI {
errors.push(format!("{}: {}", path, e)); errors.push(format!("{}: {}", path, e));
} }
} }
let progress = (idx + 1) as f32 / total;
let _ = result_holder.lock().map(|mut holder| {
*holder = OperationResult::ProgressUpdate(op_id, progress, format!("Удалено {} из {}", idx + 1, total));
});
std::thread::sleep(std::time::Duration::from_millis(10));
} }
if errors.is_empty() { let result = if errors.is_empty() {
Ok(format!("Успешно удалено {} элементов", deleted_count)) Ok(format!("Успешно удалено {} элементов", deleted_count))
} else { } else {
Err(format!("Удалено {} элементов, но возникли ошибки:\n{}", deleted_count, errors.join("\n"))) Err(format!("Удалено {} элементов, но возникли ошибки:\n{}", deleted_count, errors.join("\n")))
} };
let mut holder = result_holder.lock().unwrap();
*holder = match result {
Ok(msg) => OperationResult::Success(op_id, msg),
Err(e) => OperationResult::Failure(op_id, e),
};
}); });
} }
} }
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: "Начато".to_string(),
start_time: Instant::now(),
});
id
}
fn create_remote_with_warning(&mut self, name: &str, r#type: &str) { 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.pending_remote_creation = Some((name.to_string(), r#type.to_string()));
self.show_browser_warning = true; self.show_browser_warning = true;
} }
fn execute_remote_creation(&mut self, name: &str, r#type: &str) { fn execute_remote_creation(&mut self, name: &str, r#type: &str) {
if self.active_task_count > 0 || self.rclone.is_none() {
return;
}
if let Some(rclone) = self.rclone.clone() { if let Some(rclone) = self.rclone.clone() {
let name = name.to_string(); let name = name.to_string();
let r#type = r#type.to_string(); let r#type = r#type.to_string();
let config = std::collections::HashMap::new(); let config = std::collections::HashMap::new();
self.start_background_operation(move || { let op_id = self.add_operation(format!("Создание хранилища '{}'", name));
operations::remotes::create(&rclone, &name, &r#type, &config) self.active_task_count += 1;
.map(|_| format!("Хранилище '{}' создано", name)) let result_holder = self.operation_result.clone();
.map_err(|e| format!("Ошибка создания: {}", e)) std::thread::spawn(move || {
let _ = result_holder.lock().map(|mut holder| {
*holder = OperationResult::ProgressUpdate(op_id, 0.5, "Открытие браузера для авторизации...".to_string());
}); });
} let result = operations::remotes::create(&rclone, &name, &r#type, &config)
} .map(|_| format!("Хранилище '{}' создано", name))
.map_err(|e| format!("Ошибка создания: {}", e));
fn search_files(&mut self, pattern: &str) { let mut holder = result_holder.lock().unwrap();
if let Some(rclone) = &self.rclone { *holder = match result {
let options = FindOptions { Ok(msg) => OperationResult::Success(op_id, msg),
recursive: true, Err(e) => OperationResult::Failure(op_id, e),
max_results: 100,
}; };
match operations::search::by_name(rclone, &self.current_path, pattern, &options) { });
Ok(results) => self.search_results = results,
Err(e) => self.error_message = Some(e),
}
} }
} }
@@ -413,33 +540,43 @@ impl RcloneUI {
const KB: u64 = 1024; const KB: u64 = 1024;
const MB: u64 = KB * 1024; const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024; const GB: u64 = MB * 1024;
if bytes >= GB { format!("{:.2} ГБ", bytes as f64 / GB as f64) } if bytes >= GB {
else if bytes >= MB { format!("{:.2} МБ", bytes as f64 / MB as f64) } format!("{:.2} ГБ", bytes as f64 / GB as f64)
else if bytes >= KB { format!("{:.2} КБ", bytes as f64 / KB as f64) } } else if bytes >= MB {
else { format!("{} Б", bytes) } format!("{:.2} МБ", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.2} КБ", bytes as f64 / KB as f64)
} else {
format!("{} Б", bytes)
}
} }
#[allow(dead_code)]
fn format_speed(speed: f64) -> String { fn format_speed(speed: f64) -> String {
if speed < 1024.0 { format!("{:.0} Б/с", speed) } if speed < 1024.0 {
else if speed < 1024.0 * 1024.0 { format!("{:.1} КБ/с", speed / 1024.0) } format!("{:.0} Б/с", speed)
else if speed < 1024.0 * 1024.0 * 1024.0 { format!("{:.1} МБ/с", speed / (1024.0 * 1024.0)) } } else if speed < 1024.0 * 1024.0 {
else { format!("{:.2} ГБ/с", speed / (1024.0 * 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 { impl eframe::App for RcloneUI {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.poll_rclone_init();
self.poll_background_operation(); 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::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
egui::menu::bar(ui, |ui| { egui::menu::bar(ui, |ui| {
ui.menu_button("Файл", |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| { ui.menu_button("Хранилища", |ui| {
if ui.button("Добавить новое хранилище").clicked() { if ui.button("Добавить новое хранилище").clicked() {
@@ -462,20 +599,27 @@ impl eframe::App for RcloneUI {
}); });
}); });
ui.separator(); ui.separator();
// Индикатор состояния с анимацией if self.active_task_count > 0 {
if self.background_working {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.add(Spinner::new().size(16.0)); ui.add(Spinner::new().size(16.0));
ui.colored_label(egui::Color32::YELLOW, " Работа в фоне..."); ui.colored_label(egui::Color32::YELLOW, format!(" Работа в фоне... ({} задач)", self.active_task_count));
}); });
} else { } else {
match &self.state { match &self.state {
AppState::Ready => { ui.colored_label(Color32::GREEN, "● Готов"); } AppState::Ready => {
AppState::Initializing => { ui.colored_label(Color32::YELLOW, "● Инициализация..."); } ui.colored_label(Color32::GREEN, "● Готов");
AppState::Error(_) => { ui.colored_label(Color32::RED, "● Ошибка"); } }
_ => { ui.colored_label(Color32::BLUE, "● Занят"); } 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 { if let Some(rclone) = &self.rclone {
ui.separator(); ui.separator();
ui.label(format!("rclone: {}", rclone.get_rclone_path().display())); ui.label(format!("rclone: {}", rclone.get_rclone_path().display()));
@@ -484,7 +628,7 @@ impl eframe::App for RcloneUI {
}); });
// Левая панель со списком хранилищ // Левая панель со списком хранилищ
egui::SidePanel::left("remotes_panel") SidePanel::left("remotes_panel")
.default_width(220.0) .default_width(220.0)
.show(ctx, |ui| { .show(ctx, |ui| {
ui.heading("Хранилища"); ui.heading("Хранилища");
@@ -499,7 +643,8 @@ impl eframe::App for RcloneUI {
} else { } else {
for remote in &remotes { for remote in &remotes {
ui.horizontal(|ui| { ui.horizontal(|ui| {
if ui.button(format!("📡 {}", remote.name)).clicked() { let btn = ui.button(format!("📡 {}", remote.name));
if btn.clicked() && self.active_task_count == 0 {
self.pending_load_path = Some(format!("{}:", remote.name)); self.pending_load_path = Some(format!("{}:", remote.name));
} }
if ui.button("🗑️").on_hover_text("Удалить хранилище").clicked() { if ui.button("🗑️").on_hover_text("Удалить хранилище").clicked() {
@@ -513,6 +658,29 @@ impl eframe::App for RcloneUI {
}); });
}); });
// Правая панель с операциями
SidePanel::right("operations_panel")
.default_width(280.0)
.show(ctx, |ui| {
ui.heading("Операции");
ui.separator();
if self.active_operations.is_empty() {
ui.colored_label(Color32::GRAY, "Нет активных операций");
} else {
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.colored_label(Color32::LIGHT_BLUE, &op.status);
ui.label(format!("Время: {:.0} сек", op.start_time.elapsed().as_secs()));
});
ui.add_space(5.0);
}
});
}
});
// Центральная панель // Центральная панель
CentralPanel::default().show(ctx, |ui| { CentralPanel::default().show(ctx, |ui| {
let current_path = self.current_path.clone(); let current_path = self.current_path.clone();
@@ -525,29 +693,25 @@ impl eframe::App for RcloneUI {
ui.label("🔍 Поиск:"); ui.label("🔍 Поиск:");
if ui.text_edit_singleline(&mut new_search).changed() { if ui.text_edit_singleline(&mut new_search).changed() {
self.search_pattern = new_search.clone(); self.search_pattern = new_search.clone();
if !self.search_pattern.is_empty() && !self.current_path.is_empty() { if !self.search_pattern.is_empty() && !self.current_path.is_empty() && self.active_task_count == 0 {
self.pending_search_pattern = Some(self.search_pattern.clone()); self.pending_search_pattern = Some(self.search_pattern.clone());
} else { } else {
self.search_results.clear(); self.search_results.clear();
} }
} }
if ui.button("🔄 Обновить").clicked() && !self.current_path.is_empty() { if ui.button("🔄 Обновить").clicked() && !self.current_path.is_empty() && self.active_task_count == 0 {
self.pending_load_path = Some(self.current_path.clone()); self.pending_load_path = Some(self.current_path.clone());
} }
}); });
ui.separator(); ui.separator();
// Анимированная загрузка в центральной панели if self.active_task_count > 0 {
if self.background_working {
ui.centered_and_justified(|ui| { ui.centered_and_justified(|ui| {
ui.add(Spinner::new().size(64.0)); ui.add(Spinner::new().size(64.0));
ui.add_space(10.0); ui.add_space(10.0);
ui.colored_label( ui.colored_label(egui::Color32::LIGHT_BLUE, "⟳ Выполняется операция... Пожалуйста, подождите.");
egui::Color32::LIGHT_BLUE,
"⟳ Выполняется операция... Пожалуйста, подождите."
);
}); });
ctx.request_repaint_after(Duration::from_millis(16)); ctx.request_repaint_after(Duration::from_millis(50));
return; return;
} }
@@ -566,7 +730,7 @@ impl eframe::App for RcloneUI {
for file in &search_results { for file in &search_results {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label(file.icon()); ui.label(file.icon());
if ui.link(&file.name).clicked() && file.is_dir { if ui.link(&file.name).clicked() && file.is_dir && self.active_task_count == 0 {
let new_path = if current_path.ends_with(':') { let new_path = if current_path.ends_with(':') {
format!("{}{}", current_path, file.name) format!("{}{}", current_path, file.name)
} else { } else {
@@ -582,13 +746,12 @@ impl eframe::App for RcloneUI {
let current_files = self.current_files.clone(); let current_files = self.current_files.clone();
let current_path = self.current_path.clone(); let current_path = self.current_path.clone();
ScrollArea::vertical().show(ui, |ui| { ScrollArea::vertical().show(ui, |ui| {
if current_path.contains('/') && ui.button("📁 .. (Родительская папка)").clicked() { if current_path.contains('/') && ui.button("📁 .. (Родительская папка)").clicked() && self.active_task_count == 0 {
let parent = current_path.rsplit_once('/').map(|(p, _)| p).unwrap_or(""); let parent = current_path.rsplit_once('/').map(|(p, _)| p).unwrap_or("");
self.pending_load_path = Some(parent.to_string()); self.pending_load_path = Some(parent.to_string());
} }
ui.heading("Файлы и папки"); ui.heading("Файлы и папки");
for file in &current_files { for file in &current_files {
// Формируем полный путь rclone (с именем удалённого хранилища)
let full_path = if current_path.ends_with(':') { let full_path = if current_path.ends_with(':') {
format!("{}{}", current_path, file.name) format!("{}{}", current_path, file.name)
} else { } else {
@@ -596,7 +759,7 @@ impl eframe::App for RcloneUI {
}; };
let selected = self.selected_paths.contains(&full_path); let selected = self.selected_paths.contains(&full_path);
ui.horizontal(|ui| { ui.horizontal(|ui| {
if ui.selectable_label(selected, file.icon()).clicked() { if ui.selectable_label(selected, file.icon()).clicked() && self.active_task_count == 0 {
if selected { if selected {
self.selected_paths.retain(|p| p != &full_path); self.selected_paths.retain(|p| p != &full_path);
} else { } else {
@@ -604,7 +767,7 @@ impl eframe::App for RcloneUI {
} }
} }
if file.is_dir { if file.is_dir {
if ui.link(&file.name).clicked() { if ui.link(&file.name).clicked() && self.active_task_count == 0 {
let new_path = if current_path.ends_with(':') { let new_path = if current_path.ends_with(':') {
format!("{}{}", current_path, file.name) format!("{}{}", current_path, file.name)
} else { } else {
@@ -630,7 +793,7 @@ impl eframe::App for RcloneUI {
} }
ui.separator(); ui.separator();
if !self.selected_paths.is_empty() { if !self.selected_paths.is_empty() && self.active_task_count == 0 {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label(format!("Выбрано: {}", self.selected_paths.len())); ui.label(format!("Выбрано: {}", self.selected_paths.len()));
if ui.button("📋 Копировать").clicked() { if ui.button("📋 Копировать").clicked() {
@@ -652,13 +815,17 @@ impl eframe::App for RcloneUI {
// Обработка отложенных действий // Обработка отложенных действий
if let Some(path) = self.pending_load_path.take() { if let Some(path) = self.pending_load_path.take() {
self.current_path = path.clone();
self.search_results.clear();
self.search_pattern.clear();
self.selected_paths.clear();
self.load_files(&path); self.load_files(&path);
} }
if let Some(pattern) = self.pending_search_pattern.take() { if let Some(pattern) = self.pending_search_pattern.take() {
self.search_files(&pattern); self.search_files(&pattern);
} }
// Диалог передачи файлов (поддерживает несколько источников) // Диалог передачи файлов (копирование/перемещение)
if self.show_transfer_dialog { if self.show_transfer_dialog {
let source_count = self.transfer_source_list.len(); let source_count = self.transfer_source_list.len();
let dest = self.transfer_dest.clone(); let dest = self.transfer_dest.clone();
@@ -713,13 +880,19 @@ impl eframe::App for RcloneUI {
let mut name = self.new_remote_name.clone(); let mut name = self.new_remote_name.clone();
let mut typ = self.new_remote_type.clone(); let mut typ = self.new_remote_type.clone();
let available_types = self.available_remote_types.clone(); let available_types = self.available_remote_types.clone();
Window::new("Добавление хранилища").collapsible(false).resizable(false).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { Window::new("Добавление хранилища")
.collapsible(false)
.resizable(false)
.anchor(Align2::CENTER_CENTER, [0.0, 0.0])
.show(ctx, |ui| {
ui.label("Имя хранилища:"); ui.label("Имя хранилища:");
if ui.text_edit_singleline(&mut name).changed() { if ui.text_edit_singleline(&mut name).changed() {
self.new_remote_name = name; self.new_remote_name = name;
} }
ui.label("Тип хранилища:"); ui.label("Тип хранилища:");
egui::ComboBox::from_label("").selected_text(&typ).show_ui(ui, |ui| { egui::ComboBox::from_label("")
.selected_text(&typ)
.show_ui(ui, |ui| {
for t in &available_types { for t in &available_types {
if ui.selectable_value(&mut typ, t.clone(), t).clicked() { if ui.selectable_value(&mut typ, t.clone(), t).clicked() {
self.new_remote_type = typ.clone(); self.new_remote_type = typ.clone();
@@ -751,7 +924,11 @@ impl eframe::App for RcloneUI {
// Диалог предупреждения о браузере // Диалог предупреждения о браузере
if self.show_browser_warning { if self.show_browser_warning {
Window::new("Внимание").collapsible(false).resizable(false).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { Window::new("Внимание")
.collapsible(false)
.resizable(false)
.anchor(Align2::CENTER_CENTER, [0.0, 0.0])
.show(ctx, |ui| {
ui.colored_label(Color32::YELLOW, "🌐 Будет открыт браузер для авторизации"); ui.colored_label(Color32::YELLOW, "🌐 Будет открыт браузер для авторизации");
ui.label("После завершения авторизации в браузере вернитесь в приложение."); ui.label("После завершения авторизации в браузере вернитесь в приложение.");
ui.label("Операция выполняется в фоне, интерфейс не зависнет."); ui.label("Операция выполняется в фоне, интерфейс не зависнет.");
@@ -772,7 +949,11 @@ impl eframe::App for RcloneUI {
// Диалог подтверждения удаления хранилища // Диалог подтверждения удаления хранилища
if self.show_delete_remote_dialog { if self.show_delete_remote_dialog {
let remote_name = self.remote_to_delete.clone(); 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| { Window::new("Удаление хранилища")
.collapsible(false)
.resizable(false)
.anchor(Align2::CENTER_CENTER, [0.0, 0.0])
.show(ctx, |ui| {
if let Some(ref name) = remote_name { if let Some(ref name) = remote_name {
ui.label(format!("Удалить хранилище '{}'?", name)); ui.label(format!("Удалить хранилище '{}'?", name));
ui.label("Это действие не удаляет данные, только конфигурацию."); ui.label("Это действие не удаляет данные, только конфигурацию.");
@@ -794,11 +975,19 @@ impl eframe::App for RcloneUI {
}); });
} }
// Диалог ошибок // Диалог ошибок / уведомлений
if let Some(error) = &self.error_message { if let Some(error) = &self.error_message {
let error_clone = error.clone(); let error_clone = error.clone();
Window::new("Ошибка").collapsible(false).resizable(false).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { Window::new("Сообщение")
.collapsible(false)
.resizable(false)
.anchor(Align2::CENTER_CENTER, [0.0, 0.0])
.show(ctx, |ui| {
if error_clone.starts_with("✅") || error_clone.contains("успеш") || error_clone.contains("скопировано") {
ui.colored_label(Color32::GREEN, "✅ Успешно:");
} else {
ui.colored_label(Color32::RED, "⚠️ Произошла ошибка:"); ui.colored_label(Color32::RED, "⚠️ Произошла ошибка:");
}
ui.label(&error_clone); ui.label(&error_clone);
if ui.button("OK").clicked() { if ui.button("OK").clicked() {
self.error_message = None; self.error_message = None;
@@ -809,6 +998,6 @@ impl eframe::App for RcloneUI {
}); });
} }
ctx.request_repaint(); ctx.request_repaint_after(Duration::from_millis(100));
} }
} }