From 3e868c184a5baa141979b1c8331e25b9f3eae7d1 Mon Sep 17 00:00:00 2001 From: Kirill Date: Tue, 2 Jun 2026 11:41:10 +0300 Subject: [PATCH 01/11] sys: Added readme file --- README.md | 181 ++++++++++++++++++++++++++++++++++++++++++ assets/screenshot.png | Bin 0 -> 60136 bytes 2 files changed, 181 insertions(+) create mode 100644 assets/screenshot.png diff --git a/README.md b/README.md index e69de29..8f348ee 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,181 @@ +# 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 подключений. +- **Локальный файловый менеджер** – навигация по локальной файловой системе, выбор папок назначения. +- **Основные файловые операции**: + - Копирование / Перемещение / Удаление нескольких файлов или папок. + - Рекурсивная обработка директорий. +- **Фоновые задачи** – асинхронные операции с индикаторами прогресса и статусом выполнения. +- **История навигации** – кнопки «Назад» / «Вперёд» для быстрого перемещения. +- **Пользовательские шрифты** – поддержка 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 (если они есть). Если remotes не настроены, используйте командную строку rclone для их добавления – в будущих версиях появится графический интерфейс для настройки. +3. Нажмите на имя remote, чтобы просмотреть его содержимое. +4. Выберите файлы/папки, щёлкая по их иконке (📁 или 📄). Можно выбирать несколько элементов. +5. После выбора внизу появятся кнопки: **Копировать**, **Переместить**, **Удалить**. +6. Для копирования/перемещения укажите путь назначения: + - **Облако** – введите путь в формате rclone (например, `gdrive:/backup`). + - **ПК** – выберите локальную папку через встроенный файловый браузер или введите путь вручную. +7. Нажмите **Начать** – операция запустится в фоне. Прогресс отображается на правой панели. + +### Навигация + +- **⬅ / ➡** – переход назад / вперёд по истории. +- **🔄** – обновить текущую директорию. +- **📁 .. (Вверх)** – подняться на уровень выше. + +### Обработка ошибок + +Сообщения об ошибках появляются во всплывающих окнах. Активные задачи перечислены на правой панели. Вы можете закрыть приложение, пока операции выполняются (они запускаются в отдельных потоках). + +## Конфигурация + +Приложение хранит настройки в стандартной пользовательской папке данных: +- 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 +│ ├── sync.rs # copy/move/delete/purge +│ ├── remotes.rs # list/get/create/update/delete remotes +│ ├── 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`). +- [ ] Отображение информации о ёмкости хранилища (rclone about). +- [ ] Переключение светлой/тёмной темы. + +## Лицензия + +Проект лицензируется на условиях **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 0000000000000000000000000000000000000000..4a6d2499952c196bdc0e9c1277e4aabbf1fd746d GIT binary patch literal 60136 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

*u^uYf!yt0E6?62H1CAOtLSAACOxJ+2xS) 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

