Initial commit: LoFlum FTP/SFTP client
Tauri 2 + Rust + Svelte 5. SFTP via ssh2, FTP via suppaftp. Transfer queue with async worker, SQLite site storage, keychain.
This commit is contained in:
parent
0f182ab76f
commit
79855585bd
60 changed files with 14532 additions and 18 deletions
36
.gitignore
vendored
36
.gitignore
vendored
|
|
@ -1,21 +1,21 @@
|
|||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
debug
|
||||
target
|
||||
node_modules/
|
||||
dist/
|
||||
target/
|
||||
.tauri/
|
||||
src-tauri/target/
|
||||
*.lock
|
||||
!Cargo.lock
|
||||
.DS_Store
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||
*.pdb
|
||||
# Env
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Generated by cargo mutants
|
||||
# Contains mutation testing data
|
||||
**/mutants.out*/
|
||||
|
||||
# RustRover
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
# OS
|
||||
Thumbs.db
|
||||
|
|
|
|||
6302
Cargo.lock
generated
Normal file
6302
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
3
Cargo.toml
Normal file
3
Cargo.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[workspace]
|
||||
members = ["src-tauri"]
|
||||
resolver = "2"
|
||||
22
LICENSE
22
LICENSE
|
|
@ -392,8 +392,13 @@ governed by this License along with a term that is a further
|
|||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
<<<<<<< HEAD
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
=======
|
||||
of that license document, provided that the further restriction does not
|
||||
survive such relicensing or conveying.
|
||||
>>>>>>> 049e4f1 (Initial commit: LoFlum FTP/SFTP client)
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
|
|
@ -511,12 +516,20 @@ in a country, would infringe one or more identifiable patents in that
|
|||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
<<<<<<< HEAD
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
=======
|
||||
arrangement, you convey, or procure by conveying, a covered work, and
|
||||
grant a patent license to some of the parties receiving the covered work
|
||||
authorizing them to use, propagate, modify or convey a specific copy of
|
||||
the covered work, then the patent license you grant is automatically
|
||||
extended to all recipients of the covered work and works based on it.
|
||||
>>>>>>> 049e4f1 (Initial commit: LoFlum FTP/SFTP client)
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
|
|
@ -534,8 +547,13 @@ contain the covered work, unless you entered into that arrangement,
|
|||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
<<<<<<< HEAD
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
=======
|
||||
any implied license or other defenses to infringement that may be
|
||||
otherwise available to you under applicable patent law.
|
||||
>>>>>>> 049e4f1 (Initial commit: LoFlum FTP/SFTP client)
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
|
|
@ -576,7 +594,11 @@ Foundation. If the Program does not specify a version number of the
|
|||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
<<<<<<< HEAD
|
||||
If the Program specifies that a proxy can decide which future
|
||||
=======
|
||||
If the Program specifies that a proxy can decide whether future
|
||||
>>>>>>> 049e4f1 (Initial commit: LoFlum FTP/SFTP client)
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
|
|
|||
60
README.md
Normal file
60
README.md
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# LoFlum
|
||||
|
||||
Современный FTP/SFTP клиент на Tauri 2 + Rust + Svelte 5.
|
||||
|
||||
## Стек
|
||||
|
||||
- **Tauri 2** — шелл
|
||||
- **Rust** — протоколы, файловая система, очередь передач
|
||||
- **Svelte 5** — UI
|
||||
- **tokio** — async runtime
|
||||
|
||||
## Протоколы
|
||||
|
||||
| Версия | Протоколы |
|
||||
|--------|-----------|
|
||||
| v1 | SFTP, FTP, FTPS |
|
||||
| v2 | S3, WebDAV |
|
||||
| v3 | Google Drive, Dropbox |
|
||||
|
||||
## Разработка
|
||||
|
||||
```bash
|
||||
# Зависимости
|
||||
bun install
|
||||
|
||||
# Dev режим
|
||||
bun run tauri dev
|
||||
|
||||
# Сборка
|
||||
bun run tauri build
|
||||
```
|
||||
|
||||
## Зависимости системы
|
||||
|
||||
- Rust + cargo
|
||||
- Bun (или npm/pnpm)
|
||||
- libssh2 (`pacman -S libssh2` на Arch)
|
||||
- webkit2gtk (`pacman -S webkit2gtk-4.1` на Arch)
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
src-tauri/src/
|
||||
├── domain/ — модели данных (Connection, FileEntry, TransferTask, Site)
|
||||
├── protocols/ — RemoteFs trait + sftp.rs, ftp.rs
|
||||
├── transfer/ — очередь, воркеры, прогресс
|
||||
├── storage/ — SQLite (сайты), keychain (пароли)
|
||||
├── fs/ — локальная ФС + реестр удалённых соединений
|
||||
└── commands/ — tauri commands (вызовы из фронта)
|
||||
```
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] Chunked upload/download с прогресс-событиями
|
||||
- [x] FTP/FTPS — FTP реализован (FTPS — stub)
|
||||
- [ ] Site Manager UI
|
||||
- [x] Диалог нового соединения
|
||||
- [x] Transfer Queue с воркером и прогресс-событиями
|
||||
- [ ] Drag & drop
|
||||
- [ ] Горячие клавиши
|
||||
12
index.html
Normal file
12
index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>LoFlum</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
20
package.json
Normal file
20
package.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "loflum",
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
"svelte": "^5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^4",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"vite": "^6"
|
||||
}
|
||||
}
|
||||
46
src-tauri/Cargo.toml
Normal file
46
src-tauri/Cargo.toml
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
[package]
|
||||
name = "loflum"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "loflum_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[[bin]]
|
||||
name = "loflum"
|
||||
path = "src/main.rs"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-shell = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
anyhow = "1"
|
||||
thiserror = "1"
|
||||
camino = { version = "1", features = ["serde1"] }
|
||||
async-trait = "0.1"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
keyring = "2"
|
||||
suppaftp = { version = "6", features = ["async", "async-native-tls"] }
|
||||
ssh2 = "0.9"
|
||||
once_cell = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
dirs = "5"
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
futures = "0.3"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
chrono = "0.4"
|
||||
async-std = "1"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
panic = "abort"
|
||||
3
src-tauri/build.rs
Normal file
3
src-tauri/build.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
src-tauri/gen/schemas/capabilities.json
Normal file
1
src-tauri/gen/schemas/capabilities.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{}
|
||||
2612
src-tauri/gen/schemas/desktop-schema.json
Normal file
2612
src-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load diff
2612
src-tauri/gen/schemas/linux-schema.json
Normal file
2612
src-tauri/gen/schemas/linux-schema.json
Normal file
File diff suppressed because it is too large
Load diff
BIN
src-tauri/icons/128x128.png
Normal file
BIN
src-tauri/icons/128x128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 387 B |
BIN
src-tauri/icons/128x128@2x.png
Normal file
BIN
src-tauri/icons/128x128@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 850 B |
BIN
src-tauri/icons/32x32.png
Normal file
BIN
src-tauri/icons/32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 98 B |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.icns
Normal file
Binary file not shown.
BIN
src-tauri/icons/icon.ico
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 120 B |
57
src-tauri/src/commands/connection.rs
Normal file
57
src-tauri/src/commands/connection.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
use tauri::State;
|
||||
use std::sync::Arc;
|
||||
use crate::domain::connection::{ConnectionParams, Protocol};
|
||||
use crate::fs::remote::RemoteRegistry;
|
||||
use crate::protocols::sftp::SftpClient;
|
||||
use crate::protocols::ftp::FtpClient;
|
||||
use crate::storage::keychain;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn connect(
|
||||
registry: State<'_, Arc<RemoteRegistry>>,
|
||||
params: ConnectionParams,
|
||||
) -> Result<(), String> {
|
||||
let password = if let Some(p) = ¶ms.password {
|
||||
p.clone()
|
||||
} else {
|
||||
keychain::get_password(¶ms.id).map_err(|e| e.to_string())?
|
||||
};
|
||||
|
||||
let fs: Arc<dyn crate::protocols::RemoteFs> = match params.protocol {
|
||||
Protocol::Sftp => {
|
||||
if let Some(key_path) = ¶ms.key_path {
|
||||
Arc::new(
|
||||
SftpClient::connect_key(¶ms.host, params.port, ¶ms.username, key_path)
|
||||
.map_err(|e| e.to_string())?
|
||||
)
|
||||
} else {
|
||||
Arc::new(
|
||||
SftpClient::connect_password(¶ms.host, params.port, ¶ms.username, &password)
|
||||
.map_err(|e| e.to_string())?
|
||||
)
|
||||
}
|
||||
}
|
||||
Protocol::Ftp => {
|
||||
Arc::new(
|
||||
FtpClient::connect(¶ms.host, params.port, ¶ms.username, &password)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
)
|
||||
}
|
||||
Protocol::Ftps => {
|
||||
return Err("FTPS not yet implemented (suppaftp API compat)".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
registry.insert(params.id, fs);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn disconnect(
|
||||
registry: State<'_, Arc<RemoteRegistry>>,
|
||||
connection_id: String,
|
||||
) -> Result<(), String> {
|
||||
registry.remove(&connection_id);
|
||||
Ok(())
|
||||
}
|
||||
54
src-tauri/src/commands/fs.rs
Normal file
54
src-tauri/src/commands/fs.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
use crate::domain::file_entry::FileEntry;
|
||||
use crate::fs::{local, remote::RemoteRegistry};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_local(path: String) -> Result<Vec<FileEntry>, String> {
|
||||
local::list(&path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_remote(
|
||||
registry: State<'_, Arc<RemoteRegistry>>,
|
||||
connection_id: String,
|
||||
path: String,
|
||||
) -> Result<Vec<FileEntry>, String> {
|
||||
let fs = registry.get(&connection_id)
|
||||
.ok_or_else(|| format!("no connection: {}", connection_id))?;
|
||||
fs.list(&path).await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remote_mkdir(
|
||||
registry: State<'_, Arc<RemoteRegistry>>,
|
||||
connection_id: String,
|
||||
path: String,
|
||||
) -> Result<(), String> {
|
||||
let fs = registry.get(&connection_id)
|
||||
.ok_or_else(|| format!("no connection: {}", connection_id))?;
|
||||
fs.mkdir(&path).await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remote_rename(
|
||||
registry: State<'_, Arc<RemoteRegistry>>,
|
||||
connection_id: String,
|
||||
from: String,
|
||||
to: String,
|
||||
) -> Result<(), String> {
|
||||
let fs = registry.get(&connection_id)
|
||||
.ok_or_else(|| format!("no connection: {}", connection_id))?;
|
||||
fs.rename(&from, &to).await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remote_delete(
|
||||
registry: State<'_, Arc<RemoteRegistry>>,
|
||||
connection_id: String,
|
||||
path: String,
|
||||
) -> Result<(), String> {
|
||||
let fs = registry.get(&connection_id)
|
||||
.ok_or_else(|| format!("no connection: {}", connection_id))?;
|
||||
fs.delete(&path).await.map_err(|e| e.to_string())
|
||||
}
|
||||
4
src-tauri/src/commands/mod.rs
Normal file
4
src-tauri/src/commands/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
pub mod connection;
|
||||
pub mod fs;
|
||||
pub mod sites;
|
||||
pub mod transfer;
|
||||
21
src-tauri/src/commands/sites.rs
Normal file
21
src-tauri/src/commands/sites.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
use tauri::State;
|
||||
use crate::domain::site::Site;
|
||||
use crate::storage::db::Db;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_sites(db: State<'_, Db>) -> Result<Vec<Site>, String> {
|
||||
let conn = db.0.lock().unwrap();
|
||||
crate::storage::db::get_sites(&conn).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_site(db: State<'_, Db>, site: Site) -> Result<(), String> {
|
||||
let conn = db.0.lock().unwrap();
|
||||
crate::storage::db::save_site(&conn, &site).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_site(db: State<'_, Db>, id: String) -> Result<(), String> {
|
||||
let conn = db.0.lock().unwrap();
|
||||
crate::storage::db::delete_site(&conn, &id).map_err(|e| e.to_string())
|
||||
}
|
||||
103
src-tauri/src/commands/transfer.rs
Normal file
103
src-tauri/src/commands/transfer.rs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
use tauri::State;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::domain::transfer::{TransferTask, TransferKind, TaskState};
|
||||
use crate::transfer::manager::TransferManager;
|
||||
use crate::domain::file_entry::EntryKind;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn upload(
|
||||
manager: State<'_, Arc<TransferManager>>,
|
||||
connection_id: String,
|
||||
local_path: String,
|
||||
remote_path: String,
|
||||
) -> Result<String, String> {
|
||||
let meta = std::fs::metadata(&local_path).map_err(|e| format!("cannot stat local file: {}", e))?;
|
||||
if !meta.is_file() {
|
||||
return Err("local path is not a file".into());
|
||||
}
|
||||
|
||||
let file_name = std::path::Path::new(&local_path)
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
let task = TransferTask::new(
|
||||
TransferKind::Upload,
|
||||
connection_id,
|
||||
local_path,
|
||||
remote_path,
|
||||
file_name,
|
||||
meta.len(),
|
||||
);
|
||||
let task_id = task.id.clone();
|
||||
|
||||
// Проверяем, что соединение живо
|
||||
if manager.registry().get(&task.connection_id).is_none() {
|
||||
return Err(format!("no connection: {}", task.connection_id));
|
||||
}
|
||||
|
||||
manager.queue.push(task);
|
||||
Ok(task_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn download(
|
||||
manager: State<'_, Arc<TransferManager>>,
|
||||
connection_id: String,
|
||||
remote_path: String,
|
||||
local_path: String,
|
||||
) -> Result<String, String> {
|
||||
// Узнаём размер удалённого файла через stat
|
||||
let registry = manager.registry();
|
||||
let fs = registry.get(&connection_id)
|
||||
.ok_or_else(|| format!("no connection: {}", connection_id))?;
|
||||
let stat = fs.stat(&remote_path).await.map_err(|e| format!("stat failed: {}", e))?;
|
||||
|
||||
let file_name = std::path::Path::new(&remote_path)
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
let total = match stat.kind {
|
||||
EntryKind::File => stat.size.unwrap_or(0),
|
||||
_ => return Err("remote path is not a file".into()),
|
||||
};
|
||||
|
||||
let task = TransferTask::new(
|
||||
TransferKind::Download,
|
||||
connection_id,
|
||||
local_path,
|
||||
remote_path,
|
||||
file_name,
|
||||
total,
|
||||
);
|
||||
let task_id = task.id.clone();
|
||||
manager.queue.push(task);
|
||||
Ok(task_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_queue(
|
||||
manager: State<'_, Arc<TransferManager>>,
|
||||
) -> Result<Vec<TransferTask>, String> {
|
||||
Ok(manager.queue.all())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn pause_task(
|
||||
manager: State<'_, Arc<TransferManager>>,
|
||||
task_id: String,
|
||||
) -> Result<(), String> {
|
||||
manager.queue.update_state(&task_id, TaskState::Paused);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cancel_task(
|
||||
manager: State<'_, Arc<TransferManager>>,
|
||||
task_id: String,
|
||||
) -> Result<(), String> {
|
||||
manager.queue.update_state(&task_id, TaskState::Cancelled);
|
||||
Ok(())
|
||||
}
|
||||
32
src-tauri/src/domain/connection.rs
Normal file
32
src-tauri/src/domain/connection.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Protocol {
|
||||
Sftp,
|
||||
Ftp,
|
||||
Ftps,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConnectionParams {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub protocol: Protocol,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub username: String,
|
||||
/// None = use keychain
|
||||
pub password: Option<String>,
|
||||
pub key_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ConnectionStatus {
|
||||
Connected,
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Error(String),
|
||||
}
|
||||
20
src-tauri/src/domain/file_entry.rs
Normal file
20
src-tauri/src/domain/file_entry.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum EntryKind {
|
||||
File,
|
||||
Dir,
|
||||
Symlink,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileEntry {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub kind: EntryKind,
|
||||
pub size: Option<u64>,
|
||||
pub modified: Option<i64>, // unix timestamp
|
||||
pub permissions: Option<String>,
|
||||
}
|
||||
4
src-tauri/src/domain/mod.rs
Normal file
4
src-tauri/src/domain/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
pub mod connection;
|
||||
pub mod file_entry;
|
||||
pub mod site;
|
||||
pub mod transfer;
|
||||
16
src-tauri/src/domain/site.rs
Normal file
16
src-tauri/src/domain/site.rs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use crate::domain::connection::Protocol;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Site {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub protocol: Protocol,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub username: String,
|
||||
pub key_path: Option<String>,
|
||||
pub folder: Option<String>,
|
||||
pub note: Option<String>,
|
||||
}
|
||||
85
src-tauri/src/domain/transfer.rs
Normal file
85
src-tauri/src/domain/transfer.rs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
use std::fmt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TransferKind {
|
||||
Upload,
|
||||
Download,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TaskState {
|
||||
Queued,
|
||||
Running,
|
||||
Paused,
|
||||
Cancelled,
|
||||
Completed,
|
||||
Failed(String),
|
||||
Retrying(u32),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TransferTask {
|
||||
pub id: String,
|
||||
pub kind: TransferKind,
|
||||
pub connection_id: String,
|
||||
pub local_path: String,
|
||||
pub remote_path: String,
|
||||
pub file_name: String,
|
||||
pub total_bytes: u64,
|
||||
pub transferred_bytes: u64,
|
||||
pub state: TaskState,
|
||||
/// bytes/sec, updated with throttle
|
||||
pub speed: Option<u64>,
|
||||
pub eta_secs: Option<u64>,
|
||||
}
|
||||
|
||||
impl fmt::Display for TaskState {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
TaskState::Queued => write!(f, "queued"),
|
||||
TaskState::Running => write!(f, "running"),
|
||||
TaskState::Paused => write!(f, "paused"),
|
||||
TaskState::Cancelled => write!(f, "cancelled"),
|
||||
TaskState::Completed => write!(f, "completed"),
|
||||
TaskState::Failed(e) => write!(f, "failed: {}", e),
|
||||
TaskState::Retrying(n) => write!(f, "retrying({})", n),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TransferTask {
|
||||
pub fn new(
|
||||
kind: TransferKind,
|
||||
connection_id: String,
|
||||
local_path: String,
|
||||
remote_path: String,
|
||||
file_name: String,
|
||||
total_bytes: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
kind,
|
||||
connection_id,
|
||||
local_path,
|
||||
remote_path,
|
||||
file_name,
|
||||
total_bytes,
|
||||
transferred_bytes: 0,
|
||||
state: TaskState::Queued,
|
||||
speed: None,
|
||||
eta_secs: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn progress_pct(&self) -> f64 {
|
||||
if self.total_bytes == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
(self.transferred_bytes as f64 / self.total_bytes as f64) * 100.0
|
||||
}
|
||||
}
|
||||
54
src-tauri/src/fs/local.rs
Normal file
54
src-tauri/src/fs/local.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
use anyhow::Result;
|
||||
use std::fs;
|
||||
use crate::domain::file_entry::{FileEntry, EntryKind};
|
||||
|
||||
pub fn list(path: &str) -> Result<Vec<FileEntry>> {
|
||||
let mut entries: Vec<FileEntry> = fs::read_dir(path)?
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| {
|
||||
let meta = e.metadata().ok();
|
||||
let kind = meta.as_ref().map(|m| {
|
||||
if m.is_dir() { EntryKind::Dir }
|
||||
else if m.is_symlink() { EntryKind::Symlink }
|
||||
else { EntryKind::File }
|
||||
}).unwrap_or(EntryKind::File);
|
||||
|
||||
let size = meta.as_ref().and_then(|m| {
|
||||
if m.is_file() { Some(m.len()) } else { None }
|
||||
});
|
||||
|
||||
let modified = meta.as_ref()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs() as i64);
|
||||
|
||||
FileEntry {
|
||||
name: e.file_name().to_string_lossy().to_string(),
|
||||
path: e.path().to_string_lossy().to_string(),
|
||||
kind,
|
||||
size,
|
||||
modified,
|
||||
permissions: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// папки сначала, потом файлы, по имени
|
||||
entries.sort_by(|a, b| {
|
||||
match (&a.kind, &b.kind) {
|
||||
(EntryKind::Dir, EntryKind::Dir) => a.name.cmp(&b.name),
|
||||
(EntryKind::Dir, _) => std::cmp::Ordering::Less,
|
||||
(_, EntryKind::Dir) => std::cmp::Ordering::Greater,
|
||||
_ => a.name.cmp(&b.name),
|
||||
}
|
||||
});
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub fn home_dir() -> String {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
2
src-tauri/src/fs/mod.rs
Normal file
2
src-tauri/src/fs/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod local;
|
||||
pub mod remote;
|
||||
25
src-tauri/src/fs/remote.rs
Normal file
25
src-tauri/src/fs/remote.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Абстракция над RemoteFs для хранения активных соединений
|
||||
// TODO: HashMap<connection_id, Arc<dyn RemoteFs>>
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use crate::protocols::RemoteFs;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct RemoteRegistry {
|
||||
connections: Mutex<HashMap<String, Arc<dyn RemoteFs>>>,
|
||||
}
|
||||
|
||||
impl RemoteRegistry {
|
||||
pub fn insert(&self, id: String, fs: Arc<dyn RemoteFs>) {
|
||||
self.connections.lock().unwrap().insert(id, fs);
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<Arc<dyn RemoteFs>> {
|
||||
self.connections.lock().unwrap().get(id).cloned()
|
||||
}
|
||||
|
||||
pub fn remove(&self, id: &str) {
|
||||
self.connections.lock().unwrap().remove(id);
|
||||
}
|
||||
}
|
||||
47
src-tauri/src/lib.rs
Normal file
47
src-tauri/src/lib.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
pub mod commands;
|
||||
pub mod domain;
|
||||
pub mod fs;
|
||||
pub mod protocols;
|
||||
pub mod storage;
|
||||
pub mod transfer;
|
||||
|
||||
use std::sync::Arc;
|
||||
use tauri::Manager;
|
||||
use crate::transfer::manager::TransferManager;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::fs::list_local,
|
||||
commands::fs::list_remote,
|
||||
commands::fs::remote_mkdir,
|
||||
commands::fs::remote_rename,
|
||||
commands::fs::remote_delete,
|
||||
commands::connection::connect,
|
||||
commands::connection::disconnect,
|
||||
commands::transfer::upload,
|
||||
commands::transfer::download,
|
||||
commands::transfer::get_queue,
|
||||
commands::transfer::pause_task,
|
||||
commands::transfer::cancel_task,
|
||||
commands::sites::get_sites,
|
||||
commands::sites::save_site,
|
||||
commands::sites::delete_site,
|
||||
])
|
||||
.setup(|app| {
|
||||
let registry = Arc::new(crate::fs::remote::RemoteRegistry::default());
|
||||
let manager = TransferManager::new(registry.clone(), app.handle().clone());
|
||||
|
||||
app.manage(registry);
|
||||
app.manage(manager);
|
||||
|
||||
storage::db::init(app.handle())?;
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running loflum");
|
||||
}
|
||||
6
src-tauri/src/main.rs
Normal file
6
src-tauri/src/main.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// Prevents additional console window on Windows in release
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
loflum_lib::run();
|
||||
}
|
||||
155
src-tauri/src/protocols/ftp.rs
Normal file
155
src-tauri/src/protocols/ftp.rs
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
use async_trait::async_trait;
|
||||
use anyhow::Result;
|
||||
use std::str::FromStr;
|
||||
use std::time::SystemTime;
|
||||
use tokio::sync::Mutex;
|
||||
use async_std::io::{ReadExt, WriteExt};
|
||||
|
||||
use suppaftp::{AsyncFtpStream, list::File};
|
||||
|
||||
use crate::domain::file_entry::{FileEntry, EntryKind};
|
||||
use super::RemoteFs;
|
||||
|
||||
pub struct FtpClient {
|
||||
pub conn: Mutex<Option<AsyncFtpStream>>,
|
||||
}
|
||||
|
||||
impl FtpClient {
|
||||
pub async fn connect(host: &str, port: u16, user: &str, pass: &str) -> Result<Self> {
|
||||
let addr = format!("{}:{}", host, port);
|
||||
let mut stream = AsyncFtpStream::connect(&addr).await
|
||||
.map_err(|e| anyhow::anyhow!("FTP connect failed: {}", e))?;
|
||||
stream.login(user, pass).await
|
||||
.map_err(|e| anyhow::anyhow!("FTP login failed: {}", e))?;
|
||||
Ok(Self { conn: Mutex::new(Some(stream)) })
|
||||
}
|
||||
|
||||
fn parse_list_entry(line: &str) -> Option<FileEntry> {
|
||||
let file = File::from_str(line).ok()?;
|
||||
let kind = if file.is_directory() {
|
||||
EntryKind::Dir
|
||||
} else if file.is_symlink() {
|
||||
EntryKind::Symlink
|
||||
} else {
|
||||
EntryKind::File
|
||||
};
|
||||
|
||||
let modified = file.modified()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.ok()
|
||||
.map(|d| d.as_secs() as i64);
|
||||
|
||||
Some(FileEntry {
|
||||
name: file.name().to_string(),
|
||||
path: file.name().to_string(),
|
||||
kind,
|
||||
size: Some(file.size() as u64),
|
||||
modified,
|
||||
permissions: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RemoteFs for FtpClient {
|
||||
async fn list(&self, path: &str) -> Result<Vec<FileEntry>> {
|
||||
let mut guard = self.conn.lock().await;
|
||||
let conn = guard.as_mut().ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||
let lines = conn.list(Some(path)).await
|
||||
.map_err(|e| anyhow::anyhow!("FTP list failed: {}", e))?;
|
||||
|
||||
let mut entries: Vec<FileEntry> = lines.iter()
|
||||
.filter_map(|line| Self::parse_list_entry(line))
|
||||
.collect();
|
||||
|
||||
entries.sort_by(|a, b| {
|
||||
match (&a.kind, &b.kind) {
|
||||
(EntryKind::Dir, EntryKind::Dir) => a.name.cmp(&b.name),
|
||||
(EntryKind::Dir, _) => std::cmp::Ordering::Less,
|
||||
(_, EntryKind::Dir) => std::cmp::Ordering::Greater,
|
||||
_ => a.name.cmp(&b.name),
|
||||
}
|
||||
});
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
async fn upload(&self, local: &str, remote: &str) -> Result<()> {
|
||||
let data = std::fs::read(local)
|
||||
.map_err(|e| anyhow::anyhow!("cannot read local file: {}", e))?;
|
||||
let mut guard = self.conn.lock().await;
|
||||
let conn = guard.as_mut().ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||
let mut stream = conn.put_with_stream(remote).await
|
||||
.map_err(|e| anyhow::anyhow!("FTP upload stream failed: {}", e))?;
|
||||
stream.write_all(&data).await
|
||||
.map_err(|e| anyhow::anyhow!("FTP upload write failed: {}", e))?;
|
||||
conn.finalize_put_stream(stream).await
|
||||
.map_err(|e| anyhow::anyhow!("FTP upload finalize failed: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download(&self, remote: &str, local: &str) -> Result<()> {
|
||||
let mut guard = self.conn.lock().await;
|
||||
let conn = guard.as_mut().ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||
let mut stream = conn.retr_as_stream(remote).await
|
||||
.map_err(|e| anyhow::anyhow!("FTP download stream failed: {}", e))?;
|
||||
let mut buf = Vec::new();
|
||||
stream.read_to_end(&mut buf).await
|
||||
.map_err(|e| anyhow::anyhow!("FTP download read failed: {}", e))?;
|
||||
conn.finalize_retr_stream(stream).await
|
||||
.map_err(|e| anyhow::anyhow!("FTP download finalize failed: {}", e))?;
|
||||
std::fs::write(local, &buf)
|
||||
.map_err(|e| anyhow::anyhow!("cannot write local file: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn mkdir(&self, path: &str) -> Result<()> {
|
||||
let mut guard = self.conn.lock().await;
|
||||
let conn = guard.as_mut().ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||
conn.mkdir(path).await
|
||||
.map_err(|e| anyhow::anyhow!("FTP mkdir failed: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn rename(&self, from: &str, to: &str) -> Result<()> {
|
||||
let mut guard = self.conn.lock().await;
|
||||
let conn = guard.as_mut().ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||
conn.rename(from, to).await
|
||||
.map_err(|e| anyhow::anyhow!("FTP rename failed: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, path: &str) -> Result<()> {
|
||||
let mut guard = self.conn.lock().await;
|
||||
let conn = guard.as_mut().ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||
if conn.rm(path).await.is_err() {
|
||||
conn.rmdir(path).await
|
||||
.map_err(|e| anyhow::anyhow!("FTP delete failed: {}", e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stat(&self, path: &str) -> Result<FileEntry> {
|
||||
let mut guard = self.conn.lock().await;
|
||||
let conn = guard.as_mut().ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||
let size = conn.size(path).await
|
||||
.map_err(|e| anyhow::anyhow!("FTP stat failed: {}", e))?;
|
||||
let modified = conn.mdtm(path).await
|
||||
.map(|dt| dt.and_utc().timestamp())
|
||||
.ok();
|
||||
|
||||
let name = std::path::Path::new(path)
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| path.to_string());
|
||||
|
||||
Ok(FileEntry {
|
||||
name,
|
||||
path: path.to_string(),
|
||||
kind: EntryKind::File,
|
||||
size: Some(size as u64),
|
||||
modified,
|
||||
permissions: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
19
src-tauri/src/protocols/mod.rs
Normal file
19
src-tauri/src/protocols/mod.rs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
pub mod ftp;
|
||||
pub mod sftp;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use crate::domain::file_entry::FileEntry;
|
||||
use anyhow::Result;
|
||||
|
||||
/// Единый контракт для всех протоколов.
|
||||
/// Очередь передач не знает — FTP это или SFTP.
|
||||
#[async_trait]
|
||||
pub trait RemoteFs: Send + Sync {
|
||||
async fn list(&self, path: &str) -> Result<Vec<FileEntry>>;
|
||||
async fn upload(&self, local: &str, remote: &str) -> Result<()>;
|
||||
async fn download(&self, remote: &str, local: &str) -> Result<()>;
|
||||
async fn mkdir(&self, path: &str) -> Result<()>;
|
||||
async fn rename(&self, from: &str, to: &str) -> Result<()>;
|
||||
async fn delete(&self, path: &str) -> Result<()>;
|
||||
async fn stat(&self, path: &str) -> Result<FileEntry>;
|
||||
}
|
||||
131
src-tauri/src/protocols/sftp.rs
Normal file
131
src-tauri/src/protocols/sftp.rs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
use async_trait::async_trait;
|
||||
use anyhow::{Result, Context};
|
||||
use ssh2::Session;
|
||||
use std::net::TcpStream;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::domain::file_entry::{FileEntry, EntryKind};
|
||||
use super::RemoteFs;
|
||||
|
||||
pub struct SftpClient {
|
||||
session: Session,
|
||||
}
|
||||
|
||||
impl SftpClient {
|
||||
/// Подключение по паролю
|
||||
pub fn connect_password(host: &str, port: u16, user: &str, password: &str) -> Result<Self> {
|
||||
let tcp = TcpStream::connect(format!("{}:{}", host, port))
|
||||
.context("TCP connect failed")?;
|
||||
let mut session = Session::new().context("SSH session init failed")?;
|
||||
session.set_tcp_stream(tcp);
|
||||
session.handshake().context("SSH handshake failed")?;
|
||||
session.userauth_password(user, password)
|
||||
.context("SSH password auth failed")?;
|
||||
Ok(Self { session })
|
||||
}
|
||||
|
||||
/// Подключение по ключу
|
||||
pub fn connect_key(host: &str, port: u16, user: &str, key_path: &str) -> Result<Self> {
|
||||
let tcp = TcpStream::connect(format!("{}:{}", host, port))
|
||||
.context("TCP connect failed")?;
|
||||
let mut session = Session::new().context("SSH session init failed")?;
|
||||
session.set_tcp_stream(tcp);
|
||||
session.handshake().context("SSH handshake failed")?;
|
||||
session.userauth_pubkey_file(user, None, Path::new(key_path), None)
|
||||
.context("SSH key auth failed")?;
|
||||
Ok(Self { session })
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RemoteFs for SftpClient {
|
||||
async fn list(&self, path: &str) -> Result<Vec<FileEntry>> {
|
||||
// TODO: перенести в spawn_blocking когда будет Arc<Session>
|
||||
let sftp = self.session.sftp().context("SFTP subsystem failed")?;
|
||||
let entries = sftp.readdir(Path::new(path)).context("readdir failed")?;
|
||||
|
||||
let result = entries.into_iter().map(|(pb, stat)| {
|
||||
let kind = if stat.is_dir() {
|
||||
EntryKind::Dir
|
||||
} else if stat.file_type().is_symlink() {
|
||||
EntryKind::Symlink
|
||||
} else {
|
||||
EntryKind::File
|
||||
};
|
||||
FileEntry {
|
||||
name: pb.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
path: pb.to_string_lossy().to_string(),
|
||||
kind,
|
||||
size: stat.size,
|
||||
modified: stat.mtime.map(|t| t as i64),
|
||||
permissions: None,
|
||||
}
|
||||
}).collect();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn upload(&self, local: &str, remote: &str) -> Result<()> {
|
||||
// TODO: chunked upload с прогресс-событиями
|
||||
let sftp = self.session.sftp().context("SFTP subsystem failed")?;
|
||||
let mut local_file = std::fs::File::open(local)
|
||||
.context("open local file failed")?;
|
||||
let mut remote_file = sftp.create(Path::new(remote))
|
||||
.context("create remote file failed")?;
|
||||
std::io::copy(&mut local_file, &mut remote_file)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download(&self, remote: &str, local: &str) -> Result<()> {
|
||||
// TODO: chunked download с прогресс-событиями
|
||||
let sftp = self.session.sftp().context("SFTP subsystem failed")?;
|
||||
let mut remote_file = sftp.open(Path::new(remote))
|
||||
.context("open remote file failed")?;
|
||||
let mut local_file = std::fs::File::create(local)
|
||||
.context("create local file failed")?;
|
||||
std::io::copy(&mut remote_file, &mut local_file)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn mkdir(&self, path: &str) -> Result<()> {
|
||||
let sftp = self.session.sftp().context("SFTP subsystem failed")?;
|
||||
sftp.mkdir(Path::new(path), 0o755).context("mkdir failed")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn rename(&self, from: &str, to: &str) -> Result<()> {
|
||||
let sftp = self.session.sftp().context("SFTP subsystem failed")?;
|
||||
sftp.rename(Path::new(from), Path::new(to), None)
|
||||
.context("rename failed")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, path: &str) -> Result<()> {
|
||||
let sftp = self.session.sftp().context("SFTP subsystem failed")?;
|
||||
// пробуем как файл, потом как директорию
|
||||
if sftp.unlink(Path::new(path)).is_err() {
|
||||
sftp.rmdir(Path::new(path)).context("delete failed")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stat(&self, path: &str) -> Result<FileEntry> {
|
||||
let sftp = self.session.sftp().context("SFTP subsystem failed")?;
|
||||
let stat = sftp.stat(Path::new(path)).context("stat failed")?;
|
||||
Ok(FileEntry {
|
||||
name: Path::new(path)
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
path: path.to_string(),
|
||||
kind: if stat.is_dir() { EntryKind::Dir } else { EntryKind::File },
|
||||
size: stat.size,
|
||||
modified: stat.mtime.map(|t| t as i64),
|
||||
permissions: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
85
src-tauri/src/storage/db.rs
Normal file
85
src-tauri/src/storage/db.rs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
use anyhow::Result;
|
||||
use rusqlite::{Connection, params};
|
||||
use tauri::{AppHandle, Manager};
|
||||
use std::sync::Mutex;
|
||||
use crate::domain::site::Site;
|
||||
use crate::domain::connection::Protocol;
|
||||
|
||||
pub struct Db(pub Mutex<Connection>);
|
||||
|
||||
pub fn init(app: &AppHandle) -> Result<()> {
|
||||
let path = app.path().app_data_dir()
|
||||
.expect("no app data dir")
|
||||
.join("loflum.db");
|
||||
|
||||
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||
let conn = Connection::open(&path)?;
|
||||
|
||||
conn.execute_batch("
|
||||
PRAGMA journal_mode=WAL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sites (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
key_path TEXT,
|
||||
folder TEXT,
|
||||
note TEXT
|
||||
);
|
||||
")?;
|
||||
|
||||
app.manage(Db(Mutex::new(conn)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_sites(conn: &Connection) -> Result<Vec<Site>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, protocol, host, port, username, key_path, folder, note FROM sites ORDER BY name"
|
||||
)?;
|
||||
let sites = stmt.query_map([], |row| {
|
||||
let proto_str: String = row.get(2)?;
|
||||
let protocol = match proto_str.as_str() {
|
||||
"sftp" => Protocol::Sftp,
|
||||
"ftps" => Protocol::Ftps,
|
||||
_ => Protocol::Ftp,
|
||||
};
|
||||
Ok(Site {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
protocol,
|
||||
host: row.get(3)?,
|
||||
port: row.get(4)?,
|
||||
username: row.get(5)?,
|
||||
key_path: row.get(6)?,
|
||||
folder: row.get(7)?,
|
||||
note: row.get(8)?,
|
||||
})
|
||||
})?.filter_map(|r| r.ok()).collect();
|
||||
Ok(sites)
|
||||
}
|
||||
|
||||
pub fn save_site(conn: &Connection, site: &Site) -> Result<()> {
|
||||
let proto = match site.protocol {
|
||||
Protocol::Sftp => "sftp",
|
||||
Protocol::Ftp => "ftp",
|
||||
Protocol::Ftps => "ftps",
|
||||
};
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO sites
|
||||
(id, name, protocol, host, port, username, key_path, folder, note)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)",
|
||||
params![
|
||||
site.id, site.name, proto, site.host, site.port,
|
||||
site.username, site.key_path, site.folder, site.note
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_site(conn: &Connection, id: &str) -> Result<()> {
|
||||
conn.execute("DELETE FROM sites WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
21
src-tauri/src/storage/keychain.rs
Normal file
21
src-tauri/src/storage/keychain.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
use anyhow::Result;
|
||||
use keyring::Entry;
|
||||
|
||||
const SERVICE: &str = "loflum-ftp-client";
|
||||
|
||||
pub fn store_password(site_id: &str, password: &str) -> Result<()> {
|
||||
let entry = Entry::new(SERVICE, site_id)?;
|
||||
entry.set_password(password)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_password(site_id: &str) -> Result<String> {
|
||||
let entry = Entry::new(SERVICE, site_id)?;
|
||||
Ok(entry.get_password()?)
|
||||
}
|
||||
|
||||
pub fn delete_password(site_id: &str) -> Result<()> {
|
||||
let entry = Entry::new(SERVICE, site_id)?;
|
||||
entry.delete_password()?;
|
||||
Ok(())
|
||||
}
|
||||
2
src-tauri/src/storage/mod.rs
Normal file
2
src-tauri/src/storage/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod db;
|
||||
pub mod keychain;
|
||||
32
src-tauri/src/transfer/manager.rs
Normal file
32
src-tauri/src/transfer/manager.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
use std::sync::Arc;
|
||||
use tauri::AppHandle;
|
||||
use crate::transfer::queue::TransferQueue;
|
||||
use crate::transfer::worker::spawn_worker;
|
||||
use crate::fs::remote::RemoteRegistry;
|
||||
|
||||
/// Хранит очередь передач и запускает воркер.
|
||||
pub struct TransferManager {
|
||||
pub queue: TransferQueue,
|
||||
registry: Arc<RemoteRegistry>,
|
||||
app: AppHandle,
|
||||
}
|
||||
|
||||
impl TransferManager {
|
||||
pub fn new(registry: Arc<RemoteRegistry>, app: AppHandle) -> Arc<Self> {
|
||||
let manager = Arc::new(Self {
|
||||
queue: TransferQueue::default(),
|
||||
registry: registry.clone(),
|
||||
app: app.clone(),
|
||||
});
|
||||
spawn_worker(manager.clone(), registry, app);
|
||||
manager
|
||||
}
|
||||
|
||||
pub fn registry(&self) -> &RemoteRegistry {
|
||||
&self.registry
|
||||
}
|
||||
|
||||
pub fn app(&self) -> &AppHandle {
|
||||
&self.app
|
||||
}
|
||||
}
|
||||
5
src-tauri/src/transfer/mod.rs
Normal file
5
src-tauri/src/transfer/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
pub mod manager;
|
||||
pub mod progress;
|
||||
pub mod queue;
|
||||
pub mod task;
|
||||
pub mod worker;
|
||||
35
src-tauri/src/transfer/progress.rs
Normal file
35
src-tauri/src/transfer/progress.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Throttle прогресс-событий — максимум 10 в секунду.
|
||||
/// Пользователь разницы не увидит, а тысяч emit'ов не будет.
|
||||
pub struct ProgressThrottle {
|
||||
last_emit: Instant,
|
||||
interval: Duration,
|
||||
}
|
||||
|
||||
impl Default for ProgressThrottle {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
last_emit: Instant::now() - Duration::from_secs(1),
|
||||
interval: Duration::from_millis(100), // 10 fps
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProgressThrottle {
|
||||
pub fn should_emit(&mut self) -> bool {
|
||||
let now = Instant::now();
|
||||
if now.duration_since(self.last_emit) >= self.interval {
|
||||
self.last_emit = now;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Всегда emit при завершении/ошибке
|
||||
pub fn force(&mut self) -> bool {
|
||||
self.last_emit = Instant::now() - self.interval;
|
||||
true
|
||||
}
|
||||
}
|
||||
46
src-tauri/src/transfer/queue.rs
Normal file
46
src-tauri/src/transfer/queue.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use crate::domain::transfer::{TransferTask, TaskState};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct TransferQueue {
|
||||
inner: Arc<Mutex<VecDeque<TransferTask>>>,
|
||||
}
|
||||
|
||||
impl TransferQueue {
|
||||
pub fn push(&self, task: TransferTask) {
|
||||
self.inner.lock().unwrap().push_back(task);
|
||||
}
|
||||
|
||||
pub fn pop(&self) -> Option<TransferTask> {
|
||||
self.inner.lock().unwrap().pop_front()
|
||||
}
|
||||
|
||||
pub fn all(&self) -> Vec<TransferTask> {
|
||||
self.inner.lock().unwrap().iter().cloned().collect()
|
||||
}
|
||||
|
||||
pub fn update_state(&self, id: &str, state: TaskState) {
|
||||
let mut q = self.inner.lock().unwrap();
|
||||
if let Some(t) = q.iter_mut().find(|t| t.id == id) {
|
||||
t.state = state;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_progress(&self, id: &str, transferred: u64, speed: u64) {
|
||||
let mut q = self.inner.lock().unwrap();
|
||||
if let Some(t) = q.iter_mut().find(|t| t.id == id) {
|
||||
t.transferred_bytes = transferred;
|
||||
t.speed = Some(speed);
|
||||
if speed > 0 {
|
||||
let remaining = t.total_bytes.saturating_sub(transferred);
|
||||
t.eta_secs = Some(remaining / speed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&self, id: &str) {
|
||||
let mut q = self.inner.lock().unwrap();
|
||||
q.retain(|t| t.id != id);
|
||||
}
|
||||
}
|
||||
19
src-tauri/src/transfer/task.rs
Normal file
19
src-tauri/src/transfer/task.rs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
use crate::domain::transfer::{TransferTask, TransferKind};
|
||||
|
||||
pub fn new_task(
|
||||
kind: TransferKind,
|
||||
connection_id: impl Into<String>,
|
||||
local_path: impl Into<String>,
|
||||
remote_path: impl Into<String>,
|
||||
file_name: impl Into<String>,
|
||||
total_bytes: u64,
|
||||
) -> TransferTask {
|
||||
TransferTask::new(
|
||||
kind,
|
||||
connection_id.into(),
|
||||
local_path.into(),
|
||||
remote_path.into(),
|
||||
file_name.into(),
|
||||
total_bytes,
|
||||
)
|
||||
}
|
||||
99
src-tauri/src/transfer/worker.rs
Normal file
99
src-tauri/src/transfer/worker.rs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
use crate::domain::transfer::{TaskState, TransferKind};
|
||||
use crate::fs::remote::RemoteRegistry;
|
||||
use crate::transfer::manager::TransferManager;
|
||||
|
||||
/// Событие для фронтенда
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TransferProgress {
|
||||
task_id: String,
|
||||
state: String,
|
||||
transferred_bytes: u64,
|
||||
total_bytes: u64,
|
||||
speed: Option<u64>,
|
||||
eta_secs: Option<u64>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
pub fn spawn_worker(
|
||||
manager: Arc<TransferManager>,
|
||||
registry: Arc<RemoteRegistry>,
|
||||
app: AppHandle,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let task = {
|
||||
let task = manager.queue.pop();
|
||||
if let Some(ref t) = task {
|
||||
if t.state != TaskState::Queued {
|
||||
// paused/cancelled — не трогаем, вернём
|
||||
manager.queue.push(t.clone());
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
task
|
||||
};
|
||||
|
||||
let mut task = match task {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Старт
|
||||
task.state = TaskState::Running;
|
||||
manager.queue.update_state(&task.id, TaskState::Running);
|
||||
emit_progress(&app, &task, None);
|
||||
|
||||
let fs = match registry.get(&task.connection_id) {
|
||||
Some(fs) => fs,
|
||||
None => {
|
||||
task.state = TaskState::Failed("connection not found".into());
|
||||
manager.queue.update_state(&task.id, task.state.clone());
|
||||
emit_progress(&app, &task, Some("connection not found"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let result = match task.kind {
|
||||
TransferKind::Upload => fs.upload(&task.local_path, &task.remote_path).await,
|
||||
TransferKind::Download => fs.download(&task.remote_path, &task.local_path).await,
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
task.state = TaskState::Completed;
|
||||
task.transferred_bytes = task.total_bytes;
|
||||
manager.queue.update_state(&task.id, TaskState::Completed);
|
||||
manager.queue.update_progress(&task.id, task.total_bytes, 0);
|
||||
emit_progress(&app, &task, None);
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
task.state = TaskState::Failed(msg.clone());
|
||||
manager.queue.update_state(&task.id, TaskState::Failed(msg.clone()));
|
||||
emit_progress(&app, &task, Some(&msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn emit_progress(app: &AppHandle, task: &crate::domain::transfer::TransferTask, error: Option<&str>) {
|
||||
let _ = app.emit("transfer-progress", TransferProgress {
|
||||
task_id: task.id.clone(),
|
||||
state: task.state.to_string(),
|
||||
transferred_bytes: task.transferred_bytes,
|
||||
total_bytes: task.total_bytes,
|
||||
speed: task.speed,
|
||||
eta_secs: task.eta_secs,
|
||||
error: error.map(|s| s.to_string()),
|
||||
});
|
||||
}
|
||||
39
src-tauri/tauri.conf.json
Normal file
39
src-tauri/tauri.conf.json
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "LoFlum",
|
||||
"version": "0.1.0",
|
||||
"identifier": "dev.loflum.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
"beforeBuildCommand": "bun run build",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "LoFlum",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"decorations": true,
|
||||
"transparent": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
153
src/App.svelte
Normal file
153
src/App.svelte
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
<script lang="ts">
|
||||
import "./app.css";
|
||||
import Tabs from "./lib/components/Tabs/Tabs.svelte";
|
||||
import Toolbar from "./lib/components/Toolbar/Toolbar.svelte";
|
||||
import LocalPane from "./lib/components/Panes/LocalPane.svelte";
|
||||
import RemotePane from "./lib/components/Panes/RemotePane.svelte";
|
||||
import TransferQueue from "./lib/components/Queue/TransferQueue.svelte";
|
||||
import StatusBar from "./lib/components/StatusBar.svelte";
|
||||
import { activeConnection } from "./lib/stores/connection.svelte";
|
||||
import NewConnectionDialog from "./lib/components/Dialogs/NewConnectionDialog.svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { FileEntry } from "./lib/types";
|
||||
|
||||
let queueOpen = $state(true);
|
||||
let dialogOpen = $state(false);
|
||||
|
||||
// Состояния для операций
|
||||
let localPath = $state("C:\\Users\\user");
|
||||
let remotePath = $state("/home/user");
|
||||
let localSelected = $state<FileEntry | null>(null);
|
||||
let remoteSelected = $state<FileEntry | null>(null);
|
||||
|
||||
async function upload() {
|
||||
const conn = $activeConnection;
|
||||
if (!conn || conn.status !== "connected" || !localSelected) return;
|
||||
const target = remotePath.endsWith("/") ? remotePath + localSelected.name : remotePath + "/" + localSelected.name;
|
||||
try {
|
||||
await invoke("upload", {
|
||||
connectionId: conn.id,
|
||||
localPath: localSelected.path,
|
||||
remotePath: target,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("upload failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function download() {
|
||||
const conn = $activeConnection;
|
||||
if (!conn || conn.status !== "connected" || !remoteSelected) return;
|
||||
const target = localPath.endsWith("/") ? localPath + remoteSelected.name : localPath + "/" + remoteSelected.name;
|
||||
try {
|
||||
await invoke("download", {
|
||||
connectionId: conn.id,
|
||||
remotePath: remoteSelected.path,
|
||||
localPath: target,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("download failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function mkdir() {
|
||||
const conn = $activeConnection;
|
||||
if (!conn || conn.status !== "connected") return;
|
||||
const name = prompt("Имя новой папки:");
|
||||
if (!name) return;
|
||||
const target = remotePath.endsWith("/") ? remotePath + name : remotePath + "/" + name;
|
||||
try {
|
||||
await invoke("remote_mkdir", { connectionId: conn.id, path: target });
|
||||
} catch (e) {
|
||||
console.error("mkdir failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFile(entry: FileEntry) {
|
||||
const conn = $activeConnection;
|
||||
if (!conn || conn.status !== "connected") return;
|
||||
if (!confirm(`Удалить ${entry.kind === "dir" ? "папку" : "файл"} "${entry.name}"?`)) return;
|
||||
try {
|
||||
await invoke("remote_delete", { connectionId: conn.id, path: entry.path });
|
||||
} catch (e) {
|
||||
console.error("delete failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function rename(entry: FileEntry) {
|
||||
const conn = $activeConnection;
|
||||
if (!conn || conn.status !== "connected") return;
|
||||
const newName = prompt("Новое имя:", entry.name);
|
||||
if (!newName || newName === entry.name) return;
|
||||
const newPath = entry.path.replace(/[^/]+$/, newName);
|
||||
try {
|
||||
await invoke("remote_rename", { connectionId: conn.id, from: entry.path, to: newPath });
|
||||
} catch (e) {
|
||||
console.error("rename failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
function onLocalPathChange(p: string) { localPath = p; }
|
||||
function onRemotePathChange(p: string) { remotePath = p; }
|
||||
</script>
|
||||
|
||||
<div class="app-shell">
|
||||
<!-- Вкладки соединений -->
|
||||
<Tabs />
|
||||
|
||||
<!-- Тулбар -->
|
||||
<Toolbar
|
||||
onNewConnection={() => dialogOpen = true}
|
||||
{upload}
|
||||
{download}
|
||||
{mkdir}
|
||||
/>
|
||||
|
||||
<!-- Диалог нового соединения -->
|
||||
<NewConnectionDialog bind:open={dialogOpen} />
|
||||
|
||||
<!-- Основная область: левая + правая панели -->
|
||||
<div class="panes">
|
||||
<LocalPane
|
||||
bind:selected={localSelected}
|
||||
onPathChange={onLocalPathChange}
|
||||
/>
|
||||
<div class="pane-divider"></div>
|
||||
<RemotePane
|
||||
bind:selected={remoteSelected}
|
||||
onPathChange={onRemotePathChange}
|
||||
onDelete={(e) => deleteFile(e)}
|
||||
onRename={(e) => rename(e)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Очередь передач -->
|
||||
<TransferQueue bind:open={queueOpen} />
|
||||
|
||||
<!-- Статусбар -->
|
||||
<StatusBar />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
background: var(--bg-0);
|
||||
}
|
||||
|
||||
.panes {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.pane-divider {
|
||||
width: 1px;
|
||||
background: var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
80
src/app.css
Normal file
80
src/app.css
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-0: #1a1a1a;
|
||||
--bg-1: #222222;
|
||||
--bg-2: #2a2a2a;
|
||||
--bg-3: #333333;
|
||||
--bg-hover: #2e2e2e;
|
||||
--bg-selected: #1e4a7a;
|
||||
|
||||
--border: #3a3a3a;
|
||||
--border-light: #444;
|
||||
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #999;
|
||||
--text-muted: #666;
|
||||
|
||||
--accent: #4a9eff;
|
||||
--accent-dim: #2a5a9a;
|
||||
--green: #4caf50;
|
||||
--yellow: #ffc107;
|
||||
--red: #f44336;
|
||||
|
||||
--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-mono: "JetBrains Mono", "Cascadia Code", "Fira Code", monospace;
|
||||
|
||||
--tab-h: 40px;
|
||||
--toolbar-h: 48px;
|
||||
--queue-h: 180px;
|
||||
--sidebar-w: 200px;
|
||||
--statusbar-h: 28px;
|
||||
}
|
||||
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg-0);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 13px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input {
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--border-light); }
|
||||
252
src/lib/components/Dialogs/NewConnectionDialog.svelte
Normal file
252
src/lib/components/Dialogs/NewConnectionDialog.svelte
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { connections, activeId } from "../../stores/connection.svelte";
|
||||
|
||||
let { open = $bindable(false) }: { open: boolean } = $props();
|
||||
|
||||
let protocol = $state<"sftp" | "ftp" | "ftps">("sftp");
|
||||
let host = $state("");
|
||||
let port = $state(22);
|
||||
let username = $state("");
|
||||
let password = $state("");
|
||||
let keyPath = $state("");
|
||||
let label = $state("");
|
||||
let useKey = $state(false);
|
||||
let error = $state("");
|
||||
let connecting = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
port = protocol === "sftp" ? 22 : 21;
|
||||
});
|
||||
|
||||
async function connect() {
|
||||
if (!host || !username) { error = "Хост и логин обязательны"; return; }
|
||||
error = "";
|
||||
connecting = true;
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const connLabel = label || host;
|
||||
|
||||
try {
|
||||
await invoke("connect", {
|
||||
params: {
|
||||
id,
|
||||
label: connLabel,
|
||||
protocol,
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
password: useKey ? null : password,
|
||||
keyPath: useKey ? keyPath : null,
|
||||
}
|
||||
});
|
||||
|
||||
connections.add({ id, label: connLabel, protocol, host, status: "connected" });
|
||||
activeId.set(id);
|
||||
open = false;
|
||||
reset();
|
||||
} catch (e: any) {
|
||||
error = e?.toString() ?? "Ошибка соединения";
|
||||
} finally {
|
||||
connecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
host = ""; port = 22; username = ""; password = "";
|
||||
keyPath = ""; label = ""; useKey = false; error = "";
|
||||
}
|
||||
|
||||
function close() { open = false; reset(); }
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div class="overlay" onclick={close}>
|
||||
<div class="dialog" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="dialog-header">
|
||||
<span>Новое соединение</span>
|
||||
<button class="close-btn" onclick={close}>✕</button>
|
||||
</div>
|
||||
|
||||
<div class="dialog-body">
|
||||
<!-- Протокол -->
|
||||
<div class="field">
|
||||
<label>Протокол</label>
|
||||
<div class="radio-group">
|
||||
{#each ["sftp", "ftp", "ftps"] as p}
|
||||
<label class="radio-label">
|
||||
<input type="radio" bind:group={protocol} value={p} />
|
||||
{p.toUpperCase()}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="field grow">
|
||||
<label>Хост</label>
|
||||
<input bind:value={host} placeholder="192.168.1.1" spellcheck="false" />
|
||||
</div>
|
||||
<div class="field w80">
|
||||
<label>Порт</label>
|
||||
<input type="number" bind:value={port} min="1" max="65535" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Метка (необязательно)</label>
|
||||
<input bind:value={label} placeholder={host || "my-server"} spellcheck="false" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Пользователь</label>
|
||||
<input bind:value={username} placeholder="root" spellcheck="false" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
{#if protocol === "sftp"}
|
||||
<div class="field">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" bind:checked={useKey} />
|
||||
Использовать SSH ключ
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if useKey && protocol === "sftp"}
|
||||
<div class="field">
|
||||
<label>Путь к ключу</label>
|
||||
<input bind:value={keyPath} placeholder="~/.ssh/id_rsa" spellcheck="false" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="field">
|
||||
<label>Пароль</label>
|
||||
<input type="password" bind:value={password} autocomplete="off" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="dialog-footer">
|
||||
<button class="btn-cancel" onclick={close}>Отмена</button>
|
||||
<button class="btn-connect" onclick={connect} disabled={connecting}>
|
||||
{connecting ? "Подключение..." : "Подключиться"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
width: 420px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 24px; height: 24px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.close-btn:hover { background: var(--bg-3); color: var(--text-primary); }
|
||||
|
||||
.dialog-body {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.field label { font-size: 11px; color: var(--text-secondary); }
|
||||
.field input { width: 100%; }
|
||||
|
||||
.row { display: flex; gap: 10px; align-items: flex-end; }
|
||||
.grow { flex: 1; }
|
||||
.w80 { width: 80px; flex-shrink: 0; }
|
||||
|
||||
.radio-group { display: flex; gap: 12px; }
|
||||
.radio-label {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
font-size: 12px; color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 12px; color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
flex-direction: row !important;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #3a1a1a;
|
||||
border: 1px solid #6a2a2a;
|
||||
border-radius: 4px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
padding: 6px 14px;
|
||||
border-radius: 5px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.btn-cancel:hover { background: var(--bg-3); color: var(--text-primary); }
|
||||
|
||||
.btn-connect {
|
||||
padding: 6px 16px;
|
||||
border-radius: 5px;
|
||||
background: var(--accent-dim);
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn-connect:hover:not(:disabled) { background: var(--accent); }
|
||||
.btn-connect:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
</style>
|
||||
299
src/lib/components/Panes/FilePane.svelte
Normal file
299
src/lib/components/Panes/FilePane.svelte
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
<script lang="ts">
|
||||
import type { FileEntry } from "../../types";
|
||||
|
||||
let {
|
||||
title,
|
||||
icon,
|
||||
path = $bindable(""),
|
||||
entries = [],
|
||||
loading = false,
|
||||
selected = $bindable<string | null>(null),
|
||||
selectedEntry = $bindable<FileEntry | null>(null),
|
||||
onNavigate,
|
||||
onPathChange,
|
||||
onUpload,
|
||||
onDownload,
|
||||
onMkdir,
|
||||
onDelete,
|
||||
onRename,
|
||||
}: {
|
||||
title: string;
|
||||
icon: string;
|
||||
path: string;
|
||||
entries: FileEntry[];
|
||||
loading?: boolean;
|
||||
selected?: string | null;
|
||||
selectedEntry?: FileEntry | null;
|
||||
onNavigate?: (entry: FileEntry) => void;
|
||||
onPathChange?: (path: string) => void;
|
||||
onUpload?: () => void;
|
||||
onDownload?: () => void;
|
||||
onMkdir?: () => void;
|
||||
onDelete?: (entry: FileEntry) => void;
|
||||
onRename?: (entry: FileEntry) => void;
|
||||
} = $props();
|
||||
|
||||
function formatSize(bytes?: number): string {
|
||||
if (bytes == null) return "";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatDate(ts?: number): string {
|
||||
if (!ts) return "";
|
||||
return new Date(ts * 1000).toLocaleString("ru-RU", {
|
||||
day: "2-digit", month: "2-digit", year: "numeric",
|
||||
hour: "2-digit", minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function getIcon(entry: FileEntry): string {
|
||||
if (entry.kind === "dir") return "📁";
|
||||
const ext = entry.name.split(".").pop()?.toLowerCase();
|
||||
const icons: Record<string, string> = {
|
||||
rs: "🦀", ts: "📘", js: "📜", json: "📋",
|
||||
md: "📝", toml: "⚙", png: "🖼", jpg: "🖼",
|
||||
zip: "📦", gz: "📦", tar: "📦", php: "🐘",
|
||||
html: "🌐", css: "🎨", txt: "📄",
|
||||
};
|
||||
return icons[ext ?? ""] ?? "📄";
|
||||
}
|
||||
|
||||
function handleClick(entry: FileEntry) {
|
||||
selected = entry.name;
|
||||
selectedEntry = entry;
|
||||
}
|
||||
|
||||
function handleDblClick(entry: FileEntry) {
|
||||
if (entry.kind === "dir") {
|
||||
onNavigate?.(entry);
|
||||
}
|
||||
}
|
||||
|
||||
function goUp() {
|
||||
const parent = path.includes("/")
|
||||
? path.replace(/\/[^/]+\/?$/, "") || "/"
|
||||
: path.replace(/\\[^\\]+\\?$/, "") || "C:\\";
|
||||
onPathChange?.(parent);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="file-pane">
|
||||
<!-- Заголовок панели с кнопками операций -->
|
||||
<div class="pane-header">
|
||||
<span class="pane-icon">{icon}</span>
|
||||
<span class="pane-title">{title}</span>
|
||||
<div class="pane-actions">
|
||||
{#if onUpload}
|
||||
<button class="action-btn" onclick={onUpload} title="Загрузить">↑</button>
|
||||
{/if}
|
||||
{#if onDownload}
|
||||
<button class="action-btn" onclick={onDownload} title="Скачать">↓</button>
|
||||
{/if}
|
||||
{#if onMkdir}
|
||||
<button class="action-btn" onclick={onMkdir} title="Создать папку">+📁</button>
|
||||
{/if}
|
||||
{#if onDelete}
|
||||
<button class="action-btn red" onclick={() => selectedEntry && onDelete?.(selectedEntry)} title="Удалить" disabled={!selectedEntry}>🗑</button>
|
||||
{/if}
|
||||
{#if onRename}
|
||||
<button class="action-btn" onclick={() => selectedEntry && onRename?.(selectedEntry)} title="Переименовать" disabled={!selectedEntry}>✎</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Адресная строка -->
|
||||
<div class="address-bar">
|
||||
<button class="nav-btn" onclick={goUp} title="Вверх">←</button>
|
||||
<input
|
||||
class="path-input"
|
||||
value={path}
|
||||
onchange={(e) => onPathChange?.((e.target as HTMLInputElement).value)}
|
||||
spellcheck="false"
|
||||
/>
|
||||
<button class="nav-btn" title="Вперёд">→</button>
|
||||
</div>
|
||||
|
||||
<!-- Заголовки колонок -->
|
||||
<div class="col-headers">
|
||||
<span class="col-name">Имя</span>
|
||||
<span class="col-size">Размер</span>
|
||||
<span class="col-type">Тип</span>
|
||||
<span class="col-date">Изменён ↕</span>
|
||||
</div>
|
||||
|
||||
<!-- Список файлов -->
|
||||
<div class="file-list">
|
||||
{#if loading}
|
||||
<div class="empty-state">Загрузка...</div>
|
||||
{:else if entries.length === 0}
|
||||
<div class="empty-state">Пусто</div>
|
||||
{:else}
|
||||
{#each entries as entry}
|
||||
<button
|
||||
class="file-row"
|
||||
class:selected={selected === entry.name}
|
||||
onclick={() => handleClick(entry)}
|
||||
ondblclick={() => handleDblClick(entry)}
|
||||
>
|
||||
<span class="col-name">
|
||||
<span class="file-icon">{getIcon(entry)}</span>
|
||||
{entry.name}
|
||||
</span>
|
||||
<span class="col-size">{formatSize(entry.size)}</span>
|
||||
<span class="col-type">{entry.kind === "dir" ? "Папка" : entry.name.split(".").pop()?.toUpperCase() ?? "Файл"}</span>
|
||||
<span class="col-date">{formatDate(entry.modified)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Футер -->
|
||||
<div class="pane-footer">
|
||||
{entries.length} элементов
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.file-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
background: var(--bg-0);
|
||||
}
|
||||
|
||||
.pane-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: var(--bg-1);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pane-icon { font-size: 14px; }
|
||||
.pane-title { font-weight: 500; color: var(--text-primary); flex-shrink: 0; }
|
||||
|
||||
.pane-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 3px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.action-btn:hover:not(:disabled) { background: var(--bg-3); color: var(--text-primary); }
|
||||
.action-btn.red:hover:not(:disabled) { color: var(--red); }
|
||||
.action-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.address-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
background: var(--bg-1);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 3px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.nav-btn:hover { background: var(--bg-3); color: var(--text-primary); }
|
||||
|
||||
.path-input {
|
||||
flex: 1;
|
||||
height: 26px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
background: var(--bg-2);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.col-headers {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 80px 80px 140px;
|
||||
padding: 4px 12px;
|
||||
background: var(--bg-1);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.file-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 80px 80px 140px;
|
||||
width: 100%;
|
||||
padding: 3px 12px;
|
||||
text-align: left;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
border-radius: 0;
|
||||
align-items: center;
|
||||
}
|
||||
.file-row:hover { background: var(--bg-hover); }
|
||||
.file-row.selected { background: var(--bg-selected); }
|
||||
|
||||
.col-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-icon { font-size: 13px; flex-shrink: 0; }
|
||||
|
||||
.col-size, .col-type { color: var(--text-secondary); font-size: 11px; }
|
||||
.col-date { color: var(--text-secondary); font-size: 11px; font-family: var(--font-mono); }
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pane-footer {
|
||||
padding: 4px 12px;
|
||||
background: var(--bg-1);
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
49
src/lib/components/Panes/LocalPane.svelte
Normal file
49
src/lib/components/Panes/LocalPane.svelte
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import FilePane from "./FilePane.svelte";
|
||||
import type { FileEntry } from "../../types";
|
||||
|
||||
let {
|
||||
selected = $bindable<FileEntry | null>(null),
|
||||
onPathChange = (p: string) => {},
|
||||
}: {
|
||||
selected?: FileEntry | null;
|
||||
onPathChange?: (p: string) => void;
|
||||
} = $props();
|
||||
|
||||
let path = $state("C:\\Users\\user");
|
||||
let entries = $state<FileEntry[]>([]);
|
||||
let loading = $state(false);
|
||||
let localSelected = $state<FileEntry | null>(null);
|
||||
|
||||
// Sync local selected with parent
|
||||
$effect(() => { selected = localSelected; });
|
||||
|
||||
async function loadDir(p: string) {
|
||||
loading = true;
|
||||
try {
|
||||
entries = await invoke<FileEntry[]>("list_local", { path: p });
|
||||
path = p;
|
||||
onPathChange(p);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadDir(path);
|
||||
});
|
||||
</script>
|
||||
|
||||
<FilePane
|
||||
title="Локальные файлы"
|
||||
icon="💻"
|
||||
bind:path
|
||||
{entries}
|
||||
{loading}
|
||||
bind:selected={localSelected}
|
||||
onNavigate={(e) => loadDir(e.path)}
|
||||
onPathChange={(p) => loadDir(p)}
|
||||
/>
|
||||
62
src/lib/components/Panes/RemotePane.svelte
Normal file
62
src/lib/components/Panes/RemotePane.svelte
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import FilePane from "./FilePane.svelte";
|
||||
import { activeConnection } from "../../stores/connection.svelte";
|
||||
import type { FileEntry } from "../../types";
|
||||
|
||||
let {
|
||||
selected = $bindable<FileEntry | null>(null),
|
||||
onPathChange = (p: string) => {},
|
||||
onDelete,
|
||||
onRename,
|
||||
}: {
|
||||
selected?: FileEntry | null;
|
||||
onPathChange?: (p: string) => void;
|
||||
onDelete?: (entry: FileEntry) => void;
|
||||
onRename?: (entry: FileEntry) => void;
|
||||
} = $props();
|
||||
|
||||
let path = $state("/home/user");
|
||||
let entries = $state<FileEntry[]>([]);
|
||||
let loading = $state(false);
|
||||
let remoteSelected = $state<FileEntry | null>(null);
|
||||
|
||||
$effect(() => { selected = remoteSelected; });
|
||||
|
||||
async function loadDir(p: string) {
|
||||
const conn = $activeConnection;
|
||||
if (!conn || conn.status !== "connected") return;
|
||||
loading = true;
|
||||
try {
|
||||
entries = await invoke<FileEntry[]>("list_remote", {
|
||||
connectionId: conn.id,
|
||||
path: p,
|
||||
});
|
||||
path = p;
|
||||
onPathChange(p);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if ($activeConnection?.status === "connected") {
|
||||
loadDir(path);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<FilePane
|
||||
title="Удалённые файлы"
|
||||
icon="🖥"
|
||||
bind:path
|
||||
{entries}
|
||||
{loading}
|
||||
bind:selected={remoteSelected}
|
||||
{onDelete}
|
||||
{onRename}
|
||||
onNavigate={(e) => loadDir(e.path)}
|
||||
onPathChange={(p) => loadDir(p)}
|
||||
/>
|
||||
279
src/lib/components/Queue/TransferQueue.svelte
Normal file
279
src/lib/components/Queue/TransferQueue.svelte
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import type { TransferTask } from "../../types";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
|
||||
let { open = $bindable(true) }: { open: boolean } = $props();
|
||||
|
||||
let tasks = $state<TransferTask[]>([]);
|
||||
let unlisten: (() => void) | null = null;
|
||||
|
||||
onMount(() => {
|
||||
invoke<TransferTask[]>("get_queue").then(t => { tasks = t; }).catch(e => console.error("get_queue failed", e));
|
||||
|
||||
listen<{
|
||||
taskId: string;
|
||||
state: string;
|
||||
transferredBytes: number;
|
||||
totalBytes: number;
|
||||
speed?: number | null;
|
||||
etaSecs?: number | null;
|
||||
error?: string | null;
|
||||
}>("transfer-progress", (event) => {
|
||||
const { taskId, state, transferredBytes, totalBytes, speed, etaSecs, error } = event.payload;
|
||||
tasks = tasks.map(t => {
|
||||
if (t.id !== taskId) return t;
|
||||
const newState = parseState(state);
|
||||
if (newState === "completed" || newState === "failed" || newState === "cancelled") {
|
||||
return { ...t, state: newState, transferredBytes, speed: speed ?? null, etaSecs: etaSecs ?? null };
|
||||
}
|
||||
return { ...t, state: newState, transferredBytes, speed: speed ?? null, etaSecs: etaSecs ?? null };
|
||||
});
|
||||
}).then(unsub => { unlisten = unsub; });
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unlisten?.();
|
||||
});
|
||||
|
||||
function parseState(s: string): TransferTask["state"] {
|
||||
if (s.startsWith("failed")) return "failed";
|
||||
if (s.startsWith("retrying")) return "queued";
|
||||
return s as TransferTask["state"];
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatSpeed(bps?: number | null): string {
|
||||
if (!bps) return "—";
|
||||
if (bps < 1024 * 1024) return `${(bps / 1024).toFixed(1)} KB/s`;
|
||||
return `${(bps / 1024 / 1024).toFixed(1)} MB/s`;
|
||||
}
|
||||
|
||||
function formatEta(secs?: number | null): string {
|
||||
if (!secs) return "—";
|
||||
const h = Math.floor(secs / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
const s = secs % 60;
|
||||
return [h, m, s].map(v => String(v).padStart(2, "0")).join(":");
|
||||
}
|
||||
|
||||
function pct(task: TransferTask): number {
|
||||
if (!task.totalBytes) return 0;
|
||||
return Math.round(task.transferredBytes / task.totalBytes * 100);
|
||||
}
|
||||
|
||||
function stateLabel(state: string): string {
|
||||
return ({ queued: "В очереди", running: "Передача", paused: "Пауза",
|
||||
completed: "Завершено", cancelled: "Отменено",
|
||||
failed: "Ошибка" } as any)[state] ?? state;
|
||||
}
|
||||
|
||||
async function pause(id: string) {
|
||||
await invoke("pause_task", { taskId: id });
|
||||
tasks = tasks.map(t => t.id === id ? { ...t, state: "paused" as const } : t);
|
||||
}
|
||||
|
||||
async function cancel(id: string) {
|
||||
await invoke("cancel_task", { taskId: id });
|
||||
tasks = tasks.filter(t => t.id !== id);
|
||||
}
|
||||
|
||||
let totalSpeed = $derived(
|
||||
tasks.filter(t => t.state === "running").reduce((s, t) => s + (t.speed ?? 0), 0)
|
||||
);
|
||||
|
||||
let activeTasks = $derived(tasks);
|
||||
</script>
|
||||
|
||||
<div class="queue-wrap" class:collapsed={!open}>
|
||||
<div class="queue-header">
|
||||
<span class="queue-title">Очередь передач ({tasks.length})</span>
|
||||
<button class="collapse-btn" onclick={() => open = !open} title={open ? "Свернуть" : "Развернуть"}>
|
||||
{open ? "▼" : "▲"}
|
||||
</button>
|
||||
<button class="sort-btn" title="Сортировка">⇅</button>
|
||||
</div>
|
||||
|
||||
{#if open}
|
||||
<div class="queue-body">
|
||||
<div class="queue-cols">
|
||||
<span>Имя</span>
|
||||
<span>Операция</span>
|
||||
<span>Источник</span>
|
||||
<span>Назначение</span>
|
||||
<span>Размер</span>
|
||||
<span>Прогресс</span>
|
||||
<span>Скорость</span>
|
||||
<span>Осталось</span>
|
||||
<span>Статус</span>
|
||||
</div>
|
||||
|
||||
{#each activeTasks as task}
|
||||
<div class="queue-row" class:completed={task.state === "completed"}>
|
||||
<span class="q-name">
|
||||
<span class="q-file-icon">📄</span>
|
||||
{task.fileName}
|
||||
</span>
|
||||
<span class="q-op">{task.kind === "upload" ? "Загрузка" : "Скачать"}</span>
|
||||
<span class="q-path" title={task.localPath}>{task.localPath}</span>
|
||||
<span class="q-path" title={task.remotePath}>{task.remotePath}</span>
|
||||
<span class="q-size">{formatSize(task.totalBytes)}</span>
|
||||
<span class="q-progress">
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress-fill"
|
||||
class:green={task.state === "completed"}
|
||||
class:blue={task.state === "running"}
|
||||
style="width: {pct(task)}%"
|
||||
></div>
|
||||
<span class="progress-label">{pct(task)}%</span>
|
||||
</div>
|
||||
</span>
|
||||
<span class="q-speed">{formatSpeed(task.speed)}</span>
|
||||
<span class="q-eta">{formatEta(task.etaSecs)}</span>
|
||||
<span class="q-state">
|
||||
{#if task.state === "completed"}
|
||||
<span class="badge green">✓ {stateLabel(task.state)}</span>
|
||||
{:else if task.state === "running"}
|
||||
<span class="badge blue">{stateLabel(task.state)}</span>
|
||||
<button class="icon-btn" onclick={() => pause(task.id)} title="Пауза">⏸</button>
|
||||
<button class="icon-btn red" onclick={() => cancel(task.id)} title="Отмена">✕</button>
|
||||
{:else}
|
||||
<span class="badge">{stateLabel(task.state)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="queue-empty">Нет активных передач</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.queue-wrap {
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-1);
|
||||
border-top: 1px solid var(--border);
|
||||
max-height: var(--queue-h);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: max-height 0.2s;
|
||||
}
|
||||
.queue-wrap.collapsed { max-height: 36px; }
|
||||
|
||||
.queue-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
height: 36px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.queue-title { font-size: 12px; font-weight: 500; color: var(--text-secondary); flex: 1; }
|
||||
|
||||
.collapse-btn, .sort-btn {
|
||||
width: 24px; height: 24px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 3px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.collapse-btn:hover, .sort-btn:hover { background: var(--bg-3); color: var(--text-primary); }
|
||||
|
||||
.queue-body { overflow-y: auto; flex: 1; }
|
||||
|
||||
.queue-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.queue-cols {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 70px 200px 200px 70px 120px 80px 80px 140px;
|
||||
padding: 3px 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky; top: 0;
|
||||
background: var(--bg-1);
|
||||
}
|
||||
|
||||
.queue-row {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 70px 200px 200px 70px 120px 80px 80px 140px;
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.queue-row:hover { background: var(--bg-hover); }
|
||||
.queue-row.completed { opacity: 0.7; }
|
||||
|
||||
.q-name { display: flex; align-items: center; gap: 5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.q-file-icon { font-size: 12px; flex-shrink: 0; }
|
||||
.q-op { color: var(--text-secondary); }
|
||||
.q-path { color: var(--text-muted); font-family: var(--font-mono); font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.q-size { color: var(--text-secondary); }
|
||||
.q-speed, .q-eta { color: var(--text-secondary); font-family: var(--font-mono); font-size: 11px; }
|
||||
|
||||
.progress-bar {
|
||||
height: 16px;
|
||||
background: var(--bg-3);
|
||||
border-radius: 3px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s;
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
.progress-fill.green { background: #2e7d32; }
|
||||
.progress-fill.blue { background: var(--accent-dim); }
|
||||
.progress-label {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.q-state { display: flex; align-items: center; gap: 4px; }
|
||||
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-3);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.badge.green { background: #1b3a1c; color: #66bb6a; }
|
||||
.badge.blue { background: #1a2e4a; color: var(--accent); }
|
||||
|
||||
.icon-btn {
|
||||
width: 20px; height: 20px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 3px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.icon-btn:hover { background: var(--bg-3); color: var(--text-primary); }
|
||||
.icon-btn.red:hover { color: var(--red); }
|
||||
</style>
|
||||
78
src/lib/components/StatusBar.svelte
Normal file
78
src/lib/components/StatusBar.svelte
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<script lang="ts">
|
||||
import { activeConnection } from "../stores/connection.svelte";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
|
||||
let conn = $derived($activeConnection);
|
||||
let totalSpeed = $state(0);
|
||||
let unlisten: (() => void) | null = null;
|
||||
const speeds = new Map<string, number>();
|
||||
|
||||
onMount(() => {
|
||||
listen<{ taskId: string; speed?: number | null }>("transfer-progress", (event) => {
|
||||
const s = event.payload.speed ?? 0;
|
||||
if (event.payload.taskId) {
|
||||
speeds.set(event.payload.taskId, s);
|
||||
}
|
||||
totalSpeed = [...speeds.values()].reduce((a, b) => a + b, 0);
|
||||
}).then((unsub) => {
|
||||
unlisten = unsub;
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unlisten?.();
|
||||
});
|
||||
|
||||
function formatSpeed(bps: number): string {
|
||||
if (bps <= 0) return "0 B/s";
|
||||
if (bps < 1024) return `${bps} B/s`;
|
||||
if (bps < 1024 * 1024) return `${(bps / 1024).toFixed(1)} KB/s`;
|
||||
return `${(bps / 1024 / 1024).toFixed(1)} MB/s`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="statusbar">
|
||||
<span class="status-dot" class:connected={conn?.status === "connected"}></span>
|
||||
<span class="status-text">
|
||||
{#if conn?.status === "connected"}
|
||||
Подключено к {conn.label} ({conn.protocol.toUpperCase()})
|
||||
{:else}
|
||||
Нет соединения
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<div class="statusbar-spacer"></div>
|
||||
|
||||
{#if totalSpeed > 0}
|
||||
<span class="speed-indicator">📊 {formatSpeed(totalSpeed)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: var(--statusbar-h);
|
||||
padding: 0 12px;
|
||||
background: var(--bg-1);
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-dot.connected { background: var(--green); }
|
||||
|
||||
.statusbar-spacer { flex: 1; }
|
||||
|
||||
.speed-indicator { font-family: var(--font-mono); font-size: 11px; }
|
||||
</style>
|
||||
116
src/lib/components/Tabs/Tabs.svelte
Normal file
116
src/lib/components/Tabs/Tabs.svelte
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
<script lang="ts">
|
||||
import { connections, activeId } from "../../stores/connection.svelte";
|
||||
|
||||
function addConnection() {
|
||||
// TODO: открыть диалог нового соединения
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="tabs-bar">
|
||||
<button class="menu-btn" title="Меню">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="tabs">
|
||||
{#each $connections as conn}
|
||||
<button
|
||||
class="tab"
|
||||
class:active={$activeId === conn.id}
|
||||
onclick={() => activeId.set(conn.id)}
|
||||
>
|
||||
<span class="tab-dot" class:connected={conn.status === "connected"}></span>
|
||||
<span class="tab-label">{conn.label} ({conn.protocol.toUpperCase()})</span>
|
||||
<span class="tab-close" role="button" tabindex="0" onclick={(e) => { e.stopPropagation(); connections.remove(conn.id); }} onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); connections.remove(conn.id); } }}>×</span>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<button class="tab-add" onclick={addConnection} title="Новое соединение">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tabs-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: var(--tab-h);
|
||||
background: var(--bg-1);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.menu-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.menu-btn:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
overflow-x: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 10px 0 10px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tab:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
.tab.active { background: var(--bg-2); color: var(--text-primary); }
|
||||
|
||||
.tab-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tab-dot.connected { background: var(--green); }
|
||||
|
||||
.tab-close {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 3px;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1;
|
||||
}
|
||||
.tab-close:hover { background: var(--bg-3); color: var(--text-primary); }
|
||||
|
||||
.tab-add {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tab-add:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
</style>
|
||||
139
src/lib/components/Toolbar/Toolbar.svelte
Normal file
139
src/lib/components/Toolbar/Toolbar.svelte
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<script lang="ts">
|
||||
import { activeConnection } from "../../stores/connection.svelte";
|
||||
|
||||
let {
|
||||
onNewConnection,
|
||||
upload,
|
||||
download,
|
||||
mkdir,
|
||||
}: {
|
||||
onNewConnection?: () => void;
|
||||
upload?: () => void;
|
||||
download?: () => void;
|
||||
mkdir?: () => void;
|
||||
} = $props();
|
||||
|
||||
let connected = $derived($activeConnection?.status === "connected");
|
||||
</script>
|
||||
|
||||
<div class="toolbar">
|
||||
<button class="new-conn-btn" onclick={onNewConnection}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="16"/>
|
||||
<line x1="8" y1="12" x2="16" y2="12"/>
|
||||
</svg>
|
||||
Новое соединение
|
||||
</button>
|
||||
|
||||
<div class="separator"></div>
|
||||
|
||||
<button class="toolbar-btn" onclick={upload} disabled={!connected} title="Загрузить на сервер">
|
||||
<span class="toolbar-icon">↑</span>
|
||||
<span class="toolbar-label">Загрузить</span>
|
||||
</button>
|
||||
|
||||
<button class="toolbar-btn" onclick={download} disabled={!connected} title="Скачать с сервера">
|
||||
<span class="toolbar-icon">↓</span>
|
||||
<span class="toolbar-label">Скачать</span>
|
||||
</button>
|
||||
|
||||
<button class="toolbar-btn" onclick={mkdir} disabled={!connected} title="Создать папку">
|
||||
<span class="toolbar-icon">□</span>
|
||||
<span class="toolbar-label">Создать папку</span>
|
||||
</button>
|
||||
|
||||
<div class="toolbar-spacer"></div>
|
||||
|
||||
<div class="view-switcher">
|
||||
<button class="view-btn active" title="Детали">⊞</button>
|
||||
<button class="view-btn" title="Список">☰</button>
|
||||
<button class="view-btn" title="Иконки">⊟</button>
|
||||
</div>
|
||||
|
||||
<button class="settings-btn" title="Настройки">⚙</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: var(--toolbar-h);
|
||||
background: var(--bg-1);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 8px;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.new-conn-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 5px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.new-conn-btn:hover { background: var(--bg-3); }
|
||||
|
||||
.separator {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--border);
|
||||
margin: 0 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.toolbar-btn:hover:not(:disabled) { background: var(--bg-hover); color: var(--text-primary); }
|
||||
.toolbar-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.toolbar-icon { font-size: 13px; }
|
||||
|
||||
.toolbar-spacer { flex: 1; }
|
||||
|
||||
.view-switcher {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
font-size: 15px;
|
||||
}
|
||||
.view-btn:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
.view-btn.active { background: var(--bg-3); color: var(--text-primary); }
|
||||
|
||||
.settings-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
font-size: 16px;
|
||||
}
|
||||
.settings-btn:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||
</style>
|
||||
39
src/lib/stores/connection.svelte.ts
Normal file
39
src/lib/stores/connection.svelte.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { writable, derived } from "svelte/store";
|
||||
|
||||
export interface Connection {
|
||||
id: string;
|
||||
label: string;
|
||||
protocol: "sftp" | "ftp" | "ftps";
|
||||
host: string;
|
||||
status: "connected" | "disconnected" | "connecting" | "error";
|
||||
}
|
||||
|
||||
function createConnections() {
|
||||
const { subscribe, set, update } = writable<Connection[]>([
|
||||
// Моковые соединения для разработки UI
|
||||
{ id: "srv1", label: "srv1", protocol: "sftp", host: "192.168.1.1", status: "connected" },
|
||||
{ id: "srv2", label: "srv2", protocol: "ftp", host: "192.168.1.2", status: "disconnected" },
|
||||
{ id: "backup", label: "backup", protocol: "ftps", host: "backup.example.com", status: "disconnected" },
|
||||
]);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
add(conn: Connection) {
|
||||
update(cs => [...cs, conn]);
|
||||
},
|
||||
remove(id: string) {
|
||||
update(cs => cs.filter(c => c.id !== id));
|
||||
},
|
||||
setStatus(id: string, status: Connection["status"]) {
|
||||
update(cs => cs.map(c => c.id === id ? { ...c, status } : c));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const connections = createConnections();
|
||||
export const activeId = writable<string | null>("srv1");
|
||||
|
||||
export const activeConnection = derived(
|
||||
[connections, activeId],
|
||||
([conns, id]) => conns.find(c => c.id === id) ?? null
|
||||
);
|
||||
34
src/lib/types.ts
Normal file
34
src/lib/types.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
export interface FileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
kind: "file" | "dir" | "symlink";
|
||||
size?: number;
|
||||
modified?: number;
|
||||
permissions?: string;
|
||||
}
|
||||
|
||||
export interface TransferTask {
|
||||
id: string;
|
||||
kind: "upload" | "download";
|
||||
connectionId: string;
|
||||
localPath: string;
|
||||
remotePath: string;
|
||||
fileName: string;
|
||||
totalBytes: number;
|
||||
transferredBytes: number;
|
||||
state: "queued" | "running" | "paused" | "cancelled" | "completed" | "failed";
|
||||
speed?: number | null;
|
||||
etaSecs?: number | null;
|
||||
}
|
||||
|
||||
export interface Site {
|
||||
id: string;
|
||||
name: string;
|
||||
protocol: "sftp" | "ftp" | "ftps";
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
keyPath?: string;
|
||||
folder?: string;
|
||||
note?: string;
|
||||
}
|
||||
6
src/main.ts
Normal file
6
src/main.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import App from "./App.svelte";
|
||||
import { mount } from "svelte";
|
||||
|
||||
const app = mount(App, { target: document.getElementById("app")! });
|
||||
|
||||
export default app;
|
||||
16
vite.config.ts
Normal file
16
vite.config.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { defineConfig } from "vite";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
watch: { ignored: ["**/src-tauri/**"] },
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
target: "chrome120",
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue