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

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>