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:
loki5512344 2026-06-13 12:06:56 +02:00
parent 0f182ab76f
commit 79855585bd
60 changed files with 14532 additions and 18 deletions

36
.gitignore vendored
View file

@ -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

File diff suppressed because it is too large Load diff

3
Cargo.toml Normal file
View file

@ -0,0 +1,3 @@
[workspace]
members = ["src-tauri"]
resolver = "2"

22
LICENSE
View file

@ -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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
{}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 850 B

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

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 B

View 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) = &params.password {
p.clone()
} else {
keychain::get_password(&params.id).map_err(|e| e.to_string())?
};
let fs: Arc<dyn crate::protocols::RemoteFs> = match params.protocol {
Protocol::Sftp => {
if let Some(key_path) = &params.key_path {
Arc::new(
SftpClient::connect_key(&params.host, params.port, &params.username, key_path)
.map_err(|e| e.to_string())?
)
} else {
Arc::new(
SftpClient::connect_password(&params.host, params.port, &params.username, &password)
.map_err(|e| e.to_string())?
)
}
}
Protocol::Ftp => {
Arc::new(
FtpClient::connect(&params.host, params.port, &params.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(())
}

View 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())
}

View file

@ -0,0 +1,4 @@
pub mod connection;
pub mod fs;
pub mod sites;
pub mod transfer;

View 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())
}

View 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(())
}

View 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),
}

View 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>,
}

View file

@ -0,0 +1,4 @@
pub mod connection;
pub mod file_entry;
pub mod site;
pub mod transfer;

View 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>,
}

View 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
View 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
View file

@ -0,0 +1,2 @@
pub mod local;
pub mod remote;

View 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
View 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
View 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();
}

View 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,
})
}
}

View 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>;
}

View 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,
})
}
}

View 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(())
}

View 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(())
}

View file

@ -0,0 +1,2 @@
pub mod db;
pub mod keychain;

View 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
}
}

View file

@ -0,0 +1,5 @@
pub mod manager;
pub mod progress;
pub mod queue;
pub mod task;
pub mod worker;

View 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
}
}

View 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);
}
}

View 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,
)
}

View 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
View 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
View 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
View 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); }

View 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>

View 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>

View 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)}
/>

View 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)}
/>

View 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>

View 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>

View 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>

View 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>

View 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
View 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
View 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
View 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",
},
});