diff --git a/README.md b/README.md index e69de29..61fce3d 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,191 @@ +# Rclone UI Manager + +Кроссплатформенный графический интерфейс для [rclone](https://rclone.org/), написанный на Rust с использованием [egui](https://github.com/emilk/egui). Приложение предоставляет удобный способ управления облачными хранилищами (Google Drive, Dropbox, S3 и др.) и локальными файлами, поддерживает копирование, перемещение, удаление и синхронизацию с отображением хода выполнения операций. + +![Скриншот Rclone UI Manager](assets/screenshot.png) + +## Возможности + +- **Автоматическая установка rclone** – обнаруживает системный rclone или загружает последнюю версию автоматически. +- **Управление удалёнными хранилищами (remotes)** – просмотр, создание, проверка, удаление и просмотр информации обо всех подключениях rclone. +- **Создание хранилищ через GUI** – мастер добавления с пошаговыми вопросами rclone (`--non-interactive`): укажите имя и тип, а для облаков с авторизацией (Google Drive, Dropbox и др.) автоматически откроется браузер для входа в аккаунт (OAuth). +- **Контекстное меню хранилищ** – по правой кнопке мыши: открыть, проверить доступность, посмотреть использование (объём/число файлов), удалить с подтверждением. +- **Локальный файловый менеджер** – навигация по локальной файловой системе, выбор папок назначения. +- **Основные файловые операции**: + - Копирование / Перемещение / Удаление нескольких файлов или папок. + - Рекурсивная обработка директорий. +- **Фоновые задачи** – асинхронные операции с индикаторами прогресса и статусом выполнения. +- **История навигации** – кнопки «Назад» / «Вперёд» для быстрого перемещения. +- **Пользовательские шрифты** – поддержка Sans.otf и NotoColorEmoji.ttf для улучшенного внешнего вида (опционально). +- **Кроссплатформенность** – работает на Windows, macOS и Linux. + +## Системные требования + +- **Rust** (при сборке из исходников) – стабильный инструментарий. +- **Cargo** – входит в состав Rust. +- **Операционная система** – Windows 10/11, macOS 10.15+ или современный дистрибутив Linux. +- **rclone** – не требуется, приложение загрузит его при необходимости. + +## Установка + +### Готовые сборки (пока не предоставляются) + +В будущих релизах могут быть добавлены исполняемые файлы. Пока что собирайте из исходников. + +### Сборка из исходников + +1. Клонируйте репозиторий: + ```bash + git clone https://github.com/yourusername/rclone-ui-manager.git + cd rclone-ui-manager + ``` + +2. (Опционально) Поместите пользовательские шрифты в папку `assets/fonts/`: + - `Sans.otf` + - `NotoColorEmoji.ttf` + +3. Соберите и запустите: + ```bash + cargo run --release + ``` + + Первый запуск может занять некоторое время из-за компиляции зависимостей. Исполняемый файл появится в `target/release/rclone-ui-manager` (или `rclone-ui-manager.exe` на Windows). + +### Зависимости для Linux + +На Linux могут потребоваться следующие системные библиотеки: + +```bash +# Ubuntu / Debian +sudo apt install libx11-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libwayland-dev pkg-config + +# Fedora +sudo dnf install libX11-devel libXrandr-devel libXinerama-devel libXcursor-devel libXi-devel wayland-devel pkgconf +``` + +## Использование + +1. Запустите приложение. +2. На левой боковой панели отобразится список ваших rclone remote. Чтобы добавить новое хранилище, нажмите кнопку **«➕ Добавить облако»** и заполните мастер: + - **Имя** – например, `mydrive`. + - **Тип** – например, `drive`, `dropbox`, `s3`, `webdav`. + - Если выбранный тип требует авторизации (OAuth), после нажатия «Создать» автоматически откроется браузер для входа в аккаунт. При необходимости rclone задаст дополнительные вопросы (например, о Team Drive) – ответьте в окне мастера. +3. Нажмите на имя remote **левой кнопкой мыши**, чтобы просмотреть его содержимое. +4. **Правая кнопка мыши** по хранилищу открывает контекстное меню: + - **📂 Открыть** – перейти в хранилище. + - **🔍 Проверить доступность** – проверить подключение. + - **📊 Использование** – показать общий объём, занятое и свободное место, число файлов. + - **🗑️ Удалить** – удалить хранилище из конфигурации rclone (файлы в облаке при этом не удаляются; требуется подтверждение). +5. Выберите файлы/папки, щёлкая по их иконке (📁 или 📄). Можно выбирать несколько элементов. +6. После выбора внизу появятся кнопки: **Копировать**, **Переместить**, **Удалить**. +7. Для копирования/перемещения укажите путь назначения: + - **Облако** – введите путь в формате rclone (например, `gdrive:/backup`). + - **ПК** – выберите локальную папку через встроенный файловый браузер или введите путь вручную. +8. Нажмите **Начать** – операция запустится в фоне. Прогресс отображается на правой панели. + +### Навигация + +- **⬅ / ➡** – переход назад / вперёд по истории. +- **🔄** – обновить текущую директорию. +- **📁 .. (Вверх)** – подняться на уровень выше. +- **Правая кнопка мыши по хранилищу** – контекстное меню хранилища. + +### Обработка ошибок + +Сообщения об ошибках появляются во всплывающих окнах. Активные задачи перечислены на правой панели. Вы можете закрыть приложение, пока операции выполняются (они запускаются в отдельных потоках). + +## Конфигурация + +Приложение хранит настройки в стандартной пользовательской папке данных: +- Windows: `%APPDATA%\rclone-ui\` +- macOS: `~/Library/Application Support/rclone-ui/` +- Linux: `~/.local/share/rclone-ui/` + +Сейчас в `AppSettings` хранятся только некоторые параметры (показ скрытых файлов, ограничение скорости). В будущих версиях они будут доступны через интерфейс. + +## Разработка + +### Структура проекта + +``` +src/ +├── main.rs # Точка входа, загрузка шрифтов +├── ui.rs # Состояние UI и типы +├── ui/ +│ ├── app.rs # Реализация egui::App +│ └── rcloneui.rs # Логика UI, фоновые задачи +├── operations/ +│ ├── files.rs # Обёртка над rclone lsjson / about +│ ├── sync.rs # copy/move/delete/purge +│ ├── remotes.rs # list/get/check, интерактивное создание (config create --non-interactive) +│ ├── local_fs.rs # Чтение локальных директорий +│ ├── fileinfo.rs # Вспомогательные методы FileInfo +│ └── ... # info, search, check +└── rclone_install/ + ├── mod.rs # Определение RcloneApp + └── rclone_install_real.rs # Обнаружение и загрузка rclone +``` + +### Добавление новых функций + +- **Новые операции с remotes** – расширяйте `operations/remotes.rs`. +- **Дополнительные команды файловых операций** – реализуйте в `operations/sync.rs` и вызывайте из `RcloneUI`. +- **Новые панели UI** – используйте `egui::SidePanel`, `CentralPanel` или `Window`. + +### Тестирование + +Запуск всех тестов: + +```bash +cargo test +``` + +Некоторые тесты помечены как `ignore` (например, реальная загрузка rclone) – запустите их командой `cargo test -- --ignored`. + +## Устранение неполадок + +### «Rclone not found» / ошибка установки + +- Проверьте интернет-соединение – приложение скачивает rclone с GitHub. +- На Linux убедитесь, что установлен `unzip` (хотя crate `zip` не требует его, но для ручной проверки может понадобиться). +- При работе через корпоративный прокси установите переменные окружения `HTTP_PROXY` и `HTTPS_PROXY`. + +### Отсутствие шрифтов + +Приложение нормально работает со стандартными системными шрифтами. Поместите файлы шрифтов в `assets/fonts/`, чтобы изменить внешний вид. + +### Ошибки компиляции на Linux + +Установите все необходимые пакеты для разработки X11 (см. раздел «Зависимости для Linux»). + +### Приложение не показывает локальные директории на Windows + +Локальный браузер по умолчанию открывает `C:\`. Чтобы перейти на другой диск, введите путь вручную (например, `D:\`) в поле назначения. + +## Планы на будущее + +- [ ] Графическое изменение конфигурации существующих remote. +- [ ] Предварительный просмотр синхронизации (dry-run со списком файлов). +- [ ] Ползунок ограничения скорости. +- [ ] Поиск файлов по имени/пути. +- [ ] Поддержка фильтров rclone (`--include`, `--exclude`). +- [ ] Переключение светлой/тёмной темы. + +## Лицензия + +Проект лицензируется на условиях **MIT License** или **Apache License 2.0** на ваш выбор. Файл `LICENSE` будет добавлен позднее. + +## Участие в разработке + +Приветствуются любые вклады! Пожалуйста, создавайте issue или pull request. Соблюдайте существующий стиль кода и по возможности добавляйте тесты. + +## Благодарности + +- [rclone](https://rclone.org/) – основа всех операций с хранилищами. +- [egui](https://github.com/emilk/egui) – библиотека для мгновенного GUI. +- [tokio](https://tokio.rs/) – асинхронная среда выполнения для загрузки rclone. +- [dirs](https://crates.io/crates/dirs) – кросс-платформенное определение директорий. + +--- + +**Наслаждайтесь удобным управлением вашими облачными файлами!** diff --git a/assets/screenshot.png b/assets/screenshot.png new file mode 100644 index 0000000..4a6d249 Binary files /dev/null and b/assets/screenshot.png differ diff --git a/src/operations/operations_tests.rs b/src/operations/operations_tests.rs index 0ed7ecc..f078375 100644 --- a/src/operations/operations_tests.rs +++ b/src/operations/operations_tests.rs @@ -1,4 +1,20 @@ +use crate::operations::remotes::{config_create_step, ConfigStep}; use crate::operations::{FileInfo, SyncStats}; +use crate::rclone_install::RcloneApp; +use std::fs; +use tempfile::tempdir; + +#[cfg(not(windows))] +fn write_mock_rclone(script: &str) -> (tempfile::TempDir, std::path::PathBuf) { + use std::os::unix::fs::PermissionsExt; + let temp_dir = tempdir().expect("Failed to create temp dir"); + let path = temp_dir.path().join("rclone"); + fs::write(&path, script).unwrap(); + let mut perms = fs::metadata(&path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&path, perms).unwrap(); + (temp_dir, path) +} #[test] fn test_file_info_formatting() { @@ -27,3 +43,131 @@ fn test_sync_stats_formatting() { assert_eq!(stats.format_speed(), "2.4 MB/s"); } + +#[test] +#[cfg(not(windows))] +fn test_config_create_step_returns_question() { + let script = r#"#!/bin/sh +cat <<'EOF' +{ + "State": "some-state", + "Option": { + "Name": "config_is_local", + "Help": "Use web browser to automatically authenticate rclone with remote?", + "Default": true, + "DefaultStr": "true", + "IsPassword": false, + "Examples": [ + {"Value": "true", "Help": "Yes"}, + {"Value": "false", "Help": "No"} + ] + }, + "Error": "", + "Result": "" +} +EOF +"#; + let (_dir, path) = write_mock_rclone(script); + let app = RcloneApp::new_with_path(path, false); + + let mut stderr_lines: Vec = Vec::new(); + let step = config_create_step(&app, "mydrive", "drive", None, None, &mut |l| { + stderr_lines.push(l.to_string()) + }) + .unwrap(); + + match step { + ConfigStep::Question { state, question } => { + assert_eq!(state, "some-state"); + assert_eq!(question.name, "config_is_local"); + assert!(question.help.contains("web browser")); + assert_eq!(question.default, "true"); + assert!(!question.is_password); + assert_eq!(question.examples.len(), 2); + assert_eq!(question.examples[0], ("true".to_string(), "Yes".to_string())); + } + _ => panic!("Ожидался вопрос, получено другое"), + } + assert!(stderr_lines.is_empty()); +} + +#[test] +#[cfg(not(windows))] +fn test_config_create_step_done() { + let script = r#"#!/bin/sh +cat <<'EOF' +{ + "State": "", + "Option": null, + "Error": "", + "Result": "" +} +EOF +"#; + let (_dir, path) = write_mock_rclone(script); + let app = RcloneApp::new_with_path(path, false); + + let step = config_create_step(&app, "local1", "local", None, None, &mut |_| {}).unwrap(); + assert!(matches!(step, ConfigStep::Done)); +} + +#[test] +#[cfg(not(windows))] +fn test_config_create_step_passes_continue_args() { + let script = r#"#!/bin/sh +if [ "$1" = "config" ] && printf '%s\n' "$@" | grep -q -- "--continue" \ + && printf '%s\n' "$@" | grep -q -- "--state" \ + && printf '%s\n' "$@" | grep -q -- "--result"; then + cat <<'EOF' +{"State": "next", "Option": null, "Error": "", "Result": ""} +EOF +else + echo "no continue args" >&2 + exit 1 +fi +"#; + let (_dir, path) = write_mock_rclone(script); + let app = RcloneApp::new_with_path(path, false); + + let step = config_create_step( + &app, + "mydrive", + "drive", + Some("st"), + Some("answer"), + &mut |_| {}, + ) + .unwrap(); + assert!(matches!(step, ConfigStep::Done)); +} + +#[test] +#[cfg(not(windows))] +fn test_config_create_step_error_from_stderr() { + let script = r#"#!/bin/sh +echo "Error: couldn't find backend for type \"bogus\"" >&2 +exit 1 +"#; + let (_dir, path) = write_mock_rclone(script); + let app = RcloneApp::new_with_path(path, false); + + let result = config_create_step(&app, "x", "bogus", None, None, &mut |_| {}); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("couldn't find backend")); +} + +#[test] +#[cfg(not(windows))] +fn test_config_create_step_error_from_json() { + let script = r#"#!/bin/sh +cat <<'EOF' +{"State": "", "Option": null, "Error": "config already exists", "Result": ""} +EOF +"#; + let (_dir, path) = write_mock_rclone(script); + let app = RcloneApp::new_with_path(path, false); + + let result = config_create_step(&app, "x", "drive", None, None, &mut |_| {}); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("already exists")); +} diff --git a/src/operations/remotes.rs b/src/operations/remotes.rs index 571858e..b05991f 100644 --- a/src/operations/remotes.rs +++ b/src/operations/remotes.rs @@ -71,24 +71,100 @@ pub fn get_config(app: &RcloneApp, remote_name: &str) -> Result } } -/// Создать новое удаленное хранилище -pub fn create( +/// Вопрос, который rclone задает в процессе создания remote +#[derive(Debug, Clone)] +pub struct ConfigQuestion { + pub name: String, + pub help: String, + pub default: String, + pub is_password: bool, + pub examples: Vec<(String, String)>, +} + +#[derive(Debug)] +pub enum ConfigStep { + /// Создание завершено + Done, + /// rclone задает вопрос, нужен ответ пользователя + Question { + state: String, + question: ConfigQuestion, + }, +} + +/// Один шаг интерактивного создания remote через `config create --non-interactive`. +/// Вызывается в цикле: без state — первый вопрос, далее с state и ответом +/// пользователя через `--continue`. `on_stderr` получает строки stderr +/// (например, ссылку для авторизации в браузере). +pub fn config_create_step( app: &RcloneApp, name: &str, r#type: &str, - config: &HashMap, -) -> Result<(), String> { - log::info!("Создание нового remote: {} типа {}", name, r#type); - let mut args = vec!["config", "create", name, r#type]; - - for (key, value) in config { - args.push(key.as_str()); - args.push(value.as_str()); + state: Option<&str>, + result: Option<&str>, + on_stderr: &mut dyn FnMut(&str), +) -> Result { + log::debug!("config create шаг: remote={} тип={} state={:?}", name, r#type, state); + let mut args = vec!["config", "create", name, r#type, "--non-interactive"]; + if let Some(s) = state { + args.extend([ + "--continue", + "--state", + s, + "--result", + result.unwrap_or_default(), + ]); } - app.run_command(&args)?; - log::info!("Remote {} успешно создан", name); - Ok(()) + let output = app.run_command_with_stderr_feed(&args, on_stderr)?; + + let value: Value = serde_json::from_str(&output) + .map_err(|e| format!("Ошибка парсинга ответа rclone: {}", e))?; + + if let Some(err) = value.get("Error").and_then(|v| v.as_str()) { + if !err.is_empty() { + log::error!("rclone вернул ошибку: {}", err); + return Err(err.to_string()); + } + } + + if value["Option"].is_null() { + return Ok(ConfigStep::Done); + } + + let opt = &value["Option"]; + let examples = opt + .get("Examples") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|e| { + let val = match e.get("Value") { + Some(Value::String(s)) => s.clone(), + Some(other) => other.to_string(), + None => return None, + }; + let help = e + .get("Help") + .and_then(|h| h.as_str()) + .unwrap_or("") + .to_string(); + Some((val, help)) + }) + .collect() + }) + .unwrap_or_default(); + + Ok(ConfigStep::Question { + state: value["State"].as_str().unwrap_or("").to_string(), + question: ConfigQuestion { + name: opt["Name"].as_str().unwrap_or("").to_string(), + help: opt["Help"].as_str().unwrap_or("").to_string(), + default: opt["DefaultStr"].as_str().unwrap_or("").to_string(), + is_password: opt["IsPassword"].as_bool().unwrap_or(false), + examples, + }, + }) } /// Обновить конфигурацию remote diff --git a/src/rclone_install/rclone_install_real.rs b/src/rclone_install/rclone_install_real.rs index 8453998..0353412 100644 --- a/src/rclone_install/rclone_install_real.rs +++ b/src/rclone_install/rclone_install_real.rs @@ -249,6 +249,65 @@ impl RcloneApp { } } + /// Запуск команды с передачей строк stderr в колбэк (например, для показа + /// ссылки авторизации rclone во время OAuth). + pub fn run_command_with_stderr_feed( + &self, + args: &[&str], + mut on_stderr: impl FnMut(&str), + ) -> Result { + use std::io::{BufRead, Read}; + use std::process::{Command, Stdio}; + + log::debug!("Выполнение команды rclone (с потоком stderr): {} {:?}", self.rclone_path.display(), args); + + let mut child = Command::new(&self.rclone_path) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| { + log::error!("Ошибка запуска rclone: {}", e); + format!("Ошибка запуска: {}", e) + })?; + + let stderr = child.stderr.take().expect("stderr должен быть доступен"); + let mut stderr_text = String::new(); + { + let reader = std::io::BufReader::new(stderr); + for line in reader.lines() { + match line { + Ok(l) => { + on_stderr(l.trim()); + stderr_text.push_str(&l); + stderr_text.push('\n'); + } + Err(_) => break, + } + } + } + + let status = child.wait().map_err(|e| format!("Ошибка ожидания rclone: {}", e))?; + + let mut stdout = String::new(); + if let Some(mut out) = child.stdout.take() { + let _ = out.read_to_string(&mut stdout); + } + + if status.success() { + Ok(stdout) + } else { + let err = if stderr_text.trim().is_empty() { + stdout + } else { + stderr_text + }; + log::error!("Ошибка выполнения команды: {}", err); + Err(err) + } + } + pub fn version(&self) -> Result { log::debug!("Получение версии rclone"); self.run_command(&["version"]) diff --git a/src/ui.rs b/src/ui.rs index 76a014d..bae1b69 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,6 +1,9 @@ //! Графический интерфейс для операций rclone с интегрированным Local FS и поддержкой кастомных шрифтов. +use crate::operations::remotes::ConfigQuestion; use crate::operations::{FileInfo, Remote}; use crate::rclone_install::RcloneApp; +use eframe::egui; +use std::collections::HashMap; use std::sync::Arc; use std::sync::mpsc::{Receiver, Sender}; use std::time::Instant; @@ -32,9 +35,39 @@ enum OperationResult { Failure(u32, String), FileList(Vec), RemoteList(Vec), + RemoteAdded(u32, Vec), + ConfigQuestion(u32, String, ConfigQuestion), + RemoteCheck(u32, String, bool), + RemoteAbout(u32, String, HashMap), + RemoteDeleted(u32, String), ProgressUpdate(u32, f32, String), } +/// Результат проверки или информации о хранилище для окна +pub struct RemoteInfoView { + pub title: String, + pub content: String, + pub success: bool, +} + +/// Шаг мастера добавления облака +#[derive(Clone)] +pub enum AddRemoteStep { + /// Ввод имени и типа + Form, + /// rclone задал вопрос + Question(ConfigQuestion), + /// Команда выполняется в фоне (например, ожидание авторизации в браузере) + Busy, +} + +/// Популярные типы удаленных хранилищ rclone +pub const REMOTE_TYPES: &[&str] = &[ + "drive", "dropbox", "onedrive", "s3", "gcs", "b2", "azureblob", + "webdav", "sftp", "ftp", "smb", "local", "crypt", "http", "mega", + "pcloud", "box", "googlephotos", "jottacloud", "koofr", +]; + pub struct RcloneUI { rclone: Option>, state: AppState, @@ -61,6 +94,19 @@ pub struct RcloneUI { #[allow(dead_code)] settings: AppSettings, + show_add_remote_dialog: bool, + new_remote_name: String, + new_remote_type: String, + add_remote_step: AddRemoteStep, + add_remote_state: Option, + add_remote_answer: String, + add_remote_status: String, + + remote_to_delete: Option, + remote_info: Option, + context_menu: Option<(String, egui::Pos2)>, + context_menu_requested: bool, + active_task_count: u32, operation_tx: Sender, operation_rx: Receiver, diff --git a/src/ui/app.rs b/src/ui/app.rs index 4195f02..94b0a68 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -7,6 +7,14 @@ use egui::{ use std::path::Path; use std::time::Duration; +/// Действия контекстного меню хранилища +enum RemoteMenuAction { + Open, + Check, + About, + Delete, +} + impl eframe::App for RcloneUI { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.poll_rclone_init(); @@ -42,16 +50,28 @@ impl eframe::App for RcloneUI { 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); + let selected = self.current_path.starts_with(&remote.name); + let response = + ui.selectable_label(selected, format!("📡 {}", remote.name)); + if response.clicked() { + log::debug!("Открытие remote: {}", remote.name); self.navigate_to(format!("{}:", remote.name)); } + if response.secondary_clicked() { + log::debug!("Контекстное меню remote: {}", remote.name); + let pos = ctx + .input(|i| i.pointer.interact_pos()) + .unwrap_or_default(); + self.context_menu = Some((remote.name.clone(), pos)); + self.context_menu_requested = true; + } + } + }); + ui.separator(); + ui.add_enabled_ui(self.rclone.is_some(), |ui| { + if ui.button("➕ Добавить облако").clicked() { + log::debug!("Открытие диалога добавления облака"); + self.show_add_remote_dialog = true; } }); }); @@ -170,6 +190,134 @@ impl eframe::App for RcloneUI { } }); + // Диалог добавления нового облака + if self.show_add_remote_dialog { + Window::new("Новое облако") + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) + .collapsible(false) + .resizable(false) + .fixed_size([480.0, 420.0]) + .show(ctx, |ui| { + match self.add_remote_step.clone() { + AddRemoteStep::Form => { + ui.label("Имя:"); + ui.add( + egui::TextEdit::singleline(&mut self.new_remote_name) + .hint_text("например: mydrive") + .desired_width(240.0), + ); + ui.add_space(6.0); + ui.label("Тип:"); + egui::ComboBox::from_id_source("remote_type") + .selected_text(self.new_remote_type.as_str()) + .width(240.0) + .show_ui(ui, |ui| { + for t in REMOTE_TYPES { + ui.selectable_value( + &mut self.new_remote_type, + t.to_string(), + *t, + ); + } + }); + ui.add_space(8.0); + ui.add( + egui::Label::new( + "Для облаков с авторизацией (Google Drive, Dropbox и др.) при создании откроется браузер для входа в аккаунт.", + ) + .wrap(true), + ); + ui.add_space(10.0); + ui.separator(); + ui.horizontal(|ui| { + let can_create = !self.new_remote_name.trim().is_empty() + && !self.new_remote_type.trim().is_empty() + && self.active_task_count == 0; + if ui + .add_enabled( + can_create, + egui::Button::new("✅ Создать"), + ) + .clicked() + { + log::debug!("Кнопка 'Создать' нажата"); + self.start_add_remote(); + } + if ui.button("Отмена").clicked() { + self.show_add_remote_dialog = false; + } + }); + } + AddRemoteStep::Question(question) => { + ui.heading(format!("❓ {}", question.name)); + ui.add(egui::Label::new(&question.help).wrap(true)); + if !question.default.is_empty() { + ui.weak(format!("По умолчанию: {}", question.default)); + } + if !question.examples.is_empty() { + ui.add_space(6.0); + ui.label("Варианты:"); + for (value, help) in &question.examples { + let selected = self.add_remote_answer == *value; + let label = if help.is_empty() { + value.clone() + } else { + format!("{} — {}", value, help) + }; + if ui.selectable_label(selected, label).clicked() { + self.add_remote_answer = value.clone(); + } + } + } + ui.add_space(6.0); + ui.add( + egui::TextEdit::singleline(&mut self.add_remote_answer) + .password(question.is_password) + .hint_text(if question.is_password { "секрет" } else { "ответ" }) + .desired_width(320.0), + ); + ui.add_space(10.0); + ui.separator(); + ui.horizontal(|ui| { + if ui + .add_enabled( + self.active_task_count == 0, + egui::Button::new("✅ Ответить"), + ) + .clicked() + { + log::debug!("Ответ на вопрос rclone отправлен"); + self.answer_add_remote(); + } + if ui.button("Отмена").clicked() { + self.show_add_remote_dialog = false; + } + }); + } + AddRemoteStep::Busy => { + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.add(egui::Spinner::new()); + ui.label(&self.add_remote_status); + }); + ui.add( + egui::Label::new( + "Если страница авторизации не открылась — скопируйте ссылку из статуса ниже в браузер.", + ) + .wrap(true), + ); + ui.add_space(10.0); + ui.separator(); + ui.horizontal(|ui| { + if ui.button("Отмена").clicked() { + self.show_add_remote_dialog = false; + } + }); + } + } + }); + } + // Модальное окно трансфера if self.show_transfer_dialog { Window::new("Трансфер") @@ -345,6 +493,130 @@ impl eframe::App for RcloneUI { } } + // Подтверждение удаления хранилища + if let Some(remote_name) = self.remote_to_delete.clone() { + let mut close = false; + let mut confirm = false; + Window::new("Удаление хранилища") + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) + .collapsible(false) + .resizable(false) + .fixed_size([430.0, 170.0]) + .show(ctx, |ui| { + ui.label(format!("Удалить хранилище «{}»?", remote_name)); + ui.add( + egui::Label::new( + "Файлы в облаке не удаляются — удаляется только запись из конфигурации rclone.", + ) + .wrap(true), + ); + ui.separator(); + ui.horizontal(|ui| { + if ui.button("🗑️ Удалить").clicked() { + confirm = true; + } + if ui.button("Отмена").clicked() { + close = true; + } + }); + }); + if close { + self.remote_to_delete = None; + } + if confirm { + self.remote_to_delete = None; + self.delete_remote(remote_name); + } + } + + // Окно с результатом проверки или информацией о хранилище + if let Some(info) = &self.remote_info { + let mut close = false; + Window::new(&info.title) + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) + .collapsible(false) + .resizable(false) + .fixed_size([430.0, 260.0]) + .show(ctx, |ui| { + let color = if info.success { + Color32::LIGHT_GREEN + } else { + Color32::KHAKI + }; + ui.add( + egui::Label::new(egui::RichText::new(&info.content).color(color)) + .wrap(true), + ); + ui.separator(); + if ui.button("ОК").clicked() { + close = true; + } + }); + if close { + self.remote_info = None; + } + } + + // Контекстное меню хранилища (правая кнопка мыши) + if let Some((menu_name, menu_pos)) = self.context_menu.clone() { + let remote_type = self + .remote_list + .iter() + .find(|r| r.name == menu_name) + .map(|r| r.r#type.clone()) + .unwrap_or_default(); + let mut action: Option = None; + Window::new("remote_menu") + .title_bar(false) + .resizable(false) + .collapsible(false) + .fixed_pos(menu_pos) + .show(ctx, |ui| { + ui.weak(format!("Тип: {}", remote_type)); + ui.separator(); + if ui.button("📂 Открыть").clicked() { + action = Some(RemoteMenuAction::Open); + } + if ui.button("🔍 Проверить доступность").clicked() { + action = Some(RemoteMenuAction::Check); + } + if ui.button("📊 Использование").clicked() { + action = Some(RemoteMenuAction::About); + } + ui.separator(); + if ui.button("🗑️ Удалить").clicked() { + action = Some(RemoteMenuAction::Delete); + } + }); + if let Some(action) = action { + self.context_menu = None; + match action { + RemoteMenuAction::Open => { + log::debug!("Открытие remote из меню: {}", menu_name); + self.navigate_to(format!("{}:", menu_name)); + } + RemoteMenuAction::Check => { + log::debug!("Проверка remote из меню: {}", menu_name); + self.check_remote(menu_name.clone()); + } + RemoteMenuAction::About => { + log::debug!("Использование remote из меню: {}", menu_name); + self.about_remote(menu_name.clone()); + } + RemoteMenuAction::Delete => { + log::debug!("Запрос удаления remote из меню: {}", menu_name); + self.remote_to_delete = Some(menu_name.clone()); + } + } + } else { + let just_opened = std::mem::take(&mut self.context_menu_requested); + if !just_opened && ctx.input(|i| i.pointer.any_click()) { + log::debug!("Контекстное меню закрыто"); + self.context_menu = None; + } + } + } + if let Some(path) = self.pending_load_path.take() { self.current_path = path.clone(); self.selected_paths.clear(); diff --git a/src/ui/rcloneui.rs b/src/ui/rcloneui.rs index a0920e3..4c917f1 100644 --- a/src/ui/rcloneui.rs +++ b/src/ui/rcloneui.rs @@ -1,4 +1,5 @@ use super::*; +use crate::operations::remotes::ConfigStep; use crate::operations::{self, CopyOptions, DeleteOptions, MoveOptions}; use crate::rclone_install::RcloneApp; use eframe::egui; @@ -9,6 +10,19 @@ use std::sync::mpsc::channel; use std::time::Instant; use tokio::runtime::Runtime; +/// Форматирование размера в человекочитаемый вид +pub fn format_bytes(bytes: u64) -> String { + if bytes < 1024 { + format!("{} B", bytes) + } else if bytes < 1024 * 1024 { + format!("{:.1} KB", bytes as f64 / 1024.0) + } else if bytes < 1024 * 1024 * 1024 { + format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) + } else { + format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) + } +} + impl RcloneUI { pub fn new(cc: &eframe::CreationContext<'_>) -> Self { log::debug!("Инициализация RcloneUI"); @@ -88,6 +102,17 @@ impl RcloneUI { local_browser_files: Vec::new(), is_move_mode: false, settings: AppSettings::default(), + show_add_remote_dialog: false, + new_remote_name: String::new(), + new_remote_type: "drive".to_string(), + add_remote_step: AddRemoteStep::Form, + add_remote_state: None, + add_remote_answer: String::new(), + add_remote_status: String::new(), + remote_to_delete: None, + remote_info: None, + context_menu: None, + context_menu_requested: false, active_task_count: 0, operation_tx: op_tx, operation_rx: op_rx, @@ -173,8 +198,174 @@ impl RcloneUI { } } - pub fn load_remotes(&mut self) { + /// Фоновый цикл создания remote: rclone задает вопросы (--non-interactive), + /// на вопрос config_is_local автоматически отвечаем "true" — открывается + /// браузер для авторизации; остальные вопросы передаются в UI. + fn run_add_remote_loop( + rclone: Arc, + name: String, + rtype: String, + initial_state: Option, + initial_answer: Option, + op_id: u32, + tx: Sender, + ) { + std::thread::spawn(move || { + let mut state = initial_state; + let mut answer = initial_answer; + loop { + let feed_tx = tx.clone(); + let step = operations::remotes::config_create_step( + &rclone, + &name, + &rtype, + state.as_deref(), + answer.as_deref(), + &mut |line: &str| { + if !line.is_empty() { + let _ = feed_tx.send(OperationResult::ProgressUpdate( + op_id, + 0.0, + line.to_string(), + )); + } + }, + ); + match step { + Ok(ConfigStep::Done) => { + log::info!("Remote {} успешно создан", name); + let _ = tx.send(match operations::remotes::list(&rclone) { + Ok(list) => OperationResult::RemoteAdded(op_id, list), + Err(e) => OperationResult::Failure( + op_id, + format!("Облако создано, но список обновить не удалось: {}", e), + ), + }); + return; + } + Ok(ConfigStep::Question { state: s, question: q }) => { + if q.name == "config_is_local" { + log::info!("Открытие браузера для авторизации {}", rtype); + state = Some(s); + answer = Some("true".to_string()); + let _ = tx.send(OperationResult::ProgressUpdate( + op_id, + 0.0, + "Открыт браузер для авторизации. Ожидание входа...".into(), + )); + continue; + } + log::debug!("rclone задал вопрос: {}", q.name); + let _ = tx.send(OperationResult::ConfigQuestion(op_id, s, q)); + return; + } + Err(e) => { + log::error!("Ошибка создания remote {}: {}", name, e); + let _ = tx.send(OperationResult::Failure(op_id, e)); + return; + } + } + } + }); + } + + pub fn start_add_remote(&mut self) { + let name = self.new_remote_name.trim().to_string(); + let rtype = self.new_remote_type.trim().to_string(); + if name.is_empty() || rtype.is_empty() { + log::warn!("Не удалось создать remote: пустое имя или тип"); + return; + } if let Some(rclone) = self.rclone.clone() { + log::info!("Создание нового remote: {} типа {}", name, rtype); + self.active_task_count += 1; + self.add_remote_step = AddRemoteStep::Busy; + self.add_remote_status = "Создание облака...".into(); + let op_id = self.add_operation(format!("Создание облака {}", name)); + Self::run_add_remote_loop(rclone, name, rtype, None, None, op_id, self.operation_tx.clone()); + } + } + + pub fn answer_add_remote(&mut self) { + if let (Some(rclone), Some(state)) = (self.rclone.clone(), self.add_remote_state.clone()) { + let answer = self.add_remote_answer.clone(); + let name = self.new_remote_name.clone(); + let rtype = self.new_remote_type.clone(); + log::info!("Ответ на вопрос rclone: {}", answer); + self.active_task_count += 1; + self.add_remote_step = AddRemoteStep::Busy; + self.add_remote_status = "Отправка ответа...".into(); + let op_id = self.add_operation(format!("Создание облака {}", name)); + Self::run_add_remote_loop(rclone, name, rtype, Some(state), Some(answer), op_id, self.operation_tx.clone()); + } + } + + /// Проверить доступность хранилища + pub fn check_remote(&mut self, name: String) { + if let Some(rclone) = self.rclone.clone() { + log::info!("Проверка доступности remote: {}", name); + self.active_task_count += 1; + let op_id = self.add_operation(format!("Проверка {}", name)); + let tx = self.operation_tx.clone(); + let remote_path = format!("{}:", name); + std::thread::spawn(move || { + match operations::remotes::check(&rclone, &remote_path) { + Ok(true) => { + let _ = tx.send(OperationResult::RemoteCheck(op_id, name, true)); + } + Ok(false) => { + let _ = tx.send(OperationResult::RemoteCheck(op_id, name, false)); + } + Err(e) => { + let _ = tx.send(OperationResult::Failure(op_id, e)); + } + } + }); + } + } + + /// Получить информацию об использовании хранилища + pub fn about_remote(&mut self, name: String) { + if let Some(rclone) = self.rclone.clone() { + log::info!("Получение информации об использовании remote: {}", name); + self.active_task_count += 1; + let op_id = self.add_operation(format!("Использование {}", name)); + let tx = self.operation_tx.clone(); + let remote_path = format!("{}:", name); + std::thread::spawn(move || { + match operations::files::about(&rclone, &remote_path) { + Ok(info) => { + let _ = tx.send(OperationResult::RemoteAbout(op_id, name, info)); + } + Err(e) => { + let _ = tx.send(OperationResult::Failure(op_id, e)); + } + } + }); + } + } + + /// Удалить хранилище из конфигурации rclone + pub fn delete_remote(&mut self, name: String) { + if let Some(rclone) = self.rclone.clone() { + log::info!("Удаление remote: {}", name); + self.active_task_count += 1; + let op_id = self.add_operation(format!("Удаление {}", name)); + let tx = self.operation_tx.clone(); + std::thread::spawn(move || { + match operations::remotes::delete(&rclone, &name) { + Ok(()) => { + let _ = tx.send(OperationResult::RemoteDeleted(op_id, name)); + } + Err(e) => { + let _ = tx.send(OperationResult::Failure(op_id, e)); + } + } + }); + } + } + + 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(); @@ -229,6 +420,9 @@ impl RcloneUI { 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); + if matches!(self.add_remote_step, AddRemoteStep::Busy) { + self.add_remote_step = AddRemoteStep::Form; + } self.error_message = Some(e); } OperationResult::FileList(files) => { @@ -243,12 +437,85 @@ impl RcloneUI { self.remote_list = remotes; self.active_task_count = self.active_task_count.saturating_sub(1); } + OperationResult::RemoteAdded(op_id, remotes) => { + let count = remotes.len(); + log::info!("Облако добавлено, список обновлен: {} элементов", count); + self.remote_list = remotes; + self.active_task_count = self.active_task_count.saturating_sub(1); + self.active_operations.retain(|op| op.id != op_id); + self.show_add_remote_dialog = false; + self.add_remote_step = AddRemoteStep::Form; + self.add_remote_state = None; + self.new_remote_name.clear(); + self.add_remote_answer.clear(); + } + OperationResult::ConfigQuestion(op_id, state, question) => { + log::debug!("Получен вопрос rclone: {}", question.name); + self.active_task_count = self.active_task_count.saturating_sub(1); + self.active_operations.retain(|op| op.id != op_id); + self.add_remote_state = Some(state); + self.add_remote_answer = question.default.clone(); + self.add_remote_step = AddRemoteStep::Question(question); + } + OperationResult::RemoteCheck(op_id, name, ok) => { + log::debug!("Проверка remote {} завершена: доступен={}", name, ok); + self.active_task_count = self.active_task_count.saturating_sub(1); + self.active_operations.retain(|op| op.id != op_id); + self.remote_info = Some(RemoteInfoView { + title: format!("Проверка {}", name), + content: if ok { + "Хранилище доступно".to_string() + } else { + "Хранилище недоступно".to_string() + }, + success: ok, + }); + } + OperationResult::RemoteAbout(op_id, name, info) => { + log::debug!("Информация о remote {} получена", name); + self.active_task_count = self.active_task_count.saturating_sub(1); + self.active_operations.retain(|op| op.id != op_id); + let mut lines = Vec::new(); + for (key, value) in &info { + match key.as_str() { + "total" => lines.push(format!("Общий объем: {}", format_bytes(*value))), + "used" => lines.push(format!("Использовано: {}", format_bytes(*value))), + "free" => lines.push(format!("Свободно: {}", format_bytes(*value))), + "trashed" => lines.push(format!("В корзине: {}", format_bytes(*value))), + "objects" => lines.push(format!("Файлов: {}", value)), + "versions" => lines.push(format!("Версий: {}", value)), + _ => lines.push(format!("{}: {}", key, format_bytes(*value))), + } + } + self.remote_info = Some(RemoteInfoView { + title: format!("Использование {}", name), + content: lines.join("\n"), + success: true, + }); + } + OperationResult::RemoteDeleted(op_id, name) => { + log::info!("Remote {} удален", name); + self.active_task_count = self.active_task_count.saturating_sub(1); + self.active_operations.retain(|op| op.id != op_id); + self.remote_to_delete = None; + if self.current_path.starts_with(&name) { + self.current_path.clear(); + self.current_files.clear(); + self.selected_paths.clear(); + self.history_back.clear(); + self.history_forward.clear(); + } + self.load_remotes(); + } 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); } + if matches!(&self.add_remote_step, AddRemoteStep::Busy) { + self.add_remote_status = status; + } } } }