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

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;