feat: FTPS, AppError, Site Manager, tests, multi-window, auto-clear, CI/CD, virtual scroll

- FTPS: реализован FtpsClient через suppaftp + async-native-tls
- AppError: кастомный enum с Serialize вместо Result<T, String>
- Site Manager UI: создание/редактирование/удаление сайтов
- Тесты: 53 тестов (domain, storage, transfer, fs, remote)
- Multi-window: новое окно + сохранение геометрии
- Auto-clear: воркер чистит завершённые задачи по таймеру
- CI/CD: 3-ступенчатый пайплайн (lint→build→test) с кэшированием
- Virtual scroll: fileTable с рендером только видимых строк
- UI: исправлены close-кнопки, color-scheme для select, скриншоты в README
This commit is contained in:
loki5512344 2026-07-11 00:04:13 +02:00
parent 545dd4ed0b
commit e188fbef36
Signed by: boba
GPG key ID: 253067914055423B
52 changed files with 5188 additions and 523 deletions

2
.cargo/config.toml Normal file
View file

@ -0,0 +1,2 @@
[build]
jobs = 6

View file

@ -10,13 +10,74 @@ env:
CARGO_TERM_COLOR: always
jobs:
build:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose
- uses: actions/checkout@v4
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libjavascriptcoregtk-4.1-dev \
libsoup-3.0-dev \
libgtk-3-dev \
libappindicator3-dev \
librsvg2-dev \
patchelf \
libssl-dev \
libdbus-1-dev
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
- name: Check formatting
run: cargo fmt --check
- name: Lint with clippy
run: cargo clippy -- -D warnings
build:
runs-on: ubuntu-latest
needs: [lint]
steps:
- uses: actions/checkout@v4
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libjavascriptcoregtk-4.1-dev \
libsoup-3.0-dev \
libgtk-3-dev \
libappindicator3-dev \
librsvg2-dev \
patchelf \
libssl-dev \
libdbus-1-dev
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Build (release)
run: cargo build --release
test:
runs-on: ubuntu-latest
needs: [build]
steps:
- uses: actions/checkout@v4
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libjavascriptcoregtk-4.1-dev \
libsoup-3.0-dev \
libgtk-3-dev \
libappindicator3-dev \
librsvg2-dev \
patchelf \
libssl-dev \
libdbus-1-dev
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Run tests
run: cargo test

View file

