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:
parent
545dd4ed0b
commit
e188fbef36
52 changed files with 5188 additions and 523 deletions
2
.cargo/config.toml
Normal file
2
.cargo/config.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[build]
|
||||
jobs = 6
|
||||
77
.github/workflows/rust.yml
vendored
77
.github/workflows/rust.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
17
README.md
17
README.md
|
|
@ -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
BIN
docs/img/connect_screen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
docs/img/settings_screen.png
Normal file
BIN
docs/img/settings_screen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
BIN
docs/img/start_screen.png
Normal file
BIN
docs/img/start_screen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
374
frontend/js/components/siteManager.js
Normal file
374
frontend/js/components/siteManager.js
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
363
src/commands.rs
363
src/commands.rs
|
|
@ -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) = ¶ms.key_path {
|
||||
Arc::new(
|
||||
SftpClient::connect_key(
|
||||
¶ms.host,
|
||||
params.port,
|
||||
¶ms.username,
|
||||
key_path,
|
||||
params.password.as_deref(),
|
||||
)
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
Arc::new(SftpClient::connect_key(
|
||||
¶ms.host,
|
||||
params.port,
|
||||
¶ms.username,
|
||||
key_path,
|
||||
params.password.as_deref(),
|
||||
)?)
|
||||
} else if let Some(password) = ¶ms.password {
|
||||
Arc::new(
|
||||
SftpClient::connect_password(¶ms.host, params.port, ¶ms.username, password)
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
Arc::new(SftpClient::connect_password(
|
||||
¶ms.host,
|
||||
params.port,
|
||||
¶ms.username,
|
||||
password,
|
||||
)?)
|
||||
} else {
|
||||
Arc::new(
|
||||
SftpClient::connect_auto(¶ms.host, params.port, ¶ms.username)
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
Arc::new(SftpClient::connect_auto(
|
||||
¶ms.host,
|
||||
params.port,
|
||||
¶ms.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(¶ms.host, params.port, ¶ms.username, &password)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?,
|
||||
FtpClient::connect(¶ms.host, params.port, ¶ms.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(¶ms.host, params.port, ¶ms.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
105
src/domain/error.rs
Normal 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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
pub mod connection;
|
||||
pub mod error;
|
||||
pub mod file_entry;
|
||||
pub mod site;
|
||||
pub mod transfer;
|
||||
pub mod window_state;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
12
src/domain/window_state.rs
Normal file
12
src/domain/window_state.rs
Normal 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,
|
||||
}
|
||||
114
src/fs/local.rs
114
src/fs/local.rs
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2358
src/gen/schemas/linux-schema.json
Normal file
2358
src/gen/schemas/linux-schema.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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…",
|
||||
],
|
||||
),
|
||||
(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
),
|
||||
(
|
||||
|
|
|
|||
|
|
@ -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ı",
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
),
|
||||
(
|
||||
|
|
|
|||
55
src/lib.rs
55
src/lib.rs
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
pub mod ftp;
|
||||
pub use ftp::FtpsClient;
|
||||
pub mod sftp;
|
||||
|
||||
use crate::domain::file_entry::FileEntry;
|
||||
|
|
|
|||
|
|
@ -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")?;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Wherry",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
|
|
|
|||
110
src/tests/commands_test.rs
Normal file
110
src/tests/commands_test.rs
Normal 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();
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue