Compare commits
26
Commits
dev/ui
..
a4b3242052
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4b3242052 | ||
|
|
8d213972d6 | ||
|
|
1679c8b08e | ||
|
|
1f5ec2dede | ||
|
|
0a504966ee | ||
|
|
c1ac7b28bb | ||
|
|
65939bd501 | ||
|
|
9fb0e7fb19 | ||
|
|
b90ff7f18d | ||
|
|
3c64c39cf3 | ||
|
|
3e868c184a | ||
|
|
cd60bace64 | ||
|
|
dda6b93724 | ||
|
|
58ad6ca9d7 | ||
|
|
d606a5a600 | ||
|
|
4eabcc8aef | ||
|
|
35e8d8077d | ||
|
|
6e9a113629 | ||
|
|
75afcea506 | ||
|
|
0bb876915b | ||
|
|
1411991417 | ||
|
|
f15ee0a413 | ||
|
|
dfa7c463ac | ||
|
|
25080e3cca | ||
|
|
44b94bdb0b | ||
|
|
e77f051149 |
No files matched your search
@@ -1,101 +1,101 @@
|
|||||||
name: Rust CI
|
name: Rust CI (Native Install + Cache)
|
||||||
on: [push, pull_request]
|
on:
|
||||||
|
push:
|
||||||
|
branches-ignore:
|
||||||
|
- release
|
||||||
|
pull_request:
|
||||||
|
branches-ignore:
|
||||||
|
- release
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
build_and_check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
run: |
|
run: |
|
||||||
|
if ! command -v rustc &> /dev/null; then
|
||||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||||
true
|
fi
|
||||||
|
|
||||||
- name: Verify Rust installation
|
- 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: Cargo Check & Build
|
||||||
run: |
|
run: |
|
||||||
rustc --version
|
cargo check --verbose
|
||||||
cargo --version
|
cargo build --verbose
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|
||||||
test:
|
test:
|
||||||
needs: build
|
needs: build_and_check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
run: |
|
run: |
|
||||||
|
if ! command -v rustc &> /dev/null; then
|
||||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Verify Rust installation
|
- name: Cache Cargo
|
||||||
run: |
|
uses: actions/cache@v3
|
||||||
rustc --version
|
with:
|
||||||
cargo --version
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
- name: Test
|
- name: Run Tests
|
||||||
run: cargo test --verbose
|
run: cargo test --verbose
|
||||||
|
|
||||||
docs:
|
docs:
|
||||||
needs: build
|
needs: build_and_check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
run: |
|
run: |
|
||||||
|
if ! command -v rustc &> /dev/null; then
|
||||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Verify Rust installation
|
- name: Cache Cargo
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
|
- name: Build docs
|
||||||
run: |
|
run: |
|
||||||
rustc --version
|
cargo doc --no-deps --document-private-items
|
||||||
cargo --version
|
mkdir -p ./docs-output && cp -r target/doc/* ./docs-output/
|
||||||
|
|
||||||
- name: Build documentation
|
- name: Upload Artifact
|
||||||
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
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: rust-documentation
|
name: rust-documentation
|
||||||
path: ./docs-output/
|
path: ./docs-output/
|
||||||
retention-days: 30
|
retention-days: 7
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
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"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install System Dependencies
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
# Базовые инструменты сборки (исправляет ошибку ring на Linux/Windows)
|
||||||
|
apt-get install -y build-essential llvm-dev libclang-dev clang
|
||||||
|
|
||||||
|
# Зависимости для графики egui
|
||||||
|
apt-get install -y libwayland-dev libxkbcommon-dev libegl1-mesa-dev libgbm-dev
|
||||||
|
|
||||||
|
if [[ "${{ matrix.target }}" == *"windows"* ]]; then
|
||||||
|
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: |
|
||||||
|
# Для 'ring' при кросс-компиляции часто помогает использование clang вместо gcc
|
||||||
|
if [[ "${{ matrix.target }}" == *"apple"* ]]; then
|
||||||
|
export AR_x86_64_apple_darwin=x86_64-apple-darwin-ar
|
||||||
|
export CC_x86_64_apple_darwin=x86_64-apple-darwin-clang
|
||||||
|
fi
|
||||||
|
cargo build --release --target ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Prepare Artifact
|
||||||
|
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
|
||||||
+53
-52
@@ -1,100 +1,101 @@
|
|||||||
name: Rust CI
|
name: Rust CI (Native Install + Cache)
|
||||||
on: [push, pull_request]
|
on:
|
||||||
|
push:
|
||||||
|
branches-ignore:
|
||||||
|
- release
|
||||||
|
pull_request:
|
||||||
|
branches-ignore:
|
||||||
|
- release
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
build_and_check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
run: |
|
run: |
|
||||||
|
if ! command -v rustc &> /dev/null; then
|
||||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Verify Rust installation
|
- 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: Cargo Check & Build
|
||||||
run: |
|
run: |
|
||||||
rustc --version
|
cargo check --verbose
|
||||||
cargo --version
|
cargo build --verbose
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|
||||||
test:
|
test:
|
||||||
needs: [check, build]
|
needs: build_and_check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
run: |
|
run: |
|
||||||
|
if ! command -v rustc &> /dev/null; then
|
||||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Verify Rust installation
|
- name: Cache Cargo
|
||||||
run: |
|
uses: actions/cache@v3
|
||||||
rustc --version
|
with:
|
||||||
cargo --version
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
- name: Test
|
- name: Run Tests
|
||||||
run: cargo test --verbose
|
run: cargo test --verbose
|
||||||
|
|
||||||
docs:
|
docs:
|
||||||
needs: [check, build]
|
needs: build_and_check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
run: |
|
run: |
|
||||||
|
if ! command -v rustc &> /dev/null; then
|
||||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Verify Rust installation
|
- name: Cache Cargo
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
|
- name: Build docs
|
||||||
run: |
|
run: |
|
||||||
rustc --version
|
cargo doc --no-deps --document-private-items
|
||||||
cargo --version
|
mkdir -p ./docs-output && cp -r target/doc/* ./docs-output/
|
||||||
|
|
||||||
- name: Build documentation
|
- name: Upload Artifact
|
||||||
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
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: rust-documentation
|
name: rust-documentation
|
||||||
path: ./docs-output/
|
path: ./docs-output/
|
||||||
retention-days: 30
|
retention-days: 7
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
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"
|
||||||
|
|
||||||
|
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@v4
|
||||||
|
with:
|
||||||
|
name: ${{ env.APP_NAME }}-${{ matrix.target }}
|
||||||
|
path: ./out/
|
||||||
|
retention-days: 5
|
||||||
Generated
+32
@@ -11,8 +11,10 @@ dependencies = [
|
|||||||
"eframe",
|
"eframe",
|
||||||
"egui",
|
"egui",
|
||||||
"egui_extras",
|
"egui_extras",
|
||||||
|
"env_logger",
|
||||||
"flate2",
|
"flate2",
|
||||||
"futures",
|
"futures",
|
||||||
|
"log",
|
||||||
"mockito",
|
"mockito",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rfd",
|
"rfd",
|
||||||
@@ -1362,6 +1364,19 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "epaint"
|
name = "epaint"
|
||||||
version = "0.27.2"
|
version = "0.27.2"
|
||||||
@@ -2009,6 +2024,12 @@ version = "1.0.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "humantime"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "0.14.32"
|
version = "0.14.32"
|
||||||
@@ -2278,6 +2299,17 @@ version = "2.11.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
|
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]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.17"
|
version = "1.0.17"
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ eframe = { version = "0.27", features = ["default"] }
|
|||||||
egui = "0.27"
|
egui = "0.27"
|
||||||
egui_extras = { version = "0.27" }
|
egui_extras = { version = "0.27" }
|
||||||
|
|
||||||
|
# Логирование
|
||||||
|
log = "0.4"
|
||||||
|
env_logger = "0.10"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3.8"
|
tempfile = "3.8"
|
||||||
serial_test = "2.0"
|
serial_test = "2.0"
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# Rclone UI Manager
|
||||||
|
|
||||||
|
Кроссплатформенный графический интерфейс для [rclone](https://rclone.org/), написанный на Rust с использованием [egui](https://github.com/emilk/egui). Приложение предоставляет удобный способ управления облачными хранилищами (Google Drive, Dropbox, S3 и др.) и локальными файлами, поддерживает копирование, перемещение, удаление и синхронизацию с отображением хода выполнения операций.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- **Автоматическая установка rclone** – обнаруживает системный rclone или загружает последнюю версию автоматически.
|
||||||
|
- **Управление удалёнными хранилищами (remotes)** – просмотр, создание, проверка, удаление и просмотр информации обо всех подключениях rclone.
|
||||||
|
- **Создание хранилищ через GUI** – мастер добавления с пошаговыми вопросами rclone (`--non-interactive`): укажите имя и тип, а для облаков с авторизацией (Google Drive, Dropbox и др.) автоматически откроется браузер для входа в аккаунт (OAuth).
|
||||||
|
- **Контекстное меню хранилищ** – по правой кнопке мыши: открыть, проверить доступность, посмотреть использование (объём/число файлов), удалить с подтверждением.
|
||||||
|
- **Локальный файловый менеджер** – навигация по локальной файловой системе, выбор папок назначения.
|
||||||
|
- **Основные файловые операции**:
|
||||||
|
- Копирование / Перемещение / Удаление нескольких файлов или папок.
|
||||||
|
- Рекурсивная обработка директорий.
|
||||||
|
- **Фоновые задачи** – асинхронные операции с индикаторами прогресса и статусом выполнения.
|
||||||
|
- **История навигации** – кнопки «Назад» / «Вперёд» для быстрого перемещения.
|
||||||
|
- **Пользовательские шрифты** – поддержка Sans.otf и NotoColorEmoji.ttf для улучшенного внешнего вида (опционально).
|
||||||
|
- **Кроссплатформенность** – работает на Windows, macOS и Linux.
|
||||||
|
|
||||||
|
## Системные требования
|
||||||
|
|
||||||
|
- **Rust** (при сборке из исходников) – стабильный инструментарий.
|
||||||
|
- **Cargo** – входит в состав Rust.
|
||||||
|
- **Операционная система** – Windows 10/11, macOS 10.15+ или современный дистрибутив Linux.
|
||||||
|
- **rclone** – не требуется, приложение загрузит его при необходимости.
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
### Готовые сборки (пока не предоставляются)
|
||||||
|
|
||||||
|
В будущих релизах могут быть добавлены исполняемые файлы. Пока что собирайте из исходников.
|
||||||
|
|
||||||
|
### Сборка из исходников
|
||||||
|
|
||||||
|
1. Клонируйте репозиторий:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yourusername/rclone-ui-manager.git
|
||||||
|
cd rclone-ui-manager
|
||||||
|
```
|
||||||
|
|
||||||
|
2. (Опционально) Поместите пользовательские шрифты в папку `assets/fonts/`:
|
||||||
|
- `Sans.otf`
|
||||||
|
- `NotoColorEmoji.ttf`
|
||||||
|
|
||||||
|
3. Соберите и запустите:
|
||||||
|
```bash
|
||||||
|
cargo run --release
|
||||||
|
```
|
||||||
|
|
||||||
|
Первый запуск может занять некоторое время из-за компиляции зависимостей. Исполняемый файл появится в `target/release/rclone-ui-manager` (или `rclone-ui-manager.exe` на Windows).
|
||||||
|
|
||||||
|
### Зависимости для Linux
|
||||||
|
|
||||||
|
На Linux могут потребоваться следующие системные библиотеки:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ubuntu / Debian
|
||||||
|
sudo apt install libx11-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libwayland-dev pkg-config
|
||||||
|
|
||||||
|
# Fedora
|
||||||
|
sudo dnf install libX11-devel libXrandr-devel libXinerama-devel libXcursor-devel libXi-devel wayland-devel pkgconf
|
||||||
|
```
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
1. Запустите приложение.
|
||||||
|
2. На левой боковой панели отобразится список ваших rclone remote. Чтобы добавить новое хранилище, нажмите кнопку **«➕ Добавить облако»** и заполните мастер:
|
||||||
|
- **Имя** – например, `mydrive`.
|
||||||
|
- **Тип** – например, `drive`, `dropbox`, `s3`, `webdav`.
|
||||||
|
- Если выбранный тип требует авторизации (OAuth), после нажатия «Создать» автоматически откроется браузер для входа в аккаунт. При необходимости rclone задаст дополнительные вопросы (например, о Team Drive) – ответьте в окне мастера.
|
||||||
|
3. Нажмите на имя remote **левой кнопкой мыши**, чтобы просмотреть его содержимое.
|
||||||
|
4. **Правая кнопка мыши** по хранилищу открывает контекстное меню:
|
||||||
|
- **📂 Открыть** – перейти в хранилище.
|
||||||
|
- **🔍 Проверить доступность** – проверить подключение.
|
||||||
|
- **📊 Использование** – показать общий объём, занятое и свободное место, число файлов.
|
||||||
|
- **🗑️ Удалить** – удалить хранилище из конфигурации rclone (файлы в облаке при этом не удаляются; требуется подтверждение).
|
||||||
|
5. Выберите файлы/папки, щёлкая по их иконке (📁 или 📄). Можно выбирать несколько элементов.
|
||||||
|
6. После выбора внизу появятся кнопки: **Копировать**, **Переместить**, **Удалить**.
|
||||||
|
7. Для копирования/перемещения укажите путь назначения:
|
||||||
|
- **Облако** – введите путь в формате rclone (например, `gdrive:/backup`).
|
||||||
|
- **ПК** – выберите локальную папку через встроенный файловый браузер или введите путь вручную.
|
||||||
|
8. Нажмите **Начать** – операция запустится в фоне. Прогресс отображается на правой панели.
|
||||||
|
|
||||||
|
### Навигация
|
||||||
|
|
||||||
|
- **⬅ / ➡** – переход назад / вперёд по истории.
|
||||||
|
- **🔄** – обновить текущую директорию.
|
||||||
|
- **📁 .. (Вверх)** – подняться на уровень выше.
|
||||||
|
- **Правая кнопка мыши по хранилищу** – контекстное меню хранилища.
|
||||||
|
|
||||||
|
### Обработка ошибок
|
||||||
|
|
||||||
|
Сообщения об ошибках появляются во всплывающих окнах. Активные задачи перечислены на правой панели. Вы можете закрыть приложение, пока операции выполняются (они запускаются в отдельных потоках).
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
Приложение хранит настройки в стандартной пользовательской папке данных:
|
||||||
|
- Windows: `%APPDATA%\rclone-ui\`
|
||||||
|
- macOS: `~/Library/Application Support/rclone-ui/`
|
||||||
|
- Linux: `~/.local/share/rclone-ui/`
|
||||||
|
|
||||||
|
Сейчас в `AppSettings` хранятся только некоторые параметры (показ скрытых файлов, ограничение скорости). В будущих версиях они будут доступны через интерфейс.
|
||||||
|
|
||||||
|
## Разработка
|
||||||
|
|
||||||
|
### Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── main.rs # Точка входа, загрузка шрифтов
|
||||||
|
├── ui.rs # Состояние UI и типы
|
||||||
|
├── ui/
|
||||||
|
│ ├── app.rs # Реализация egui::App
|
||||||
|
│ └── rcloneui.rs # Логика UI, фоновые задачи
|
||||||
|
├── operations/
|
||||||
|
│ ├── files.rs # Обёртка над rclone lsjson / about
|
||||||
|
│ ├── sync.rs # copy/move/delete/purge
|
||||||
|
│ ├── remotes.rs # list/get/check, интерактивное создание (config create --non-interactive)
|
||||||
|
│ ├── local_fs.rs # Чтение локальных директорий
|
||||||
|
│ ├── fileinfo.rs # Вспомогательные методы FileInfo
|
||||||
|
│ └── ... # info, search, check
|
||||||
|
└── rclone_install/
|
||||||
|
├── mod.rs # Определение RcloneApp
|
||||||
|
└── rclone_install_real.rs # Обнаружение и загрузка rclone
|
||||||
|
```
|
||||||
|
|
||||||
|
### Добавление новых функций
|
||||||
|
|
||||||
|
- **Новые операции с remotes** – расширяйте `operations/remotes.rs`.
|
||||||
|
- **Дополнительные команды файловых операций** – реализуйте в `operations/sync.rs` и вызывайте из `RcloneUI`.
|
||||||
|
- **Новые панели UI** – используйте `egui::SidePanel`, `CentralPanel` или `Window`.
|
||||||
|
|
||||||
|
### Тестирование
|
||||||
|
|
||||||
|
Запуск всех тестов:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
Некоторые тесты помечены как `ignore` (например, реальная загрузка rclone) – запустите их командой `cargo test -- --ignored`.
|
||||||
|
|
||||||
|
## Устранение неполадок
|
||||||
|
|
||||||
|
### «Rclone not found» / ошибка установки
|
||||||
|
|
||||||
|
- Проверьте интернет-соединение – приложение скачивает rclone с GitHub.
|
||||||
|
- На Linux убедитесь, что установлен `unzip` (хотя crate `zip` не требует его, но для ручной проверки может понадобиться).
|
||||||
|
- При работе через корпоративный прокси установите переменные окружения `HTTP_PROXY` и `HTTPS_PROXY`.
|
||||||
|
|
||||||
|
### Отсутствие шрифтов
|
||||||
|
|
||||||
|
Приложение нормально работает со стандартными системными шрифтами. Поместите файлы шрифтов в `assets/fonts/`, чтобы изменить внешний вид.
|
||||||
|
|
||||||
|
### Ошибки компиляции на Linux
|
||||||
|
|
||||||
|
Установите все необходимые пакеты для разработки X11 (см. раздел «Зависимости для Linux»).
|
||||||
|
|
||||||
|
### Приложение не показывает локальные директории на Windows
|
||||||
|
|
||||||
|
Локальный браузер по умолчанию открывает `C:\`. Чтобы перейти на другой диск, введите путь вручную (например, `D:\`) в поле назначения.
|
||||||
|
|
||||||
|
## Планы на будущее
|
||||||
|
|
||||||
|
- [ ] Графическое изменение конфигурации существующих remote.
|
||||||
|
- [ ] Предварительный просмотр синхронизации (dry-run со списком файлов).
|
||||||
|
- [ ] Ползунок ограничения скорости.
|
||||||
|
- [ ] Поиск файлов по имени/пути.
|
||||||
|
- [ ] Поддержка фильтров rclone (`--include`, `--exclude`).
|
||||||
|
- [ ] Переключение светлой/тёмной темы.
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
Проект лицензируется на условиях **MIT License** или **Apache License 2.0** на ваш выбор. Файл `LICENSE` будет добавлен позднее.
|
||||||
|
|
||||||
|
## Участие в разработке
|
||||||
|
|
||||||
|
Приветствуются любые вклады! Пожалуйста, создавайте issue или pull request. Соблюдайте существующий стиль кода и по возможности добавляйте тесты.
|
||||||
|
|
||||||
|
## Благодарности
|
||||||
|
|
||||||
|
- [rclone](https://rclone.org/) – основа всех операций с хранилищами.
|
||||||
|
- [egui](https://github.com/emilk/egui) – библиотека для мгновенного GUI.
|
||||||
|
- [tokio](https://tokio.rs/) – асинхронная среда выполнения для загрузки rclone.
|
||||||
|
- [dirs](https://crates.io/crates/dirs) – кросс-платформенное определение директорий.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Наслаждайтесь удобным управлением вашими облачными файлами!**
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
@@ -35,6 +35,8 @@ pkgs.mkShell {
|
|||||||
pkgs.xorg.libX11
|
pkgs.xorg.libX11
|
||||||
pkgs.mesa
|
pkgs.mesa
|
||||||
pkgs.libglvnd
|
pkgs.libglvnd
|
||||||
|
pkgs.gtk3
|
||||||
|
pkgs.glib
|
||||||
];
|
];
|
||||||
|
|
||||||
# (Необязательно) принудительно использовать X11 backend
|
# (Необязательно) принудительно использовать X11 backend
|
||||||
|
|||||||
+14
@@ -6,6 +6,13 @@ use eframe::egui;
|
|||||||
use egui::{FontData, FontDefinitions, FontFamily};
|
use egui::{FontData, FontDefinitions, FontFamily};
|
||||||
|
|
||||||
fn main() -> Result<(), eframe::Error> {
|
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()
|
let viewport = egui::ViewportBuilder::default()
|
||||||
.with_inner_size([1200.0, 800.0])
|
.with_inner_size([1200.0, 800.0])
|
||||||
.with_min_inner_size([800.0, 600.0])
|
.with_min_inner_size([800.0, 600.0])
|
||||||
@@ -20,11 +27,15 @@ fn main() -> Result<(), eframe::Error> {
|
|||||||
"Rclone UI Manager",
|
"Rclone UI Manager",
|
||||||
options,
|
options,
|
||||||
Box::new(|cc| {
|
Box::new(|cc| {
|
||||||
|
log::debug!("Инициализация шрифтов");
|
||||||
let mut fonts = FontDefinitions::default();
|
let mut fonts = FontDefinitions::default();
|
||||||
|
|
||||||
if let Ok(font_data) = std::fs::read("assets/fonts/Sans.otf") {
|
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.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::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") {
|
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()) {
|
if !prop.contains(&"emoji".to_owned()) {
|
||||||
prop.push("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);
|
cc.egui_ctx.set_fonts(fonts);
|
||||||
|
|||||||
+3
-20
@@ -1,6 +1,7 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use serde::{Serialize, Deserialize};
|
|
||||||
|
|
||||||
|
pub mod local_fs;
|
||||||
/// === Модуль для работы с удаленными хранилищами ===
|
/// === Модуль для работы с удаленными хранилищами ===
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -23,25 +24,7 @@ pub struct FileInfo {
|
|||||||
pub modified: Option<String>,
|
pub modified: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FileInfo {
|
pub mod 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 files;
|
pub mod files;
|
||||||
|
|
||||||
/// === Модуль для операций синхронизации ===
|
/// === Модуль для операций синхронизации ===
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
use super::*;
|
|
||||||
use crate::rclone_install::RcloneApp;
|
use crate::rclone_install::RcloneApp;
|
||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
/// Проверить разницу между двумя директориями
|
/// Проверить разницу между двумя директориями
|
||||||
pub fn diff(app: &RcloneApp, source: &str, dest: &str) -> Result<Vec<String>, String> {
|
pub fn diff(app: &RcloneApp, source: &str, dest: &str) -> Result<Vec<String>, String> {
|
||||||
|
|||||||
@@ -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,4 +1,3 @@
|
|||||||
use super::*;
|
|
||||||
use crate::rclone_install::RcloneApp;
|
use crate::rclone_install::RcloneApp;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
+108
-13
@@ -4,6 +4,7 @@ use serde_json::Value;
|
|||||||
|
|
||||||
/// Получить список всех удаленных хранилищ
|
/// Получить список всех удаленных хранилищ
|
||||||
pub fn list(app: &RcloneApp) -> Result<Vec<Remote>, String> {
|
pub fn list(app: &RcloneApp) -> Result<Vec<Remote>, String> {
|
||||||
|
log::debug!("Получение списка удаленных хранилищ");
|
||||||
let output = app.run_command(&["listremotes"])?;
|
let output = app.run_command(&["listremotes"])?;
|
||||||
|
|
||||||
let mut remotes = Vec::new();
|
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) {
|
if let Ok(config) = get_config(app, name) {
|
||||||
remotes.push(config);
|
remotes.push(config);
|
||||||
} else {
|
} else {
|
||||||
|
log::warn!("Не удалось получить конфигурацию для remote '{}'", name);
|
||||||
// Если не удалось получить конфиг, добавляем базовую информацию
|
// Если не удалось получить конфиг, добавляем базовую информацию
|
||||||
remotes.push(Remote {
|
remotes.push(Remote {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
@@ -24,11 +26,13 @@ pub fn list(app: &RcloneApp) -> Result<Vec<Remote>, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log::info!("Найдено {} удаленных хранилищ", remotes.len());
|
||||||
Ok(remotes)
|
Ok(remotes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Получить конфигурацию конкретного remote
|
/// Получить конфигурацию конкретного remote
|
||||||
pub fn get_config(app: &RcloneApp, remote_name: &str) -> Result<Remote, String> {
|
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 output = app.run_command(&["config", "dump"])?;
|
||||||
|
|
||||||
let configs: Value = serde_json::from_str(&output)
|
let configs: Value = serde_json::from_str(&output)
|
||||||
@@ -54,36 +58,118 @@ pub fn get_config(app: &RcloneApp, remote_name: &str) -> Result<Remote, String>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log::debug!("Получена конфигурация для {} типа {}", remote_name, r#type);
|
||||||
Ok(Remote {
|
Ok(Remote {
|
||||||
name: remote_name.to_string(),
|
name: remote_name.to_string(),
|
||||||
r#type,
|
r#type,
|
||||||
config: config_map,
|
config: config_map,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
Err(format!("Remote '{}' не найден", remote_name))
|
let error_msg = format!("Remote '{}' не найден", remote_name);
|
||||||
|
log::error!("{}", error_msg);
|
||||||
|
Err(error_msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Создать новое удаленное хранилище
|
/// Вопрос, который 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>,
|
||||||
let mut args = vec!["config", "create", name, r#type];
|
on_stderr: &mut dyn FnMut(&str),
|
||||||
|
) -> Result<ConfigStep, String> {
|
||||||
for (key, value) in config {
|
log::debug!("config create шаг: remote={} тип={} state={:?}", name, r#type, state);
|
||||||
args.push(key.as_str());
|
let mut args = vec!["config", "create", name, r#type, "--non-interactive"];
|
||||||
args.push(value.as_str());
|
if let Some(s) = state {
|
||||||
|
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)?;
|
||||||
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
|
||||||
pub fn update(app: &RcloneApp, name: &str, config: &HashMap<String, String>) -> Result<(), String> {
|
pub fn update(app: &RcloneApp, name: &str, config: &HashMap<String, String>) -> Result<(), String> {
|
||||||
|
log::info!("Обновление конфигурации remote: {}", name);
|
||||||
let mut args = vec!["config", "update", name];
|
let mut args = vec!["config", "update", name];
|
||||||
|
|
||||||
for (key, value) in config {
|
for (key, value) in config {
|
||||||
@@ -92,24 +178,33 @@ pub fn update(app: &RcloneApp, name: &str, config: &HashMap<String, String>) ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.run_command(&args)?;
|
app.run_command(&args)?;
|
||||||
|
log::info!("Конфигурация remote {} успешно обновлена", name);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Удалить remote
|
/// Удалить remote
|
||||||
pub fn delete(app: &RcloneApp, name: &str) -> Result<(), String> {
|
pub fn delete(app: &RcloneApp, name: &str) -> Result<(), String> {
|
||||||
|
log::info!("Удаление remote: {}", name);
|
||||||
app.run_command(&["config", "delete", name])?;
|
app.run_command(&["config", "delete", name])?;
|
||||||
|
log::info!("Remote {} успешно удален", name);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Проверить доступность remote
|
/// Проверить доступность remote
|
||||||
pub fn check(app: &RcloneApp, remote_name: &str) -> Result<bool, String> {
|
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"]);
|
let result = app.run_command(&["lsd", remote_name, "--max-depth", "1"]);
|
||||||
match result {
|
match result {
|
||||||
Ok(_) => Ok(true),
|
Ok(_) => {
|
||||||
|
log::debug!("Remote {} доступен", remote_name);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if e.contains("directory not found") {
|
if e.contains("directory not found") {
|
||||||
|
log::warn!("Remote {} существует, но пуст", remote_name);
|
||||||
Ok(true) // Remote существует, но пустой
|
Ok(true) // Remote существует, но пустой
|
||||||
} else {
|
} else {
|
||||||
|
log::error!("Ошибка доступа к remote {}: {}", remote_name, e);
|
||||||
Err(e)
|
Err(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::rclone_install::RcloneApp;
|
use crate::rclone_install::RcloneApp;
|
||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
/// Скопировать файлы/директории
|
/// Скопировать файлы/директории
|
||||||
pub fn copy(
|
pub fn copy(
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
use flate2::read::GzDecoder;
|
|
||||||
use reqwest;
|
use reqwest;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use tar::Archive;
|
|
||||||
use tokio;
|
|
||||||
|
|
||||||
use super::RcloneApp;
|
use super::RcloneApp;
|
||||||
|
|
||||||
impl RcloneApp {
|
impl RcloneApp {
|
||||||
pub async fn new() -> Result<Self, Box<dyn std::error::Error>> {
|
pub async fn new() -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
|
log::info!("Инициализация RcloneApp");
|
||||||
|
|
||||||
// Сначала проверяем системный rclone
|
// Сначала проверяем системный rclone
|
||||||
if let Some(system_rclone) = Self::check_system_rclone() {
|
if let Some(system_rclone) = Self::check_system_rclone() {
|
||||||
println!("Найден системный rclone: {:?}", system_rclone);
|
log::info!("Найден системный rclone: {:?}", system_rclone);
|
||||||
return Ok(Self {
|
return Ok(Self {
|
||||||
rclone_path: system_rclone,
|
rclone_path: system_rclone,
|
||||||
using_system_rclone: true,
|
using_system_rclone: true,
|
||||||
@@ -32,7 +31,7 @@ impl RcloneApp {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if rclone_path.exists() {
|
if rclone_path.exists() {
|
||||||
println!("Найден rclone в директории приложения: {:?}", rclone_path);
|
log::info!("Найден rclone в директории приложения: {:?}", rclone_path);
|
||||||
return Ok(Self {
|
return Ok(Self {
|
||||||
rclone_path,
|
rclone_path,
|
||||||
using_system_rclone: false,
|
using_system_rclone: false,
|
||||||
@@ -40,12 +39,13 @@ impl RcloneApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Если ничего не найдено - устанавливаем
|
// Если ничего не найдено - устанавливаем
|
||||||
println!("rclone не найден. Начинаю установку...");
|
log::warn!("rclone не найден. Начинаю установку...");
|
||||||
Self::install_latest_rclone().await
|
Self::install_latest_rclone().await
|
||||||
}
|
}
|
||||||
|
|
||||||
// Публичный конструктор для тестов и создания экземпляра с заданными параметрами
|
// Публичный конструктор для тестов и создания экземпляра с заданными параметрами
|
||||||
pub fn new_with_path(rclone_path: PathBuf, using_system_rclone: bool) -> Self {
|
pub fn new_with_path(rclone_path: PathBuf, using_system_rclone: bool) -> Self {
|
||||||
|
log::debug!("Создание RcloneApp с путем {:?}, системный: {}", rclone_path, using_system_rclone);
|
||||||
Self {
|
Self {
|
||||||
rclone_path,
|
rclone_path,
|
||||||
using_system_rclone,
|
using_system_rclone,
|
||||||
@@ -63,30 +63,36 @@ impl RcloneApp {
|
|||||||
|
|
||||||
// Сеттеры
|
// Сеттеры
|
||||||
pub fn set_rclone_path(&mut self, path: PathBuf) {
|
pub fn set_rclone_path(&mut self, path: PathBuf) {
|
||||||
|
log::debug!("Изменение пути rclone с {:?} на {:?}", self.rclone_path, path);
|
||||||
self.rclone_path = path;
|
self.rclone_path = path;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_using_system_rclone(&mut self, value: bool) {
|
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;
|
self.using_system_rclone = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn check_system_rclone() -> Option<PathBuf> {
|
pub fn check_system_rclone() -> Option<PathBuf> {
|
||||||
|
log::debug!("Проверка системного rclone");
|
||||||
if let Ok(path) = which::which("rclone") {
|
if let Ok(path) = which::which("rclone") {
|
||||||
|
log::debug!("Найден системный rclone по пути: {:?}", path);
|
||||||
return Some(path);
|
return Some(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
if let Ok(path) = which::which("rclone.exe") {
|
if let Ok(path) = which::which("rclone.exe") {
|
||||||
|
log::debug!("Найден системный rclone.exe по пути: {:?}", path);
|
||||||
return Some(path);
|
return Some(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log::debug!("Системный rclone не найден");
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn install_latest_rclone() -> Result<Self, Box<dyn std::error::Error>> {
|
async fn install_latest_rclone() -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
println!("Начинаю установку последней версии rclone...");
|
log::info!("Начинаю установку последней версии rclone...");
|
||||||
|
|
||||||
// Определяем ОС и архитектуру
|
// Определяем ОС и архитектуру
|
||||||
let os = if cfg!(target_os = "linux") {
|
let os = if cfg!(target_os = "linux") {
|
||||||
@@ -109,8 +115,12 @@ impl RcloneApp {
|
|||||||
return Err("Неподдерживаемая архитектура".into());
|
return Err("Неподдерживаемая архитектура".into());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
log::info!("ОС: {}, архитектура: {}", os, arch);
|
||||||
|
|
||||||
// Получаем информацию о последнем релизе
|
// Получаем информацию о последнем релизе
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
|
log::debug!("Запрос последней версии rclone с GitHub API");
|
||||||
|
|
||||||
let latest_release: serde_json::Value = client
|
let latest_release: serde_json::Value = client
|
||||||
.get("https://api.github.com/repos/rclone/rclone/releases/latest")
|
.get("https://api.github.com/repos/rclone/rclone/releases/latest")
|
||||||
.header("User-Agent", "rclone-ui-installer")
|
.header("User-Agent", "rclone-ui-installer")
|
||||||
@@ -124,27 +134,33 @@ impl RcloneApp {
|
|||||||
.ok_or("Не удалось получить версию")?
|
.ok_or("Не удалось получить версию")?
|
||||||
.trim_start_matches('v');
|
.trim_start_matches('v');
|
||||||
|
|
||||||
|
log::info!("Найдена последняя версия rclone: {}", version);
|
||||||
|
|
||||||
// Формируем URL для скачивания
|
// Формируем URL для скачивания
|
||||||
let download_url = format!(
|
let download_url = format!(
|
||||||
"https://github.com/rclone/rclone/releases/download/v{}/rclone-v{}-{}-{}.zip",
|
"https://github.com/rclone/rclone/releases/download/v{}/rclone-v{}-{}-{}.zip",
|
||||||
version, version, os, arch
|
version, version, os, arch
|
||||||
);
|
);
|
||||||
|
|
||||||
println!("Скачиваю rclone версии {} с: {}", version, download_url);
|
log::info!("Скачиваю rclone с: {}", download_url);
|
||||||
|
|
||||||
// Скачиваем архив
|
// Скачиваем архив
|
||||||
let response = client.get(&download_url).send().await?;
|
let response = client.get(&download_url).send().await?;
|
||||||
if !response.status().is_success() {
|
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?;
|
let bytes = response.bytes().await?;
|
||||||
|
log::info!("Архив скачан, размер: {} байт", bytes.len());
|
||||||
|
|
||||||
// Создаем директорию приложения
|
// Создаем директорию приложения
|
||||||
let app_dir = dirs::data_dir()
|
let app_dir = dirs::data_dir()
|
||||||
.ok_or("Не удалось найти папку для данных")?
|
.ok_or("Не удалось найти папку для данных")?
|
||||||
.join("rclone-ui");
|
.join("rclone-ui");
|
||||||
fs::create_dir_all(&app_dir)?;
|
fs::create_dir_all(&app_dir)?;
|
||||||
|
log::debug!("Директория приложения: {:?}", app_dir);
|
||||||
|
|
||||||
// Определяем имя исполняемого файла
|
// Определяем имя исполняемого файла
|
||||||
let rclone_filename = if cfg!(windows) {
|
let rclone_filename = if cfg!(windows) {
|
||||||
@@ -165,16 +181,18 @@ impl RcloneApp {
|
|||||||
let file_name = file.name().to_string();
|
let file_name = file.name().to_string();
|
||||||
|
|
||||||
if file_name.ends_with("rclone") || file_name.ends_with("rclone.exe") {
|
if file_name.ends_with("rclone") || file_name.ends_with("rclone.exe") {
|
||||||
|
log::debug!("Найден исполняемый файл в архиве: {}", file_name);
|
||||||
let mut contents = Vec::new();
|
let mut contents = Vec::new();
|
||||||
std::io::copy(&mut file, &mut contents)?;
|
std::io::copy(&mut file, &mut contents)?;
|
||||||
fs::write(&rclone_path, contents)?;
|
fs::write(&rclone_path, contents)?;
|
||||||
extracted = true;
|
extracted = true;
|
||||||
println!("Найден и распакован: {}", file_name);
|
log::info!("Распакован: {} в {:?}", file_name, rclone_path);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !extracted {
|
if !extracted {
|
||||||
|
log::error!("Не удалось найти исполняемый файл rclone в архиве");
|
||||||
return Err("Не удалось найти исполняемый файл rclone в архиве".into());
|
return Err("Не удалось найти исполняемый файл rclone в архиве".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,9 +203,10 @@ impl RcloneApp {
|
|||||||
let mut perms = fs::metadata(&rclone_path)?.permissions();
|
let mut perms = fs::metadata(&rclone_path)?.permissions();
|
||||||
perms.set_mode(0o755);
|
perms.set_mode(0o755);
|
||||||
fs::set_permissions(&rclone_path, perms)?;
|
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)
|
let output = Command::new(&rclone_path)
|
||||||
@@ -197,7 +216,9 @@ impl RcloneApp {
|
|||||||
|
|
||||||
if output.status.success() {
|
if output.status.success() {
|
||||||
let version_output = String::from_utf8_lossy(&output.stdout);
|
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 {
|
Ok(Self {
|
||||||
@@ -207,19 +228,88 @@ impl RcloneApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn run_command(&self, args: &[&str]) -> Result<String, String> {
|
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)
|
let output = Command::new(&self.rclone_path)
|
||||||
.args(args)
|
.args(args)
|
||||||
.output()
|
.output()
|
||||||
.map_err(|e| format!("Ошибка запуска: {}", e))?;
|
.map_err(|e| {
|
||||||
|
log::error!("Ошибка запуска rclone: {}", e);
|
||||||
|
format!("Ошибка запуска: {}", e)
|
||||||
|
})?;
|
||||||
|
|
||||||
if output.status.success() {
|
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 {
|
} 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Запуск команды с передачей строк 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");
|
||||||
self.run_command(&["version"])
|
self.run_command(&["version"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
use super::RcloneApp;
|
use super::RcloneApp;
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
use std::env;
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
//! Графический интерфейс для операций rclone.
|
//! Графический интерфейс для операций rclone с интегрированным Local FS и поддержкой кастомных шрифтов.
|
||||||
|
use crate::operations::remotes::ConfigQuestion;
|
||||||
use eframe::egui;
|
use crate::operations::{FileInfo, Remote};
|
||||||
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;
|
|
||||||
use crate::rclone_install::RcloneApp;
|
use crate::rclone_install::RcloneApp;
|
||||||
use crate::operations::{self, FileInfo, Remote, CopyOptions, MoveOptions, DeleteOptions};
|
use eframe::egui;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::mpsc::{Receiver, Sender};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
#[derive(Clone, PartialEq)]
|
#[derive(Clone, PartialEq)]
|
||||||
pub enum AppState {
|
pub enum AppState {
|
||||||
@@ -16,6 +15,12 @@ pub enum AppState {
|
|||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Clone, Copy)]
|
||||||
|
enum TransferTab {
|
||||||
|
Remote,
|
||||||
|
Local,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Operation {
|
pub struct Operation {
|
||||||
pub id: u32,
|
pub id: u32,
|
||||||
@@ -30,16 +35,45 @@ 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,
|
||||||
error_message: Option<String>,
|
error_message: Option<String>,
|
||||||
|
|
||||||
current_path: String,
|
current_path: String,
|
||||||
// История переходов
|
|
||||||
history_back: Vec<String>,
|
history_back: Vec<String>,
|
||||||
history_forward: Vec<String>,
|
history_forward: Vec<String>,
|
||||||
|
|
||||||
@@ -49,11 +83,30 @@ pub struct RcloneUI {
|
|||||||
|
|
||||||
transfer_source_info: Vec<(String, bool)>,
|
transfer_source_info: Vec<(String, bool)>,
|
||||||
transfer_dest: String,
|
transfer_dest: String,
|
||||||
|
active_transfer_tab: TransferTab,
|
||||||
|
|
||||||
show_transfer_dialog: bool,
|
show_transfer_dialog: bool,
|
||||||
|
show_local_browser: bool,
|
||||||
|
local_browser_path: String,
|
||||||
|
local_browser_files: Vec<FileInfo>,
|
||||||
|
|
||||||
is_move_mode: bool,
|
is_move_mode: bool,
|
||||||
|
#[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>,
|
||||||
@@ -65,402 +118,11 @@ pub struct RcloneUI {
|
|||||||
next_op_id: u32,
|
next_op_id: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub mod app;
|
||||||
|
pub mod rcloneui;
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
pub struct AppSettings {
|
pub struct AppSettings {
|
||||||
pub show_hidden: bool,
|
pub show_hidden: bool,
|
||||||
pub bandwidth_limit: Option<String>,
|
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+628
@@ -0,0 +1,628 @@
|
|||||||
|
use super::*;
|
||||||
|
use eframe::egui;
|
||||||
|
use egui::{
|
||||||
|
Align2, CentralPanel, Color32, ProgressBar, ScrollArea,
|
||||||
|
SidePanel, Spinner, Window,
|
||||||
|
};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Действия контекстного меню хранилища
|
||||||
|
enum RemoteMenuAction {
|
||||||
|
Open,
|
||||||
|
Check,
|
||||||
|
About,
|
||||||
|
Delete,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl eframe::App for RcloneUI {
|
||||||
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||||
|
self.poll_rclone_init();
|
||||||
|
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() {
|
||||||
|
let selected = self.current_path.starts_with(&remote.name);
|
||||||
|
let response =
|
||||||
|
ui.selectable_label(selected, format!("📡 {}", remote.name));
|
||||||
|
if response.clicked() {
|
||||||
|
log::debug!("Открытие remote: {}", remote.name);
|
||||||
|
self.navigate_to(format!("{}:", remote.name));
|
||||||
|
}
|
||||||
|
if response.secondary_clicked() {
|
||||||
|
log::debug!("Контекстное меню remote: {}", remote.name);
|
||||||
|
let pos = ctx
|
||||||
|
.input(|i| i.pointer.interact_pos())
|
||||||
|
.unwrap_or_default();
|
||||||
|
self.context_menu = Some((remote.name.clone(), pos));
|
||||||
|
self.context_menu_requested = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.separator();
|
||||||
|
ui.add_enabled_ui(self.rclone.is_some(), |ui| {
|
||||||
|
if ui.button("➕ Добавить облако").clicked() {
|
||||||
|
log::debug!("Открытие диалога добавления облака");
|
||||||
|
self.show_add_remote_dialog = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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_add_remote_dialog {
|
||||||
|
Window::new("Новое облако")
|
||||||
|
.anchor(Align2::CENTER_CENTER, [0.0, 0.0])
|
||||||
|
.collapsible(false)
|
||||||
|
.resizable(false)
|
||||||
|
.fixed_size([480.0, 420.0])
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
match self.add_remote_step.clone() {
|
||||||
|
AddRemoteStep::Form => {
|
||||||
|
ui.label("Имя:");
|
||||||
|
ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut self.new_remote_name)
|
||||||
|
.hint_text("например: mydrive")
|
||||||
|
.desired_width(240.0),
|
||||||
|
);
|
||||||
|
ui.add_space(6.0);
|
||||||
|
ui.label("Тип:");
|
||||||
|
egui::ComboBox::from_id_source("remote_type")
|
||||||
|
.selected_text(self.new_remote_type.as_str())
|
||||||
|
.width(240.0)
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
for t in REMOTE_TYPES {
|
||||||
|
ui.selectable_value(
|
||||||
|
&mut self.new_remote_type,
|
||||||
|
t.to_string(),
|
||||||
|
*t,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.add(
|
||||||
|
egui::Label::new(
|
||||||
|
"Для облаков с авторизацией (Google Drive, Dropbox и др.) при создании откроется браузер для входа в аккаунт.",
|
||||||
|
)
|
||||||
|
.wrap(true),
|
||||||
|
);
|
||||||
|
ui.add_space(10.0);
|
||||||
|
ui.separator();
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let can_create = !self.new_remote_name.trim().is_empty()
|
||||||
|
&& !self.new_remote_type.trim().is_empty()
|
||||||
|
&& self.active_task_count == 0;
|
||||||
|
if ui
|
||||||
|
.add_enabled(
|
||||||
|
can_create,
|
||||||
|
egui::Button::new("✅ Создать"),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
log::debug!("Кнопка 'Создать' нажата");
|
||||||
|
self.start_add_remote();
|
||||||
|
}
|
||||||
|
if ui.button("Отмена").clicked() {
|
||||||
|
self.show_add_remote_dialog = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
AddRemoteStep::Question(question) => {
|
||||||
|
ui.heading(format!("❓ {}", question.name));
|
||||||
|
ui.add(egui::Label::new(&question.help).wrap(true));
|
||||||
|
if !question.default.is_empty() {
|
||||||
|
ui.weak(format!("По умолчанию: {}", question.default));
|
||||||
|
}
|
||||||
|
if !question.examples.is_empty() {
|
||||||
|
ui.add_space(6.0);
|
||||||
|
ui.label("Варианты:");
|
||||||
|
for (value, help) in &question.examples {
|
||||||
|
let selected = self.add_remote_answer == *value;
|
||||||
|
let label = if help.is_empty() {
|
||||||
|
value.clone()
|
||||||
|
} else {
|
||||||
|
format!("{} — {}", value, help)
|
||||||
|
};
|
||||||
|
if ui.selectable_label(selected, label).clicked() {
|
||||||
|
self.add_remote_answer = value.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ui.add_space(6.0);
|
||||||
|
ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut self.add_remote_answer)
|
||||||
|
.password(question.is_password)
|
||||||
|
.hint_text(if question.is_password { "секрет" } else { "ответ" })
|
||||||
|
.desired_width(320.0),
|
||||||
|
);
|
||||||
|
ui.add_space(10.0);
|
||||||
|
ui.separator();
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui
|
||||||
|
.add_enabled(
|
||||||
|
self.active_task_count == 0,
|
||||||
|
egui::Button::new("✅ Ответить"),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
log::debug!("Ответ на вопрос rclone отправлен");
|
||||||
|
self.answer_add_remote();
|
||||||
|
}
|
||||||
|
if ui.button("Отмена").clicked() {
|
||||||
|
self.show_add_remote_dialog = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
AddRemoteStep::Busy => {
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.add(egui::Spinner::new());
|
||||||
|
ui.label(&self.add_remote_status);
|
||||||
|
});
|
||||||
|
ui.add(
|
||||||
|
egui::Label::new(
|
||||||
|
"Если страница авторизации не открылась — скопируйте ссылку из статуса ниже в браузер.",
|
||||||
|
)
|
||||||
|
.wrap(true),
|
||||||
|
);
|
||||||
|
ui.add_space(10.0);
|
||||||
|
ui.separator();
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui.button("Отмена").clicked() {
|
||||||
|
self.show_add_remote_dialog = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Модальное окно трансфера
|
||||||
|
if self.show_transfer_dialog {
|
||||||
|
Window::new("Трансфер")
|
||||||
|
.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(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() {
|
||||||
|
self.current_path = path.clone();
|
||||||
|
self.selected_paths.clear();
|
||||||
|
self.load_files(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.request_repaint_after(Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,687 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::operations::remotes::ConfigStep;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// Форматирование размера в человекочитаемый вид
|
||||||
|
pub fn format_bytes(bytes: u64) -> String {
|
||||||
|
if bytes < 1024 {
|
||||||
|
format!("{} B", bytes)
|
||||||
|
} else if bytes < 1024 * 1024 {
|
||||||
|
format!("{:.1} KB", bytes as f64 / 1024.0)
|
||||||
|
} else if bytes < 1024 * 1024 * 1024 {
|
||||||
|
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
|
||||||
|
} else {
|
||||||
|
format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RcloneUI {
|
||||||
|
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
|
||||||
|
log::debug!("Инициализация RcloneUI");
|
||||||
|
|
||||||
|
// --- ВОССТАНОВЛЕНИЕ ШРИФТОВ ---
|
||||||
|
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(),
|
||||||
|
show_add_remote_dialog: false,
|
||||||
|
new_remote_name: String::new(),
|
||||||
|
new_remote_type: "drive".to_string(),
|
||||||
|
add_remote_step: AddRemoteStep::Form,
|
||||||
|
add_remote_state: None,
|
||||||
|
add_remote_answer: String::new(),
|
||||||
|
add_remote_status: String::new(),
|
||||||
|
remote_to_delete: None,
|
||||||
|
remote_info: None,
|
||||||
|
context_menu: None,
|
||||||
|
context_menu_requested: false,
|
||||||
|
active_task_count: 0,
|
||||||
|
operation_tx: op_tx,
|
||||||
|
operation_rx: op_rx,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Фоновый цикл создания 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() {
|
||||||
|
log::info!("Создание нового remote: {} типа {}", name, rtype);
|
||||||
|
self.active_task_count += 1;
|
||||||
|
self.add_remote_step = AddRemoteStep::Busy;
|
||||||
|
self.add_remote_status = "Создание облака...".into();
|
||||||
|
let op_id = self.add_operation(format!("Создание облака {}", name));
|
||||||
|
Self::run_add_remote_loop(rclone, name, rtype, None, None, op_id, self.operation_tx.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn answer_add_remote(&mut self) {
|
||||||
|
if let (Some(rclone), Some(state)) = (self.rclone.clone(), self.add_remote_state.clone()) {
|
||||||
|
let answer = self.add_remote_answer.clone();
|
||||||
|
let name = self.new_remote_name.clone();
|
||||||
|
let rtype = self.new_remote_type.clone();
|
||||||
|
log::info!("Ответ на вопрос rclone: {}", answer);
|
||||||
|
self.active_task_count += 1;
|
||||||
|
self.add_remote_step = AddRemoteStep::Busy;
|
||||||
|
self.add_remote_status = "Отправка ответа...".into();
|
||||||
|
let op_id = self.add_operation(format!("Создание облака {}", name));
|
||||||
|
Self::run_add_remote_loop(rclone, name, rtype, Some(state), Some(answer), op_id, self.operation_tx.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Проверить доступность хранилища
|
||||||
|
pub fn check_remote(&mut self, name: String) {
|
||||||
|
if let Some(rclone) = self.rclone.clone() {
|
||||||
|
log::info!("Проверка доступности remote: {}", name);
|
||||||
|
self.active_task_count += 1;
|
||||||
|
let op_id = self.add_operation(format!("Проверка {}", name));
|
||||||
|
let tx = self.operation_tx.clone();
|
||||||
|
let remote_path = format!("{}:", name);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
match operations::remotes::check(&rclone, &remote_path) {
|
||||||
|
Ok(true) => {
|
||||||
|
let _ = tx.send(OperationResult::RemoteCheck(op_id, name, true));
|
||||||
|
}
|
||||||
|
Ok(false) => {
|
||||||
|
let _ = tx.send(OperationResult::RemoteCheck(op_id, name, false));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = tx.send(OperationResult::Failure(op_id, e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получить информацию об использовании хранилища
|
||||||
|
pub fn about_remote(&mut self, name: String) {
|
||||||
|
if let Some(rclone) = self.rclone.clone() {
|
||||||
|
log::info!("Получение информации об использовании remote: {}", name);
|
||||||
|
self.active_task_count += 1;
|
||||||
|
let op_id = self.add_operation(format!("Использование {}", name));
|
||||||
|
let tx = self.operation_tx.clone();
|
||||||
|
let remote_path = format!("{}:", name);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
match operations::files::about(&rclone, &remote_path) {
|
||||||
|
Ok(info) => {
|
||||||
|
let _ = tx.send(OperationResult::RemoteAbout(op_id, name, info));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = tx.send(OperationResult::Failure(op_id, e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Удалить хранилище из конфигурации rclone
|
||||||
|
pub fn delete_remote(&mut self, name: String) {
|
||||||
|
if let Some(rclone) = self.rclone.clone() {
|
||||||
|
log::info!("Удаление remote: {}", name);
|
||||||
|
self.active_task_count += 1;
|
||||||
|
let op_id = self.add_operation(format!("Удаление {}", name));
|
||||||
|
let tx = self.operation_tx.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
match operations::remotes::delete(&rclone, &name) {
|
||||||
|
Ok(()) => {
|
||||||
|
let _ = tx.send(OperationResult::RemoteDeleted(op_id, name));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = tx.send(OperationResult::Failure(op_id, e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_remotes(&mut self) { if let Some(rclone) = self.rclone.clone() {
|
||||||
|
log::info!("Загрузка списка удаленных хранилищ");
|
||||||
|
self.active_task_count += 1;
|
||||||
|
let tx = self.operation_tx.clone();
|
||||||
|
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);
|
||||||
|
if matches!(self.add_remote_step, AddRemoteStep::Busy) {
|
||||||
|
self.add_remote_step = AddRemoteStep::Form;
|
||||||
|
}
|
||||||
|
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::RemoteAdded(op_id, remotes) => {
|
||||||
|
let count = remotes.len();
|
||||||
|
log::info!("Облако добавлено, список обновлен: {} элементов", count);
|
||||||
|
self.remote_list = remotes;
|
||||||
|
self.active_task_count = self.active_task_count.saturating_sub(1);
|
||||||
|
self.active_operations.retain(|op| op.id != op_id);
|
||||||
|
self.show_add_remote_dialog = false;
|
||||||
|
self.add_remote_step = AddRemoteStep::Form;
|
||||||
|
self.add_remote_state = None;
|
||||||
|
self.new_remote_name.clear();
|
||||||
|
self.add_remote_answer.clear();
|
||||||
|
}
|
||||||
|
OperationResult::ConfigQuestion(op_id, state, question) => {
|
||||||
|
log::debug!("Получен вопрос rclone: {}", question.name);
|
||||||
|
self.active_task_count = self.active_task_count.saturating_sub(1);
|
||||||
|
self.active_operations.retain(|op| op.id != op_id);
|
||||||
|
self.add_remote_state = Some(state);
|
||||||
|
self.add_remote_answer = question.default.clone();
|
||||||
|
self.add_remote_step = AddRemoteStep::Question(question);
|
||||||
|
}
|
||||||
|
OperationResult::RemoteCheck(op_id, name, ok) => {
|
||||||
|
log::debug!("Проверка remote {} завершена: доступен={}", name, ok);
|
||||||
|
self.active_task_count = self.active_task_count.saturating_sub(1);
|
||||||
|
self.active_operations.retain(|op| op.id != op_id);
|
||||||
|
self.remote_info = Some(RemoteInfoView {
|
||||||
|
title: format!("Проверка {}", name),
|
||||||
|
content: if ok {
|
||||||
|
"Хранилище доступно".to_string()
|
||||||
|
} else {
|
||||||
|
"Хранилище недоступно".to_string()
|
||||||
|
},
|
||||||
|
success: ok,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
OperationResult::RemoteAbout(op_id, name, info) => {
|
||||||
|
log::debug!("Информация о remote {} получена", name);
|
||||||
|
self.active_task_count = self.active_task_count.saturating_sub(1);
|
||||||
|
self.active_operations.retain(|op| op.id != op_id);
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
for (key, value) in &info {
|
||||||
|
match key.as_str() {
|
||||||
|
"total" => lines.push(format!("Общий объем: {}", format_bytes(*value))),
|
||||||
|
"used" => lines.push(format!("Использовано: {}", format_bytes(*value))),
|
||||||
|
"free" => lines.push(format!("Свободно: {}", format_bytes(*value))),
|
||||||
|
"trashed" => lines.push(format!("В корзине: {}", format_bytes(*value))),
|
||||||
|
"objects" => lines.push(format!("Файлов: {}", value)),
|
||||||
|
"versions" => lines.push(format!("Версий: {}", value)),
|
||||||
|
_ => lines.push(format!("{}: {}", key, format_bytes(*value))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.remote_info = Some(RemoteInfoView {
|
||||||
|
title: format!("Использование {}", name),
|
||||||
|
content: lines.join("\n"),
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
OperationResult::RemoteDeleted(op_id, name) => {
|
||||||
|
log::info!("Remote {} удален", name);
|
||||||
|
self.active_task_count = self.active_task_count.saturating_sub(1);
|
||||||
|
self.active_operations.retain(|op| op.id != op_id);
|
||||||
|
self.remote_to_delete = None;
|
||||||
|
if self.current_path.starts_with(&name) {
|
||||||
|
self.current_path.clear();
|
||||||
|
self.current_files.clear();
|
||||||
|
self.selected_paths.clear();
|
||||||
|
self.history_back.clear();
|
||||||
|
self.history_forward.clear();
|
||||||
|
}
|
||||||
|
self.load_remotes();
|
||||||
|
}
|
||||||
|
OperationResult::ProgressUpdate(op_id, progress, status) => {
|
||||||
|
if let Some(op) = self.active_operations.iter_mut().find(|op| op.id == op_id) {
|
||||||
|
op.progress = progress;
|
||||||
|
op.status = status.clone();
|
||||||
|
log::trace!("Операция {}: прогресс {:.1}%, статус: {}", op_id, progress * 100.0, status);
|
||||||
|
}
|
||||||
|
if matches!(&self.add_remote_step, AddRemoteStep::Busy) {
|
||||||
|
self.add_remote_status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user