remove leftover Svelte frontend files
This commit is contained in:
parent
ff9a1b9b4a
commit
b7b70fe22d
16 changed files with 0 additions and 1634 deletions
153
src/App.svelte
153
src/App.svelte
|
|
@ -1,153 +0,0 @@
|
|||
<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
80
src/app.css
|
|
@ -1,80 +0,0 @@
|
|||
*, *::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); }
|
||||
|
|
@ -1,252 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,299 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
<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)}
|
||||
/>
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
<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)}
|
||||
/>
|
||||
|
|
@ -1,279 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
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
|
||||
);
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import App from "./App.svelte";
|
||||
import { mount } from "svelte";
|
||||
|
||||
const app = mount(App, { target: document.getElementById("app")! });
|
||||
|
||||
export default app;
|
||||
Loading…
Add table
Add a link
Reference in a new issue