@ -8,6 +8,11 @@
FTP/SFTP клиент на Tauri 2: бэкенд на Rust, фронтенд — vanilla HTML/CSS/JS.
<p align="center">
<img src="docs/img/start_screen.png" alt="Wherry main screen" width="80%" style="max-width:800px;border-radius:10px">
<br><em>Welcome-экран с сохранёнными сайтами и историей подключений</em>
</p>
## Стек
- **Tauri 2** — нативная оболочка (Rust-бэкенд + WebView-фронтенд)
@ -23,7 +28,15 @@ FTP/SFTP клиент на Tauri 2: бэкенд на Rust, фронтенд —
|----------|--------------|
| SFTP | реализован |
| FTP | реализован |
| FTPS | заглушка |
| FTPS | реализован |
## Скриншоты
<p align="center">
<img src="docs/img/connect_screen.png" alt="Connect dialog" height="400" style="border-radius:10px;margin-right:12px">
<img src="docs/img/settings_screen.png" alt="Settings screen" height="400" style="border-radius:10px">
<br><em>Диалог подключения (слева) и окно настроек (справа)</em>
</p>
## Возможности
@ -34,8 +47,6 @@ FTP/SFTP клиент на Tauri 2: бэкенд на Rust, фронтенд —
- Welcome-экран: сохранённые сайты, недавние подключения, шорткаты
- Settings: General / Appearance / Connections / History / Security / Transfers / About
В разработке: полноценная реализация FTPS, Site Manager UI (создание/редактирование сайтов).
## Разработка
```bash

BIN
docs/img/connect_screen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
docs/img/start_screen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View file

@ -414,8 +414,8 @@
flex: 1;
}
.dialog-close {
width: 24px;
height: 24px;
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
border: none;
background: transparent;
@ -541,6 +541,10 @@
cursor: pointer;
min-width: 180px;
}
.settings-select option {
background: var(--bg-panel);
color: var(--text-primary);
}
.settings-select:hover {
border-color: var(--accent);
}

View file

@ -305,8 +305,8 @@
color: var(--text-primary);
}
.tab-close {
width: 16px;
height: 16px;
width: 22px;
height: 22px;
border-radius: 4px;
border: none;
background: transparent;
@ -314,13 +314,15 @@
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-size: 13px;
line-height: 1;
cursor: pointer;
opacity: 0.7;
}
.tab-close:hover {
background: rgba(255, 255, 255, 0.12);
color: var(--text-primary);
opacity: 1;
}
/* localPane.js/remotePane.js render straight into this element (there's no
@ -708,17 +710,17 @@ html[data-platform="linux"] ::-webkit-scrollbar {
}
.pane-split-btn,
.pane-close-btn {
width: 18px;
height: 18px;
width: 24px;
height: 24px;
border: none;
border-radius: 3px;
border-radius: 4px;
background: transparent;
color: var(--text-hint);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 10px;
font-size: 13px;
padding: 0;
}
.pane-split-btn:hover,

View file

@ -94,7 +94,7 @@ function build() {
<div class="dialog-header">
<span class="icon icon--important">${iconMarkup("serverSquare", 16)}</span>
<span class="dialog-title">${t("connect.title")}</span>
<button type="button" class="dialog-close" data-action="close">${iconMarkup("close", 13)}</button>
<button type="button" class="dialog-close" data-action="close">${iconMarkup("close", 16)}</button>
</div>
<div class="dialog-body">
<div class="segmented" data-role="protocol">

View file

@ -1,6 +1,3 @@
// Reusable file listing table shared by the Local and Remote panes — mirrors
// the old src/ui/panels/file_pane module (table.rs/row.rs/sort.rs collapsed
// into one file since there's no split-module pressure in JS).
import { t } from "../i18n.js";
import { iconMarkup, fileIconFor } from "../icons.js";
import { formatSize, formatTime } from "../format.js";
@ -8,6 +5,11 @@ import { makeDraggable, makeDropTarget } from "../dnd.js";
import { showContextMenu } from "./contextMenu.js";
import { escapeHtml } from "../dom.js";
const ROW_HEIGHT = 32;
const OVERSCAN = 10;
let rafId = null;
function sortEntries(entries, sort) {
const dotdotIdx = entries.findIndex((e) => e.name === "..");
const dotdot = dotdotIdx >= 0 ? entries[dotdotIdx] : null;
@ -57,29 +59,64 @@ function sortHeaderHtml(label, col, sort) {
return `<button type="button" class="sort-header ${active ? "active" : ""}" data-col="${col}">${label}${arrow}</button>`;
}
/**
* opts:
* - entries: FileEntry[]
* - selected: string[] (names of selected entries)
* - anchor: string|null (name shift-click range selection extends from)
* - sort: { col, dir }
* - onSort(nextSort)
* - onSelect(names, anchor)
* - onOpen(name)
* - dragPayload(entry) => payload|null
* - onDropOnEntry(entry, payload) only called for dir/".." rows
* - onHoverOpen(entry) spring-loaded open: fires after a drag lingers on
* a dir/".." row for HOVER_OPEN_DELAY, without
* ending the drag -- only called for dir/".." rows
* - contextMenuItems(entry, selectedEntries) => menu items for showContextMenu, or null to skip
*/
export function renderFileTable(container, opts) {
const scrollPos = container.querySelector(".file-table-body")?.scrollTop ?? 0;
function computeVisibleRange(scrollTop, viewportHeight, totalItems) {
const start = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
const end = Math.min(totalItems, Math.ceil((scrollTop + viewportHeight) / ROW_HEIGHT) + OVERSCAN);
return { start, end };
}
function getSignature(sorted, sort) {
const len = sorted.length;
if (len === 0) return `0:${sort.col}:${sort.dir}`;
const mid = Math.min(len >> 1, len - 1);
return `${len}:${sort.col}:${sort.dir}:${sorted[0].name}:${sorted[mid].name}:${sorted[len - 1].name}`;
}
function initStructure(container) {
let cache = container._fileTableCache;
if (cache) return cache;
container.innerHTML = "";
container.classList.add("file-table-wrap");
const header = document.createElement("div");
header.className = "file-table-header";
container.appendChild(header);
const body = document.createElement("div");
body.className = "file-table-body";
container.appendChild(body);
const spacer = document.createElement("div");
spacer.style.position = "relative";
body.appendChild(spacer);
cache = {
header,
body,
spacer,
lastSignature: null,
sorted: [],
selectableNames: [],
opts: null,
};
container._fileTableCache = cache;
body.addEventListener("scroll", () => {
if (rafId) return;
rafId = requestAnimationFrame(() => {
rafId = null;
const c = container._fileTableCache;
if (c && c.sorted.length > 0) {
updateVisibleRows(c);
}
});
});
return cache;
}
function updateHeader(header, opts) {
header.innerHTML = `
${sortHeaderHtml(t("panels.colName"), "name", opts.sort)}
${sortHeaderHtml(t("panels.colModified"), "modified", opts.sort)}
@ -96,81 +133,141 @@ export function renderFileTable(container, opts) {
opts.onSort(next);
});
});
container.appendChild(header);
}
const body = document.createElement("div");
body.className = "file-table-body";
container.appendChild(body);
function createRowElement(entry, index, cache) {
const row = document.createElement("div");
row.className = "file-row";
row.dataset.index = index;
const sorted = sortEntries(opts.entries, opts.sort);
const selectableNames = sorted.filter((e) => e.name !== "..").map((e) => e.name);
const current = cache.opts;
if (current.selected.includes(entry.name)) row.classList.add("selected");
for (const entry of sorted) {
const row = document.createElement("div");
row.className = "file-row";
if (opts.selected.includes(entry.name)) row.classList.add("selected");
row.style.position = "absolute";
row.style.top = index * ROW_HEIGHT + "px";
row.style.left = "0";
row.style.right = "0";
row.style.height = ROW_HEIGHT + "px";
const [iconName, iconColor] = rowIcon(entry);
const nameCol = entry.name === ".." ? "text-hint" : "text-primary";
const [iconName, iconColor] = rowIcon(entry);
const nameCol = entry.name === ".." ? "text-hint" : "text-primary";
row.innerHTML = `
<div class="file-row-name">
<span class="icon ${iconColor}">${iconMarkup(iconName, 15)}</span>
<span class="${nameCol}">${escapeHtml(entry.name)}</span>
</div>
<div class="file-row-modified mono">${formatTime(entry.modified)}</div>
<div class="file-row-type">${typeLabel(entry)}</div>
<div class="file-row-size mono">${formatSize(entry.size)}</div>
`;
row.innerHTML = `
<div class="file-row-name">
<span class="icon ${iconColor}">${iconMarkup(iconName, 15)}</span>
<span class="${nameCol}">${escapeHtml(entry.name)}</span>
</div>
<div class="file-row-modified mono">${formatTime(entry.modified)}</div>
<div class="file-row-type">${typeLabel(entry)}</div>
<div class="file-row-size mono">${formatSize(entry.size)}</div>
`;
row.addEventListener("click", (e) => {
if (entry.name === "..") {
opts.onSelect([], null);
return;
}
const idx = selectableNames.indexOf(entry.name);
if (e.shiftKey && opts.anchor && selectableNames.includes(opts.anchor)) {
const anchorIdx = selectableNames.indexOf(opts.anchor);
const [lo, hi] = anchorIdx < idx ? [anchorIdx, idx] : [idx, anchorIdx];
opts.onSelect(selectableNames.slice(lo, hi + 1), opts.anchor);
} else if (e.ctrlKey || e.metaKey) {
const next = new Set(opts.selected);
if (next.has(entry.name)) next.delete(entry.name);
else next.add(entry.name);
opts.onSelect(selectableNames.filter((n) => next.has(n)), entry.name);
} else {
opts.onSelect([entry.name], entry.name);
row.addEventListener("click", (e) => {
const opts = cache.opts;
if (entry.name === "..") {
opts.onSelect([], null);
return;
}
const idx = cache.selectableNames.indexOf(entry.name);
if (e.shiftKey && opts.anchor && cache.selectableNames.includes(opts.anchor)) {
const anchorIdx = cache.selectableNames.indexOf(opts.anchor);
const [lo, hi] = anchorIdx < idx ? [anchorIdx, idx] : [idx, anchorIdx];
opts.onSelect(cache.selectableNames.slice(lo, hi + 1), opts.anchor);
} else if (e.ctrlKey || e.metaKey) {
const next = new Set(opts.selected);
if (next.has(entry.name)) next.delete(entry.name);
else next.add(entry.name);
opts.onSelect(cache.selectableNames.filter((n) => next.has(n)), entry.name);
} else {
opts.onSelect([entry.name], entry.name);
}
});
row.addEventListener("dblclick", () => cache.opts.onOpen(entry.name));
if (entry.name !== "..") {
row.addEventListener("contextmenu", (e) => {
e.preventDefault();
const opts = cache.opts;
let selection = opts.selected;
if (!selection.includes(entry.name)) {
selection = [entry.name];
opts.onSelect(selection, entry.name);
}
const selectedEntries = cache.sorted.filter((en) => selection.includes(en.name));
const items = opts.contextMenuItems?.(entry, selectedEntries);
if (items) showContextMenu(e.clientX, e.clientY, items);
});
row.addEventListener("dblclick", () => opts.onOpen(entry.name));
if (entry.name !== "..") {
row.addEventListener("contextmenu", (e) => {
e.preventDefault();
let selection = opts.selected;
if (!selection.includes(entry.name)) {
selection = [entry.name];
opts.onSelect(selection, entry.name);
}
const selectedEntries = sorted.filter((en) => selection.includes(en.name));
const items = opts.contextMenuItems?.(entry, selectedEntries);
if (items) showContextMenu(e.clientX, e.clientY, items);
});
const payload = opts.dragPayload?.(entry);
if (payload) makeDraggable(row.querySelector(".file-row-name"), entry, payload);
}
if (entry.kind === "dir" || entry.name === "..") {
makeDropTarget(row, {
onDrop: (payload) => opts.onDropOnEntry?.(entry, payload),
onHoverChange: (hover) => row.classList.toggle("drag-hover", hover),
onHoverHold: () => opts.onHoverOpen?.(entry),
});
}
body.appendChild(row);
const payload = current.dragPayload?.(entry);
if (payload) makeDraggable(row.querySelector(".file-row-name"), entry, payload);
}
body.scrollTop = scrollPos;
if (entry.kind === "dir" || entry.name === "..") {
makeDropTarget(row, {
onDrop: (payload) => cache.opts.onDropOnEntry?.(entry, payload),
onHoverChange: (hover) => row.classList.toggle("drag-hover", hover),
onHoverHold: () => cache.opts.onHoverOpen?.(entry),
});
}
return row;
}
function updateVisibleRows(cache) {
const sorted = cache.sorted;
if (sorted.length === 0) return;
const { start, end } = computeVisibleRange(
cache.body.scrollTop,
cache.body.clientHeight,
sorted.length,
);
const existing = cache.spacer.querySelectorAll(".file-row");
for (const row of existing) {
const idx = parseInt(row.dataset.index, 10);
if (idx < start || idx >= end) {
row.remove();
}
}
for (let i = start; i < end; i++) {
if (!cache.spacer.querySelector(`.file-row[data-index="${i}"]`)) {
const row = createRowElement(sorted[i], i, cache);
cache.spacer.appendChild(row);
}
}
}
function updateSelection(cache, selected) {
const rows = cache.spacer.querySelectorAll(".file-row");
for (const row of rows) {
const idx = parseInt(row.dataset.index, 10);
const entry = cache.sorted[idx];
if (entry) {
row.classList.toggle("selected", selected.includes(entry.name));
}
}
}
export function renderFileTable(container, opts) {
const cache = initStructure(container);
cache.opts = opts;
updateHeader(cache.header, opts);
const sorted = sortEntries(opts.entries, opts.sort);
const sig = getSignature(sorted, opts.sort);
if (sig !== cache.lastSignature) {
cache.lastSignature = sig;
cache.sorted = sorted;
cache.selectableNames = sorted.filter((e) => e.name !== "..").map((e) => e.name);
cache.spacer.style.height = sorted.length * ROW_HEIGHT + "px";
cache.body.scrollTop = 0;
cache.spacer.innerHTML = "";
updateVisibleRows(cache);
} else {
updateSelection(cache, opts.selected);
}
}

View file

@ -64,7 +64,7 @@ function createPaneCell(pane) {
const closeBtn = document.createElement("button");
closeBtn.className = "pane-close-btn";
closeBtn.title = "Close";
closeBtn.innerHTML = iconMarkup("close", 10);
closeBtn.innerHTML = iconMarkup("close", 13);
closeBtn.addEventListener("click", (e) => { e.stopPropagation(); closePane(pane.id); });
header.appendChild(closeBtn);
}

View file

@ -22,6 +22,7 @@ import {
setLanguage,
setTheme,
} from "../settingsActions.js";
import { openSiteManager } from "./siteManager.js";
const LANG_OPTIONS = [
["en", "English"],
@ -176,6 +177,7 @@ function sectionConnections() {
<div class="settings-list-row-title">${escapeHtml(s.name)}</div>
<div class="settings-list-row-sub">${escapeHtml(s.username)}@${escapeHtml(s.host)}:${s.port}</div>
</div>
<button type="button" class="btn icon-btn btn-ghost" data-action="edit-site" data-id="${s.id}" title="${t("siteManager.editSiteHover")}">${iconMarkup("pen", 13)}</button>
<button type="button" class="btn icon-btn btn-ghost" data-action="delete-site" data-id="${s.id}" title="${t("settings.deleteSiteHover")}">${iconMarkup("trash", 14)}</button>
</div>`,
)
@ -348,7 +350,7 @@ function build() {
<div class="dialog-header">
<span class="icon icon--important">${iconMarkup("settings", 16)}</span>
<span class="dialog-title">${t("settings.title")}</span>
<button type="button" class="dialog-close" data-action="close">${iconMarkup("close", 13)}</button>
<button type="button" class="dialog-close" data-action="close">${iconMarkup("close", 16)}</button>
</div>
<div class="settings-body">
<div class="settings-nav" data-role="nav"></div>
@ -408,6 +410,11 @@ function wireContentActions(root) {
});
});
root.querySelectorAll("[data-action='edit-site']").forEach((btn) => {
btn.addEventListener("click", () => {
openSiteManager(btn.dataset.id);
});
});
root.querySelectorAll("[data-action='delete-site']").forEach((btn) => {
btn.addEventListener("click", async () => {
await deleteSite(btn.dataset.id);

View file

@ -0,0 +1,374 @@
import { state, notify } from "../store.js";
import { t } from "../i18n.js";
import { iconMarkup } from "../icons.js";
import { escapeHtml } from "../dom.js";
import { connectFromSite, PROTOCOL_LABELS } from "../connectActions.js";
import * as ipc from "../ipc.js";
const PROTOCOLS = ["sftp", "ftp", "ftps"];
const DEFAULT_PORTS = { sftp: 22, ftp: 21, ftps: 990 };
let overlayEl = null;
let viewMode = "list";
let form = emptyForm();
let editingId = null;
let saving = false;
let deleteConfirmId = null;
function emptyForm() {
return {
name: "",
protocol: "sftp",
host: "",
port: "",
username: "",
password: "",
keyPath: "",
folder: "",
note: "",
};
}
export function openSiteManager(siteId) {
const site = state.sites.find((s) => s.id === siteId);
if (!site) return;
editingId = siteId;
form = {
name: site.name,
protocol: site.protocol,
host: site.host,
port: String(site.port),
username: site.username,
password: site.password ?? "",
keyPath: site.keyPath ?? "",
folder: site.folder ?? "",
note: site.note ?? "",
};
viewMode = "form";
saving = false;
deleteConfirmId = null;
state.showSettingsDialog = false;
state.showSiteManager = true;
notify();
}
export function openSiteManagerNew() {
editingId = null;
form = emptyForm();
viewMode = "list";
saving = false;
deleteConfirmId = null;
state.showSiteManager = true;
notify();
}
function close() {
state.showSiteManager = false;
overlayEl?.remove();
overlayEl = null;
notify();
}
function switchToList() {
viewMode = "list";
deleteConfirmId = null;
notify();
}
async function saveCurrent() {
if (saving) return;
const name = form.name.trim();
const host = form.host.trim();
const username = form.username.trim();
if (!name || !host || !username) return;
let port = DEFAULT_PORTS[form.protocol];
if (form.port.trim()) {
const parsed = Number(form.port);
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) return;
port = parsed;
}
saving = true;
notify();
const site = {
id: editingId || crypto.randomUUID(),
name,
protocol: form.protocol,
host,
port,
username,
password: form.password || null,
keyPath: form.keyPath || null,
folder: form.folder || null,
note: form.note || null,
};
try {
await ipc.saveSite(site);
state.sites = await ipc.listSites();
state.statusMessage = t("siteManager.saved");
viewMode = "list";
saving = false;
notify();
} catch {
saving = false;
notify();
}
}
async function deleteSiteAction(id) {
const site = state.sites.find((s) => s.id === id);
await ipc.deleteSite(id);
await ipc.deletePassword(id);
state.sites = state.sites.filter((s) => s.id !== id);
if (site) state.statusMessage = `Removed ${site.name}`;
notify();
}
async function browseKeyFile() {
try {
const dialog = window.__TAURI__.dialog;
const path = await dialog.open({ title: "Choose SSH key", multiple: false, directory: false });
if (typeof path === "string") {
form.keyPath = path;
const input = overlayEl?.querySelector("[data-field=keyPath]");
if (input) input.value = path;
}
} catch {
/* dialog plugin unavailable / user cancelled */
}
}
function build() {
const overlay = document.createElement("div");
overlay.className = "overlay";
overlay.addEventListener("mousedown", (e) => {
if (e.target === overlay) close();
});
const dialog = document.createElement("div");
dialog.className = "dialog";
dialog.style.width = "440px";
dialog.addEventListener("mousedown", (e) => e.stopPropagation());
dialog.addEventListener("keydown", (e) => {
if (e.key === "Enter" && viewMode === "form") saveCurrent();
});
dialog.innerHTML = `
<div class="dialog-header">
<span class="icon icon--important">${iconMarkup("serverSquare", 16)}</span>
<span class="dialog-title" data-role="title">${t("siteManager.title")}</span>
<button type="button" class="dialog-close" data-action="close">${iconMarkup("close", 16)}</button>
</div>
<div class="dialog-body" data-role="body" style="max-height:60vh;overflow-y:auto"></div>
`;
dialog.querySelector('[data-action="close"]').addEventListener("click", close);
overlay.appendChild(dialog);
return overlay;
}
function renderList() {
const title = overlayEl.querySelector('[data-role="title"]');
title.textContent = t("siteManager.title");
const body = overlayEl.querySelector('[data-role="body"]');
let html = "";
if (!state.sites.length) {
html += `<div class="settings-empty">${t("siteManager.noSites")}</div>`;
} else {
html += state.sites.map(
(s) => `
<div class="settings-list-row">
<div class="settings-list-row-main">
<div class="settings-list-row-title">
<span class="proto-badge" style="margin-right:6px">${PROTOCOL_LABELS[s.protocol] ?? s.protocol}</span>
${escapeHtml(s.name)}
</div>
<div class="settings-list-row-sub">${escapeHtml(s.username)}@${escapeHtml(s.host)}:${s.port}</div>
</div>
<div class="settings-row" style="flex-shrink:0;gap:4px">
<button type="button" class="btn icon-btn btn-ghost" data-action="sm-connect" data-id="${s.id}" title="${t("connect.connect")}">${iconMarkup("playCircle", 14)}</button>
<button type="button" class="btn icon-btn btn-ghost" data-action="sm-edit" data-id="${s.id}" title="${t("siteManager.editSiteHover")}">${iconMarkup("pen", 13)}</button>
<button type="button" class="btn icon-btn btn-ghost" data-action="sm-delete" data-id="${s.id}" title="${t("settings.deleteSiteHover")}">${iconMarkup("trash", 14)}</button>
</div>
</div>`,
).join("");
}
html += `
<div style="margin-top:12px">
<button type="button" class="btn btn-accent" data-action="sm-add" style="width:100%">
${iconMarkup("addCircle", 14)}<span>${t("siteManager.addSite")}</span>
</button>
</div>
`;
body.innerHTML = html;
body.querySelectorAll("[data-action='sm-connect']").forEach((btn) => {
btn.addEventListener("click", async () => {
const site = state.sites.find((s) => s.id === btn.dataset.id);
if (site) {
close();
await connectFromSite(site);
}
});
});
body.querySelectorAll("[data-action='sm-edit']").forEach((btn) => {
btn.addEventListener("click", () => openSiteManager(btn.dataset.id));
});
body.querySelectorAll("[data-action='sm-delete']").forEach((btn) => {
btn.addEventListener("click", async () => {
await deleteSiteAction(btn.dataset.id);
if (overlayEl) renderList();
});
});
body.querySelector("[data-action='sm-add']").addEventListener("click", () => {
editingId = null;
form = emptyForm();
viewMode = "form";
notify();
});
}
function renderForm() {
const title = overlayEl.querySelector('[data-role="title"]');
title.textContent = editingId ? t("siteManager.editSite") : t("siteManager.addSite");
const body = overlayEl.querySelector('[data-role="body"]');
body.innerHTML = `
<div class="field">
<span class="field-label">${t("siteManager.name")}</span>
<input class="field-input" data-field="name" type="text" value="${escapeHtml(form.name)}" />
</div>
<div class="field">
<span class="field-label">${t("siteManager.protocol")}</span>
<div class="segmented" data-role="protocol">
${PROTOCOLS.map((p) => `<button type="button" class="segmented-item ${form.protocol === p ? "active" : ""}" data-protocol="${p}">${PROTOCOL_LABELS[p]}</button>`).join("")}
</div>
</div>
<div class="field">
<span class="field-label">${t("connect.host")}</span>
<input class="field-input" data-field="host" type="text" value="${escapeHtml(form.host)}" placeholder="example.com" />
</div>
<div class="field-row">
<div class="field" style="flex:1">
<span class="field-label">${t("connect.username")}</span>
<input class="field-input" data-field="username" type="text" value="${escapeHtml(form.username)}" />
</div>
<div class="field" style="width:80px">
<span class="field-label">${t("connect.port")}</span>
<input class="field-input" data-field="port" type="text" value="${escapeHtml(form.port)}" placeholder="${DEFAULT_PORTS[form.protocol]}" />
</div>
</div>
<div class="field">
<span class="field-label">${t("connect.password")}</span>
<div class="field-with-icon">
<input class="field-input" data-field="password" type="password" value="${escapeHtml(form.password)}" />
<button type="button" class="field-icon-btn" data-action="toggle-pass" title="Show/hide">${iconMarkup("eye", 15)}</button>
</div>
</div>
<div data-role="key-section" style="display:${form.protocol === "sftp" ? "block" : "none"}">
<p class="field-hint">${t("siteManager.keyHint")}</p>
<div class="field">
<span class="field-label">${t("connect.keyFile")}</span>
<div class="field-with-btn">
<input class="field-input" data-field="keyPath" type="text" value="${escapeHtml(form.keyPath)}" />
<button type="button" class="btn btn-ghost" data-action="browse" style="flex-shrink:0">${t("connect.browse")}</button>
</div>
</div>
</div>
<div class="field">
<span class="field-label">${t("siteManager.folder")}</span>
<input class="field-input" data-field="folder" type="text" value="${escapeHtml(form.folder)}" />
</div>
<div class="field">
<span class="field-label">${t("siteManager.note")}</span>
<textarea class="field-input" data-field="note" rows="3" style="resize:vertical;padding:6px 10px;height:auto;line-height:1.4;font-family:inherit">${escapeHtml(form.note)}</textarea>
</div>
<div class="dialog-footer" style="margin-top:16px">
<button type="button" class="btn btn-ghost" data-action="cancel">${t("common.cancel")}</button>
<div class="spacer"></div>
<button type="button" class="btn btn-accent" data-action="save" ${saving ? "disabled" : ""}>
${saving ? t("siteManager.saving") : t("common.save")}
</button>
${saving ? '<span class="spinner" style="margin-left:8px"></span>' : ""}
</div>
`;
body.querySelector('[data-action="cancel"]').addEventListener("click", switchToList);
body.querySelector('[data-action="save"]').addEventListener("click", saveCurrent);
body.querySelectorAll("[data-field]").forEach((input) => {
input.addEventListener("input", () => {
form[input.dataset.field] = input.value;
});
});
const passInput = body.querySelector('[data-field="password"]');
const toggleBtn = body.querySelector('[data-action="toggle-pass"]');
if (toggleBtn && passInput) {
toggleBtn.addEventListener("click", () => {
const showing = passInput.type === "text";
passInput.type = showing ? "password" : "text";
toggleBtn.innerHTML = iconMarkup(showing ? "eye" : "eyeClosed", 15);
});
}
const browseBtn = body.querySelector('[data-action="browse"]');
if (browseBtn) browseBtn.addEventListener("click", browseKeyFile);
body.querySelectorAll("[data-protocol]").forEach((btn) => {
btn.addEventListener("click", () => {
const proto = btn.dataset.protocol;
form.protocol = proto;
const portInput = body.querySelector('[data-field="port"]');
if (!form.port) {
portInput.placeholder = String(DEFAULT_PORTS[proto]);
}
body.querySelectorAll("[data-protocol]").forEach((b) => b.classList.toggle("active", b === btn));
const keySection = body.querySelector('[data-role="key-section"]');
if (keySection) keySection.style.display = proto === "sftp" ? "block" : "none";
});
});
setTimeout(() => body.querySelector('[data-field="name"]')?.focus(), 0);
}
export function renderSiteManager() {
if (!state.showSiteManager) {
if (overlayEl) {
overlayEl.remove();
overlayEl = null;
}
return;
}
if (!overlayEl) {
overlayEl = build();
document.body.appendChild(overlayEl);
}
if (viewMode === "list") {
renderList();
} else {
renderForm();
}
}

View file

@ -65,7 +65,7 @@ function createTab(id, label, status, closable) {
const close = document.createElement("button");
close.type = "button";
close.className = "tab-close";
close.innerHTML = iconMarkup("close", 10);
close.innerHTML = iconMarkup("close", 13);
close.title = "Close";
close.addEventListener("click", (e) => {
e.stopPropagation();

View file

@ -1,6 +1,7 @@
import { state, notify, activeTab } from "../store.js";
import { t } from "../i18n.js";
import { iconMarkup } from "../icons.js";
import * as ipc from "../ipc.js";
import { openNew } from "./connectDialog.js";
import { toggle as toggleHistory } from "./historyPopup.js";
import { openMkdir, openDelete, openRename } from "./opDialogs.js";
@ -49,6 +50,7 @@ export function renderToolbar(container) {
<button type="button" class="btn icon-btn btn-ghost" data-action="new-folder" title="${t("common.newFolder")} (${shortcutLabel("N", { shift: true })})" ${state.activePane === "remote" && !connected ? "disabled" : ""}>${iconMarkup("folderAdd", 16)}</button>
<button type="button" class="btn icon-btn btn-ghost" data-action="delete" title="${t("common.delete")} (${isMac ? "⌘⌫" : "Del"})" ${currentSelection().length > 0 ? "" : "disabled"}>${iconMarkup("trash", 16)}</button>
<button type="button" class="btn icon-btn btn-ghost" data-action="rename" title="${t("common.rename")} (${isMac ? "⏎" : "F2"})" ${currentSelection().length === 1 ? "" : "disabled"}>${iconMarkup("pen", 16)}</button>
<button type="button" class="btn icon-btn btn-ghost" data-action="new-window" title="New Window">${iconMarkup("copy", 15)}</button>
<div class="toolbar-spacer"></div>
<button type="button" class="btn icon-btn btn-ghost" data-action="settings" title="${t("common.settings")} (${shortcutLabel(",")})">${iconMarkup("settings", 16)}</button>
<button type="button" class="btn icon-btn btn-toggle ${state.showHistoryPopup ? "active" : ""}" data-action="history" data-history-toggle title="${t("common.history")}">${iconMarkup("history", 16)}</button>
@ -90,6 +92,9 @@ export function renderToolbar(container) {
state.opTarget = state.activePane;
openRename(names[0]);
});
container.querySelector('[data-action="new-window"]').addEventListener("click", () => {
ipc.newWindow();
});
container.querySelector('[data-action="history"]').addEventListener("click", (e) => {
toggleHistory(e.currentTarget);
});

View file

@ -4,6 +4,7 @@ import { iconMarkup } from "../icons.js";
import { renderHistoryRows } from "./historyList.js";
import { openNew } from "./connectDialog.js";
import { openSettings } from "./settingsDialog.js";
import { openSiteManagerNew } from "./siteManager.js";
import { connectFromSite, PROTOCOL_LABELS } from "../connectActions.js";
import { escapeHtml } from "../dom.js";
import { shortcutLabel } from "../platform.js";
@ -31,6 +32,9 @@ export function renderWelcome(container) {
<div class="welcome-card" data-role="sites" style="display:none">
<div class="welcome-card-title">${iconMarkup("star", 12)}<span>${t("welcome.savedSites")}</span></div>
<div data-role="sites-list"></div>
<div style="margin-top:6px;text-align:center">
<button type="button" class="btn btn-ghost" data-action="manage-sites" style="font-size:11px;height:28px">${t("siteManager.manageSites")}</button>
</div>
</div>
<div class="welcome-card" data-role="recent" style="display:none">
<div class="welcome-card-title">${iconMarkup("history", 12)}<span>${t("welcome.recentConnections")}</span></div>
@ -42,6 +46,7 @@ export function renderWelcome(container) {
container.querySelector('[data-action="new-connection"]').addEventListener("click", openNew);
container.querySelector('[data-action="open-settings"]').addEventListener("click", openSettings);
container.querySelector('[data-action="manage-sites"]')?.addEventListener("click", openSiteManagerNew);
updateWelcome(container);
}

View file

@ -112,6 +112,22 @@ export default {
deleteTitleMulti: "Delete {n} items?",
deleteBody: "This can't be undone.",
},
siteManager: {
title: "Site Manager",
addSite: "Add Site",
editSite: "Edit Site",
name: "Name",
protocol: "Protocol",
keyHint: "Only used for SFTP connections.",
folder: "Remote folder (optional)",
note: "Note (optional)",
saving: "Saving…",
saved: "Site saved",
confirmDelete: 'Delete "{name}"?',
noSites: "No saved sites yet.",
manageSites: "Manage Sites",
editSiteHover: "Edit this site",
},
settings: {
title: "Settings",
navGeneral: "General",

View file

@ -85,6 +85,7 @@ export const setPref = (key, value) => invoke("set_pref", { key, value });
export const deletePassword = (siteId) => invoke("delete_password", { siteId });
export const appDataDir = () => invoke("app_data_dir");
export const platformInfo = () => invoke("platform_info");
export const newWindow = () => invoke("new_window");
// --- Events -------------------------------------------------------------------
// Payloads here are snake_case on the wire (worker.rs's ProgressPayload/

View file

@ -15,6 +15,7 @@ import { renderConnectDialog } from "./components/connectDialog.js";
import { renderHistoryPopup } from "./components/historyPopup.js";
import { renderOpDialogs, closeDialog } from "./components/opDialogs.js";
import { renderSettingsDialog } from "./components/settingsDialog.js";
import { renderSiteManager } from "./components/siteManager.js";
const root = document.getElementById("screen-root");
@ -104,6 +105,7 @@ function render() {
renderHistoryPopup();
renderOpDialogs();
renderSettingsDialog();
renderSiteManager();
}
// Settings → Appearance toggles — animated via CSS transitions.
@ -129,6 +131,9 @@ window.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
if (state.dialog) {
closeDialog();
} else if (state.showSiteManager) {
state.showSiteManager = false;
notify();
} else if (state.showConnectDialog && !state.connectLoading) {
state.showConnectDialog = false;
notify();

View file

@ -43,11 +43,14 @@ export async function loadSettings() {
const themeTransitionMs = 300;
let themeTimeout = null;
const DARK_THEMES = new Set(["ember", "midnight", "forest", "ash"]);
export function applyTheme(themeId) {
const html = document.documentElement;
// Add a class to enable CSS transitions during theme switch
html.classList.add("theme-transitioning");
html.setAttribute("data-theme", themeId);
html.style.colorScheme = DARK_THEMES.has(themeId) ? "dark" : "light";
clearTimeout(themeTimeout);
themeTimeout = setTimeout(() => {
html.classList.remove("theme-transitioning");

View file

@ -66,6 +66,7 @@ export const state = {
dialog: null, // { kind: 'mkdir'|'rename'|'delete', ... }
showSettingsDialog: false,
showSiteManager: false,
// Persisted (via get_pref/set_pref) + session-only (panel visibility)
// preferences -- see js/components/settingsDialog.js.
settings: {

View file

@ -4,13 +4,20 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use tauri::State;
use tauri::Manager;
use crate::domain::connection::{ConnectionParams, Protocol};
use crate::domain::error::AppError;
use crate::domain::file_entry::FileEntry;
use crate::domain::site::Site;
use crate::domain::transfer::{TaskState, TransferKind, TransferTask};
use crate::domain::window_state::WindowState;
use crate::fs::remote::RemoteRegistry;
use crate::protocols::{RemoteFs, ftp::FtpClient, sftp::SftpClient};
use crate::protocols::{
RemoteFs,
ftp::{FtpClient, FtpsClient},
sftp::SftpClient,
};
use crate::storage::db::{self, HistoryRow};
use crate::transfer::queue::TransferQueue;
@ -19,12 +26,16 @@ pub struct AppState {
pub registry: Arc<RemoteRegistry>,
pub queue: TransferQueue,
pub max_concurrent: Arc<AtomicU32>,
pub auto_clear_secs: Arc<AtomicU32>,
}
// --- Connections ---------------------------------------------------------
#[tauri::command]
pub async fn connect(state: State<'_, AppState>, params: ConnectionParams) -> Result<String, String> {
pub async fn connect(
state: State<'_, AppState>,
params: ConnectionParams,
) -> Result<String, AppError> {
let registry = state.registry.clone();
let db = state.db.clone();
@ -44,40 +55,44 @@ pub async fn connect(state: State<'_, AppState>, params: ConnectionParams) -> Re
let fs: Arc<dyn RemoteFs> = match params.protocol {
Protocol::Sftp => {
if let Some(key_path) = &params.key_path {
Arc::new(
SftpClient::connect_key(
&params.host,
params.port,
&params.username,
key_path,
params.password.as_deref(),
)
.map_err(|e| e.to_string())?,
)
Arc::new(SftpClient::connect_key(
&params.host,
params.port,
&params.username,
key_path,
params.password.as_deref(),
)?)
} else if let Some(password) = &params.password {
Arc::new(
SftpClient::connect_password(&params.host, params.port, &params.username, password)
.map_err(|e| e.to_string())?,
)
Arc::new(SftpClient::connect_password(
&params.host,
params.port,
&params.username,
password,
)?)
} else {
Arc::new(
SftpClient::connect_auto(&params.host, params.port, &params.username)
.map_err(|e| e.to_string())?,
)
Arc::new(SftpClient::connect_auto(
&params.host,
params.port,
&params.username,
)?)
}
}
Protocol::Ftp => {
let password = params
.password
.clone()
.ok_or("Password is required for FTP connections.")?;
let password = params.password.clone().ok_or_else(|| {
AppError::InvalidInput("Password is required for FTP connections.".into())
})?;
Arc::new(
FtpClient::connect(&params.host, params.port, &params.username, &password)
.await
.map_err(|e| e.to_string())?,
FtpClient::connect(&params.host, params.port, &params.username, &password).await?,
)
}
Protocol::Ftps => {
let password = params.password.clone().ok_or_else(|| {
AppError::InvalidInput("Password is required for FTPS connections.".into())
})?;
Arc::new(
FtpsClient::connect(&params.host, params.port, &params.username, &password).await?,
)
}
Protocol::Ftps => return Err("FTPS not yet implemented".into()),
};
registry.insert(params.id.clone(), fs);
@ -104,11 +119,11 @@ pub fn disconnect(state: State<'_, AppState>, connection_id: String) {
// --- Remote file ops -------------------------------------------------------
fn get_remote(state: &AppState, connection_id: &str) -> Result<Arc<dyn RemoteFs>, String> {
fn get_remote(state: &AppState, connection_id: &str) -> Result<Arc<dyn RemoteFs>, AppError> {
state
.registry
.get(connection_id)
.ok_or_else(|| "connection not found".to_string())
.ok_or_else(|| AppError::NotFound("connection not found".into()))
}
#[tauri::command]
@ -116,11 +131,8 @@ pub async fn remote_list(
state: State<'_, AppState>,
connection_id: String,
path: String,
) -> Result<Vec<FileEntry>, String> {
get_remote(&state, &connection_id)?
.list(&path)
.await
.map_err(|e| e.to_string())
) -> Result<Vec<FileEntry>, AppError> {
Ok(get_remote(&state, &connection_id)?.list(&path).await?)
}
#[tauri::command]
@ -128,11 +140,8 @@ pub async fn remote_stat(
state: State<'_, AppState>,
connection_id: String,
path: String,
) -> Result<FileEntry, String> {
get_remote(&state, &connection_id)?
.stat(&path)
.await
.map_err(|e| e.to_string())
) -> Result<FileEntry, AppError> {
Ok(get_remote(&state, &connection_id)?.stat(&path).await?)
}
#[tauri::command]
@ -140,11 +149,8 @@ pub async fn remote_mkdir(
state: State<'_, AppState>,
connection_id: String,
path: String,
) -> Result<(), String> {
get_remote(&state, &connection_id)?
.mkdir(&path)
.await
.map_err(|e| e.to_string())
) -> Result<(), AppError> {
Ok(get_remote(&state, &connection_id)?.mkdir(&path).await?)
}
#[tauri::command]
@ -153,11 +159,10 @@ pub async fn remote_rename(
connection_id: String,
from: String,
to: String,
) -> Result<(), String> {
get_remote(&state, &connection_id)?
) -> Result<(), AppError> {
Ok(get_remote(&state, &connection_id)?
.rename(&from, &to)
.await
.map_err(|e| e.to_string())
.await?)
}
#[tauri::command]
@ -165,18 +170,15 @@ pub async fn remote_delete(
state: State<'_, AppState>,
connection_id: String,
path: String,
) -> Result<(), String> {
get_remote(&state, &connection_id)?
.delete(&path)
.await
.map_err(|e| e.to_string())
) -> Result<(), AppError> {
Ok(get_remote(&state, &connection_id)?.delete(&path).await?)
}
// --- Local file ops --------------------------------------------------------
#[tauri::command]
pub fn local_list(path: String) -> Result<Vec<FileEntry>, String> {
crate::fs::local::list(&path).map_err(|e| e.to_string())
pub fn local_list(path: String) -> Result<Vec<FileEntry>, AppError> {
Ok(crate::fs::local::list(&path)?)
}
#[tauri::command]
@ -185,28 +187,28 @@ pub fn local_home_dir() -> String {
}
#[tauri::command]
pub fn local_mkdir(path: String) -> Result<(), String> {
crate::fs::local::mkdir(&path).map_err(|e| e.to_string())
pub fn local_mkdir(path: String) -> Result<(), AppError> {
Ok(crate::fs::local::mkdir(&path)?)
}
#[tauri::command]
pub fn local_rename(from: String, to: String) -> Result<(), String> {
crate::fs::local::rename(&from, &to).map_err(|e| e.to_string())
pub fn local_rename(from: String, to: String) -> Result<(), AppError> {
Ok(crate::fs::local::rename(&from, &to)?)
}
#[tauri::command]
pub fn local_delete(path: String) -> Result<(), String> {
crate::fs::local::delete(&path).map_err(|e| e.to_string())
pub fn local_delete(path: String) -> Result<(), AppError> {
Ok(crate::fs::local::delete(&path)?)
}
#[tauri::command]
pub fn local_open(path: String) -> Result<(), String> {
crate::fs::local::open(&path).map_err(|e| e.to_string())
pub fn local_open(path: String) -> Result<(), AppError> {
Ok(crate::fs::local::open(&path)?)
}
#[tauri::command]
pub fn local_move_into(src_path: String, dest_dir: String) -> Result<(), String> {
crate::fs::local::move_into(&src_path, &dest_dir).map_err(|e| e.to_string())
pub fn local_move_into(src_path: String, dest_dir: String) -> Result<(), AppError> {
Ok(crate::fs::local::move_into(&src_path, &dest_dir)?)
}
// --- Transfers --------------------------------------------------------------
@ -222,7 +224,14 @@ pub fn enqueue_transfer(
file_name: String,
total_bytes: u64,
) -> String {
let task = TransferTask::new(kind, connection_id, local_path, remote_path, file_name, total_bytes);
let task = TransferTask::new(
kind,
connection_id,
local_path,
remote_path,
file_name,
total_bytes,
);
let id = task.id.clone();
state.queue.push(task);
id
@ -264,51 +273,79 @@ pub fn set_max_concurrent(state: State<'_, AppState>, n: u32) {
// --- Sites / bookmarks / history / settings ---------------------------------
#[tauri::command]
pub fn list_sites(state: State<'_, AppState>) -> Result<Vec<Site>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
db::get_sites(&conn).map_err(|e| e.to_string())
pub fn list_sites(state: State<'_, AppState>) -> Result<Vec<Site>, AppError> {
let conn = state
.db
.lock()
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(db::get_sites(&conn)?)
}
#[tauri::command]
pub fn save_site(state: State<'_, AppState>, site: Site) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
db::save_site(&conn, &site).map_err(|e| e.to_string())
pub fn save_site(state: State<'_, AppState>, site: Site) -> Result<(), AppError> {
let conn = state
.db
.lock()
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(db::save_site(&conn, &site)?)
}
#[tauri::command]
pub fn delete_site(state: State<'_, AppState>, id: String) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
db::delete_site(&conn, &id).map_err(|e| e.to_string())
pub fn delete_site(state: State<'_, AppState>, id: String) -> Result<(), AppError> {
let conn = state
.db
.lock()
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(db::delete_site(&conn, &id)?)
}
#[tauri::command]
pub fn list_bookmarks(state: State<'_, AppState>) -> Result<Vec<(i64, String, String)>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
db::get_bookmarks(&conn).map_err(|e| e.to_string())
pub fn list_bookmarks(state: State<'_, AppState>) -> Result<Vec<(i64, String, String)>, AppError> {
let conn = state
.db
.lock()
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(db::get_bookmarks(&conn)?)
}
#[tauri::command]
pub fn add_bookmark(state: State<'_, AppState>, name: String, path: String) -> Result<i64, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
db::add_bookmark(&conn, &name, &path).map_err(|e| e.to_string())
pub fn add_bookmark(
state: State<'_, AppState>,
name: String,
path: String,
) -> Result<i64, AppError> {
let conn = state
.db
.lock()
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(db::add_bookmark(&conn, &name, &path)?)
}
#[tauri::command]
pub fn remove_bookmark(state: State<'_, AppState>, id: i64) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
db::remove_bookmark(&conn, id).map_err(|e| e.to_string())
pub fn remove_bookmark(state: State<'_, AppState>, id: i64) -> Result<(), AppError> {
let conn = state
.db
.lock()
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(db::remove_bookmark(&conn, id)?)
}
#[tauri::command]
pub fn list_history(state: State<'_, AppState>) -> Result<Vec<HistoryRow>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
db::get_history(&conn).map_err(|e| e.to_string())
pub fn list_history(state: State<'_, AppState>) -> Result<Vec<HistoryRow>, AppError> {
let conn = state
.db
.lock()
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(db::get_history(&conn)?)
}
#[tauri::command]
pub fn clear_history(state: State<'_, AppState>) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
db::clear_history(&conn).map_err(|e| e.to_string())
pub fn clear_history(state: State<'_, AppState>) -> Result<(), AppError> {
let conn = state
.db
.lock()
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(db::clear_history(&conn)?)
}
#[tauri::command]
@ -317,32 +354,47 @@ pub fn find_history_conn_id(
host: String,
port: u16,
username: String,
) -> Result<Option<String>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
db::find_history_conn_id(&conn, &host, port, &username).map_err(|e| e.to_string())
) -> Result<Option<String>, AppError> {
let conn = state
.db
.lock()
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(db::find_history_conn_id(&conn, &host, port, &username)?)
}
#[tauri::command]
pub fn get_pref(state: State<'_, AppState>, key: String) -> Result<Option<String>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
pub fn get_pref(state: State<'_, AppState>, key: String) -> Result<Option<String>, AppError> {
let conn = state
.db
.lock()
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(db::get_setting(&conn, &key))
}
#[tauri::command]
pub fn set_pref(state: State<'_, AppState>, key: String, value: String) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
db::set_setting(&conn, &key, &value).map_err(|e| e.to_string())
pub fn set_pref(state: State<'_, AppState>, key: String, value: String) -> Result<(), AppError> {
if key == "auto_clear_completed_secs" && let Ok(secs) = value.parse::<u32>() {
state.auto_clear_secs.store(secs, Ordering::Relaxed);
}
let conn = state
.db
.lock()
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(db::set_setting(&conn, &key, &value)?)
}
/// Удалить пароль из сохранённого сайта (очистить поле password в БД).
#[tauri::command]
pub fn delete_password(state: State<'_, AppState>, site_id: String) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
pub fn delete_password(state: State<'_, AppState>, site_id: String) -> Result<(), AppError> {
let conn = state
.db
.lock()
.map_err(|e| AppError::Internal(e.to_string()))?;
conn.execute(
"UPDATE sites SET password = NULL WHERE id = ?1",
rusqlite::params![site_id],
)
.map_err(|e| e.to_string())?;
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(())
}
@ -381,5 +433,110 @@ pub fn platform_info() -> PlatformInfo {
} else {
(None, None)
};
PlatformInfo { os, desktop, session }
PlatformInfo {
os,
desktop,
session,
}
}
// --- Multi-window support ---------------------------------------------------
#[tauri::command]
pub fn save_window_state(
state: State<'_, AppState>,
window_state: WindowState,
) -> Result<(), AppError> {
let conn = state
.db
.lock()
.map_err(|e| AppError::Internal(e.to_string()))?;
let key = format!("window_state_{}", window_state.label);
let json =
serde_json::to_string(&window_state).map_err(|e| AppError::Internal(e.to_string()))?;
db::set_setting(&conn, &key, &json)?;
Ok(())
}
#[tauri::command]
pub fn load_window_state(
state: State<'_, AppState>,
label: String,
) -> Result<Option<WindowState>, AppError> {
let conn = state
.db
.lock()
.map_err(|e| AppError::Internal(e.to_string()))?;
let key = format!("window_state_{}", label);
match db::get_setting(&conn, &key) {
Some(json) => {
let ws: WindowState =
serde_json::from_str(&json).map_err(|e| AppError::Internal(e.to_string()))?;
Ok(Some(ws))
}
None => Ok(None),
}
}
#[tauri::command]
pub async fn new_window(
app_handle: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
let label = format!("browser-{}", uuid::Uuid::new_v4());
let window = tauri::WebviewWindowBuilder::new(
&app_handle,
&label,
tauri::WebviewUrl::App("/".into()),
)
.title("Wherry")
.inner_size(1280.0, 800.0)
.min_inner_size(900.0, 600.0)
.center()
.build()
.map_err(|e| e.to_string())?;
let db = state.db.clone();
let app_handle = app_handle.clone();
let label_clone = label.clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { api: _, .. } = event
&& let Some(win) = app_handle.get_webview_window(&label_clone)
{
save_window_state_internal(&win, &db);
}
});
Ok(())
}
pub(crate) fn save_window_state_internal(
window: &tauri::WebviewWindow,
db: &Arc<std::sync::Mutex<rusqlite::Connection>>,
) {
let label = window.label().to_string();
let pos = window.outer_position().ok();
let size = window
.outer_size()
.unwrap_or(tauri::PhysicalSize {
width: 1280,
height: 800,
});
let maximized = window.is_maximized().unwrap_or(false);
let ws = WindowState {
label: label.clone(),
x: pos.map(|p| p.x),
y: pos.map(|p| p.y),
width: size.width,
height: size.height,
maximized,
};
if let Ok(conn) = db.lock() {
let key = format!("window_state_{}", label);
if let Ok(json) = serde_json::to_string(&ws) {
let _ = db::set_setting(&conn, &key, &json);
}
}
}

View file

@ -30,3 +30,53 @@ pub enum ConnectionStatus {
Connecting,
Error(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_protocol_serde() {
let sftp = Protocol::Sftp;
let json = serde_json::to_string(&sftp).unwrap();
assert_eq!(json, "\"sftp\"");
let deserialized: Protocol = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized, Protocol::Sftp);
let ftp = Protocol::Ftp;
let json = serde_json::to_string(&ftp).unwrap();
assert_eq!(json, "\"ftp\"");
let ftps = Protocol::Ftps;
let json = serde_json::to_string(&ftps).unwrap();
assert_eq!(json, "\"ftps\"");
}
#[test]
fn test_connection_params() {
let params = ConnectionParams {
id: "test-id".into(),
label: "Test".into(),
protocol: Protocol::Sftp,
host: "example.com".into(),
port: 22,
username: "user".into(),
password: Some("pass".into()),
key_path: None,
};
assert_eq!(params.id, "test-id");
assert_eq!(params.protocol, Protocol::Sftp);
assert_eq!(params.port, 22);
assert!(params.password.is_some());
assert!(params.key_path.is_none());
}
#[test]
fn test_connection_status_serde() {
let json = serde_json::to_string(&ConnectionStatus::Connected).unwrap();
assert_eq!(json, "\"connected\"");
let json = serde_json::to_string(&ConnectionStatus::Error("timeout".into())).unwrap();
assert_eq!(json, "{\"error\":\"timeout\"}");
}
}

105
src/domain/error.rs Normal file
View file

@ -0,0 +1,105 @@
use serde::Serialize;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum AppError {
NotFound(String),
AuthFailed(String),
Io(String),
Protocol(String),
InvalidInput(String),
AlreadyExists(String),
Internal(String),
}
impl std::fmt::Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AppError::NotFound(msg) => write!(f, "Not found: {msg}"),
AppError::AuthFailed(msg) => write!(f, "Authentication failed: {msg}"),
AppError::Io(msg) => write!(f, "IO error: {msg}"),
AppError::Protocol(msg) => write!(f, "Protocol error: {msg}"),
AppError::InvalidInput(msg) => write!(f, "Invalid input: {msg}"),
AppError::AlreadyExists(msg) => write!(f, "Already exists: {msg}"),
AppError::Internal(msg) => write!(f, "Internal error: {msg}"),
}
}
}
impl std::error::Error for AppError {}
impl From<anyhow::Error> for AppError {
fn from(e: anyhow::Error) -> Self {
AppError::Internal(e.to_string())
}
}
impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self {
AppError::Io(e.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_app_error_display() {
assert_eq!(
AppError::NotFound("file".into()).to_string(),
"Not found: file"
);
assert_eq!(
AppError::AuthFailed("bad creds".into()).to_string(),
"Authentication failed: bad creds"
);
assert_eq!(
AppError::Io("disk full".into()).to_string(),
"IO error: disk full"
);
assert_eq!(
AppError::Protocol("timeout".into()).to_string(),
"Protocol error: timeout"
);
assert_eq!(
AppError::InvalidInput("bad".into()).to_string(),
"Invalid input: bad"
);
assert_eq!(
AppError::AlreadyExists("file".into()).to_string(),
"Already exists: file"
);
assert_eq!(
AppError::Internal("oops".into()).to_string(),
"Internal error: oops"
);
}
#[test]
fn test_app_error_serialize() {
let err = AppError::NotFound("missing".into());
let json = serde_json::to_string(&err).unwrap();
assert!(json.contains("notFound"));
assert!(json.contains("missing"));
}
#[test]
fn test_app_error_from_anyhow() {
let app_err: AppError = anyhow::anyhow!("something went wrong").into();
match app_err {
AppError::Internal(msg) => assert_eq!(msg, "something went wrong"),
_ => panic!("expected Internal variant"),
}
}
#[test]
fn test_app_error_from_io() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
let app_err: AppError = io_err.into();
match app_err {
AppError::Io(msg) => assert!(msg.contains("file not found")),
_ => panic!("expected Io variant"),
}
}
}

View file

@ -18,3 +18,49 @@ pub struct FileEntry {
pub modified: Option<i64>, // unix timestamp
pub permissions: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_entry_kind_serde() {
assert_eq!(serde_json::to_string(&EntryKind::File).unwrap(), "\"file\"");
assert_eq!(serde_json::to_string(&EntryKind::Dir).unwrap(), "\"dir\"");
assert_eq!(
serde_json::to_string(&EntryKind::Symlink).unwrap(),
"\"symlink\""
);
}
#[test]
fn test_file_entry() {
let entry = FileEntry {
name: "test.txt".into(),
path: "/tmp/test.txt".into(),
kind: EntryKind::File,
size: Some(1024),
modified: Some(1234567890),
permissions: Some("rw-r--r--".into()),
};
assert_eq!(entry.name, "test.txt");
assert_eq!(entry.kind, EntryKind::File);
assert_eq!(entry.size, Some(1024));
}
#[test]
fn test_file_entry_serde() {
let entry = FileEntry {
name: "f".into(),
path: "/f".into(),
kind: EntryKind::Dir,
size: None,
modified: None,
permissions: None,
};
let json = serde_json::to_string(&entry).unwrap();
let deserialized: FileEntry = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.name, "f");
assert_eq!(deserialized.kind, EntryKind::Dir);
}
}

View file

@ -1,4 +1,6 @@
pub mod connection;
pub mod error;
pub mod file_entry;
pub mod site;
pub mod transfer;
pub mod window_state;

View file

@ -15,3 +15,56 @@ pub struct Site {
pub folder: Option<String>,
pub note: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::connection::Protocol;
#[test]
fn test_site_serde() {
let site = Site {
id: "site-1".into(),
name: "My Server".into(),
protocol: Protocol::Sftp,
host: "example.com".into(),
port: 22,
username: "admin".into(),
password: Some("secret".into()),
key_path: None,
folder: Some("/remote".into()),
note: Some("my note".into()),
};
let json = serde_json::to_string(&site).unwrap();
let deserialized: Site = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.id, site.id);
assert_eq!(deserialized.name, site.name);
assert_eq!(deserialized.protocol, site.protocol);
assert_eq!(deserialized.host, site.host);
assert_eq!(deserialized.port, site.port);
assert_eq!(deserialized.username, site.username);
assert_eq!(deserialized.password, site.password);
assert_eq!(deserialized.folder, site.folder);
assert_eq!(deserialized.note, site.note);
}
#[test]
fn test_site_minimal() {
let site = Site {
id: "site-2".into(),
name: "Minimal".into(),
protocol: Protocol::Ftp,
host: "ftp.example.com".into(),
port: 21,
username: "user".into(),
password: None,
key_path: None,
folder: None,
note: None,
};
let json = serde_json::to_string(&site).unwrap();
let deserialized: Site = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.protocol, Protocol::Ftp);
assert!(deserialized.password.is_none());
}
}

View file

@ -83,3 +83,88 @@ impl TransferTask {
(self.transferred_bytes as f64 / self.total_bytes as f64) * 100.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_transfer_task_new() {
let task = TransferTask::new(
TransferKind::Download,
"conn-1".into(),
"/local".into(),
"/remote".into(),
"file.txt".into(),
1000,
);
assert!(!task.id.is_empty());
assert_eq!(task.kind, TransferKind::Download);
assert_eq!(task.total_bytes, 1000);
assert_eq!(task.transferred_bytes, 0);
assert_eq!(task.state, TaskState::Queued);
assert!(task.speed.is_none());
assert!(task.eta_secs.is_none());
}
#[test]
fn test_progress_pct() {
let mut task = TransferTask::new(
TransferKind::Upload,
"conn-1".into(),
"/local".into(),
"/remote".into(),
"file.txt".into(),
200,
);
assert_eq!(task.progress_pct(), 0.0);
task.transferred_bytes = 50;
assert!((task.progress_pct() - 25.0).abs() < f64::EPSILON);
task.transferred_bytes = 200;
assert!((task.progress_pct() - 100.0).abs() < f64::EPSILON);
}
#[test]
fn test_progress_pct_zero_total() {
let task = TransferTask::new(
TransferKind::Upload,
"conn-1".into(),
"/local".into(),
"/remote".into(),
"file.txt".into(),
0,
);
assert_eq!(task.progress_pct(), 0.0);
}
#[test]
fn test_task_state_display() {
assert_eq!(TaskState::Queued.to_string(), "queued");
assert_eq!(TaskState::Running.to_string(), "running");
assert_eq!(TaskState::Paused.to_string(), "paused");
assert_eq!(TaskState::Cancelled.to_string(), "cancelled");
assert_eq!(TaskState::Completed.to_string(), "completed");
assert_eq!(TaskState::Failed("err".into()).to_string(), "failed: err");
assert_eq!(TaskState::Retrying(3).to_string(), "retrying(3)");
}
#[test]
fn test_transfer_kind_serde() {
assert_eq!(
serde_json::to_string(&TransferKind::Upload).unwrap(),
"\"upload\""
);
assert_eq!(
serde_json::to_string(&TransferKind::Download).unwrap(),
"\"download\""
);
}
#[test]
fn test_task_state_serde() {
let json = serde_json::to_string(&TaskState::Retrying(2)).unwrap();
assert_eq!(json, "{\"retrying\":2}");
let deserialized: TaskState = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized, TaskState::Retrying(2));
}
}

View file

@ -0,0 +1,12 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WindowState {
pub label: String,
pub x: Option<i32>,
pub y: Option<i32>,
pub width: u32,
pub height: u32,
pub maximized: bool,
}

View file

@ -101,3 +101,117 @@ pub fn move_into(src_path: &str, dest_dir: &str) -> Result<()> {
fs::rename(src_path, dest)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
fn test_dir() -> std::path::PathBuf {
let n = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("wherry-test-{}-{}", std::process::id(), n));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn test_list_empty_dir() {
let dir = test_dir();
let entries = list(&dir.to_string_lossy()).unwrap();
assert!(entries.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_mkdir_and_list() {
let dir = test_dir();
let sub = dir.join("subdir");
mkdir(&sub.to_string_lossy()).unwrap();
let entries = list(&dir.to_string_lossy()).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "subdir");
assert_eq!(entries[0].kind, EntryKind::Dir);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_rename_file() {
let dir = test_dir();
let old = dir.join("old.txt");
let new = dir.join("new.txt");
std::fs::write(&old, "hello").unwrap();
rename(&old.to_string_lossy(), &new.to_string_lossy()).unwrap();
assert!(!old.exists());
assert!(new.exists());
assert_eq!(std::fs::read_to_string(&new).unwrap(), "hello");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_delete_file() {
let dir = test_dir();
let file = dir.join("delete_me.txt");
std::fs::write(&file, "content").unwrap();
delete(&file.to_string_lossy()).unwrap();
assert!(!file.exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_delete_dir() {
let dir = test_dir();
let sub = dir.join("subdir");
std::fs::create_dir(&sub).unwrap();
delete(&sub.to_string_lossy()).unwrap();
assert!(!sub.exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_home_dir() {
let home = home_dir();
assert!(!home.is_empty());
}
#[test]
fn test_move_into() {
let dir = test_dir();
let src = dir.join("source.txt");
let dest_dir = dir.join("dest");
std::fs::create_dir(&dest_dir).unwrap();
std::fs::write(&src, "move me").unwrap();
move_into(&src.to_string_lossy(), &dest_dir.to_string_lossy()).unwrap();
assert!(!src.exists());
assert!(dest_dir.join("source.txt").exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_list_sorting() {
let dir = test_dir();
std::fs::write(dir.join("a.txt"), "").unwrap();
std::fs::create_dir(dir.join("b_dir")).unwrap();
std::fs::write(dir.join("c.txt"), "").unwrap();
let entries = list(&dir.to_string_lossy()).unwrap();
// dirs first, then files alphabetically
assert_eq!(entries[0].name, "b_dir");
assert_eq!(entries[0].kind, EntryKind::Dir);
assert_eq!(entries[1].name, "a.txt");
assert_eq!(entries[2].name, "c.txt");
let _ = std::fs::remove_dir_all(&dir);
}
}

View file

@ -23,3 +23,81 @@ impl RemoteRegistry {
self.connections.lock().unwrap().remove(id);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::file_entry::{EntryKind, FileEntry};
use crate::protocols::ProgressAction;
struct MockFs;
#[async_trait::async_trait]
impl RemoteFs for MockFs {
async fn list(&self, _path: &str) -> anyhow::Result<Vec<FileEntry>> {
Ok(vec![])
}
async fn upload_with_progress(
&self,
_local: &str,
_remote: &str,
_on_progress: Option<Box<dyn Fn(u64) -> ProgressAction + Send>>,
) -> anyhow::Result<()> {
Ok(())
}
async fn download_with_progress(
&self,
_remote: &str,
_local: &str,
_on_progress: Option<Box<dyn Fn(u64) -> ProgressAction + Send>>,
) -> anyhow::Result<()> {
Ok(())
}
async fn mkdir(&self, _path: &str) -> anyhow::Result<()> {
Ok(())
}
async fn rename(&self, _from: &str, _to: &str) -> anyhow::Result<()> {
Ok(())
}
async fn delete(&self, _path: &str) -> anyhow::Result<()> {
Ok(())
}
async fn stat(&self, _path: &str) -> anyhow::Result<FileEntry> {
Ok(FileEntry {
name: "test".into(),
path: "/test".into(),
kind: EntryKind::File,
size: None,
modified: None,
permissions: None,
})
}
}
#[test]
fn test_insert_get_remove() {
let registry = RemoteRegistry::default();
let mock = Arc::new(MockFs);
registry.insert("conn-1".into(), mock.clone());
assert!(registry.get("conn-1").is_some());
registry.remove("conn-1");
assert!(registry.get("conn-1").is_none());
}
#[test]
fn test_get_nonexistent() {
let registry = RemoteRegistry::default();
assert!(registry.get("unknown").is_none());
}
#[test]
fn test_insert_multiple() {
let registry = RemoteRegistry::default();
registry.insert("a".into(), Arc::new(MockFs));
registry.insert("b".into(), Arc::new(MockFs));
assert!(registry.get("a").is_some());
assert!(registry.get("b").is_some());
}
}

File diff suppressed because it is too large Load diff

View file

@ -7,8 +7,18 @@ pub const ENTRIES: &[Entry] = &[
(
"common.cancel",
[
"Cancel", "Отмена", "Cancelar", "Annuler", "Abbrechen", "Annulla", "Cancelar",
"Anuluj", "取消", "キャンセル", "취소", "İptal",
"Cancel",
"Отмена",
"Cancelar",
"Annuler",
"Abbrechen",
"Annulla",
"Cancelar",
"Anuluj",
"取消",
"キャンセル",
"취소",
"İptal",
],
),
(
@ -20,8 +30,18 @@ pub const ENTRIES: &[Entry] = &[
(
"common.delete",
[
"Delete", "Удалить", "Eliminar", "Supprimer", "Löschen", "Elimina", "Excluir",
"Usuń", "删除", "削除", "삭제", "Sil",
"Delete",
"Удалить",
"Eliminar",
"Supprimer",
"Löschen",
"Elimina",
"Excluir",
"Usuń",
"删除",
"削除",
"삭제",
"Sil",
],
),
(
@ -44,29 +64,69 @@ pub const ENTRIES: &[Entry] = &[
(
"common.edit",
[
"Edit", "Изменить", "Editar", "Modifier", "Bearbeiten", "Modifica", "Editar",
"Edytuj", "编辑", "編集", "편집", "Düzenle",
"Edit",
"Изменить",
"Editar",
"Modifier",
"Bearbeiten",
"Modifica",
"Editar",
"Edytuj",
"编辑",
"編集",
"편집",
"Düzenle",
],
),
(
"common.save",
[
"Save", "Сохранить", "Guardar", "Enregistrer", "Speichern", "Salva", "Salvar",
"Zapisz", "保存", "保存", "저장", "Kaydet",
"Save",
"Сохранить",
"Guardar",
"Enregistrer",
"Speichern",
"Salva",
"Salvar",
"Zapisz",
"保存",
"保存",
"저장",
"Kaydet",
],
),
(
"common.open",
[
"Open", "Открыть", "Abrir", "Ouvrir", "Öffnen", "Apri", "Abrir", "Otwórz", "打开",
"開く", "열기", "Aç",
"Open",
"Открыть",
"Abrir",
"Ouvrir",
"Öffnen",
"Apri",
"Abrir",
"Otwórz",
"打开",
"開く",
"열기",
"Aç",
],
),
(
"common.close",
[
"Close", "Закрыть", "Cerrar", "Fermer", "Schließen", "Chiudi", "Fechar", "Zamknij",
"关闭", "閉じる", "닫기", "Kapat",
"Close",
"Закрыть",
"Cerrar",
"Fermer",
"Schließen",
"Chiudi",
"Fechar",
"Zamknij",
"关闭",
"閉じる",
"닫기",
"Kapat",
],
),
(
@ -106,8 +166,18 @@ pub const ENTRIES: &[Entry] = &[
(
"common.connect",
[
"Connect", "Подключить", "Conectar", "Connecter", "Verbinden", "Connetti",
"Conectar", "Połącz", "连接", "接続", "연결", "Bağlan",
"Connect",
"Подключить",
"Conectar",
"Connecter",
"Verbinden",
"Connetti",
"Conectar",
"Połącz",
"连接",
"接続",
"연결",
"Bağlan",
],
),
(
@ -130,8 +200,18 @@ pub const ENTRIES: &[Entry] = &[
(
"common.host",
[
"Host", "Хост", "Host", "Hôte", "Host", "Host", "Host", "Host", "主机", "ホスト",
"호스트", "Sunucu",
"Host",
"Хост",
"Host",
"Hôte",
"Host",
"Host",
"Host",
"Host",
"主机",
"ホスト",
"호스트",
"Sunucu",
],
),
(
@ -154,43 +234,103 @@ pub const ENTRIES: &[Entry] = &[
(
"common.password",
[
"Password", "Пароль", "Contraseña", "Mot de passe", "Passwort", "Password", "Senha",
"Hasło", "密码", "パスワード", "비밀번호", "Parola",
"Password",
"Пароль",
"Contraseña",
"Mot de passe",
"Passwort",
"Password",
"Senha",
"Hasło",
"密码",
"パスワード",
"비밀번호",
"Parola",
],
),
(
"common.port",
[
"Port", "Порт", "Puerto", "Port", "Port", "Porta", "Porta", "Port", "端口", "ポート",
"포트", "Port",
"Port",
"Порт",
"Puerto",
"Port",
"Port",
"Porta",
"Porta",
"Port",
"端口",
"ポート",
"포트",
"Port",
],
),
(
"common.label",
[
"Label", "Метка", "Etiqueta", "Étiquette", "Bezeichnung", "Etichetta", "Rótulo",
"Etykieta", "标签", "ラベル", "라벨", "Etiket",
"Label",
"Метка",
"Etiqueta",
"Étiquette",
"Bezeichnung",
"Etichetta",
"Rótulo",
"Etykieta",
"标签",
"ラベル",
"라벨",
"Etiket",
],
),
(
"common.refresh",
[
"Refresh", "Обновить", "Actualizar", "Actualiser", "Aktualisieren", "Aggiorna",
"Atualizar", "Odśwież", "刷新", "更新", "새로고침", "Yenile",
"Refresh",
"Обновить",
"Actualizar",
"Actualiser",
"Aktualisieren",
"Aggiorna",
"Atualizar",
"Odśwież",
"刷新",
"更新",
"새로고침",
"Yenile",
],
),
(
"common.upload",
[
"Upload", "Загрузить", "Subir", "Envoyer", "Hochladen", "Carica", "Enviar",
"Prześlij", "上传", "アップロード", "업로드", "Yükle",
"Upload",
"Загрузить",
"Subir",
"Envoyer",
"Hochladen",
"Carica",
"Enviar",
"Prześlij",
"上传",
"アップロード",
"업로드",
"Yükle",
],
),
(
"common.download",
[
"Download", "Скачать", "Descargar", "Télécharger", "Herunterladen", "Scarica",
"Baixar", "Pobierz", "下载", "ダウンロード", "다운로드", "İndir",
"Download",
"Скачать",
"Descargar",
"Télécharger",
"Herunterladen",
"Scarica",
"Baixar",
"Pobierz",
"下载",
"ダウンロード",
"다운로드",
"İndir",
],
),
(
@ -213,15 +353,35 @@ pub const ENTRIES: &[Entry] = &[
(
"common.history",
[
"History", "История", "Historial", "Historique", "Verlauf", "Cronologia",
"Histórico", "Historia", "历史记录", "履歴", "기록", "Geçmiş",
"History",
"История",
"Historial",
"Historique",
"Verlauf",
"Cronologia",
"Histórico",
"Historia",
"历史记录",
"履歴",
"기록",
"Geçmiş",
],
),
(
"common.search",
[
"Search", "Поиск", "Buscar", "Rechercher", "Suchen", "Cerca", "Pesquisar", "Szukaj",
"搜索", "検索", "검색", "Ara",
"Search",
"Поиск",
"Buscar",
"Rechercher",
"Suchen",
"Cerca",
"Pesquisar",
"Szukaj",
"搜索",
"検索",
"검색",
"Ara",
],
),
(
@ -233,7 +393,17 @@ pub const ENTRIES: &[Entry] = &[
(
"common.no",
[
"No", "Нет", "No", "Non", "Nein", "No", "Não", "Nie", "否", "いいえ", "아니요",
"No",
"Нет",
"No",
"Non",
"Nein",
"No",
"Não",
"Nie",
"否",
"いいえ",
"아니요",
"Hayır",
],
),

View file

@ -39,8 +39,18 @@ pub const ENTRIES: &[Entry] = &[
(
"dialogs.browse",
[
"Browse…", "Обзор…", "Examinar…", "Parcourir…", "Durchsuchen…", "Sfoglia…",
"Procurar…", "Przeglądaj…", "浏览…", "参照…", "찾아보기…", "Gözat…",
"Browse…",
"Обзор…",
"Examinar…",
"Parcourir…",
"Durchsuchen…",
"Sfoglia…",
"Procurar…",
"Przeglądaj…",
"浏览…",
"参照…",
"찾아보기…",
"Gözat…",
],
),
(

View file

@ -5,8 +5,18 @@ pub const ENTRIES: &[Entry] = &[
(
"panels.local_label",
[
"LOCAL", "ЛОКАЛЬНО", "LOCAL", "LOCAL", "LOKAL", "LOCALE", "LOCAL", "LOKALNE", "本地",
"ローカル", "로컬", "YEREL",
"LOCAL",
"ЛОКАЛЬНО",
"LOCAL",
"LOCAL",
"LOKAL",
"LOCALE",
"LOCAL",
"LOKALNE",
"本地",
"ローカル",
"로컬",
"YEREL",
],
),
(
@ -94,29 +104,69 @@ pub const ENTRIES: &[Entry] = &[
(
"panels.col_size",
[
"Size", "Размер", "Tamaño", "Taille", "Größe", "Dimensione", "Tamanho", "Rozmiar",
"大小", "サイズ", "크기", "Boyut",
"Size",
"Размер",
"Tamaño",
"Taille",
"Größe",
"Dimensione",
"Tamanho",
"Rozmiar",
"大小",
"サイズ",
"크기",
"Boyut",
],
),
(
"panels.type_dir",
[
"Dir", "Папка", "Carpeta", "Dossier", "Ordner", "Cartella", "Pasta", "Folder",
"文件夹", "フォルダ", "폴더", "Klasör",
"Dir",
"Папка",
"Carpeta",
"Dossier",
"Ordner",
"Cartella",
"Pasta",
"Folder",
"文件夹",
"フォルダ",
"폴더",
"Klasör",
],
),
(
"panels.type_file",
[
"File", "Файл", "Archivo", "Fichier", "Datei", "File", "Arquivo", "Plik", "文件",
"ファイル", "파일", "Dosya",
"File",
"Файл",
"Archivo",
"Fichier",
"Datei",
"File",
"Arquivo",
"Plik",
"文件",
"ファイル",
"파일",
"Dosya",
],
),
(
"panels.type_link",
[
"Link", "Ссылка", "Enlace", "Lien", "Link", "Collegamento", "Link", "Link", "链接",
"リンク", "링크", "Bağlantı",
"Link",
"Ссылка",
"Enlace",
"Lien",
"Link",
"Collegamento",
"Link",
"Link",
"链接",
"リンク",
"링크",
"Bağlantı",
],
),
(
@ -139,8 +189,18 @@ pub const ENTRIES: &[Entry] = &[
(
"panels.sidebar.drives",
[
"DRIVES", "ДИСКИ", "UNIDADES", "DISQUES", "LAUFWERKE", "UNITÀ", "UNIDADES", "DYSKI",
"磁盘", "ドライブ", "드라이브", "SÜRÜCÜLER",
"DRIVES",
"ДИСКИ",
"UNIDADES",
"DISQUES",
"LAUFWERKE",
"UNITÀ",
"UNIDADES",
"DYSKI",
"磁盘",
"ドライブ",
"드라이브",
"SÜRÜCÜLER",
],
),
(
@ -197,8 +257,18 @@ pub const ENTRIES: &[Entry] = &[
(
"panels.sidebar.qa_home",
[
"Home", "Домой", "Inicio", "Accueil", "Start", "Home", "Início", "Dom", "主目录",
"ホーム", "홈", "Ana Sayfa",
"Home",
"Домой",
"Inicio",
"Accueil",
"Start",
"Home",
"Início",
"Dom",
"主目录",
"ホーム",
"홈",
"Ana Sayfa",
],
),
(
@ -221,8 +291,18 @@ pub const ENTRIES: &[Entry] = &[
(
"panels.sidebar.qa_documents",
[
"Documents", "Документы", "Documentos", "Documents", "Dokumente", "Documenti",
"Documentos", "Dokumenty", "文稿", "書類", "문서", "Belgeler",
"Documents",
"Документы",
"Documentos",
"Documents",
"Dokumente",
"Documenti",
"Documentos",
"Dokumenty",
"文稿",
"書類",
"문서",
"Belgeler",
],
),
(
@ -245,22 +325,52 @@ pub const ENTRIES: &[Entry] = &[
(
"panels.sidebar.qa_pictures",
[
"Pictures", "Изображения", "Imágenes", "Images", "Bilder", "Immagini", "Imagens",
"Obrazy", "图片", "ピクチャ", "사진", "Resimler",
"Pictures",
"Изображения",
"Imágenes",
"Images",
"Bilder",
"Immagini",
"Imagens",
"Obrazy",
"图片",
"ピクチャ",
"사진",
"Resimler",
],
),
(
"panels.sidebar.qa_music",
[
"Music", "Музыка", "Música", "Musique", "Musik", "Musica", "Música", "Muzyka",
"音乐", "ミュージック", "음악", "Müzik",
"Music",
"Музыка",
"Música",
"Musique",
"Musik",
"Musica",
"Música",
"Muzyka",
"音乐",
"ミュージック",
"음악",
"Müzik",
],
),
(
"panels.sidebar.qa_videos",
[
"Videos", "Видео", "Vídeos", "Vidéos", "Videos", "Video", "Vídeos", "Filmy", "影片",
"ムービー", "동영상", "Videolar",
"Videos",
"Видео",
"Vídeos",
"Vidéos",
"Videos",
"Video",
"Vídeos",
"Filmy",
"影片",
"ムービー",
"동영상",
"Videolar",
],
),
(

View file

@ -24,8 +24,18 @@ pub const ENTRIES: &[Entry] = &[
(
"settings.nav.group_general",
[
"GENERAL", "ОБЩЕЕ", "GENERAL", "GÉNÉRAL", "ALLGEMEIN", "GENERALE", "GERAL", "OGÓLNE",
"通用", "一般", "일반", "GENEL",
"GENERAL",
"ОБЩЕЕ",
"GENERAL",
"GÉNÉRAL",
"ALLGEMEIN",
"GENERALE",
"GERAL",
"OGÓLNE",
"通用",
"一般",
"일반",
"GENEL",
],
),
(
@ -65,15 +75,35 @@ pub const ENTRIES: &[Entry] = &[
(
"settings.nav.group_about",
[
"ABOUT", "О ПРОГРАММЕ", "ACERCA DE", "À PROPOS", "ÜBER", "INFORMAZIONI", "SOBRE",
"O PROGRAMIE", "关于", "情報", "정보", "HAKKINDA",
"ABOUT",
"О ПРОГРАММЕ",
"ACERCA DE",
"À PROPOS",
"ÜBER",
"INFORMAZIONI",
"SOBRE",
"O PROGRAMIE",
"关于",
"情報",
"정보",
"HAKKINDA",
],
),
(
"settings.nav.general",
[
"General", "Общие", "General", "Général", "Allgemein", "Generale", "Geral", "Ogólne",
"通用", "一般", "일반", "Genel",
"General",
"Общие",
"General",
"Général",
"Allgemein",
"Generale",
"Geral",
"Ogólne",
"通用",
"一般",
"일반",
"Genel",
],
),
(
@ -113,8 +143,18 @@ pub const ENTRIES: &[Entry] = &[
(
"settings.nav.history",
[
"History", "История", "Historial", "Historique", "Verlauf", "Cronologia",
"Histórico", "Historia", "历史记录", "履歴", "기록", "Geçmiş",
"History",
"История",
"Historial",
"Historique",
"Verlauf",
"Cronologia",
"Histórico",
"Historia",
"历史记录",
"履歴",
"기록",
"Geçmiş",
],
),
(
@ -154,8 +194,18 @@ pub const ENTRIES: &[Entry] = &[
(
"settings.nav.about",
[
"About", "О программе", "Acerca de", "À propos", "Über", "Informazioni", "Sobre",
"O programie", "关于", "情報", "정보", "Hakkında",
"About",
"О программе",
"Acerca de",
"À propos",
"Über",
"Informazioni",
"Sobre",
"O programie",
"关于",
"情報",
"정보",
"Hakkında",
],
),
// ── General ──────────────────────────────────────────────────────────
@ -340,8 +390,17 @@ pub const ENTRIES: &[Entry] = &[
(
"settings.appearance.sidebar",
[
"Sidebar", "Боковая панель", "Barra lateral", "Barre latérale", "Seitenleiste",
"Barra laterale", "Barra lateral", "Pasek boczny", "侧边栏", "サイドバー", "사이드바",
"Sidebar",
"Боковая панель",
"Barra lateral",
"Barre latérale",
"Seitenleiste",
"Barra laterale",
"Barra lateral",
"Pasek boczny",
"侧边栏",
"サイドバー",
"사이드바",
"Kenar Çubuğu",
],
),
@ -383,8 +442,18 @@ pub const ENTRIES: &[Entry] = &[
(
"settings.connections.sites",
[
"Sites", "Сайты", "Sitios", "Sites", "Seiten", "Siti", "Sites", "Serwery", "站点",
"サイト", "사이트", "Siteler",
"Sites",
"Сайты",
"Sitios",
"Sites",
"Seiten",
"Siti",
"Sites",
"Serwery",
"站点",
"サイト",
"사이트",
"Siteler",
],
),
(
@ -441,8 +510,18 @@ pub const ENTRIES: &[Entry] = &[
(
"settings.connections.bookmarks_title",
[
"Bookmarks", "Закладки", "Marcadores", "Favoris", "Lesezeichen", "Preferiti",
"Favoritos", "Zakładki", "书签", "ブックマーク", "북마크", "Yer İmleri",
"Bookmarks",
"Закладки",
"Marcadores",
"Favoris",
"Lesezeichen",
"Preferiti",
"Favoritos",
"Zakładki",
"书签",
"ブックマーク",
"북마크",
"Yer İmleri",
],
),
(
@ -534,9 +613,18 @@ pub const ENTRIES: &[Entry] = &[
(
"settings.history.delete_all",
[
"Delete All", "Удалить всё", "Eliminar todo", "Tout supprimer", "Alles löschen",
"Elimina tutto", "Excluir tudo", "Usuń wszystko", "全部删除", "すべて削除",
"모두 삭제", "Tümünü Sil",
"Delete All",
"Удалить всё",
"Eliminar todo",
"Tout supprimer",
"Alles löschen",
"Elimina tutto",
"Excluir tudo",
"Usuń wszystko",
"全部删除",
"すべて削除",
"모두 삭제",
"Tümünü Sil",
],
),
(
@ -748,8 +836,18 @@ pub const ENTRIES: &[Entry] = &[
(
"settings.transfers.never",
[
"Never", "Никогда", "Nunca", "Jamais", "Nie", "Mai", "Nunca", "Nigdy", "从不", "しない",
"안 함", "Asla",
"Never",
"Никогда",
"Nunca",
"Jamais",
"Nie",
"Mai",
"Nunca",
"Nigdy",
"从不",
"しない",
"안 함",
"Asla",
],
),
(
@ -792,22 +890,51 @@ pub const ENTRIES: &[Entry] = &[
(
"settings.about.version",
[
"Version", "Версия", "Versión", "Version", "Version", "Versione", "Versão", "Wersja",
"版本", "バージョン", "버전", "Sürüm",
"Version",
"Версия",
"Versión",
"Version",
"Version",
"Versione",
"Versão",
"Wersja",
"版本",
"バージョン",
"버전",
"Sürüm",
],
),
(
"settings.about.license",
[
"License", "Лицензия", "Licencia", "Licence", "Lizenz", "Licenza", "Licença",
"Licencja", "许可证", "ライセンス", "라이선스", "Lisans",
"License",
"Лицензия",
"Licencia",
"Licence",
"Lizenz",
"Licenza",
"Licença",
"Licencja",
"许可证",
"ライセンス",
"라이선스",
"Lisans",
],
),
(
"settings.about.database",
[
"Database", "База данных", "Base de datos", "Base de données", "Datenbank",
"Database", "Banco de dados", "Baza danych", "数据库", "データベース", "데이터베이스",
"Database",
"База данных",
"Base de datos",
"Base de données",
"Datenbank",
"Database",
"Banco de dados",
"Baza danych",
"数据库",
"データベース",
"데이터베이스",
"Veritabanı",
],
),

View file

@ -22,8 +22,18 @@ pub const ENTRIES: &[Entry] = &[
(
"toolbar.queue_label",
[
"Queue", "Очередь", "Cola", "File d'attente", "Warteschlange", "Coda", "Fila",
"Kolejka", "队列", "キュー", "대기열", "Kuyruk",
"Queue",
"Очередь",
"Cola",
"File d'attente",
"Warteschlange",
"Coda",
"Fila",
"Kolejka",
"队列",
"キュー",
"대기열",
"Kuyruk",
],
),
(
@ -165,8 +175,18 @@ pub const ENTRIES: &[Entry] = &[
(
"queue.state_queued",
[
"Queued", "В очереди", "En cola", "En attente", "Wartend", "In coda", "Na fila",
"W kolejce", "排队中", "待機中", "대기 중", "Sırada",
"Queued",
"В очереди",
"En cola",
"En attente",
"Wartend",
"In coda",
"Na fila",
"W kolejce",
"排队中",
"待機中",
"대기 중",
"Sırada",
],
),
(
@ -206,29 +226,69 @@ pub const ENTRIES: &[Entry] = &[
(
"queue.state_complete",
[
"Complete", "Готово", "Completado", "Terminé", "Fertig", "Completato", "Concluído",
"Gotowe", "已完成", "完了", "완료", "Tamamlandı",
"Complete",
"Готово",
"Completado",
"Terminé",
"Fertig",
"Completato",
"Concluído",
"Gotowe",
"已完成",
"完了",
"완료",
"Tamamlandı",
],
),
(
"queue.state_failed",
[
"Failed", "Ошибка", "Fallido", "Échec", "Fehlgeschlagen", "Non riuscito", "Falhou",
"Niepowodzenie", "失败", "失敗", "실패", "Başarısız",
"Failed",
"Ошибка",
"Fallido",
"Échec",
"Fehlgeschlagen",
"Non riuscito",
"Falhou",
"Niepowodzenie",
"失败",
"失敗",
"실패",
"Başarısız",
],
),
(
"queue.state_paused",
[
"Paused", "Приостановлено", "Pausado", "En pause", "Pausiert", "In pausa", "Pausado",
"Wstrzymane", "已暂停", "一時停止", "일시 중지", "Duraklatıldı",
"Paused",
"Приостановлено",
"Pausado",
"En pause",
"Pausiert",
"In pausa",
"Pausado",
"Wstrzymane",
"已暂停",
"一時停止",
"일시 중지",
"Duraklatıldı",
],
),
(
"queue.state_cancelled",
[
"Cancelled", "Отменено", "Cancelado", "Annulé", "Abgebrochen", "Annullato",
"Cancelado", "Anulowane", "已取消", "キャンセル済み", "취소됨", "İptal edildi",
"Cancelled",
"Отменено",
"Cancelado",
"Annulé",
"Abgebrochen",
"Annullato",
"Cancelado",
"Anulowane",
"已取消",
"キャンセル済み",
"취소됨",
"İptal edildi",
],
),
(

View file

@ -7,10 +7,12 @@ pub mod storage;
pub mod transfer;
use crate::commands::AppState;
use crate::domain::window_state::WindowState;
use crate::fs::remote::RemoteRegistry;
use crate::transfer::queue::TransferQueue;
use std::sync::Arc;
use std::sync::atomic::AtomicU32;
use tauri::Manager;
const DEFAULT_MAX_CONCURRENT: u32 = 2;
@ -37,6 +39,15 @@ pub fn run() {
)))
};
let auto_clear_secs = {
let c = db.lock().unwrap();
Arc::new(AtomicU32::new(storage::db::get_u32(
&c,
"auto_clear_completed_secs",
0,
)))
};
let registry = Arc::new(RemoteRegistry::default());
let queue = TransferQueue::default();
@ -47,15 +58,54 @@ pub fn run() {
registry: registry.clone(),
queue: queue.clone(),
max_concurrent: max_concurrent.clone(),
auto_clear_secs: auto_clear_secs.clone(),
})
.setup(move |app| {
let handle = app.handle().clone();
transfer::worker::spawn_worker(
queue.clone(),
registry.clone(),
tauri::async_runtime::handle().inner().clone(),
max_concurrent.clone(),
app.handle().clone(),
auto_clear_secs.clone(),
handle.clone(),
);
// Restore main window geometry from saved state
if let Some(window) = handle.get_webview_window("main") {
let db = handle.state::<AppState>().db.clone();
let label = window.label().to_string();
let key = format!("window_state_{}", label);
if let Ok(conn) = db.lock()
&& let Some(json) = storage::db::get_setting(&conn, &key)
&& let Ok(ws) = serde_json::from_str::<WindowState>(&json)
{
drop(conn);
if let (Some(x), Some(y)) = (ws.x, ws.y) {
let _ = window
.set_position(tauri::PhysicalPosition::new(x, y));
}
let _ = window
.set_size(tauri::PhysicalSize::new(ws.width, ws.height));
if ws.maximized {
let _ = window.maximize();
}
}
// Save geometry on close
let db2 = db.clone();
let handle2 = handle.clone();
let label2 = label.clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { api: _, .. } = event
&& let Some(win) = handle2.get_webview_window(&label2)
{
commands::save_window_state_internal(&win, &db2);
}
});
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
@ -94,6 +144,9 @@ pub fn run() {
commands::delete_password,
commands::app_data_dir,
commands::platform_info,
commands::save_window_state,
commands::load_window_state,
commands::new_window,
])
.run(tauri::generate_context!())
.expect("failed to start Wherry");

View file

@ -3,7 +3,10 @@ use std::str::FromStr;
use std::time::SystemTime;
use tokio::sync::Mutex;
use suppaftp::{AsyncFtpStream, list::File as FtpListFile};
use suppaftp::async_native_tls::TlsConnector;
use suppaftp::{
AsyncFtpStream, AsyncNativeTlsConnector, AsyncNativeTlsFtpStream, list::File as FtpListFile,
};
use crate::domain::file_entry::{EntryKind, FileEntry};
@ -14,6 +17,36 @@ pub struct FtpClient {
pub conn: Mutex<Option<AsyncFtpStream>>,
}
pub struct FtpsClient {
pub conn: Mutex<Option<AsyncNativeTlsFtpStream>>,
}
pub(super) fn parse_list_entry(line: &str) -> Option<FileEntry> {
let file = FtpListFile::from_str(line).ok()?;
let kind = if file.is_directory() {
EntryKind::Dir
} else if file.is_symlink() {
EntryKind::Symlink
} else {
EntryKind::File
};
let modified = file
.modified()
.duration_since(SystemTime::UNIX_EPOCH)
.ok()
.map(|d| d.as_secs() as i64);
Some(FileEntry {
name: file.name().to_string(),
path: file.name().to_string(),
kind,
size: Some(file.size() as u64),
modified,
permissions: None,
})
}
impl FtpClient {
pub async fn connect(host: &str, port: u16, user: &str, pass: &str) -> Result<Self> {
let addr = format!("{}:{}", host, port);
@ -28,30 +61,24 @@ impl FtpClient {
conn: Mutex::new(Some(stream)),
})
}
}
pub(super) fn parse_list_entry(line: &str) -> Option<FileEntry> {
let file = FtpListFile::from_str(line).ok()?;
let kind = if file.is_directory() {
EntryKind::Dir
} else if file.is_symlink() {
EntryKind::Symlink
} else {
EntryKind::File
};
let modified = file
.modified()
.duration_since(SystemTime::UNIX_EPOCH)
.ok()
.map(|d| d.as_secs() as i64);
Some(FileEntry {
name: file.name().to_string(),
path: file.name().to_string(),
kind,
size: Some(file.size() as u64),
modified,
permissions: None,
impl FtpsClient {
pub async fn connect(host: &str, port: u16, user: &str, pass: &str) -> Result<Self> {
let addr = format!("{}:{}", host, port);
let stream = AsyncNativeTlsFtpStream::connect(&addr)
.await
.map_err(|e| anyhow::anyhow!("FTPS connect failed: {}", e))?;
let mut stream = stream
.into_secure(AsyncNativeTlsConnector::from(TlsConnector::new()), host)
.await
.map_err(|e| anyhow::anyhow!("FTPS TLS upgrade failed: {}", e))?;
stream
.login(user, pass)
.await
.map_err(|e| anyhow::anyhow!("FTPS login failed: {}", e))?;
Ok(Self {
conn: Mutex::new(Some(stream)),
})
}
}

View file

@ -2,125 +2,132 @@
use anyhow::Result;
use async_trait::async_trait;
use super::{FtpClient, transfer};
use super::{FtpClient, FtpsClient, parse_list_entry, transfer};
use crate::domain::file_entry::{EntryKind, FileEntry};
use crate::protocols::{ProgressAction, RemoteFs};
#[async_trait]
impl RemoteFs for FtpClient {
async fn list(&self, path: &str) -> Result<Vec<FileEntry>> {
let mut guard = self.conn.lock().await;
let conn = guard
.as_mut()
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
let lines = conn
.list(Some(path))
.await
.map_err(|e| anyhow::anyhow!("FTP list failed: {}", e))?;
macro_rules! impl_remote_fs {
($client:ty, $label:literal, $upload:ident, $download:ident) => {
#[async_trait]
impl RemoteFs for $client {
async fn list(&self, path: &str) -> Result<Vec<FileEntry>> {
let mut guard = self.conn.lock().await;
let conn = guard
.as_mut()
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
let lines = conn
.list(Some(path))
.await
.map_err(|e| anyhow::anyhow!(concat!($label, " list failed: {}"), e))?;
let mut entries: Vec<FileEntry> = lines
.iter()
.filter_map(|line| Self::parse_list_entry(line))
.collect();
let mut entries: Vec<FileEntry> = lines
.iter()
.filter_map(|line| parse_list_entry(line))
.collect();
entries.sort_by(|a, b| match (&a.kind, &b.kind) {
(EntryKind::Dir, EntryKind::Dir) => a.name.cmp(&b.name),
(EntryKind::Dir, _) => std::cmp::Ordering::Less,
(_, EntryKind::Dir) => std::cmp::Ordering::Greater,
_ => a.name.cmp(&b.name),
});
entries.sort_by(|a, b| match (&a.kind, &b.kind) {
(EntryKind::Dir, EntryKind::Dir) => a.name.cmp(&b.name),
(EntryKind::Dir, _) => std::cmp::Ordering::Less,
(_, EntryKind::Dir) => std::cmp::Ordering::Greater,
_ => a.name.cmp(&b.name),
});
Ok(entries)
}
Ok(entries)
}
async fn upload_with_progress(
&self,
local: &str,
remote: &str,
on_progress: Option<Box<dyn Fn(u64) -> ProgressAction + Send>>,
) -> Result<()> {
let mut guard = self.conn.lock().await;
let conn = guard
.as_mut()
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
transfer::upload(conn, local, remote, on_progress).await
}
async fn upload_with_progress(
&self,
local: &str,
remote: &str,
on_progress: Option<Box<dyn Fn(u64) -> ProgressAction + Send>>,
) -> Result<()> {
let mut guard = self.conn.lock().await;
let conn = guard
.as_mut()
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
transfer::$upload(conn, local, remote, on_progress).await
}
async fn download_with_progress(
&self,
remote: &str,
local: &str,
on_progress: Option<Box<dyn Fn(u64) -> ProgressAction + Send>>,
) -> Result<()> {
let mut guard = self.conn.lock().await;
let conn = guard
.as_mut()
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
transfer::download(conn, remote, local, on_progress).await
}
async fn download_with_progress(
&self,
remote: &str,
local: &str,
on_progress: Option<Box<dyn Fn(u64) -> ProgressAction + Send>>,
) -> Result<()> {
let mut guard = self.conn.lock().await;
let conn = guard
.as_mut()
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
transfer::$download(conn, remote, local, on_progress).await
}
async fn mkdir(&self, path: &str) -> Result<()> {
let mut guard = self.conn.lock().await;
let conn = guard
.as_mut()
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
conn.mkdir(path)
.await
.map_err(|e| anyhow::anyhow!("FTP mkdir failed: {}", e))?;
Ok(())
}
async fn mkdir(&self, path: &str) -> Result<()> {
let mut guard = self.conn.lock().await;
let conn = guard
.as_mut()
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
conn.mkdir(path)
.await
.map_err(|e| anyhow::anyhow!(concat!($label, " mkdir failed: {}"), e))?;
Ok(())
}
async fn rename(&self, from: &str, to: &str) -> Result<()> {
let mut guard = self.conn.lock().await;
let conn = guard
.as_mut()
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
conn.rename(from, to)
.await
.map_err(|e| anyhow::anyhow!("FTP rename failed: {}", e))?;
Ok(())
}
async fn rename(&self, from: &str, to: &str) -> Result<()> {
let mut guard = self.conn.lock().await;
let conn = guard
.as_mut()
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
conn.rename(from, to)
.await
.map_err(|e| anyhow::anyhow!(concat!($label, " rename failed: {}"), e))?;
Ok(())
}
async fn delete(&self, path: &str) -> Result<()> {
let mut guard = self.conn.lock().await;
let conn = guard
.as_mut()
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
if conn.rm(path).await.is_err() {
conn.rmdir(path)
.await
.map_err(|e| anyhow::anyhow!("FTP delete failed: {}", e))?;
async fn delete(&self, path: &str) -> Result<()> {
let mut guard = self.conn.lock().await;
let conn = guard
.as_mut()
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
if conn.rm(path).await.is_err() {
conn.rmdir(path)
.await
.map_err(|e| anyhow::anyhow!(concat!($label, " delete failed: {}"), e))?;
}
Ok(())
}
async fn stat(&self, path: &str) -> Result<FileEntry> {
let mut guard = self.conn.lock().await;
let conn = guard
.as_mut()
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
let size = conn
.size(path)
.await
.map_err(|e| anyhow::anyhow!(concat!($label, " stat failed: {}"), e))?;
let modified = conn
.mdtm(path)
.await
.map(|dt| dt.and_utc().timestamp())
.ok();
let name = std::path::Path::new(path)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| path.to_string());
Ok(FileEntry {
name,
path: path.to_string(),
kind: EntryKind::File,
size: Some(size as u64),
modified,
permissions: None,
})
}
}
Ok(())
}
async fn stat(&self, path: &str) -> Result<FileEntry> {
let mut guard = self.conn.lock().await;
let conn = guard
.as_mut()
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
let size = conn
.size(path)
.await
.map_err(|e| anyhow::anyhow!("FTP stat failed: {}", e))?;
let modified = conn
.mdtm(path)
.await
.map(|dt| dt.and_utc().timestamp())
.ok();
let name = std::path::Path::new(path)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| path.to_string());
Ok(FileEntry {
name,
path: path.to_string(),
kind: EntryKind::File,
size: Some(size as u64),
modified,
permissions: None,
})
}
};
}
impl_remote_fs!(FtpClient, "FTP", upload_ftp, download_ftp);
impl_remote_fs!(FtpsClient, "FTPS", upload_ftps, download_ftps);

View file

@ -1,7 +1,7 @@
//! Чанкованная загрузка/выгрузка по FTP с колбэком прогресса (pause/cancel).
use anyhow::Result;
use futures_lite::io::{AsyncReadExt, AsyncWriteExt};
use suppaftp::AsyncFtpStream;
use suppaftp::{AsyncFtpStream, AsyncNativeTlsFtpStream};
use tokio::fs::File;
use tokio::io::{AsyncReadExt as TokioReadExt, AsyncWriteExt as TokioWriteExt};
@ -11,7 +11,6 @@ const CHUNK_SIZE: usize = 64 * 1024;
type ProgressCb = Option<Box<dyn Fn(u64) -> ProgressAction + Send>>;
/// Проверяет действие прогресса; возвращает Err для pause/cancel (прерывает цикл).
fn check(cb: &ProgressCb, total: u64) -> Result<()> {
if let Some(cb) = cb {
match cb(total) {
@ -23,79 +22,86 @@ fn check(cb: &ProgressCb, total: u64) -> Result<()> {
Ok(())
}
pub(super) async fn upload(
conn: &mut AsyncFtpStream,
local: &str,
remote: &str,
on_progress: ProgressCb,
) -> Result<()> {
let mut file = File::open(local)
.await
.map_err(|e| anyhow::anyhow!("cannot open local file: {}", e))?;
macro_rules! gen_transfer {
($upload:ident, $download:ident, $stream:ty) => {
pub(super) async fn $upload(
conn: &mut $stream,
local: &str,
remote: &str,
on_progress: ProgressCb,
) -> Result<()> {
let mut file = File::open(local)
.await
.map_err(|e| anyhow::anyhow!("cannot open local file: {}", e))?;
let mut stream = conn
.put_with_stream(remote)
.await
.map_err(|e| anyhow::anyhow!("FTP upload stream failed: {}", e))?;
let mut dstream = conn
.put_with_stream(remote)
.await
.map_err(|e| anyhow::anyhow!("FTP upload stream failed: {}", e))?;
let mut buf = vec![0u8; CHUNK_SIZE];
let mut total = 0u64;
loop {
let n = file
.read(&mut buf)
.await
.map_err(|e| anyhow::anyhow!("FTP upload read failed: {}", e))?;
if n == 0 {
break;
let mut buf = vec![0u8; CHUNK_SIZE];
let mut total = 0u64;
loop {
let n = file
.read(&mut buf)
.await
.map_err(|e| anyhow::anyhow!("FTP upload read failed: {}", e))?;
if n == 0 {
break;
}
dstream
.write_all(&buf[..n])
.await
.map_err(|e| anyhow::anyhow!("FTP upload write failed: {}", e))?;
total += n as u64;
check(&on_progress, total)?;
}
conn.finalize_put_stream(dstream)
.await
.map_err(|e| anyhow::anyhow!("FTP upload finalize failed: {}", e))?;
Ok(())
}
stream
.write_all(&buf[..n])
.await
.map_err(|e| anyhow::anyhow!("FTP upload write failed: {}", e))?;
total += n as u64;
check(&on_progress, total)?;
}
conn.finalize_put_stream(stream)
.await
.map_err(|e| anyhow::anyhow!("FTP upload finalize failed: {}", e))?;
Ok(())
pub(super) async fn $download(
conn: &mut $stream,
remote: &str,
local: &str,
on_progress: ProgressCb,
) -> Result<()> {
let mut dstream = conn
.retr_as_stream(remote)
.await
.map_err(|e| anyhow::anyhow!("FTP download stream failed: {}", e))?;
let mut file = File::create(local)
.await
.map_err(|e| anyhow::anyhow!("cannot create local file: {}", e))?;
let mut buf = vec![0u8; CHUNK_SIZE];
let mut total = 0u64;
loop {
let n = dstream
.read(&mut buf)
.await
.map_err(|e| anyhow::anyhow!("FTP download read failed: {}", e))?;
if n == 0 {
break;
}
file.write_all(&buf[..n])
.await
.map_err(|e| anyhow::anyhow!("FTP download write failed: {}", e))?;
total += n as u64;
check(&on_progress, total)?;
}
conn.finalize_retr_stream(dstream)
.await
.map_err(|e| anyhow::anyhow!("FTP download finalize failed: {}", e))?;
Ok(())
}
};
}
pub(super) async fn download(
conn: &mut AsyncFtpStream,
remote: &str,
local: &str,
on_progress: ProgressCb,
) -> Result<()> {
let mut stream = conn
.retr_as_stream(remote)
.await
.map_err(|e| anyhow::anyhow!("FTP download stream failed: {}", e))?;
let mut file = File::create(local)
.await
.map_err(|e| anyhow::anyhow!("cannot create local file: {}", e))?;
let mut buf = vec![0u8; CHUNK_SIZE];
let mut total = 0u64;
loop {
let n = stream
.read(&mut buf)
.await
.map_err(|e| anyhow::anyhow!("FTP download read failed: {}", e))?;
if n == 0 {
break;
}
file.write_all(&buf[..n])
.await
.map_err(|e| anyhow::anyhow!("FTP download write failed: {}", e))?;
total += n as u64;
check(&on_progress, total)?;
}
conn.finalize_retr_stream(stream)
.await
.map_err(|e| anyhow::anyhow!("FTP download finalize failed: {}", e))?;
Ok(())
}
gen_transfer!(upload_ftp, download_ftp, AsyncFtpStream);
gen_transfer!(upload_ftps, download_ftps, AsyncNativeTlsFtpStream);

View file

@ -1,4 +1,5 @@
pub mod ftp;
pub use ftp::FtpsClient;
pub mod sftp;
use crate::domain::file_entry::FileEntry;

View file

@ -1,6 +1,6 @@
//! Таблица закладок локальных папок.
use anyhow::Result;
use rusqlite::{Connection, params};
use rusqlite::{params, Connection};
pub fn get_bookmarks(conn: &Connection) -> Result<Vec<(i64, String, String)>> {
let mut stmt = conn.prepare("SELECT id, name, path FROM bookmarks ORDER BY id")?;

View file

@ -1,6 +1,6 @@
//! Таблица истории подключений (последние 20 целей, уникальных по host/port/user).
use anyhow::Result;
use rusqlite::{Connection, params};
use rusqlite::{params, Connection};
use super::{protocol_from_str, protocol_to_str};
use crate::domain::connection::Protocol;

View file

@ -10,7 +10,7 @@ mod sites;
pub use bookmarks::{add_bookmark, get_bookmarks, remove_bookmark};
pub use history::{
HistoryRow, add_history_entry, clear_history, find_history_conn_id, get_history,
add_history_entry, clear_history, find_history_conn_id, get_history, HistoryRow,
};
pub use settings::{get_bool, get_setting, get_u32, set_bool, set_setting, set_u32};
pub use sites::{delete_site, get_sites, save_site};
@ -97,3 +97,158 @@ pub fn init_tables(conn: &Connection) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::site::Site;
fn test_db() -> rusqlite::Connection {
let conn = rusqlite::Connection::open_in_memory().unwrap();
init_tables(&conn).unwrap();
conn
}
#[test]
fn test_init_tables() {
let conn = test_db();
conn.execute("SELECT 1 FROM sites", []).unwrap();
conn.execute("SELECT 1 FROM connection_history", [])
.unwrap();
conn.execute("SELECT 1 FROM bookmarks", []).unwrap();
conn.execute("SELECT 1 FROM app_settings", []).unwrap();
}
#[test]
fn test_save_get_delete_site() {
let conn = test_db();
let site = Site {
id: "site-1".into(),
name: "Test Server".into(),
protocol: Protocol::Sftp,
host: "example.com".into(),
port: 22,
username: "admin".into(),
password: Some("secret".into()),
key_path: None,
folder: Some("/srv".into()),
note: Some("test".into()),
};
save_site(&conn, &site).unwrap();
let sites = get_sites(&conn).unwrap();
assert_eq!(sites.len(), 1);
assert_eq!(sites[0].id, "site-1");
assert_eq!(sites[0].protocol, Protocol::Sftp);
delete_site(&conn, "site-1").unwrap();
assert!(get_sites(&conn).unwrap().is_empty());
}
#[test]
fn test_save_site_update() {
let conn = test_db();
let site = Site {
id: "s1".into(),
name: "Original".into(),
protocol: Protocol::Ftp,
host: "a.com".into(),
port: 21,
username: "u".into(),
password: None,
key_path: None,
folder: None,
note: None,
};
save_site(&conn, &site).unwrap();
let updated = Site {
name: "Updated".into(),
..site
};
save_site(&conn, &updated).unwrap();
let sites = get_sites(&conn).unwrap();
assert_eq!(sites.len(), 1);
assert_eq!(sites[0].name, "Updated");
}
#[test]
fn test_add_get_clear_history() {
let conn = test_db();
let protocol = Protocol::Sftp;
add_history_entry(&conn, "example.com", 22, "admin", "conn-1", &protocol, None).unwrap();
let history = get_history(&conn).unwrap();
assert_eq!(history.len(), 1);
assert_eq!(history[0].0, "example.com");
assert_eq!(history[0].1, 22);
assert_eq!(history[0].2, "admin");
clear_history(&conn).unwrap();
assert!(get_history(&conn).unwrap().is_empty());
}
#[test]
fn test_find_history_conn_id() {
let conn = test_db();
let protocol = Protocol::Sftp;
add_history_entry(&conn, "example.com", 22, "admin", "conn-1", &protocol, None).unwrap();
let found = find_history_conn_id(&conn, "example.com", 22, "admin").unwrap();
assert_eq!(found, Some("conn-1".into()));
let not_found = find_history_conn_id(&conn, "other.com", 22, "admin").unwrap();
assert!(not_found.is_none());
}
#[test]
fn test_history_dedup() {
let conn = test_db();
let protocol = Protocol::Sftp;
add_history_entry(&conn, "example.com", 22, "admin", "conn-1", &protocol, None).unwrap();
add_history_entry(&conn, "example.com", 22, "admin", "conn-2", &protocol, None).unwrap();
// same target keeps only the latest; conn_id preserved from first insert
let history = get_history(&conn).unwrap();
assert_eq!(history.len(), 1);
assert_eq!(history[0].4, "conn-1");
}
#[test]
fn test_add_get_remove_bookmark() {
let conn = test_db();
let id = add_bookmark(&conn, "Projects", "/home/user/projects").unwrap();
assert!(id > 0);
let bookmarks = get_bookmarks(&conn).unwrap();
assert_eq!(bookmarks.len(), 1);
assert_eq!(bookmarks[0].1, "Projects");
assert_eq!(bookmarks[0].2, "/home/user/projects");
remove_bookmark(&conn, id).unwrap();
assert!(get_bookmarks(&conn).unwrap().is_empty());
}
#[test]
fn test_settings_string() {
let conn = test_db();
assert!(get_setting(&conn, "theme").is_none());
set_setting(&conn, "theme", "dark").unwrap();
assert_eq!(get_setting(&conn, "theme").unwrap(), "dark");
set_setting(&conn, "theme", "light").unwrap();
assert_eq!(get_setting(&conn, "theme").unwrap(), "light");
}
#[test]
fn test_settings_bool() {
let conn = test_db();
assert!(!get_bool(&conn, "show_hidden", false));
assert!(get_bool(&conn, "show_hidden", true));
set_bool(&conn, "show_hidden", true);
assert!(get_bool(&conn, "show_hidden", false));
}
#[test]
fn test_settings_u32() {
let conn = test_db();
assert_eq!(get_u32(&conn, "timeout", 30), 30);
set_u32(&conn, "timeout", 60);
assert_eq!(get_u32(&conn, "timeout", 30), 60);
}
}

View file

@ -2,7 +2,7 @@
//! диалогом Settings для того, что должно переживать перезапуск (в отличие
//! от видимости панелей, которая остаётся только на время сессии).
use anyhow::Result;
use rusqlite::{Connection, params};
use rusqlite::{params, Connection};
pub fn get_setting(conn: &Connection, key: &str) -> Option<String> {
conn.query_row(

View file

@ -1,6 +1,6 @@
//! Таблица сохранённых серверов (Sites).
use anyhow::Result;
use rusqlite::{Connection, params};
use rusqlite::{params, Connection};
use super::{protocol_from_str, protocol_to_str};
use crate::domain::site::Site;

View file

@ -10,6 +10,7 @@
"withGlobalTauri": true,
"windows": [
{
"label": "main",
"title": "Wherry",
"width": 1280,
"height": 800,

110
src/tests/commands_test.rs Normal file
View file

@ -0,0 +1,110 @@
//! Integration-level smoke tests: verify module imports and command function
//! signatures compile correctly. Does not exercise Tauri runtime.
use wherry_lib::domain::connection::{ConnectionParams, Protocol};
use wherry_lib::domain::error::AppError;
use wherry_lib::domain::file_entry::{EntryKind, FileEntry};
use wherry_lib::domain::site::Site;
use wherry_lib::domain::transfer::{TaskState, TransferKind, TransferTask};
use wherry_lib::storage::db::{
add_bookmark, add_history_entry, clear_history, delete_site, find_history_conn_id,
get_bookmarks, get_bool, get_history, get_setting, get_sites, get_u32, init_tables,
remove_bookmark, save_site, set_bool, set_setting, set_u32, HistoryRow,
};
#[test]
fn test_domain_types_compile() {
let _ = ConnectionParams {
id: "".into(),
label: "".into(),
protocol: Protocol::Sftp,
host: "".into(),
port: 22,
username: "".into(),
password: None,
key_path: None,
};
let _ = FileEntry {
name: "".into(),
path: "".into(),
kind: EntryKind::File,
size: None,
modified: None,
permissions: None,
};
let _ = TransferTask::new(
TransferKind::Download,
"".into(),
"".into(),
"".into(),
"".into(),
0,
);
let _ = vec![
AppError::NotFound("".into()),
AppError::AuthFailed("".into()),
AppError::Io("".into()),
AppError::Protocol("".into()),
AppError::InvalidInput("".into()),
AppError::AlreadyExists("".into()),
AppError::Internal("".into()),
];
let _ = Site {
id: "".into(),
name: "".into(),
protocol: Protocol::Ftp,
host: "".into(),
port: 21,
username: "".into(),
password: None,
key_path: None,
folder: None,
note: None,
};
let _ = TaskState::Queued;
}
#[test]
fn test_db_functions_compile() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
init_tables(&conn).unwrap();
let _: Vec<Site> = get_sites(&conn).unwrap();
let _: Vec<(i64, String, String)> = get_bookmarks(&conn).unwrap();
let _: Vec<HistoryRow> = get_history(&conn).unwrap();
let _: Option<String> = find_history_conn_id(&conn, "host", 22, "user").unwrap();
let _: Option<String> = get_setting(&conn, "key");
let _: bool = get_bool(&conn, "key", false);
let _: u32 = get_u32(&conn, "key", 0);
add_bookmark(&conn, "name", "/path").unwrap();
remove_bookmark(&conn, 1).unwrap();
save_site(
&conn,
&Site {
id: "s".into(),
name: "n".into(),
protocol: Protocol::Ftp,
host: "h".into(),
port: 21,
username: "u".into(),
password: None,
key_path: None,
folder: None,
note: None,
},
)
.unwrap();
delete_site(&conn, "s").unwrap();
add_history_entry(&conn, "h", 21, "u", "c", &Protocol::Ftp, None).unwrap();
clear_history(&conn).unwrap();
set_setting(&conn, "k", "v").unwrap();
set_bool(&conn, "k", true);
set_u32(&conn, "k", 42);
}
#[test]
fn test_stateless_commands_compile() {
let _ = wherry_lib::commands::platform_info();
let _ = wherry_lib::commands::app_data_dir();
let _ = wherry_lib::commands::local_home_dir();
}

View file

@ -33,3 +33,38 @@ impl ProgressThrottle {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_first_call_emits() {
let mut throttle = ProgressThrottle::default();
assert!(throttle.should_emit());
}
#[test]
fn test_too_soon_does_not_emit() {
let mut throttle = ProgressThrottle::default();
throttle.should_emit();
assert!(!throttle.should_emit());
}
#[test]
fn test_force_resets() {
let mut throttle = ProgressThrottle::default();
throttle.should_emit();
assert!(throttle.force());
assert!(throttle.should_emit());
}
#[test]
fn test_interval_elapsed_emits() {
let mut throttle = ProgressThrottle {
last_emit: Instant::now() - Duration::from_millis(200),
interval: Duration::from_millis(100),
};
assert!(throttle.should_emit());
}
}

View file

@ -51,3 +51,115 @@ impl TransferQueue {
q.retain(|t| t.id != id);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::transfer::{TaskState, TransferKind, TransferTask};
fn make_task(id: &str, total: u64) -> TransferTask {
TransferTask {
id: id.into(),
kind: TransferKind::Download,
connection_id: "conn-1".into(),
local_path: "/local".into(),
remote_path: "/remote".into(),
file_name: "file.txt".into(),
total_bytes: total,
transferred_bytes: 0,
state: TaskState::Queued,
speed: None,
eta_secs: None,
}
}
#[test]
fn test_push_pop_all() {
let q = TransferQueue::default();
assert!(q.all().is_empty());
q.push(make_task("t1", 100));
q.push(make_task("t2", 200));
assert_eq!(q.all().len(), 2);
let t1 = q.pop().unwrap();
assert_eq!(t1.id, "t1");
assert_eq!(q.all().len(), 1);
let t2 = q.pop().unwrap();
assert_eq!(t2.id, "t2");
assert!(q.pop().is_none());
}
#[test]
fn test_get() {
let q = TransferQueue::default();
q.push(make_task("t1", 100));
q.push(make_task("t2", 200));
assert_eq!(q.get("t2").unwrap().total_bytes, 200);
assert!(q.get("nonexistent").is_none());
}
#[test]
fn test_update_state() {
let q = TransferQueue::default();
q.push(make_task("t1", 100));
for state in [
TaskState::Running,
TaskState::Paused,
TaskState::Completed,
TaskState::Failed("err".into()),
TaskState::Cancelled,
TaskState::Retrying(5),
] {
q.update_state("t1", state.clone());
assert_eq!(q.get("t1").unwrap().state, state);
}
}
#[test]
fn test_update_progress() {
let q = TransferQueue::default();
q.push(make_task("t1", 1000));
q.update_progress("t1", 250, 50);
let t = q.get("t1").unwrap();
assert_eq!(t.transferred_bytes, 250);
assert_eq!(t.speed, Some(50));
assert_eq!(t.eta_secs, Some(15));
}
#[test]
fn test_update_progress_zero_speed() {
let q = TransferQueue::default();
q.push(make_task("t1", 1000));
q.update_progress("t1", 100, 0);
let t = q.get("t1").unwrap();
assert_eq!(t.transferred_bytes, 100);
assert_eq!(t.speed, Some(0));
assert_eq!(t.eta_secs, None);
}
#[test]
fn test_remove() {
let q = TransferQueue::default();
q.push(make_task("t1", 100));
q.push(make_task("t2", 200));
q.push(make_task("t3", 300));
q.remove("t2");
let ids: Vec<String> = q.all().into_iter().map(|t| t.id).collect();
assert_eq!(ids, vec!["t1", "t3"]);
}
#[test]
fn test_remove_nonexistent() {
let q = TransferQueue::default();
q.push(make_task("t1", 100));
q.remove("does-not-exist");
assert_eq!(q.all().len(), 1);
}
}

View file

@ -1,5 +1,7 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter};
use tokio::time::sleep;
@ -69,9 +71,12 @@ pub fn spawn_worker(
registry: Arc<RemoteRegistry>,
rt_handle: tokio::runtime::Handle,
max_concurrent: Arc<AtomicU32>,
auto_clear_secs: Arc<AtomicU32>,
app: AppHandle,
) {
let in_flight = Arc::new(AtomicUsize::new(0));
let completed_at: Arc<Mutex<HashMap<String, Instant>>> = Arc::new(Mutex::new(HashMap::new()));
let completed_at_clone = completed_at.clone();
rt_handle.spawn(async move {
loop {
let limit = max_concurrent.load(Ordering::Relaxed).max(1) as usize;
@ -91,11 +96,27 @@ pub fn spawn_worker(
let registry = registry.clone();
let guard_counter = in_flight.clone();
let app = app.clone();
let completed_at = completed_at.clone();
tokio::spawn(async move {
let _guard = InFlightGuard(guard_counter);
run_transfer(task, queue, registry, app).await;
run_transfer(task, queue, registry, app, completed_at).await;
});
}
let clear_after = auto_clear_secs.load(Ordering::Relaxed);
if clear_after > 0 {
let mut completed = completed_at_clone.lock().unwrap();
let now = Instant::now();
completed.retain(|id, completion_time| {
if now.duration_since(*completion_time).as_secs() >= clear_after as u64 {
queue.remove(id);
false
} else {
true
}
});
}
sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
}
});
@ -107,6 +128,7 @@ async fn run_transfer(
queue: TransferQueue,
registry: Arc<RemoteRegistry>,
app: AppHandle,
completed_at: Arc<Mutex<HashMap<String, Instant>>>,
) {
let fs = match registry.get(&task.connection_id) {
Some(fs) => fs,
@ -153,7 +175,11 @@ async fn run_transfer(
};
if throttle.lock().unwrap().should_emit() {
queue_for_progress.update_progress(&task_id_for_progress, transferred, speed);
emit_progress(&app_for_progress, &queue_for_progress, &task_id_for_progress);
emit_progress(
&app_for_progress,
&queue_for_progress,
&task_id_for_progress,
);
}
ProgressAction::Continue
}));
@ -173,6 +199,7 @@ async fn run_transfer(
Ok(()) => {
queue.update_state(&task.id, TaskState::Completed);
queue.update_progress(&task.id, task.total_bytes, 0);
completed_at.lock().unwrap().insert(task.id.clone(), Instant::now());
emit_progress(&app, &queue, &task.id);
emit_state(&app, &task.id, TaskState::Completed);
}