24 Commits
Author SHA1 Message Date
Black_Raven aca186e724 Merge pull request 'main' (#8) from main into release
Release Build / Build for x86_64-pc-windows-gnu (push) Failing after 27s
Release Build / Build for x86_64-unknown-linux-gnu (push) Failing after 11m18s
Reviewed-on: https://git.rustprogersteam.ru/Black_Raven/UIrclone/pulls/8
2026-08-14 07:40:58 +00:00
Black_Raven a4b3242052 fix(readme): fix readme
Rust CI (Native Install + Cache) / test (push) Has been cancelled
Rust CI (Native Install + Cache) / docs (push) Has been cancelled
Rust CI (Native Install + Cache) / build_and_check (push) Has been cancelled
2026-08-13 19:11:31 +03:00
Black_Raven 8d213972d6 feat(ui): render remote context menu with delete confirmation and info windows 2026-08-13 18:38:58 +03:00
Black_Raven 1679c8b08e feat(ui): implement remote check, about and delete operations 2026-08-13 18:38:58 +03:00
Black_Raven 1f5ec2dede feat(ui): add remote context menu state and info result variants 2026-08-13 18:38:58 +03:00
Black_Raven 0a504966ee test(operations): cover config_create_step with mock rclone 2026-08-13 18:15:54 +03:00
Black_Raven c1ac7b28bb feat(ui): render add-remote wizard dialog with questions 2026-08-13 18:15:54 +03:00
Black_Raven 65939bd501 feat(ui): implement interactive add-remote background flow 2026-08-13 18:15:49 +03:00
Black_Raven 9fb0e7fb19 feat(ui): add add-remote wizard state machine 2026-08-13 18:15:49 +03:00
Black_Raven b90ff7f18d feat(operations): add interactive config_create_step for remote creation 2026-08-13 18:15:45 +03:00
Black_Raven 3c64c39cf3 feat(rclone): add run_command_with_stderr_feed for streaming stderr 2026-08-13 18:15:45 +03:00
Black_Raven 3e868c184a sys: Added readme file 2026-06-02 11:46:28 +03:00
Black_Raven 03ad2c853d Merge pull request 'fix(ci): Delete mac release' (#7) from main into release
Reviewed-on: https://git.rustprogersteam.ru/Black_Raven/UIrclone/pulls/7
2026-04-02 09:14:27 +00:00
Black_Raven cd60bace64 fix(ci): Delete mac release
I hate it
2026-04-02 12:13:20 +03:00
Black_Raven 6ce739abf6 Merge pull request 'fix: github actions' (#6) from main into release
Reviewed-on: https://git.rustprogersteam.ru/Black_Raven/UIrclone/pulls/6
2026-04-02 09:01:12 +00:00
Black_Raven dda6b93724 fix: github actions 2026-04-02 11:59:54 +03:00
Black_Raven 16eff4129e Merge pull request 'main' (#5) from main into release
Reviewed-on: https://git.rustprogersteam.ru/Black_Raven/UIrclone/pulls/5
2026-04-02 08:55:11 +00:00
Black_Raven 58ad6ca9d7 fix(ci): Fix for ring in apple build 2026-04-02 11:39:14 +03:00
Black_Raven d606a5a600 fix(CI): Fix Sudo problem 2026-03-30 20:55:02 +03:00
Black_Raven 4eabcc8aef CI_github: fix docksV4 2026-03-30 16:03:16 +03:00
Black_Raven 35e8d8077d ci: Fix ring 2026-03-30 15:59:43 +03:00
Black_Raven 6e9a113629 ci: fix sudo 2026-03-30 15:37:37 +03:00
Black_Raven 75afcea506 Merge pull request 'feat(ui): to lockal directory feature' (#4) from feature/ui into main
Reviewed-on: https://git.rustprogersteam.ru/Black_Raven/UIrclone/pulls/4
2026-03-30 11:58:45 +00:00
Black_Raven 44b94bdb0b Merge pull request 'dev/ui' (#3) from dev/ui into main
Reviewed-on: https://git.rustprogersteam.ru/Black_Raven/UIrclone/pulls/3
2026-03-29 20:02:10 +00:00
11 changed files with 1095 additions and 36 deletions

No files matched your search

+16 -9
View File
@@ -26,9 +26,6 @@ jobs:
- target: x86_64-pc-windows-gnu - target: x86_64-pc-windows-gnu
suffix: ".exe" suffix: ".exe"
binary_path: "target/x86_64-pc-windows-gnu/release" binary_path: "target/x86_64-pc-windows-gnu/release"
- target: x86_64-apple-darwin
suffix: ""
binary_path: "target/x86_64-apple-darwin/release"
steps: steps:
- name: Checkout code - name: Checkout code
@@ -36,16 +33,20 @@ jobs:
- name: Install System Dependencies - name: Install System Dependencies
run: | run: |
sudo apt-get update apt-get update
# Зависимости для egui/eframe на Linux # Базовые инструменты сборки (исправляет ошибку ring на Linux/Windows)
sudo apt-get install -y libwayland-dev libxkbcommon-dev libegl1-mesa-dev libgbm-dev apt-get install -y build-essential llvm-dev libclang-dev clang
# Инструменты кросс-компиляции
# Зависимости для графики egui
apt-get install -y libwayland-dev libxkbcommon-dev libegl1-mesa-dev libgbm-dev
if [[ "${{ matrix.target }}" == *"windows"* ]]; then if [[ "${{ matrix.target }}" == *"windows"* ]]; then
sudo apt-get install -y gcc-mingw-w64 apt-get install -y gcc-mingw-w64
fi fi
- name: Install Rust & Target - name: Install Rust & Target
run: | run: |
# Используем вашу логику установки
if ! command -v rustc &> /dev/null; then if ! command -v rustc &> /dev/null; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
echo "$HOME/.cargo/bin" >> $GITHUB_PATH echo "$HOME/.cargo/bin" >> $GITHUB_PATH
@@ -65,7 +66,13 @@ jobs:
${{ matrix.target }}-release- ${{ matrix.target }}-release-
- name: Build Binary - name: Build Binary
run: cargo build --release --target ${{ matrix.target }} run: |
# Для 'ring' при кросс-компиляции часто помогает использование clang вместо gcc
if [[ "${{ matrix.target }}" == *"apple"* ]]; then
export AR_x86_64_apple_darwin=x86_64-apple-darwin-ar
export CC_x86_64_apple_darwin=x86_64-apple-darwin-clang
fi
cargo build --release --target ${{ matrix.target }}
- name: Prepare Artifact - name: Prepare Artifact
run: | run: |
+1 -1
View File
@@ -94,7 +94,7 @@ jobs:
mkdir -p ./docs-output && cp -r target/doc/* ./docs-output/ mkdir -p ./docs-output && cp -r target/doc/* ./docs-output/
- name: Upload Artifact - name: Upload Artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: rust-documentation name: rust-documentation
path: ./docs-output/ path: ./docs-output/
+1 -4
View File
@@ -26,9 +26,6 @@ jobs:
- target: x86_64-pc-windows-gnu - target: x86_64-pc-windows-gnu
suffix: ".exe" suffix: ".exe"
binary_path: "target/x86_64-pc-windows-gnu/release" binary_path: "target/x86_64-pc-windows-gnu/release"
- target: x86_64-apple-darwin
suffix: ""
binary_path: "target/x86_64-apple-darwin/release"
steps: steps:
- name: Checkout code - name: Checkout code
@@ -76,7 +73,7 @@ jobs:
cp -r assets ./out/assets cp -r assets ./out/assets
- name: Upload Artifact - name: Upload Artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: ${{ env.APP_NAME }}-${{ matrix.target }} name: ${{ env.APP_NAME }}-${{ matrix.target }}
path: ./out/ path: ./out/
+191
View File
@@ -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) – кросс-платформенное определение директорий.
---
**Наслаждайтесь удобным управлением вашими облачными файлами!**
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

+144
View File
@@ -1,4 +1,20 @@
use crate::operations::remotes::{config_create_step, ConfigStep};
use crate::operations::{FileInfo, SyncStats}; 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] #[test]
fn test_file_info_formatting() { fn test_file_info_formatting() {
@@ -27,3 +43,131 @@ fn test_sync_stats_formatting() {
assert_eq!(stats.format_speed(), "2.4 MB/s"); 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<String> = 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"));
}
+89 -13
View File
@@ -71,24 +71,100 @@ pub fn get_config(app: &RcloneApp, remote_name: &str) -> Result<Remote, String>
} }
} }
/// Создать новое удаленное хранилище /// Вопрос, который rclone задает в процессе создания remote
pub fn create( #[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, app: &RcloneApp,
name: &str, name: &str,
r#type: &str, r#type: &str,
config: &HashMap<String, String>, state: Option<&str>,
) -> Result<(), String> { result: Option<&str>,
log::info!("Создание нового remote: {} типа {}", name, r#type); on_stderr: &mut dyn FnMut(&str),
let mut args = vec!["config", "create", name, r#type]; ) -> Result<ConfigStep, String> {
log::debug!("config create шаг: remote={} тип={} state={:?}", name, r#type, state);
for (key, value) in config { let mut args = vec!["config", "create", name, r#type, "--non-interactive"];
args.push(key.as_str()); if let Some(s) = state {
args.push(value.as_str()); args.extend([
"--continue",
"--state",
s,
"--result",
result.unwrap_or_default(),
]);
} }
app.run_command(&args)?; let output = app.run_command_with_stderr_feed(&args, on_stderr)?;
log::info!("Remote {} успешно создан", name);
Ok(()) 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 /// Обновить конфигурацию remote
+59
View File
@@ -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<String, String> {
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<String, String> { pub fn version(&self) -> Result<String, String> {
log::debug!("Получение версии rclone"); log::debug!("Получение версии rclone");
self.run_command(&["version"]) self.run_command(&["version"])
+46
View File
@@ -1,6 +1,9 @@
//! Графический интерфейс для операций rclone с интегрированным Local FS и поддержкой кастомных шрифтов. //! Графический интерфейс для операций rclone с интегрированным Local FS и поддержкой кастомных шрифтов.
use crate::operations::remotes::ConfigQuestion;
use crate::operations::{FileInfo, Remote}; use crate::operations::{FileInfo, Remote};
use crate::rclone_install::RcloneApp; use crate::rclone_install::RcloneApp;
use eframe::egui;
use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use std::sync::mpsc::{Receiver, Sender}; use std::sync::mpsc::{Receiver, Sender};
use std::time::Instant; use std::time::Instant;
@@ -32,9 +35,39 @@ enum OperationResult {
Failure(u32, String), Failure(u32, String),
FileList(Vec<FileInfo>), FileList(Vec<FileInfo>),
RemoteList(Vec<Remote>), RemoteList(Vec<Remote>),
RemoteAdded(u32, Vec<Remote>),
ConfigQuestion(u32, String, ConfigQuestion),
RemoteCheck(u32, String, bool),
RemoteAbout(u32, String, HashMap<String, u64>),
RemoteDeleted(u32, String),
ProgressUpdate(u32, f32, 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 { pub struct RcloneUI {
rclone: Option<Arc<RcloneApp>>, rclone: Option<Arc<RcloneApp>>,
state: AppState, state: AppState,
@@ -61,6 +94,19 @@ pub struct RcloneUI {
#[allow(dead_code)] #[allow(dead_code)]
settings: AppSettings, settings: AppSettings,
show_add_remote_dialog: bool,
new_remote_name: String,
new_remote_type: String,
add_remote_step: AddRemoteStep,
add_remote_state: Option<String>,
add_remote_answer: String,
add_remote_status: String,
remote_to_delete: Option<String>,
remote_info: Option<RemoteInfoView>,
context_menu: Option<(String, egui::Pos2)>,
context_menu_requested: bool,
active_task_count: u32, active_task_count: u32,
operation_tx: Sender<OperationResult>, operation_tx: Sender<OperationResult>,
operation_rx: Receiver<OperationResult>, operation_rx: Receiver<OperationResult>,
+280 -8
View File
@@ -7,6 +7,14 @@ use egui::{
use std::path::Path; use std::path::Path;
use std::time::Duration; use std::time::Duration;
/// Действия контекстного меню хранилища
enum RemoteMenuAction {
Open,
Check,
About,
Delete,
}
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_rclone_init();
@@ -42,16 +50,28 @@ impl eframe::App for RcloneUI {
ui.separator(); ui.separator();
ScrollArea::vertical().show(ui, |ui| { ScrollArea::vertical().show(ui, |ui| {
for remote in self.remote_list.clone() { for remote in self.remote_list.clone() {
if ui let selected = self.current_path.starts_with(&remote.name);
.selectable_label( let response =
self.current_path.starts_with(&remote.name), ui.selectable_label(selected, format!("📡 {}", remote.name));
format!("📡 {}", remote.name), if response.clicked() {
) log::debug!("Открытие remote: {}", remote.name);
.clicked()
{
log::debug!("Выбрано удаленное хранилище: {}", remote.name);
self.navigate_to(format!("{}:", 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 { if self.show_transfer_dialog {
Window::new("Трансфер") 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<RemoteMenuAction> = 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() { if let Some(path) = self.pending_load_path.take() {
self.current_path = path.clone(); self.current_path = path.clone();
self.selected_paths.clear(); self.selected_paths.clear();
+268 -1
View File
@@ -1,4 +1,5 @@
use super::*; use super::*;
use crate::operations::remotes::ConfigStep;
use crate::operations::{self, CopyOptions, DeleteOptions, MoveOptions}; use crate::operations::{self, CopyOptions, DeleteOptions, MoveOptions};
use crate::rclone_install::RcloneApp; use crate::rclone_install::RcloneApp;
use eframe::egui; use eframe::egui;
@@ -9,6 +10,19 @@ use std::sync::mpsc::channel;
use std::time::Instant; use std::time::Instant;
use tokio::runtime::Runtime; 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 { impl RcloneUI {
pub fn new(cc: &eframe::CreationContext<'_>) -> Self { pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
log::debug!("Инициализация RcloneUI"); log::debug!("Инициализация RcloneUI");
@@ -88,6 +102,17 @@ impl RcloneUI {
local_browser_files: Vec::new(), local_browser_files: Vec::new(),
is_move_mode: false, is_move_mode: false,
settings: AppSettings::default(), 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, active_task_count: 0,
operation_tx: op_tx, operation_tx: op_tx,
operation_rx: op_rx, 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<RcloneApp>,
name: String,
rtype: String,
initial_state: Option<String>,
initial_answer: Option<String>,
op_id: u32,
tx: Sender<OperationResult>,
) {
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() { 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!("Загрузка списка удаленных хранилищ"); log::info!("Загрузка списка удаленных хранилищ");
self.active_task_count += 1; self.active_task_count += 1;
let tx = self.operation_tx.clone(); let tx = self.operation_tx.clone();
@@ -229,6 +420,9 @@ impl RcloneUI {
log::error!("Операция {} завершена с ошибкой: {}", op_id, e); log::error!("Операция {} завершена с ошибкой: {}", op_id, e);
self.active_task_count = self.active_task_count.saturating_sub(1); self.active_task_count = self.active_task_count.saturating_sub(1);
self.active_operations.retain(|op| op.id != op_id); 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); self.error_message = Some(e);
} }
OperationResult::FileList(files) => { OperationResult::FileList(files) => {
@@ -243,12 +437,85 @@ impl RcloneUI {
self.remote_list = remotes; self.remote_list = remotes;
self.active_task_count = self.active_task_count.saturating_sub(1); 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) => { OperationResult::ProgressUpdate(op_id, progress, status) => {
if let Some(op) = self.active_operations.iter_mut().find(|op| op.id == op_id) { if let Some(op) = self.active_operations.iter_mut().find(|op| op.id == op_id) {
op.progress = progress; op.progress = progress;
op.status = status.clone(); op.status = status.clone();
log::trace!("Операция {}: прогресс {:.1}%, статус: {}", op_id, progress * 100.0, status); log::trace!("Операция {}: прогресс {:.1}%, статус: {}", op_id, progress * 100.0, status);
} }
if matches!(&self.add_remote_step, AddRemoteStep::Busy) {
self.add_remote_status = status;
}
} }
} }
} }