Wherry/src/lib/components/Queue/TransferQueue.svelte
loki5512344 79855585bd 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.
2026-06-13 12:08:08 +02:00

279 lines
9.1 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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