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
This commit is contained in:
Black_Raven committed 2026-03-30 11:58:45 +00:00
commit 75afcea506
20 files changed
+1288 -597

No files matched your search

+76 -76
View File
@@ -1,101 +1,101 @@
name: Rust CI
on: [push, pull_request]
name: Rust CI (Native Install + Cache)
on:
push:
branches-ignore:
- release
pull_request:
branches-ignore:
- release
env:
CARGO_TERM_COLOR: always
jobs:
check:
build_and_check:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
true
- name: Install Rust
run: |
if ! command -v rustc &> /dev/null; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
fi
- name: Verify Rust installation
run: |
rustc --version
cargo --version
- name: Cache Cargo
uses: actions/cache@v3
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
- name: Build
run: cargo check --verbose
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Verify Rust installation
run: |
rustc --version
cargo --version
- name: Build
run: cargo build --verbose
- name: Cargo Check & Build
run: |
cargo check --verbose
cargo build --verbose
test:
needs: build
needs: build_and_check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Install Rust
run: |
if ! command -v rustc &> /dev/null; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
fi
- name: Verify Rust installation
run: |
rustc --version
cargo --version
- name: Cache Cargo
uses: actions/cache@v3
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Test
run: cargo test --verbose
- name: Run Tests
run: cargo test --verbose
docs:
needs: build
needs: build_and_check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Install Rust
run: |
if ! command -v rustc &> /dev/null; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
fi
- name: Verify Rust installation
run: |
rustc --version
cargo --version
- name: Cache Cargo
uses: actions/cache@v3
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Build documentation
run: cargo doc --no-deps --document-private-items
- name: Create docs directory
run: mkdir -p ./docs-output && cp -r target/doc/* ./docs-output/
- name: Upload documentation as artifact
uses: actions/upload-artifact@v3
with:
name: rust-documentation
path: ./docs-output/
retention-days: 30
if-no-files-found: error
- name: Build docs
run: |
cargo doc --no-deps --document-private-items
mkdir -p ./docs-output && cp -r target/doc/* ./docs-output/
- name: Upload Artifact
uses: actions/upload-artifact@v3
with:
name: rust-documentation
path: ./docs-output/
retention-days: 7
+83
View File
@@ -0,0 +1,83 @@
name: Release Build
on:
push:
branches:
- release
pull_request:
branches:
- release
env:
CARGO_TERM_COLOR: always
# Название вашего приложения из Cargo.toml
APP_NAME: "UIrclone"
jobs:
build:
name: Build for ${{ matrix.target }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-gnu
suffix: ""
binary_path: "target/x86_64-unknown-linux-gnu/release"
- target: x86_64-pc-windows-gnu
suffix: ".exe"
binary_path: "target/x86_64-pc-windows-gnu/release"
- target: x86_64-apple-darwin
suffix: ""
binary_path: "target/x86_64-apple-darwin/release"
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install System Dependencies
run: |
sudo apt-get update
# Зависимости для egui/eframe на Linux
sudo apt-get install -y libwayland-dev libxkbcommon-dev libegl1-mesa-dev libgbm-dev
# Инструменты кросс-компиляции
if [[ "${{ matrix.target }}" == *"windows"* ]]; then
sudo apt-get install -y gcc-mingw-w64
fi
- name: Install Rust & Target
run: |
if ! command -v rustc &> /dev/null; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
source $HOME/.cargo/env
fi
rustup target add ${{ matrix.target }}
- name: Cache Cargo
uses: actions/cache@v3
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ matrix.target }}-release-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ matrix.target }}-release-
- name: Build Binary
run: cargo build --release --target ${{ matrix.target }}
- name: Prepare Artifact
run: |
mkdir -p ./out
# Копируем бинарник
cp "${{ matrix.binary_path }}/${{ env.APP_NAME }}${{ matrix.suffix }}" ./out/
# Копируем ассеты (шрифты), так как они нужны приложению в рантайме
cp -r assets ./out/assets
- name: Upload Artifact
uses: actions/upload-artifact@v3
with:
name: ${{ env.APP_NAME }}-${{ matrix.target }}
path: ./out/
retention-days: 5
+76 -75
View File
@@ -1,100 +1,101 @@
name: Rust CI
on: [push, pull_request]
name: Rust CI (Native Install + Cache)
on:
push:
branches-ignore:
- release
pull_request:
branches-ignore:
- release
env:
CARGO_TERM_COLOR: always
jobs:
check:
build_and_check:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Install Rust
run: |
if ! command -v rustc &> /dev/null; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
fi
- name: Verify Rust installation
run: |
rustc --version
cargo --version
- name: Cache Cargo
uses: actions/cache@v3
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
- name: Build
run: cargo check --verbose
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Verify Rust installation
run: |
rustc --version
cargo --version
- name: Build
run: cargo build --verbose
- name: Cargo Check & Build
run: |
cargo check --verbose
cargo build --verbose
test:
needs: [check, build]
needs: build_and_check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Install Rust
run: |
if ! command -v rustc &> /dev/null; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
fi
- name: Verify Rust installation
run: |
rustc --version
cargo --version
- name: Cache Cargo
uses: actions/cache@v3
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Test
run: cargo test --verbose
- name: Run Tests
run: cargo test --verbose
docs:
needs: [check, build]
needs: build_and_check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Install Rust
run: |
if ! command -v rustc &> /dev/null; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
fi
- name: Verify Rust installation
run: |
rustc --version
cargo --version
- name: Cache Cargo
uses: actions/cache@v3
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Build documentation
run: cargo doc --no-deps --document-private-items
- name: Create docs directory
run: mkdir -p ./docs-output && cp -r target/doc/* ./docs-output/
- name: Upload documentation as artifact
uses: actions/upload-artifact@v4
with:
name: rust-documentation
path: ./docs-output/
retention-days: 30
if-no-files-found: error
- name: Build docs
run: |
cargo doc --no-deps --document-private-items
mkdir -p ./docs-output && cp -r target/doc/* ./docs-output/
- name: Upload Artifact
uses: actions/upload-artifact@v3
with:
name: rust-documentation
path: ./docs-output/
retention-days: 7
+83
View File
@@ -0,0 +1,83 @@
name: Release Build
on:
push:
branches:
- release
pull_request:
branches:
- release
env:
CARGO_TERM_COLOR: always
# Название вашего приложения из Cargo.toml
APP_NAME: "UIrclone"
jobs:
build:
name: Build for ${{ matrix.target }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-gnu
suffix: ""
binary_path: "target/x86_64-unknown-linux-gnu/release"
- target: x86_64-pc-windows-gnu
suffix: ".exe"
binary_path: "target/x86_64-pc-windows-gnu/release"
- target: x86_64-apple-darwin
suffix: ""
binary_path: "target/x86_64-apple-darwin/release"
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install System Dependencies
run: |
sudo apt-get update
# Зависимости для egui/eframe на Linux
sudo apt-get install -y libwayland-dev libxkbcommon-dev libegl1-mesa-dev libgbm-dev
# Инструменты кросс-компиляции
if [[ "${{ matrix.target }}" == *"windows"* ]]; then
sudo apt-get install -y gcc-mingw-w64
fi
- name: Install Rust & Target
run: |
if ! command -v rustc &> /dev/null; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
source $HOME/.cargo/env
fi
rustup target add ${{ matrix.target }}
- name: Cache Cargo
uses: actions/cache@v3
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ matrix.target }}-release-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ matrix.target }}-release-
- name: Build Binary
run: cargo build --release --target ${{ matrix.target }}
- name: Prepare Artifact
run: |
mkdir -p ./out
# Копируем бинарник
cp "${{ matrix.binary_path }}/${{ env.APP_NAME }}${{ matrix.suffix }}" ./out/
# Копируем ассеты (шрифты), так как они нужны приложению в рантайме
cp -r assets ./out/assets
- name: Upload Artifact
uses: actions/upload-artifact@v3
with:
name: ${{ env.APP_NAME }}-${{ matrix.target }}
path: ./out/
retention-days: 5
Generated
+32
View File
@@ -11,8 +11,10 @@ dependencies = [
"eframe",
"egui",
"egui_extras",
"env_logger",
"flate2",
"futures",
"log",
"mockito",
"reqwest",
"rfd",
@@ -1362,6 +1364,19 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "env_logger"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580"
dependencies = [
"humantime",
"is-terminal",
"log",
"regex",
"termcolor",
]
[[package]]
name = "epaint"
version = "0.27.2"
@@ -2009,6 +2024,12 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "humantime"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
[[package]]
name = "hyper"
version = "0.14.32"
@@ -2278,6 +2299,17 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
[[package]]
name = "is-terminal"
version = "0.4.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi 0.5.2",
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "itoa"
version = "1.0.17"
+4
View File
@@ -22,6 +22,10 @@ eframe = { version = "0.27", features = ["default"] }
egui = "0.27"
egui_extras = { version = "0.27" }
# Логирование
log = "0.4"
env_logger = "0.10"
[dev-dependencies]
tempfile = "3.8"
serial_test = "2.0"
+2
View File
@@ -35,6 +35,8 @@ pkgs.mkShell {
pkgs.xorg.libX11
pkgs.mesa
pkgs.libglvnd
pkgs.gtk3
pkgs.glib
];
# (Необязательно) принудительно использовать X11 backend
+14
View File
@@ -6,6 +6,13 @@ use eframe::egui;
use egui::{FontData, FontDefinitions, FontFamily};
fn main() -> Result<(), eframe::Error> {
// Инициализация логгера
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
.format_timestamp_millis()
.init();
log::info!("Запуск Rclone UI Manager");
let viewport = egui::ViewportBuilder::default()
.with_inner_size([1200.0, 800.0])
.with_min_inner_size([800.0, 600.0])
@@ -20,11 +27,15 @@ fn main() -> Result<(), eframe::Error> {
"Rclone UI Manager",
options,
Box::new(|cc| {
log::debug!("Инициализация шрифтов");
let mut fonts = FontDefinitions::default();
if let Ok(font_data) = std::fs::read("assets/fonts/Sans.otf") {
fonts.font_data.insert("sans".to_owned(), FontData::from_owned(font_data));
fonts.families.entry(FontFamily::Proportional).or_default().insert(0, "sans".to_owned());
log::debug!("Загружен шрифт Sans.otf");
} else {
log::warn!("Не найден файл шрифта assets/fonts/Sans.otf");
}
if let Ok(emoji_data) = std::fs::read("assets/fonts/NotoColorEmoji.ttf") {
@@ -33,6 +44,9 @@ fn main() -> Result<(), eframe::Error> {
if !prop.contains(&"emoji".to_owned()) {
prop.push("emoji".to_owned());
}
log::debug!("Загружен шрифт NotoColorEmoji.ttf");
} else {
log::warn!("Не найден файл шрифта assets/fonts/NotoColorEmoji.ttf");
}
cc.egui_ctx.set_fonts(fonts);
+3 -20
View File
@@ -1,6 +1,7 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use serde::{Serialize, Deserialize};
pub mod local_fs;
/// === Модуль для работы с удаленными хранилищами ===
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -23,25 +24,7 @@ pub struct FileInfo {
pub modified: Option<String>,
}
impl FileInfo {
pub fn format_size(&self) -> String {
let bytes = self.size;
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))
}
}
pub fn icon(&self) -> &'static str {
if self.is_dir { "📁" } else { "📄" }
}
}
pub mod fileinfo;
pub mod files;
/// === Модуль для операций синхронизации ===
-2
View File
@@ -1,6 +1,4 @@
use super::*;
use crate::rclone_install::RcloneApp;
use serde_json::Value;
/// Проверить разницу между двумя директориями
pub fn diff(app: &RcloneApp, source: &str, dest: &str) -> Result<Vec<String>, String> {
+19
View File
@@ -0,0 +1,19 @@
use super::*;
impl FileInfo {
pub fn format_size(&self) -> String {
let bytes = self.size;
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))
}
}
pub fn icon(&self) -> &'static str {
if self.is_dir { "📁" } else { "📄" }
}
}
-1
View File
@@ -1,4 +1,3 @@
use super::*;
use crate::rclone_install::RcloneApp;
use serde_json::Value;
use std::collections::HashMap;
+33
View File
@@ -0,0 +1,33 @@
use super::*;
use std::fs;
use std::path::PathBuf;
pub fn list_directory(path: &str) -> Result<Vec<FileInfo>, String> {
let p = if path.is_empty() {
if cfg!(windows) {
PathBuf::from("C:\\")
} else {
PathBuf::from("/")
}
} else {
PathBuf::from(path)
};
let entries = fs::read_dir(&p).map_err(|e| e.to_string())?;
let mut files = Vec::new();
for entry in entries.flatten() {
if let Ok(meta) = entry.metadata() {
files.push(FileInfo {
name: entry.file_name().to_string_lossy().to_string(),
path: entry.path().to_string_lossy().to_string(),
size: meta.len(),
is_dir: meta.is_dir(),
modified: None,
});
}
}
// Сначала папки, потом файлы, по алфавиту
files.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then(a.name.cmp(&b.name)));
Ok(files)
}
+21 -2
View File
@@ -4,6 +4,7 @@ use serde_json::Value;
/// Получить список всех удаленных хранилищ
pub fn list(app: &RcloneApp) -> Result<Vec<Remote>, String> {
log::debug!("Получение списка удаленных хранилищ");
let output = app.run_command(&["listremotes"])?;
let mut remotes = Vec::new();
@@ -14,6 +15,7 @@ pub fn list(app: &RcloneApp) -> Result<Vec<Remote>, String> {
if let Ok(config) = get_config(app, name) {
remotes.push(config);
} else {
log::warn!("Не удалось получить конфигурацию для remote '{}'", name);
// Если не удалось получить конфиг, добавляем базовую информацию
remotes.push(Remote {
name: name.to_string(),
@@ -24,11 +26,13 @@ pub fn list(app: &RcloneApp) -> Result<Vec<Remote>, String> {
}
}
log::info!("Найдено {} удаленных хранилищ", remotes.len());
Ok(remotes)
}
/// Получить конфигурацию конкретного remote
pub fn get_config(app: &RcloneApp, remote_name: &str) -> Result<Remote, String> {
log::debug!("Получение конфигурации для remote: {}", remote_name);
let output = app.run_command(&["config", "dump"])?;
let configs: Value = serde_json::from_str(&output)
@@ -54,13 +58,16 @@ pub fn get_config(app: &RcloneApp, remote_name: &str) -> Result<Remote, String>
}
}
log::debug!("Получена конфигурация для {} типа {}", remote_name, r#type);
Ok(Remote {
name: remote_name.to_string(),
r#type,
config: config_map,
})
} else {
Err(format!("Remote '{}' не найден", remote_name))
let error_msg = format!("Remote '{}' не найден", remote_name);
log::error!("{}", error_msg);
Err(error_msg)
}
}
@@ -71,6 +78,7 @@ pub fn create(
r#type: &str,
config: &HashMap<String, String>,
) -> Result<(), String> {
log::info!("Создание нового remote: {} типа {}", name, r#type);
let mut args = vec!["config", "create", name, r#type];
for (key, value) in config {
@@ -79,11 +87,13 @@ pub fn create(
}
app.run_command(&args)?;
log::info!("Remote {} успешно создан", name);
Ok(())
}
/// Обновить конфигурацию remote
pub fn update(app: &RcloneApp, name: &str, config: &HashMap<String, String>) -> Result<(), String> {
log::info!("Обновление конфигурации remote: {}", name);
let mut args = vec!["config", "update", name];
for (key, value) in config {
@@ -92,24 +102,33 @@ pub fn update(app: &RcloneApp, name: &str, config: &HashMap<String, String>) ->
}
app.run_command(&args)?;
log::info!("Конфигурация remote {} успешно обновлена", name);
Ok(())
}
/// Удалить remote
pub fn delete(app: &RcloneApp, name: &str) -> Result<(), String> {
log::info!("Удаление remote: {}", name);
app.run_command(&["config", "delete", name])?;
log::info!("Remote {} успешно удален", name);
Ok(())
}
/// Проверить доступность remote
pub fn check(app: &RcloneApp, remote_name: &str) -> Result<bool, String> {
log::debug!("Проверка доступности remote: {}", remote_name);
let result = app.run_command(&["lsd", remote_name, "--max-depth", "1"]);
match result {
Ok(_) => Ok(true),
Ok(_) => {
log::debug!("Remote {} доступен", remote_name);
Ok(true)
}
Err(e) => {
if e.contains("directory not found") {
log::warn!("Remote {} существует, но пуст", remote_name);
Ok(true) // Remote существует, но пустой
} else {
log::error!("Ошибка доступа к remote {}: {}", remote_name, e);
Err(e)
}
}
-1
View File
@@ -1,6 +1,5 @@
use super::*;
use crate::rclone_install::RcloneApp;
use serde_json::Value;
/// Скопировать файлы/директории
pub fn copy(
+46 -15
View File
@@ -1,19 +1,18 @@
use flate2::read::GzDecoder;
use reqwest;
use std::fs;
use std::io::Cursor;
use std::path::PathBuf;
use std::process::Command;
use tar::Archive;
use tokio;
use super::RcloneApp;
impl RcloneApp {
pub async fn new() -> Result<Self, Box<dyn std::error::Error>> {
log::info!("Инициализация RcloneApp");
// Сначала проверяем системный rclone
if let Some(system_rclone) = Self::check_system_rclone() {
println!("Найден системный rclone: {:?}", system_rclone);
log::info!("Найден системный rclone: {:?}", system_rclone);
return Ok(Self {
rclone_path: system_rclone,
using_system_rclone: true,
@@ -32,7 +31,7 @@ impl RcloneApp {
};
if rclone_path.exists() {
println!("Найден rclone в директории приложения: {:?}", rclone_path);
log::info!("Найден rclone в директории приложения: {:?}", rclone_path);
return Ok(Self {
rclone_path,
using_system_rclone: false,
@@ -40,12 +39,13 @@ impl RcloneApp {
}
// Если ничего не найдено - устанавливаем
println!("rclone не найден. Начинаю установку...");
log::warn!("rclone не найден. Начинаю установку...");
Self::install_latest_rclone().await
}
// Публичный конструктор для тестов и создания экземпляра с заданными параметрами
pub fn new_with_path(rclone_path: PathBuf, using_system_rclone: bool) -> Self {
log::debug!("Создание RcloneApp с путем {:?}, системный: {}", rclone_path, using_system_rclone);
Self {
rclone_path,
using_system_rclone,
@@ -63,30 +63,36 @@ impl RcloneApp {
// Сеттеры
pub fn set_rclone_path(&mut self, path: PathBuf) {
log::debug!("Изменение пути rclone с {:?} на {:?}", self.rclone_path, path);
self.rclone_path = path;
}
pub fn set_using_system_rclone(&mut self, value: bool) {
log::debug!("Изменение флага using_system_rclone с {} на {}", self.using_system_rclone, value);
self.using_system_rclone = value;
}
pub fn check_system_rclone() -> Option<PathBuf> {
log::debug!("Проверка системного rclone");
if let Ok(path) = which::which("rclone") {
log::debug!("Найден системный rclone по пути: {:?}", path);
return Some(path);
}
#[cfg(windows)]
{
if let Ok(path) = which::which("rclone.exe") {
log::debug!("Найден системный rclone.exe по пути: {:?}", path);
return Some(path);
}
}
log::debug!("Системный rclone не найден");
None
}
async fn install_latest_rclone() -> Result<Self, Box<dyn std::error::Error>> {
println!("Начинаю установку последней версии rclone...");
log::info!("Начинаю установку последней версии rclone...");
// Определяем ОС и архитектуру
let os = if cfg!(target_os = "linux") {
@@ -109,8 +115,12 @@ impl RcloneApp {
return Err("Неподдерживаемая архитектура".into());
};
log::info!("ОС: {}, архитектура: {}", os, arch);
// Получаем информацию о последнем релизе
let client = reqwest::Client::new();
log::debug!("Запрос последней версии rclone с GitHub API");
let latest_release: serde_json::Value = client
.get("https://api.github.com/repos/rclone/rclone/releases/latest")
.header("User-Agent", "rclone-ui-installer")
@@ -124,27 +134,33 @@ impl RcloneApp {
.ok_or("Не удалось получить версию")?
.trim_start_matches('v');
log::info!("Найдена последняя версия rclone: {}", version);
// Формируем URL для скачивания
let download_url = format!(
"https://github.com/rclone/rclone/releases/download/v{}/rclone-v{}-{}-{}.zip",
version, version, os, arch
);
println!("Скачиваю rclone версии {} с: {}", version, download_url);
log::info!("Скачиваю rclone с: {}", download_url);
// Скачиваем архив
let response = client.get(&download_url).send().await?;
if !response.status().is_success() {
return Err(format!("Ошибка скачивания: HTTP {}", response.status()).into());
let error_msg = format!("Ошибка скачивания: HTTP {}", response.status());
log::error!("{}", error_msg);
return Err(error_msg.into());
}
let bytes = response.bytes().await?;
log::info!("Архив скачан, размер: {} байт", bytes.len());
// Создаем директорию приложения
let app_dir = dirs::data_dir()
.ok_or("Не удалось найти папку для данных")?
.join("rclone-ui");
fs::create_dir_all(&app_dir)?;
log::debug!("Директория приложения: {:?}", app_dir);
// Определяем имя исполняемого файла
let rclone_filename = if cfg!(windows) {
@@ -165,16 +181,18 @@ impl RcloneApp {
let file_name = file.name().to_string();
if file_name.ends_with("rclone") || file_name.ends_with("rclone.exe") {
log::debug!("Найден исполняемый файл в архиве: {}", file_name);
let mut contents = Vec::new();
std::io::copy(&mut file, &mut contents)?;
fs::write(&rclone_path, contents)?;
extracted = true;
println!("Найден и распакован: {}", file_name);
log::info!("Распакован: {} в {:?}", file_name, rclone_path);
break;
}
}
if !extracted {
log::error!("Не удалось найти исполняемый файл rclone в архиве");
return Err("Не удалось найти исполняемый файл rclone в архиве".into());
}
@@ -185,9 +203,10 @@ impl RcloneApp {
let mut perms = fs::metadata(&rclone_path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&rclone_path, perms)?;
log::debug!("Установлены права на выполнение для {:?}", rclone_path);
}
println!("rclone успешно установлен в {:?}", rclone_path);
log::info!("rclone успешно установлен в {:?}", rclone_path);
// Проверяем установку
let output = Command::new(&rclone_path)
@@ -197,7 +216,9 @@ impl RcloneApp {
if output.status.success() {
let version_output = String::from_utf8_lossy(&output.stdout);
println!("Установленная версия rclone:\n{}", version_output);
log::info!("Установленная версия rclone:\n{}", version_output);
} else {
log::warn!("Не удалось проверить версию установленного rclone");
}
Ok(Self {
@@ -207,19 +228,29 @@ impl RcloneApp {
}
pub fn run_command(&self, args: &[&str]) -> Result<String, String> {
log::debug!("Выполнение команды rclone: {} {:?}", self.rclone_path.display(), args);
let output = Command::new(&self.rclone_path)
.args(args)
.output()
.map_err(|e| format!("Ошибка запуска: {}", e))?;
.map_err(|e| {
log::error!("Ошибка запуска rclone: {}", e);
format!("Ошибка запуска: {}", e)
})?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
log::debug!("Команда выполнена успешно, вывод: {} байт", stdout.len());
Ok(stdout)
} else {
Err(String::from_utf8_lossy(&output.stderr).to_string())
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
log::error!("Ошибка выполнения команды: {}", stderr);
Err(stderr)
}
}
pub fn version(&self) -> Result<String, String> {
log::debug!("Получение версии rclone");
self.run_command(&["version"])
}
}
@@ -1,6 +1,5 @@
use super::RcloneApp;
use serial_test::serial;
use std::env;
use std::fs;
use tempfile::tempdir;
+20 -404
View File
@@ -1,13 +1,9 @@
//! Графический интерфейс для операций rclone.
use eframe::egui;
use egui::{CentralPanel, ScrollArea, Window, Align2, Color32, Spinner, ProgressBar, SidePanel};
use std::sync::Arc;
use std::sync::mpsc::{channel, Sender, Receiver};
use std::time::{Duration, Instant};
use tokio::runtime::Runtime;
//! Графический интерфейс для операций rclone с интегрированным Local FS и поддержкой кастомных шрифтов.
use crate::operations::{FileInfo, Remote};
use crate::rclone_install::RcloneApp;
use crate::operations::{self, FileInfo, Remote, CopyOptions, MoveOptions, DeleteOptions};
use std::sync::Arc;
use std::sync::mpsc::{Receiver, Sender};
use std::time::Instant;
#[derive(Clone, PartialEq)]
pub enum AppState {
@@ -16,6 +12,12 @@ pub enum AppState {
Error(String),
}
#[derive(Debug, PartialEq, Clone, Copy)]
enum TransferTab {
Remote,
Local,
}
#[derive(Clone)]
pub struct Operation {
pub id: u32,
@@ -39,7 +41,6 @@ pub struct RcloneUI {
error_message: Option<String>,
current_path: String,
// История переходов
history_back: Vec<String>,
history_forward: Vec<String>,
@@ -49,9 +50,15 @@ pub struct RcloneUI {
transfer_source_info: Vec<(String, bool)>,
transfer_dest: String,
active_transfer_tab: TransferTab,
show_transfer_dialog: bool,
show_local_browser: bool,
local_browser_path: String,
local_browser_files: Vec<FileInfo>,
is_move_mode: bool,
#[allow(dead_code)]
settings: AppSettings,
active_task_count: u32,
@@ -65,402 +72,11 @@ pub struct RcloneUI {
next_op_id: u32,
}
pub mod app;
pub mod rcloneui;
#[derive(Clone, Default)]
pub struct AppSettings {
pub show_hidden: bool,
pub bandwidth_limit: Option<String>,
}
impl RcloneUI {
pub fn new(_cc: &eframe::CreationContext<'_>) -> Self {
let (init_tx, init_rx) = channel();
let (op_tx, op_rx) = channel();
std::thread::spawn(move || {
let rt = Runtime::new().unwrap();
let result = rt.block_on(async { RcloneApp::new().await });
let res = match result {
Ok(app) => Ok(Arc::new(app)),
Err(e) => Err(e.to_string()),
};
let _ = init_tx.send(res);
});
Self {
rclone: None,
state: AppState::Initializing,
error_message: None,
current_path: String::new(),
history_back: Vec::new(),
history_forward: Vec::new(),
remote_list: Vec::new(),
current_files: Vec::new(),
selected_paths: Vec::new(),
transfer_source_info: Vec::new(),
transfer_dest: String::new(),
show_transfer_dialog: false,
is_move_mode: false,
settings: AppSettings::default(),
active_task_count: 0,
operation_tx: op_tx,
operation_rx: op_rx,
pending_load_path: None,
rclone_init_receiver: Some(init_rx),
active_operations: Vec::new(),
next_op_id: 1,
}
}
// --- Логика навигации ---
fn navigate_to(&mut self, new_path: String) {
if self.current_path == new_path { return; }
if !self.current_path.is_empty() {
self.history_back.push(self.current_path.clone());
}
self.history_forward.clear();
self.pending_load_path = Some(new_path);
}
fn go_back(&mut self) {
if let Some(prev_path) = self.history_back.pop() {
self.history_forward.push(self.current_path.clone());
self.pending_load_path = Some(prev_path);
}
}
fn go_forward(&mut self) {
if let Some(next_path) = self.history_forward.pop() {
self.history_back.push(self.current_path.clone());
self.pending_load_path = Some(next_path);
}
}
// --- Фоновые процессы ---
fn poll_rclone_init(&mut self) {
if let Some(receiver) = &self.rclone_init_receiver {
if let Ok(result) = receiver.try_recv() {
match result {
Ok(rclone) => {
self.rclone = Some(rclone);
self.load_remotes();
self.state = AppState::Ready;
}
Err(e) => {
self.error_message = Some(format!("Критическая ошибка: {}", e));
self.state = AppState::Error(e);
}
}
self.rclone_init_receiver = None;
}
}
}
fn load_remotes(&mut self) {
if let Some(rclone) = self.rclone.clone() {
self.active_task_count += 1;
let tx = self.operation_tx.clone();
std::thread::spawn(move || {
let _ = tx.send(match operations::remotes::list(&rclone) {
Ok(list) => OperationResult::RemoteList(list),
Err(e) => OperationResult::Failure(0, format!("Ошибка списка хранилищ: {}", e)),
});
});
}
}
fn load_files(&mut self, path: &str) {
if let Some(rclone) = self.rclone.clone() {
self.active_task_count += 1;
let path = path.to_string();
let tx = self.operation_tx.clone();
std::thread::spawn(move || {
let _ = tx.send(match operations::files::list(&rclone, &path) {
Ok(list) => OperationResult::FileList(list),
Err(e) => OperationResult::Failure(0, format!("Ошибка доступа к папке: {}", e)),
});
});
}
}
fn poll_background_operation(&mut self) {
while let Ok(result) = self.operation_rx.try_recv() {
match result {
OperationResult::Success(op_id, _) => {
self.active_task_count = self.active_task_count.saturating_sub(1);
self.active_operations.retain(|op| op.id != op_id);
self.pending_load_path = Some(self.current_path.clone());
}
OperationResult::Failure(op_id, e) => {
self.active_task_count = self.active_task_count.saturating_sub(1);
self.active_operations.retain(|op| op.id != op_id);
self.error_message = Some(e);
}
OperationResult::FileList(files) => {
self.current_files = files;
self.active_task_count = self.active_task_count.saturating_sub(1);
}
OperationResult::RemoteList(remotes) => {
self.remote_list = remotes;
self.active_task_count = self.active_task_count.saturating_sub(1);
}
OperationResult::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;
}
}
}
}
}
// --- Операции ---
fn perform_copy_multiple(&mut self, sources: Vec<(String, bool)>, dest: &str) {
if let Some(rclone) = self.rclone.clone() {
let options = CopyOptions { verbose: true, dry_run: false, bandwidth_limit: self.settings.bandwidth_limit.clone(), no_traverse: false };
let dest_base = dest.to_string();
let op_id = self.add_operation(format!("Копирование {} эл.", sources.len()));
self.active_task_count += 1;
let tx = self.operation_tx.clone();
std::thread::spawn(move || {
let total = sources.len() as f32;
for (idx, (source, is_dir)) in sources.iter().enumerate() {
let name = source.split([':', '/']).last().unwrap_or("");
let final_dest = if *is_dir { format!("{}/{}", dest_base.trim_end_matches('/'), name) } else { dest_base.clone() };
if let Err(e) = operations::sync::copy(&rclone, source, &final_dest, &options) {
let _ = tx.send(OperationResult::Failure(op_id, format!("Ошибка копирования {}: {}", source, e))); return;
}
let _ = tx.send(OperationResult::ProgressUpdate(op_id, (idx + 1) as f32 / total, format!("{}/{}", idx+1, total)));
}
let _ = tx.send(OperationResult::Success(op_id, "Завершено".into()));
});
}
}
fn perform_move_multiple(&mut self, sources: Vec<(String, bool)>, dest: &str) {
if let Some(rclone) = self.rclone.clone() {
let options = MoveOptions { verbose: true, dry_run: false, delete_empty_src_dirs: true };
let dest_base = dest.to_string();
let op_id = self.add_operation(format!("Перемещение {} эл.", sources.len()));
self.active_task_count += 1;
let tx = self.operation_tx.clone();
std::thread::spawn(move || {
let total = sources.len() as f32;
for (idx, (source, is_dir)) in sources.iter().enumerate() {
let name = source.split([':', '/']).last().unwrap_or("");
let final_dest = if *is_dir { format!("{}/{}", dest_base.trim_end_matches('/'), name) } else { dest_base.clone() };
if let Err(e) = operations::sync::move_files(&rclone, source, &final_dest, &options) {
let _ = tx.send(OperationResult::Failure(op_id, format!("Ошибка перемещения {}: {}", source, e))); return;
}
let _ = tx.send(OperationResult::ProgressUpdate(op_id, (idx + 1) as f32 / total, format!("{}/{}", idx+1, total)));
}
let _ = tx.send(OperationResult::Success(op_id, "Завершено".into()));
});
}
}
fn delete_selected_paths(&mut self, paths: Vec<String>) {
if let Some(rclone) = self.rclone.clone() {
let mut path_info = Vec::new();
for path in &paths {
let is_dir = self.current_files.iter().find(|f| {
let full = if self.current_path.ends_with(':') { format!("{}{}", self.current_path, f.name) }
else { format!("{}/{}", self.current_path, f.name) };
&full == path
}).map(|f| f.is_dir).unwrap_or(false);
path_info.push((path.clone(), is_dir));
}
let op_id = self.add_operation(format!("Удаление {} эл.", paths.len()));
self.active_task_count += 1;
let tx = self.operation_tx.clone();
std::thread::spawn(move || {
let total = path_info.len() as f32;
for (idx, (p, is_dir)) in path_info.iter().enumerate() {
let opts = DeleteOptions { recursive: *is_dir, verbose: false, dry_run: false };
if let Err(e) = operations::sync::delete(&rclone, p, &opts) {
let _ = tx.send(OperationResult::Failure(op_id, format!("Ошибка удаления {}: {}", p, e))); return;
}
let _ = tx.send(OperationResult::ProgressUpdate(op_id, (idx + 1) as f32 / total, format!("{}/{}", idx+1, total)));
}
let _ = tx.send(OperationResult::Success(op_id, "Удалено".into()));
});
}
}
fn add_operation(&mut self, description: String) -> u32 {
let id = self.next_op_id;
self.next_op_id += 1;
self.active_operations.push(Operation { id, description, progress: 0.0, status: "В очереди".into(), start_time: Instant::now() });
id
}
fn format_size(bytes: u64) -> String {
if bytes >= 1073741824 { format!("{:.2} ГБ", bytes as f64 / 1073741824.0) }
else if bytes >= 1048576 { format!("{:.2} МБ", bytes as f64 / 1048576.0) }
else if bytes >= 1024 { format!("{:.2} КБ", bytes as f64 / 1024.0) }
else { format!("{} Б", bytes) }
}
fn get_selected_info(&self) -> Vec<(String, bool)> {
self.selected_paths.iter().map(|path| {
let is_dir = self.current_files.iter().find(|f| {
let full = if self.current_path.ends_with(':') { format!("{}{}", self.current_path, f.name) }
else { format!("{}/{}", self.current_path, f.name) };
&full == path
}).map(|f| f.is_dir).unwrap_or(false);
(path.clone(), is_dir)
}).collect()
}
}
impl eframe::App for RcloneUI {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.poll_rclone_init();
self.poll_background_operation();
let is_busy = !self.active_operations.is_empty();
egui::TopBottomPanel::top("top").show(ctx, |ui| {
egui::menu::bar(ui, |ui| {
ui.label("🚀 UI rclone");
ui.separator();
// Навигация
ui.add_enabled_ui(!self.history_back.is_empty(), |ui| {
if ui.button("⬅").clicked() { self.go_back(); }
});
ui.add_enabled_ui(!self.history_forward.is_empty(), |ui| {
if ui.button("➡").clicked() { self.go_forward(); }
});
if self.active_task_count > 0 { ui.add(Spinner::new().size(16.0)); }
});
});
SidePanel::left("left").default_width(200.0).show(ctx, |ui| {
ui.add_space(10.0);
ui.heading("Хранилища");
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() {
self.navigate_to(format!("{}:", remote.name));
}
}
});
});
SidePanel::right("right").default_width(220.0).show(ctx, |ui| {
ui.heading("Активность");
ui.separator();
ScrollArea::vertical().show(ui, |ui| {
for op in &self.active_operations {
ui.group(|ui| {
ui.label(&op.description);
ui.add(ProgressBar::new(op.progress).text(format!("{:.0}%", op.progress * 100.0)));
ui.small(&op.status);
});
}
});
});
CentralPanel::default().show(ctx, |ui| {
ui.horizontal(|ui| {
ui.label(format!("📍 {}", self.current_path));
if ui.button("🔄").clicked() { self.pending_load_path = Some(self.current_path.clone()); }
});
ui.separator();
ScrollArea::vertical().id_source("files").show(ui, |ui| {
if !self.current_path.is_empty() {
if self.current_path.contains('/') && ui.button("📁 .. (Вверх)").clicked() {
if let Some((p, _)) = self.current_path.rsplit_once('/') { self.navigate_to(p.into()); }
}
for file in self.current_files.clone() {
let full = if self.current_path.ends_with(':') { format!("{}{}", self.current_path, file.name) }
else { format!("{}/{}", self.current_path, file.name) };
let sel = self.selected_paths.contains(&full);
ui.horizontal(|ui| {
if ui.selectable_label(sel, file.icon()).clicked() {
if sel { self.selected_paths.retain(|p| p != &full); }
else { self.selected_paths.push(full.clone()); }
}
if file.is_dir {
if ui.link(&file.name).clicked() { self.navigate_to(full); }
} else { ui.label(&file.name); }
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.small(Self::format_size(file.size));
});
});
}
} else {
ui.centered_and_justified(|ui| { ui.label("Выберите диск в левой панели"); });
}
});
if !self.selected_paths.is_empty() {
ui.separator();
ui.horizontal(|ui| {
ui.label(format!("Выбрано: {}", self.selected_paths.len()));
ui.add_enabled_ui(!is_busy, |ui| {
if ui.button("📋 Копировать").clicked() {
self.transfer_source_info = self.get_selected_info();
self.is_move_mode = false;
self.show_transfer_dialog = true;
}
if ui.button("✂ Переместить").clicked() {
self.transfer_source_info = self.get_selected_info();
self.is_move_mode = true;
self.show_transfer_dialog = true;
}
if ui.button("🗑 Удалить").clicked() {
self.delete_selected_paths(self.selected_paths.clone());
self.selected_paths.clear();
}
});
});
}
});
// Модальные окна
if self.show_transfer_dialog {
Window::new(if self.is_move_mode { "Перемещение" } else { "Копирование" })
.anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| {
ui.label("Куда отправить:");
ui.text_edit_singleline(&mut self.transfer_dest);
if !self.transfer_dest.contains(':') && !self.transfer_dest.is_empty() {
ui.colored_label(Color32::YELLOW, "⚠ Путь rclone должен содержать ':'");
}
ui.horizontal(|ui| {
if ui.button("ОК").clicked() && !self.transfer_dest.is_empty() {
let (s, d) = (self.transfer_source_info.clone(), self.transfer_dest.clone());
if self.is_move_mode { self.perform_move_multiple(s, &d); }
else { self.perform_copy_multiple(s, &d); }
self.show_transfer_dialog = false;
}
if ui.button("Отмена").clicked() { self.show_transfer_dialog = false; }
});
});
}
if let Some(msg) = &self.error_message {
let mut close = false;
Window::new("Сообщение").anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| {
ui.colored_label(Color32::LIGHT_RED, "⚠️ Событие:");
ui.label(msg);
if ui.button("Закрыть").clicked() { close = true; }
});
if close { self.error_message = None; }
}
// Выполнение отложенной загрузки
if let Some(path) = self.pending_load_path.take() {
self.current_path = path.clone();
self.selected_paths.clear();
self.load_files(&path);
}
ctx.request_repaint_after(Duration::from_millis(100));
}
}
+356
View File
@@ -0,0 +1,356 @@
use super::*;
use eframe::egui;
use egui::{
Align2, CentralPanel, Color32, ProgressBar, ScrollArea,
SidePanel, Spinner, Window,
};
use std::path::Path;
use std::time::Duration;
impl eframe::App for RcloneUI {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.poll_rclone_init();
self.poll_background_operation();
egui::TopBottomPanel::top("top").show(ctx, |ui| {
egui::menu::bar(ui, |ui| {
ui.label("🚀 Rclone GUI");
ui.separator();
ui.add_enabled_ui(!self.history_back.is_empty(), |ui| {
if ui.button("⬅").clicked() {
log::debug!("Кнопка 'Назад' нажата");
self.go_back();
}
});
ui.add_enabled_ui(!self.history_forward.is_empty(), |ui| {
if ui.button("➡").clicked() {
log::debug!("Кнопка 'Вперед' нажата");
self.go_forward();
}
});
if self.active_task_count > 0 {
ui.add(Spinner::new().size(16.0));
}
});
});
SidePanel::left("left")
.default_width(180.0)
.show(ctx, |ui| {
ui.add_space(10.0);
ui.heading("Хранилища");
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);
self.navigate_to(format!("{}:", remote.name));
}
}
});
});
SidePanel::right("right")
.default_width(200.0)
.show(ctx, |ui| {
ui.heading("Задачи");
ui.separator();
ScrollArea::vertical().show(ui, |ui| {
for op in &self.active_operations {
ui.group(|ui| {
ui.label(&op.description);
ui.add(
ProgressBar::new(op.progress)
.text(format!("{:.0}%", op.progress * 100.0)),
);
ui.small(&op.status);
});
}
if self.active_operations.is_empty() {
ui.weak("Нет активных задач");
}
});
});
CentralPanel::default().show(ctx, |ui| {
ui.horizontal(|ui| {
ui.label(format!("📍 {}", self.current_path));
ui.add_enabled_ui(self.active_task_count == 0, |ui| {
if ui.button("🔄").clicked() {
log::debug!("Обновление текущего пути: {}", self.current_path);
self.pending_load_path = Some(self.current_path.clone());
}
});
});
ui.separator();
ScrollArea::vertical().id_source("files").show(ui, |ui| {
if !self.current_path.is_empty() {
if self.current_path.contains('/') && ui.button("📁 .. (Вверх)").clicked()
{
log::debug!("Переход вверх по директории");
if let Some((p, _)) = self.current_path.rsplit_once('/') {
self.navigate_to(p.into());
} else if let Some((p, _)) = self.current_path.rsplit_once(':') {
self.navigate_to(format!("{}:", p));
}
}
for file in self.current_files.clone() {
let full = if self.current_path.ends_with(':') {
format!("{}{}", self.current_path, file.name)
} else {
format!("{}/{}", self.current_path, file.name)
};
let sel = self.selected_paths.contains(&full);
ui.horizontal(|ui| {
if ui.selectable_label(sel, file.icon()).clicked() {
if sel {
log::debug!("Снят выбор с {}", full);
self.selected_paths.retain(|p| p != &full);
} else {
log::debug!("Выбран {}", full);
self.selected_paths.push(full.clone());
}
}
if file.is_dir {
if ui.link(&file.name).clicked() {
log::debug!("Открыта директория: {}", full);
self.navigate_to(full);
}
} else {
ui.label(&file.name);
}
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
ui.small(file.format_size());
},
);
});
}
} else {
ui.centered_and_justified(|ui| {
ui.label("Выберите хранилище слева");
});
}
});
if !self.selected_paths.is_empty() {
ui.separator();
ui.horizontal(|ui| {
ui.label(format!("Выбрано: {}", self.selected_paths.len()));
ui.add_enabled_ui(self.active_task_count == 0, |ui| {
if ui.button("📋 Копировать").clicked() {
log::info!("Начало операции копирования выбранных элементов");
self.transfer_source_info = self.get_selected_info();
self.is_move_mode = false;
self.show_transfer_dialog = true;
}
if ui.button("✂ Переместить").clicked() {
log::info!("Начало операции перемещения выбранных элементов");
self.transfer_source_info = self.get_selected_info();
self.is_move_mode = true;
self.show_transfer_dialog = true;
}
if ui.button("🗑 Удалить").clicked() {
log::info!("Начало операции удаления выбранных элементов");
self.delete_selected();
self.selected_paths.clear();
}
});
});
}
});
// Модальное окно трансфера
if self.show_transfer_dialog {
Window::new("Трансфер")
.anchor(Align2::CENTER_CENTER, [0.0, 0.0])
.collapsible(false)
.show(ctx, |ui| {
ui.horizontal(|ui| {
ui.selectable_value(
&mut self.active_transfer_tab,
TransferTab::Remote,
"☁ Облако",
);
ui.selectable_value(
&mut self.active_transfer_tab,
TransferTab::Local,
"💻 ПК",
);
});
ui.separator();
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(&mut self.transfer_dest)
.hint_text("Путь назначения..."),
);
if self.active_transfer_tab == TransferTab::Local
&& ui.button("📂 Обзор").clicked()
{
log::debug!("Открыт локальный браузер файлов");
self.refresh_local_list();
self.show_local_browser = true;
}
});
if !self.transfer_dest.is_empty() && !self.is_path_valid() {
ui.colored_label(
Color32::KHAKI,
if self.active_transfer_tab == TransferTab::Remote {
"⚠ Путь облака должен содержать ':'"
} else {
"⚠ Укажите локальный путь без ':'"
},
);
}
});
ui.add_space(10.0);
ui.horizontal(|ui| {
let can_start = self.is_path_valid() && self.active_task_count == 0;
if ui
.add_enabled(can_start, egui::Button::new("🚀 Начать"))
.clicked()
{
log::info!("Запуск трансфера в {}", self.transfer_dest);
self.perform_transfer(self.is_move_mode);
self.show_transfer_dialog = false;
}
if ui.button("Отмена").clicked() {
log::debug!("Отмена трансфера");
self.show_transfer_dialog = false;
}
});
});
}
// Внутренний браузер папок
if self.show_local_browser {
Window::new("Выбор локальной папки")
.anchor(Align2::CENTER_CENTER, [0.0, 0.0])
.fixed_size([400.0, 300.0])
.show(ctx, |ui| {
ui.label(format!("📍 {}", self.local_browser_path));
ui.horizontal(|ui| {
if ui.button("⬅ Наверх").clicked() {
if let Some(parent) = Path::new(&self.local_browser_path).parent() {
self.local_browser_path = parent.to_string_lossy().into();
self.refresh_local_list();
}
}
if ui.button("🔄").clicked() {
self.refresh_local_list();
}
});
ui.separator();
// Проверяем, пуста ли папка
let is_empty = self.local_browser_files.is_empty();
// Создаем рамку для контента
egui::Frame::group(ui.style())
.inner_margin(egui::Margin::same(8.0))
.show(ui, |ui| {
if is_empty {
// Если папка пуста, показываем сообщение по центру с иконкой
let available_height = ui.available_height();
ui.centered_and_justified(|ui| {
ui.add_space(available_height / 3.0);
ui.vertical(|ui| {
ui.label("📂");
ui.add_space(8.0);
ui.colored_label(
egui::Color32::GRAY,
"Эта папка пуста"
);
});
});
} else {
// Если есть файлы, показываем список
ScrollArea::vertical()
.max_height(200.0)
.auto_shrink([false; 2])
.show(ui, |ui| {
let files = self.local_browser_files.clone();
for file in files {
if file.is_dir {
ui.horizontal(|ui| {
let response = ui.button("📁");
ui.add_space(4.0);
let link_response = ui.link(&file.name);
if response.clicked() || link_response.clicked() {
self.local_browser_path = file.path;
self.refresh_local_list();
}
});
} else {
ui.horizontal(|ui| {
ui.label("📄");
ui.add_space(4.0);
ui.label(&file.name);
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
ui.small(file.format_size());
},
);
});
}
}
});
}
});
ui.add_space(8.0);
ui.separator();
ui.horizontal(|ui| {
if ui.button("✅ Выбрать").clicked() {
log::debug!("Выбрана локальная папка: {}", self.local_browser_path);
self.transfer_dest = self.local_browser_path.clone();
self.show_local_browser = false;
}
if ui.button("Отмена").clicked() {
self.show_local_browser = false;
}
});
});
}
if let Some(msg) = &self.error_message {
let mut close = false;
Window::new("Внимание")
.anchor(Align2::CENTER_CENTER, [0.0, 0.0])
.show(ctx, |ui| {
ui.label(msg);
if ui.button("ОК").clicked() {
close = true;
}
});
if close {
log::debug!("Ошибка закрыта: {}", msg);
self.error_message = None;
}
}
if let Some(path) = self.pending_load_path.take() {
self.current_path = path.clone();
self.selected_paths.clear();
self.load_files(&path);
}
ctx.request_repaint_after(Duration::from_millis(100));
}
}
+420
View File
@@ -0,0 +1,420 @@
use super::*;
use crate::operations::{self, CopyOptions, DeleteOptions, MoveOptions};
use crate::rclone_install::RcloneApp;
use eframe::egui;
use egui::{FontData, FontDefinitions, FontFamily};
use std::path::Path;
use std::sync::Arc;
use std::sync::mpsc::channel;
use std::time::Instant;
use tokio::runtime::Runtime;
impl RcloneUI {
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
log::debug!("Инициализация RcloneUI");
// --- ВОССТАНОВЛЕНИЕ ШРИФТОВ ---
let mut fonts = FontDefinitions::default();
if let Ok(font_data) = std::fs::read("assets/fonts/Sans.otf") {
fonts
.font_data
.insert("sans".to_owned(), FontData::from_owned(font_data));
fonts
.families
.entry(FontFamily::Proportional)
.or_default()
.insert(0, "sans".to_owned());
fonts
.families
.entry(FontFamily::Monospace)
.or_default()
.push("sans".to_owned());
log::debug!("Шрифт Sans.otf загружен");
} else {
log::warn!("Файл шрифта Sans.otf не найден");
}
if let Ok(emoji_data) = std::fs::read("assets/fonts/NotoColorEmoji.ttf") {
fonts
.font_data
.insert("emoji".to_owned(), FontData::from_owned(emoji_data));
let prop = fonts.families.entry(FontFamily::Proportional).or_default();
if !prop.contains(&"emoji".to_owned()) {
prop.push("emoji".to_owned());
}
log::debug!("Шрифт NotoColorEmoji.ttf загружен");
} else {
log::warn!("Файл шрифта NotoColorEmoji.ttf не найден");
}
cc.egui_ctx.set_fonts(fonts);
let (init_tx, init_rx) = channel();
let (op_tx, op_rx) = channel();
log::info!("Запуск фоновой инициализации rclone");
std::thread::spawn(move || {
let rt = Runtime::new().unwrap();
let result = rt.block_on(async { RcloneApp::new().await });
let res = match result {
Ok(app) => {
log::info!("Rclone успешно инициализирован");
Ok(Arc::new(app))
}
Err(e) => {
log::error!("Ошибка инициализации rclone: {}", e);
Err(e.to_string())
}
};
let _ = init_tx.send(res);
});
Self {
rclone: None,
state: AppState::Initializing,
error_message: None,
current_path: String::new(),
history_back: Vec::new(),
history_forward: Vec::new(),
remote_list: Vec::new(),
current_files: Vec::new(),
selected_paths: Vec::new(),
transfer_source_info: Vec::new(),
transfer_dest: String::new(),
active_transfer_tab: TransferTab::Remote,
show_transfer_dialog: false,
show_local_browser: false,
local_browser_path: std::env::var("HOME").unwrap_or_else(|_| "/".into()),
local_browser_files: Vec::new(),
is_move_mode: false,
settings: AppSettings::default(),
active_task_count: 0,
operation_tx: op_tx,
operation_rx: op_rx,
pending_load_path: None,
rclone_init_receiver: Some(init_rx),
active_operations: Vec::new(),
next_op_id: 1,
}
}
// --- Валидация ---
pub fn is_path_valid(&self) -> bool {
if self.transfer_dest.is_empty() {
return false;
}
match self.active_transfer_tab {
TransferTab::Remote => self.transfer_dest.contains(':'),
TransferTab::Local => {
!self.transfer_dest.contains(':') || Path::new(&self.transfer_dest).is_absolute()
}
}
}
// --- Навигация ---
pub fn navigate_to(&mut self, new_path: String) {
log::debug!("Навигация: {} -> {}", self.current_path, new_path);
if self.current_path == new_path {
return;
}
if !self.current_path.is_empty() {
self.history_back.push(self.current_path.clone());
}
self.history_forward.clear();
self.pending_load_path = Some(new_path);
}
pub fn go_back(&mut self) {
if let Some(prev) = self.history_back.pop() {
log::debug!("Назад: {} -> {}", self.current_path, prev);
self.history_forward.push(self.current_path.clone());
self.pending_load_path = Some(prev);
}
}
pub fn go_forward(&mut self) {
if let Some(next) = self.history_forward.pop() {
log::debug!("Вперед: {} -> {}", self.current_path, next);
self.history_back.push(self.current_path.clone());
self.pending_load_path = Some(next);
}
}
pub fn refresh_local_list(&mut self) {
log::debug!("Обновление списка локальной директории: {}", self.local_browser_path);
if let Ok(files) = operations::local_fs::list_directory(&self.local_browser_path) {
let count = files.len();
self.local_browser_files = files;
log::debug!("Найдено {} элементов в локальной директории", count);
} else {
log::warn!("Не удалось прочитать локальную директорию: {}", self.local_browser_path);
}
}
// --- Работа с Rclone ---
pub fn poll_rclone_init(&mut self) {
if let Some(rx) = &self.rclone_init_receiver {
if let Ok(res) = rx.try_recv() {
match res {
Ok(rclone) => {
log::info!("Rclone инициализирован, загрузка удаленных хранилищ");
self.rclone = Some(rclone);
self.load_remotes();
self.state = AppState::Ready;
}
Err(e) => {
log::error!("Ошибка инициализации: {}", e);
self.error_message = Some(e.clone());
self.state = AppState::Error(e);
}
}
self.rclone_init_receiver = None;
}
}
}
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();
std::thread::spawn(move || {
log::debug!("Фоновый поток: получение списка remotes");
let _ = tx.send(match operations::remotes::list(&rclone) {
Ok(list) => {
log::info!("Загружено {} удаленных хранилищ", list.len());
OperationResult::RemoteList(list)
}
Err(e) => {
log::error!("Ошибка загрузки remotes: {}", e);
OperationResult::Failure(0, e.to_string())
}
});
});
}
}
pub fn load_files(&mut self, path: &str) {
if let Some(rclone) = self.rclone.clone() {
log::info!("Загрузка файлов из пути: {}", path);
self.active_task_count += 1;
let path = path.to_string();
let tx = self.operation_tx.clone();
std::thread::spawn(move || {
log::debug!("Фоновый поток: загрузка файлов из {}", path);
let _ = tx.send(match operations::files::list(&rclone, &path) {
Ok(list) => {
log::info!("Загружено {} файлов из {}", list.len(), path);
OperationResult::FileList(list)
}
Err(e) => {
log::error!("Ошибка загрузки файлов из {}: {}", path, e);
OperationResult::Failure(0, e.to_string())
}
});
});
}
}
pub fn poll_background_operation(&mut self) {
while let Ok(result) = self.operation_rx.try_recv() {
match result {
OperationResult::Success(op_id, _) => {
log::debug!("Операция {} успешно завершена", op_id);
self.active_task_count = self.active_task_count.saturating_sub(1);
self.active_operations.retain(|op| op.id != op_id);
self.pending_load_path = Some(self.current_path.clone());
}
OperationResult::Failure(op_id, e) => {
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);
self.error_message = Some(e);
}
OperationResult::FileList(files) => {
let count = files.len();
log::debug!("Получен список файлов: {} элементов", count);
self.current_files = files;
self.active_task_count = self.active_task_count.saturating_sub(1);
}
OperationResult::RemoteList(remotes) => {
let count = remotes.len();
log::debug!("Получен список remotes: {} элементов", count);
self.remote_list = remotes;
self.active_task_count = self.active_task_count.saturating_sub(1);
}
OperationResult::ProgressUpdate(op_id, progress, status) => {
if let Some(op) = self.active_operations.iter_mut().find(|op| op.id == op_id) {
op.progress = progress;
op.status = status.clone();
log::trace!("Операция {}: прогресс {:.1}%, статус: {}", op_id, progress * 100.0, status);
}
}
}
}
}
pub fn perform_transfer(&mut self, is_move: bool) {
if self.active_task_count > 0 {
log::warn!("Не удалось начать трансфер: активны другие операции ({})", self.active_task_count);
return;
}
if let Some(rclone) = self.rclone.clone() {
let sources = self.transfer_source_info.clone();
let dest_base = self.transfer_dest.clone();
let op_id = self.add_operation(format!(
"{} {} эл.",
if is_move {
"Перенос"
} else {
"Копия"
},
sources.len()
));
log::info!("Начало операции {}: {} элементов в {}",
if is_move { "перемещения" } else { "копирования" },
sources.len(),
dest_base
);
self.active_task_count += 1;
let tx = self.operation_tx.clone();
std::thread::spawn(move || {
let total = sources.len() as f32;
for (idx, (source, is_dir)) in sources.iter().enumerate() {
let name = source.split([':', '/', '\\']).last().unwrap_or("");
let separator = if dest_base.contains(':')
&& !dest_base.ends_with(':')
&& !dest_base.ends_with('/')
{
"/"
} else {
""
};
let final_dest = if *is_dir {
format!(
"{}{}{}",
dest_base.trim_end_matches(['/', '\\']),
separator,
name
)
} else {
dest_base.clone()
};
log::debug!("Операция {}: обработка {}/{}: {} -> {}", op_id, idx + 1, total, source, final_dest);
let res = if is_move {
operations::sync::move_files(
&rclone,
source,
&final_dest,
&MoveOptions {
verbose: true,
..Default::default()
},
)
} else {
operations::sync::copy(
&rclone,
source,
&final_dest,
&CopyOptions {
verbose: true,
..Default::default()
},
)
};
if let Err(e) = res {
log::error!("Операция {}: ошибка при обработке {}: {}", op_id, name, e);
let _ = tx.send(OperationResult::Failure(
op_id,
format!("Ошибка на {}: {}", name, e),
));
return;
}
let _ = tx.send(OperationResult::ProgressUpdate(
op_id,
(idx + 1) as f32 / total,
format!("{}/{}", idx + 1, total),
));
}
log::info!("Операция {} успешно завершена", op_id);
let _ = tx.send(OperationResult::Success(op_id, "Ок".into()));
});
}
}
pub fn delete_selected(&mut self) {
if self.active_task_count > 0 {
log::warn!("Не удалось начать удаление: активны другие операции ({})", self.active_task_count);
return;
}
if let Some(rclone) = self.rclone.clone() {
let paths = self.get_selected_info();
let op_id = self.add_operation(format!("Удаление {} эл.", paths.len()));
log::info!("Начало операции удаления {} элементов", paths.len());
self.active_task_count += 1;
let tx = self.operation_tx.clone();
std::thread::spawn(move || {
for (p, is_dir) in paths.iter() {
log::debug!("Операция {}: удаление {}", op_id, p);
let opts = DeleteOptions {
recursive: *is_dir,
..Default::default()
};
if let Err(e) = operations::sync::delete(&rclone, p, &opts) {
log::error!("Операция {}: ошибка при удалении {}: {}", op_id, p, e);
let _ = tx.send(OperationResult::Failure(op_id, e.to_string()));
return;
}
}
log::info!("Операция удаления {} успешно завершена", op_id);
let _ = tx.send(OperationResult::Success(op_id, "Ок".into()));
});
}
}
pub fn add_operation(&mut self, description: String) -> u32 {
let id = self.next_op_id;
self.next_op_id += 1;
log::debug!("Добавлена операция {}: {}", id, description);
self.active_operations.push(Operation {
id,
description,
progress: 0.0,
status: "В очереди".into(),
start_time: Instant::now(),
});
id
}
pub fn get_selected_info(&self) -> Vec<(String, bool)> {
self.selected_paths
.iter()
.map(|path| {
let is_dir = self
.current_files
.iter()
.find(|f| {
let full = if self.current_path.ends_with(':') {
format!("{}{}", self.current_path, f.name)
} else {
format!("{}/{}", self.current_path, f.name)
};
&full == path
})
.map(|f| f.is_dir)
.unwrap_or(false);
(path.clone(), is_dir)
})
.collect()
}
}