J~>@beOJI(2jLW2evCx-lxyQV_X! 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 literal 0 HcmV?d00001 From 3c64c39cf3b9ed5d8b366560c08522204b8c88e4 Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 13 Aug 2026 18:15:45 +0300 Subject: [PATCH 02/11] feat(rclone): add run_command_with_stderr_feed for streaming stderr --- src/rclone_install/rclone_install_real.rs | 59 +++++++++++++++++++++++ 1 file changed, 59 insertions(+) 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"]) From b90ff7f18dd36fc58b1372b89b55b6c258a721fa Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 13 Aug 2026 18:15:45 +0300 Subject: [PATCH 03/11] feat(operations): add interactive config_create_step for remote creation --- src/operations/remotes.rs | 102 +++++++++++++++++++++++++++++++++----- 1 file changed, 89 insertions(+), 13 deletions(-) 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 From 9fb0e7fb197d0e01bfe6d13c6aef0b8395807296 Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 13 Aug 2026 18:15:49 +0300 Subject: [PATCH 04/11] feat(ui): add add-remote wizard state machine --- src/ui.rs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/ui.rs b/src/ui.rs index 76a014d..f3ef978 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,4 +1,5 @@ //! Графический интерфейс для операций rclone с интегрированным Local FS и поддержкой кастомных шрифтов. +use crate::operations::remotes::ConfigQuestion; use crate::operations::{FileInfo, Remote}; use crate::rclone_install::RcloneApp; use std::sync::Arc; @@ -32,9 +33,29 @@ enum OperationResult { Failure(u32, String), FileList(Vec), RemoteList(Vec), + RemoteAdded(u32, Vec), + ConfigQuestion(u32, String, ConfigQuestion), ProgressUpdate(u32, f32, String), } +/// Шаг мастера добавления облака +#[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 +82,14 @@ 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, + active_task_count: u32, operation_tx: Sender, operation_rx: Receiver, From 65939bd50125cb6916ef271cd68d59c29769928d Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 13 Aug 2026 18:15:49 +0300 Subject: [PATCH 05/11] feat(ui): implement interactive add-remote background flow --- src/ui/rcloneui.rs | 137 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 136 insertions(+), 1 deletion(-) diff --git a/src/ui/rcloneui.rs b/src/ui/rcloneui.rs index a0920e3..60fa1b3 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; @@ -88,6 +89,13 @@ 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(), active_task_count: 0, operation_tx: op_tx, operation_rx: op_rx, @@ -173,8 +181,109 @@ 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 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 +338,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 +355,35 @@ 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::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; + } } } } From c1ac7b28bb5a2b69286ae3c4018d405d6ffb7bac Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 13 Aug 2026 18:15:54 +0300 Subject: [PATCH 06/11] feat(ui): render add-remote wizard dialog with questions --- src/ui/app.rs | 135 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/src/ui/app.rs b/src/ui/app.rs index 4195f02..ccdfe7b 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -54,6 +54,13 @@ impl eframe::App for RcloneUI { } } }); + ui.separator(); + ui.add_enabled_ui(self.rclone.is_some(), |ui| { + if ui.button("➕ Добавить облако").clicked() { + log::debug!("Открытие диалога добавления облака"); + self.show_add_remote_dialog = true; + } + }); }); SidePanel::right("right") @@ -170,6 +177,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("Трансфер") From 0a504966eee52d9351436f424c91d8997bf196d7 Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 13 Aug 2026 18:15:54 +0300 Subject: [PATCH 07/11] test(operations): cover config_create_step with mock rclone --- src/operations/operations_tests.rs | 144 +++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) 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")); +} From 1f5ec2dedefb198b6c6550a30ef3655ad851cb99 Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 13 Aug 2026 18:38:58 +0300 Subject: [PATCH 08/11] feat(ui): add remote context menu state and info result variants --- src/ui.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/ui.rs b/src/ui.rs index f3ef978..bae1b69 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -2,6 +2,8 @@ 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; @@ -35,9 +37,19 @@ enum OperationResult { 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 { @@ -90,6 +102,11 @@ pub struct RcloneUI { 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, From 1679c8b08e9c74e4f9afbfa6da439c5802c13fe6 Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 13 Aug 2026 18:38:58 +0300 Subject: [PATCH 09/11] feat(ui): implement remote check, about and delete operations --- src/ui/rcloneui.rs | 132 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/src/ui/rcloneui.rs b/src/ui/rcloneui.rs index 60fa1b3..4c917f1 100644 --- a/src/ui/rcloneui.rs +++ b/src/ui/rcloneui.rs @@ -10,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"); @@ -96,6 +109,10 @@ impl RcloneUI { 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, @@ -283,6 +300,71 @@ impl RcloneUI { } } + /// Проверить доступность хранилища + 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; @@ -375,6 +457,56 @@ impl RcloneUI { 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; From 8d213972d667b75b016b4da6b1b672be1556fc8b Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 13 Aug 2026 18:38:58 +0300 Subject: [PATCH 10/11] feat(ui): render remote context menu with delete confirmation and info windows --- src/ui/app.rs | 153 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 145 insertions(+), 8 deletions(-) diff --git a/src/ui/app.rs b/src/ui/app.rs index ccdfe7b..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,21 @@ 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(); @@ -480,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(); From a4b3242052e9d6841b9b6ad3c0d23740d8d6b1fd Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 13 Aug 2026 19:11:31 +0300 Subject: [PATCH 11/11] fix(readme): fix readme --- README.md | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 8f348ee..61fce3d 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,9 @@ ## Возможности - **Автоматическая установка rclone** – обнаруживает системный rclone или загружает последнюю версию автоматически. -- **Управление удалёнными хранилищами (remotes)** – просмотр, проверка и использование всех настроенных в rclone подключений. +- **Управление удалёнными хранилищами (remotes)** – просмотр, создание, проверка, удаление и просмотр информации обо всех подключениях rclone. +- **Создание хранилищ через GUI** – мастер добавления с пошаговыми вопросами rclone (`--non-interactive`): укажите имя и тип, а для облаков с авторизацией (Google Drive, Dropbox и др.) автоматически откроется браузер для входа в аккаунт (OAuth). +- **Контекстное меню хранилищ** – по правой кнопке мыши: открыть, проверить доступность, посмотреть использование (объём/число файлов), удалить с подтверждением. - **Локальный файловый менеджер** – навигация по локальной файловой системе, выбор папок назначения. - **Основные файловые операции**: - Копирование / Перемещение / Удаление нескольких файлов или папок. @@ -64,20 +66,29 @@ sudo dnf install libX11-devel libXrandr-devel libXinerama-devel libXcursor-devel ## Использование 1. Запустите приложение. -2. На левой боковой панели отобразится список ваших rclone remote (если они есть). Если remotes не настроены, используйте командную строку rclone для их добавления – в будущих версиях появится графический интерфейс для настройки. -3. Нажмите на имя remote, чтобы просмотреть его содержимое. -4. Выберите файлы/папки, щёлкая по их иконке (📁 или 📄). Можно выбирать несколько элементов. -5. После выбора внизу появятся кнопки: **Копировать**, **Переместить**, **Удалить**. -6. Для копирования/перемещения укажите путь назначения: +2. На левой боковой панели отобразится список ваших rclone remote. Чтобы добавить новое хранилище, нажмите кнопку **«➕ Добавить облако»** и заполните мастер: + - **Имя** – например, `mydrive`. + - **Тип** – например, `drive`, `dropbox`, `s3`, `webdav`. + - Если выбранный тип требует авторизации (OAuth), после нажатия «Создать» автоматически откроется браузер для входа в аккаунт. При необходимости rclone задаст дополнительные вопросы (например, о Team Drive) – ответьте в окне мастера. +3. Нажмите на имя remote **левой кнопкой мыши**, чтобы просмотреть его содержимое. +4. **Правая кнопка мыши** по хранилищу открывает контекстное меню: + - **📂 Открыть** – перейти в хранилище. + - **🔍 Проверить доступность** – проверить подключение. + - **📊 Использование** – показать общий объём, занятое и свободное место, число файлов. + - **🗑️ Удалить** – удалить хранилище из конфигурации rclone (файлы в облаке при этом не удаляются; требуется подтверждение). +5. Выберите файлы/папки, щёлкая по их иконке (📁 или 📄). Можно выбирать несколько элементов. +6. После выбора внизу появятся кнопки: **Копировать**, **Переместить**, **Удалить**. +7. Для копирования/перемещения укажите путь назначения: - **Облако** – введите путь в формате rclone (например, `gdrive:/backup`). - **ПК** – выберите локальную папку через встроенный файловый браузер или введите путь вручную. -7. Нажмите **Начать** – операция запустится в фоне. Прогресс отображается на правой панели. +8. Нажмите **Начать** – операция запустится в фоне. Прогресс отображается на правой панели. ### Навигация - **⬅ / ➡** – переход назад / вперёд по истории. - **🔄** – обновить текущую директорию. - **📁 .. (Вверх)** – подняться на уровень выше. +- **Правая кнопка мыши по хранилищу** – контекстное меню хранилища. ### Обработка ошибок @@ -104,9 +115,9 @@ src/ │ ├── app.rs # Реализация egui::App │ └── rcloneui.rs # Логика UI, фоновые задачи ├── operations/ -│ ├── files.rs # Обёртка над rclone lsjson +│ ├── files.rs # Обёртка над rclone lsjson / about │ ├── sync.rs # copy/move/delete/purge -│ ├── remotes.rs # list/get/create/update/delete remotes +│ ├── remotes.rs # list/get/check, интерактивное создание (config create --non-interactive) │ ├── local_fs.rs # Чтение локальных директорий │ ├── fileinfo.rs # Вспомогательные методы FileInfo │ └── ... # info, search, check @@ -153,12 +164,11 @@ cargo test ## Планы на будущее -- [ ] Графическая настройка remote (создание/изменение). +- [ ] Графическое изменение конфигурации существующих remote. - [ ] Предварительный просмотр синхронизации (dry-run со списком файлов). - [ ] Ползунок ограничения скорости. - [ ] Поиск файлов по имени/пути. - [ ] Поддержка фильтров rclone (`--include`, `--exclude`). -- [ ] Отображение информации о ёмкости хранилища (rclone about). - [ ] Переключение светлой/тёмной темы. ## Лицензия