Compare commits

...
10 Commits
7 changed files with 907 additions and 33 deletions

No files matched your search

+21 -11
View File
@@ -7,7 +7,9 @@
## Возможности ## Возможности
- **Автоматическая установка rclone** – обнаруживает системный rclone или загружает последнюю версию автоматически. - **Автоматическая установка 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. Запустите приложение. 1. Запустите приложение.
2. На левой боковой панели отобразится список ваших rclone remote (если они есть). Если remotes не настроены, используйте командную строку rclone для их добавления – в будущих версиях появится графический интерфейс для настройки. 2. На левой боковой панели отобразится список ваших rclone remote. Чтобы добавить новое хранилище, нажмите кнопку **«➕ Добавить облако»** и заполните мастер:
3. Нажмите на имя remote, чтобы просмотреть его содержимое. - **Имя** – например, `mydrive`.
4. Выберите файлы/папки, щёлкая по их иконке (📁 или 📄). Можно выбирать несколько элементов. - **Тип** – например, `drive`, `dropbox`, `s3`, `webdav`.
5. После выбора внизу появятся кнопки: **Копировать**, **Переместить**, **Удалить**. - Если выбранный тип требует авторизации (OAuth), после нажатия «Создать» автоматически откроется браузер для входа в аккаунт. При необходимости rclone задаст дополнительные вопросы (например, о Team Drive) – ответьте в окне мастера.
6. Для копирования/перемещения укажите путь назначения: 3. Нажмите на имя remote **левой кнопкой мыши**, чтобы просмотреть его содержимое.
4. **Правая кнопка мыши** по хранилищу открывает контекстное меню:
- **📂 Открыть** – перейти в хранилище.
- **🔍 Проверить доступность** – проверить подключение.
- **📊 Использование** – показать общий объём, занятое и свободное место, число файлов.
- **🗑️ Удалить** – удалить хранилище из конфигурации rclone (файлы в облаке при этом не удаляются; требуется подтверждение).
5. Выберите файлы/папки, щёлкая по их иконке (📁 или 📄). Можно выбирать несколько элементов.
6. После выбора внизу появятся кнопки: **Копировать**, **Переместить**, **Удалить**.
7. Для копирования/перемещения укажите путь назначения:
- **Облако** – введите путь в формате rclone (например, `gdrive:/backup`). - **Облако** – введите путь в формате rclone (например, `gdrive:/backup`).
- **ПК** – выберите локальную папку через встроенный файловый браузер или введите путь вручную. - **ПК** – выберите локальную папку через встроенный файловый браузер или введите путь вручную.
7. Нажмите **Начать** – операция запустится в фоне. Прогресс отображается на правой панели. 8. Нажмите **Начать** – операция запустится в фоне. Прогресс отображается на правой панели.
### Навигация ### Навигация
- **⬅ / ➡** – переход назад / вперёд по истории. - **⬅ / ➡** – переход назад / вперёд по истории.
- **🔄** – обновить текущую директорию. - **🔄** – обновить текущую директорию.
- **📁 .. (Вверх)** – подняться на уровень выше. - **📁 .. (Вверх)** – подняться на уровень выше.
- **Правая кнопка мыши по хранилищу** – контекстное меню хранилища.
### Обработка ошибок ### Обработка ошибок
@@ -104,9 +115,9 @@ src/
│ ├── app.rs # Реализация egui::App │ ├── app.rs # Реализация egui::App
│ └── rcloneui.rs # Логика UI, фоновые задачи │ └── rcloneui.rs # Логика UI, фоновые задачи
├── operations/ ├── operations/
│ ├── files.rs # Обёртка над rclone lsjson │ ├── files.rs # Обёртка над rclone lsjson / about
│ ├── sync.rs # copy/move/delete/purge │ ├── 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 # Чтение локальных директорий │ ├── local_fs.rs # Чтение локальных директорий
│ ├── fileinfo.rs # Вспомогательные методы FileInfo │ ├── fileinfo.rs # Вспомогательные методы FileInfo
│ └── ... # info, search, check │ └── ... # info, search, check
@@ -153,12 +164,11 @@ cargo test
## Планы на будущее ## Планы на будущее
- [ ] Графическая настройка remote (создание/изменение). - [ ] Графическое изменение конфигурации существующих remote.
- [ ] Предварительный просмотр синхронизации (dry-run со списком файлов). - [ ] Предварительный просмотр синхронизации (dry-run со списком файлов).
- [ ] Ползунок ограничения скорости. - [ ] Ползунок ограничения скорости.
- [ ] Поиск файлов по имени/пути. - [ ] Поиск файлов по имени/пути.
- [ ] Поддержка фильтров rclone (`--include`, `--exclude`). - [ ] Поддержка фильтров rclone (`--include`, `--exclude`).
- [ ] Отображение информации о ёмкости хранилища (rclone about).
- [ ] Переключение светлой/тёмной темы. - [ ] Переключение светлой/тёмной темы.
## Лицензия ## Лицензия
+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;
}
} }
} }
} }