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
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
lint:
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Build
|
- name: Install system dependencies
|
||||||
run: cargo build --verbose
|
run: |
|
||||||
- name: Run tests
|
sudo apt-get update
|
||||||
run: cargo test --verbose
|
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.
|
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-фронтенд)
|
- **Tauri 2** — нативная оболочка (Rust-бэкенд + WebView-фронтенд)
|
||||||
|
|
@ -23,7 +28,15 @@ FTP/SFTP клиент на Tauri 2: бэкенд на Rust, фронтенд —
|
||||||
|----------|--------------|
|
|----------|--------------|
|
||||||
| SFTP | реализован |
|
| SFTP | реализован |
|
||||||
| FTP | реализован |
|
| 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-экран: сохранённые сайты, недавние подключения, шорткаты
|
- Welcome-экран: сохранённые сайты, недавние подключения, шорткаты
|
||||||
- Settings: General / Appearance / Connections / History / Security / Transfers / About
|
- Settings: General / Appearance / Connections / History / Security / Transfers / About
|
||||||
|
|
||||||
В разработке: полноценная реализация FTPS, Site Manager UI (создание/редактирование сайтов).
|
|
||||||
|
|
||||||
## Разработка
|
## Разработка
|
||||||
|
|
||||||
```bash
|
```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;
|
flex: 1;
|
||||||
}
|
}
|
||||||
.dialog-close {
|
.dialog-close {
|
||||||
width: 24px;
|
width: 28px;
|
||||||
height: 24px;
|
height: 28px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
@ -541,6 +541,10 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
}
|
}
|
||||||
|
.settings-select option {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
.settings-select:hover {
|
.settings-select:hover {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -305,8 +305,8 @@
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
.tab-close {
|
.tab-close {
|
||||||
width: 16px;
|
width: 22px;
|
||||||
height: 16px;
|
height: 22px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
@ -314,13 +314,15 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
.tab-close:hover {
|
.tab-close:hover {
|
||||||
background: rgba(255, 255, 255, 0.12);
|
background: rgba(255, 255, 255, 0.12);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* localPane.js/remotePane.js render straight into this element (there's no
|
/* 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-split-btn,
|
||||||
.pane-close-btn {
|
.pane-close-btn {
|
||||||
width: 18px;
|
width: 24px;
|
||||||
height: 18px;
|
height: 24px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 3px;
|
border-radius: 4px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-hint);
|
color: var(--text-hint);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 10px;
|
font-size: 13px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.pane-split-btn:hover,
|
.pane-split-btn:hover,
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ function build() {
|
||||||
<div class="dialog-header">
|
<div class="dialog-header">
|
||||||
<span class="icon icon--important">${iconMarkup("serverSquare", 16)}</span>
|
<span class="icon icon--important">${iconMarkup("serverSquare", 16)}</span>
|
||||||
<span class="dialog-title">${t("connect.title")}</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>
|
||||||
<div class="dialog-body">
|
<div class="dialog-body">
|
||||||
<div class="segmented" data-role="protocol">
|
<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 { t } from "../i18n.js";
|
||||||
import { iconMarkup, fileIconFor } from "../icons.js";
|
import { iconMarkup, fileIconFor } from "../icons.js";
|
||||||
import { formatSize, formatTime } from "../format.js";
|
import { formatSize, formatTime } from "../format.js";
|
||||||
|
|
@ -8,6 +5,11 @@ import { makeDraggable, makeDropTarget } from "../dnd.js";
|
||||||
import { showContextMenu } from "./contextMenu.js";
|
import { showContextMenu } from "./contextMenu.js";
|
||||||
import { escapeHtml } from "../dom.js";
|
import { escapeHtml } from "../dom.js";
|
||||||
|
|
||||||
|
const ROW_HEIGHT = 32;
|
||||||
|
const OVERSCAN = 10;
|
||||||
|
|
||||||
|
let rafId = null;
|
||||||
|
|
||||||
function sortEntries(entries, sort) {
|
function sortEntries(entries, sort) {
|
||||||
const dotdotIdx = entries.findIndex((e) => e.name === "..");
|
const dotdotIdx = entries.findIndex((e) => e.name === "..");
|
||||||
const dotdot = dotdotIdx >= 0 ? entries[dotdotIdx] : null;
|
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>`;
|
return `<button type="button" class="sort-header ${active ? "active" : ""}" data-col="${col}">${label}${arrow}</button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function computeVisibleRange(scrollTop, viewportHeight, totalItems) {
|
||||||
* opts:
|
const start = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
|
||||||
* - entries: FileEntry[]
|
const end = Math.min(totalItems, Math.ceil((scrollTop + viewportHeight) / ROW_HEIGHT) + OVERSCAN);
|
||||||
* - selected: string[] (names of selected entries)
|
return { start, end };
|
||||||
* - anchor: string|null (name shift-click range selection extends from)
|
}
|
||||||
* - sort: { col, dir }
|
|
||||||
* - onSort(nextSort)
|
function getSignature(sorted, sort) {
|
||||||
* - onSelect(names, anchor)
|
const len = sorted.length;
|
||||||
* - onOpen(name)
|
if (len === 0) return `0:${sort.col}:${sort.dir}`;
|
||||||
* - dragPayload(entry) => payload|null
|
const mid = Math.min(len >> 1, len - 1);
|
||||||
* - onDropOnEntry(entry, payload) only called for dir/".." rows
|
return `${len}:${sort.col}:${sort.dir}:${sorted[0].name}:${sorted[mid].name}:${sorted[len - 1].name}`;
|
||||||
* - 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
|
function initStructure(container) {
|
||||||
* - contextMenuItems(entry, selectedEntries) => menu items for showContextMenu, or null to skip
|
let cache = container._fileTableCache;
|
||||||
*/
|
if (cache) return cache;
|
||||||
export function renderFileTable(container, opts) {
|
|
||||||
const scrollPos = container.querySelector(".file-table-body")?.scrollTop ?? 0;
|
|
||||||
container.innerHTML = "";
|
container.innerHTML = "";
|
||||||
container.classList.add("file-table-wrap");
|
container.classList.add("file-table-wrap");
|
||||||
|
|
||||||
const header = document.createElement("div");
|
const header = document.createElement("div");
|
||||||
header.className = "file-table-header";
|
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 = `
|
header.innerHTML = `
|
||||||
${sortHeaderHtml(t("panels.colName"), "name", opts.sort)}
|
${sortHeaderHtml(t("panels.colName"), "name", opts.sort)}
|
||||||
${sortHeaderHtml(t("panels.colModified"), "modified", opts.sort)}
|
${sortHeaderHtml(t("panels.colModified"), "modified", opts.sort)}
|
||||||
|
|
@ -96,81 +133,141 @@ export function renderFileTable(container, opts) {
|
||||||
opts.onSort(next);
|
opts.onSort(next);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
container.appendChild(header);
|
}
|
||||||
|
|
||||||
const body = document.createElement("div");
|
function createRowElement(entry, index, cache) {
|
||||||
body.className = "file-table-body";
|
const row = document.createElement("div");
|
||||||
container.appendChild(body);
|
row.className = "file-row";
|
||||||
|
row.dataset.index = index;
|
||||||
|
|
||||||
const sorted = sortEntries(opts.entries, opts.sort);
|
const current = cache.opts;
|
||||||
const selectableNames = sorted.filter((e) => e.name !== "..").map((e) => e.name);
|
if (current.selected.includes(entry.name)) row.classList.add("selected");
|
||||||
|
|
||||||
for (const entry of sorted) {
|
row.style.position = "absolute";
|
||||||
const row = document.createElement("div");
|
row.style.top = index * ROW_HEIGHT + "px";
|
||||||
row.className = "file-row";
|
row.style.left = "0";
|
||||||
if (opts.selected.includes(entry.name)) row.classList.add("selected");
|
row.style.right = "0";
|
||||||
|
row.style.height = ROW_HEIGHT + "px";
|
||||||
|
|
||||||
const [iconName, iconColor] = rowIcon(entry);
|
const [iconName, iconColor] = rowIcon(entry);
|
||||||
const nameCol = entry.name === ".." ? "text-hint" : "text-primary";
|
const nameCol = entry.name === ".." ? "text-hint" : "text-primary";
|
||||||
|
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div class="file-row-name">
|
<div class="file-row-name">
|
||||||
<span class="icon ${iconColor}">${iconMarkup(iconName, 15)}</span>
|
<span class="icon ${iconColor}">${iconMarkup(iconName, 15)}</span>
|
||||||
<span class="${nameCol}">${escapeHtml(entry.name)}</span>
|
<span class="${nameCol}">${escapeHtml(entry.name)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="file-row-modified mono">${formatTime(entry.modified)}</div>
|
<div class="file-row-modified mono">${formatTime(entry.modified)}</div>
|
||||||
<div class="file-row-type">${typeLabel(entry)}</div>
|
<div class="file-row-type">${typeLabel(entry)}</div>
|
||||||
<div class="file-row-size mono">${formatSize(entry.size)}</div>
|
<div class="file-row-size mono">${formatSize(entry.size)}</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
row.addEventListener("click", (e) => {
|
row.addEventListener("click", (e) => {
|
||||||
if (entry.name === "..") {
|
const opts = cache.opts;
|
||||||
opts.onSelect([], null);
|
if (entry.name === "..") {
|
||||||
return;
|
opts.onSelect([], null);
|
||||||
}
|
return;
|
||||||
const idx = selectableNames.indexOf(entry.name);
|
}
|
||||||
if (e.shiftKey && opts.anchor && selectableNames.includes(opts.anchor)) {
|
const idx = cache.selectableNames.indexOf(entry.name);
|
||||||
const anchorIdx = selectableNames.indexOf(opts.anchor);
|
if (e.shiftKey && opts.anchor && cache.selectableNames.includes(opts.anchor)) {
|
||||||
const [lo, hi] = anchorIdx < idx ? [anchorIdx, idx] : [idx, anchorIdx];
|
const anchorIdx = cache.selectableNames.indexOf(opts.anchor);
|
||||||
opts.onSelect(selectableNames.slice(lo, hi + 1), opts.anchor);
|
const [lo, hi] = anchorIdx < idx ? [anchorIdx, idx] : [idx, anchorIdx];
|
||||||
} else if (e.ctrlKey || e.metaKey) {
|
opts.onSelect(cache.selectableNames.slice(lo, hi + 1), opts.anchor);
|
||||||
const next = new Set(opts.selected);
|
} else if (e.ctrlKey || e.metaKey) {
|
||||||
if (next.has(entry.name)) next.delete(entry.name);
|
const next = new Set(opts.selected);
|
||||||
else next.add(entry.name);
|
if (next.has(entry.name)) next.delete(entry.name);
|
||||||
opts.onSelect(selectableNames.filter((n) => next.has(n)), entry.name);
|
else next.add(entry.name);
|
||||||
} else {
|
opts.onSelect(cache.selectableNames.filter((n) => next.has(n)), entry.name);
|
||||||
opts.onSelect([entry.name], 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 !== "..") {
|
const payload = current.dragPayload?.(entry);
|
||||||
row.addEventListener("contextmenu", (e) => {
|
if (payload) makeDraggable(row.querySelector(".file-row-name"), entry, payload);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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");
|
const closeBtn = document.createElement("button");
|
||||||
closeBtn.className = "pane-close-btn";
|
closeBtn.className = "pane-close-btn";
|
||||||
closeBtn.title = "Close";
|
closeBtn.title = "Close";
|
||||||
closeBtn.innerHTML = iconMarkup("close", 10);
|
closeBtn.innerHTML = iconMarkup("close", 13);
|
||||||
closeBtn.addEventListener("click", (e) => { e.stopPropagation(); closePane(pane.id); });
|
closeBtn.addEventListener("click", (e) => { e.stopPropagation(); closePane(pane.id); });
|
||||||
header.appendChild(closeBtn);
|
header.appendChild(closeBtn);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
setLanguage,
|
setLanguage,
|
||||||
setTheme,
|
setTheme,
|
||||||
} from "../settingsActions.js";
|
} from "../settingsActions.js";
|
||||||
|
import { openSiteManager } from "./siteManager.js";
|
||||||
|
|
||||||
const LANG_OPTIONS = [
|
const LANG_OPTIONS = [
|
||||||
["en", "English"],
|
["en", "English"],
|
||||||
|
|
@ -176,6 +177,7 @@ function sectionConnections() {
|
||||||
<div class="settings-list-row-title">${escapeHtml(s.name)}</div>
|
<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 class="settings-list-row-sub">${escapeHtml(s.username)}@${escapeHtml(s.host)}:${s.port}</div>
|
||||||
</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>
|
<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>`,
|
</div>`,
|
||||||
)
|
)
|
||||||
|
|
@ -348,7 +350,7 @@ function build() {
|
||||||
<div class="dialog-header">
|
<div class="dialog-header">
|
||||||
<span class="icon icon--important">${iconMarkup("settings", 16)}</span>
|
<span class="icon icon--important">${iconMarkup("settings", 16)}</span>
|
||||||
<span class="dialog-title">${t("settings.title")}</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>
|
||||||
<div class="settings-body">
|
<div class="settings-body">
|
||||||
<div class="settings-nav" data-role="nav"></div>
|
<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) => {
|
root.querySelectorAll("[data-action='delete-site']").forEach((btn) => {
|
||||||
btn.addEventListener("click", async () => {
|
btn.addEventListener("click", async () => {
|
||||||
await deleteSite(btn.dataset.id);
|
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");
|
const close = document.createElement("button");
|
||||||
close.type = "button";
|
close.type = "button";
|
||||||
close.className = "tab-close";
|
close.className = "tab-close";
|
||||||
close.innerHTML = iconMarkup("close", 10);
|
close.innerHTML = iconMarkup("close", 13);
|
||||||
close.title = "Close";
|
close.title = "Close";
|
||||||
close.addEventListener("click", (e) => {
|
close.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { state, notify, activeTab } from "../store.js";
|
import { state, notify, activeTab } from "../store.js";
|
||||||
import { t } from "../i18n.js";
|
import { t } from "../i18n.js";
|
||||||
import { iconMarkup } from "../icons.js";
|
import { iconMarkup } from "../icons.js";
|
||||||
|
import * as ipc from "../ipc.js";
|
||||||
import { openNew } from "./connectDialog.js";
|
import { openNew } from "./connectDialog.js";
|
||||||
import { toggle as toggleHistory } from "./historyPopup.js";
|
import { toggle as toggleHistory } from "./historyPopup.js";
|
||||||
import { openMkdir, openDelete, openRename } from "./opDialogs.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="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="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="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>
|
<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-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>
|
<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;
|
state.opTarget = state.activePane;
|
||||||
openRename(names[0]);
|
openRename(names[0]);
|
||||||
});
|
});
|
||||||
|
container.querySelector('[data-action="new-window"]').addEventListener("click", () => {
|
||||||
|
ipc.newWindow();
|
||||||
|
});
|
||||||
container.querySelector('[data-action="history"]').addEventListener("click", (e) => {
|
container.querySelector('[data-action="history"]').addEventListener("click", (e) => {
|
||||||
toggleHistory(e.currentTarget);
|
toggleHistory(e.currentTarget);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { iconMarkup } from "../icons.js";
|
||||||
import { renderHistoryRows } from "./historyList.js";
|
import { renderHistoryRows } from "./historyList.js";
|
||||||
import { openNew } from "./connectDialog.js";
|
import { openNew } from "./connectDialog.js";
|
||||||
import { openSettings } from "./settingsDialog.js";
|
import { openSettings } from "./settingsDialog.js";
|
||||||
|
import { openSiteManagerNew } from "./siteManager.js";
|
||||||
import { connectFromSite, PROTOCOL_LABELS } from "../connectActions.js";
|
import { connectFromSite, PROTOCOL_LABELS } from "../connectActions.js";
|
||||||
import { escapeHtml } from "../dom.js";
|
import { escapeHtml } from "../dom.js";
|
||||||
import { shortcutLabel } from "../platform.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" data-role="sites" style="display:none">
|
||||||
<div class="welcome-card-title">${iconMarkup("star", 12)}<span>${t("welcome.savedSites")}</span></div>
|
<div class="welcome-card-title">${iconMarkup("star", 12)}<span>${t("welcome.savedSites")}</span></div>
|
||||||
<div data-role="sites-list"></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>
|
||||||
<div class="welcome-card" data-role="recent" style="display:none">
|
<div class="welcome-card" data-role="recent" style="display:none">
|
||||||
<div class="welcome-card-title">${iconMarkup("history", 12)}<span>${t("welcome.recentConnections")}</span></div>
|
<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="new-connection"]').addEventListener("click", openNew);
|
||||||
container.querySelector('[data-action="open-settings"]').addEventListener("click", openSettings);
|
container.querySelector('[data-action="open-settings"]').addEventListener("click", openSettings);
|
||||||
|
container.querySelector('[data-action="manage-sites"]')?.addEventListener("click", openSiteManagerNew);
|
||||||
|
|
||||||
updateWelcome(container);
|
updateWelcome(container);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,22 @@ export default {
|
||||||
deleteTitleMulti: "Delete {n} items?",
|
deleteTitleMulti: "Delete {n} items?",
|
||||||
deleteBody: "This can't be undone.",
|
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: {
|
settings: {
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
navGeneral: "General",
|
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 deletePassword = (siteId) => invoke("delete_password", { siteId });
|
||||||
export const appDataDir = () => invoke("app_data_dir");
|
export const appDataDir = () => invoke("app_data_dir");
|
||||||
export const platformInfo = () => invoke("platform_info");
|
export const platformInfo = () => invoke("platform_info");
|
||||||
|
export const newWindow = () => invoke("new_window");
|
||||||
|
|
||||||
// --- Events -------------------------------------------------------------------
|
// --- Events -------------------------------------------------------------------
|
||||||
// Payloads here are snake_case on the wire (worker.rs's ProgressPayload/
|
// 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 { renderHistoryPopup } from "./components/historyPopup.js";
|
||||||
import { renderOpDialogs, closeDialog } from "./components/opDialogs.js";
|
import { renderOpDialogs, closeDialog } from "./components/opDialogs.js";
|
||||||
import { renderSettingsDialog } from "./components/settingsDialog.js";
|
import { renderSettingsDialog } from "./components/settingsDialog.js";
|
||||||
|
import { renderSiteManager } from "./components/siteManager.js";
|
||||||
|
|
||||||
const root = document.getElementById("screen-root");
|
const root = document.getElementById("screen-root");
|
||||||
|
|
||||||
|
|
@ -104,6 +105,7 @@ function render() {
|
||||||
renderHistoryPopup();
|
renderHistoryPopup();
|
||||||
renderOpDialogs();
|
renderOpDialogs();
|
||||||
renderSettingsDialog();
|
renderSettingsDialog();
|
||||||
|
renderSiteManager();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings → Appearance toggles — animated via CSS transitions.
|
// Settings → Appearance toggles — animated via CSS transitions.
|
||||||
|
|
@ -129,6 +131,9 @@ window.addEventListener("keydown", (e) => {
|
||||||
if (e.key !== "Escape") return;
|
if (e.key !== "Escape") return;
|
||||||
if (state.dialog) {
|
if (state.dialog) {
|
||||||
closeDialog();
|
closeDialog();
|
||||||
|
} else if (state.showSiteManager) {
|
||||||
|
state.showSiteManager = false;
|
||||||
|
notify();
|
||||||
} else if (state.showConnectDialog && !state.connectLoading) {
|
} else if (state.showConnectDialog && !state.connectLoading) {
|
||||||
state.showConnectDialog = false;
|
state.showConnectDialog = false;
|
||||||
notify();
|
notify();
|
||||||
|
|
|
||||||
|
|
@ -43,11 +43,14 @@ export async function loadSettings() {
|
||||||
const themeTransitionMs = 300;
|
const themeTransitionMs = 300;
|
||||||
let themeTimeout = null;
|
let themeTimeout = null;
|
||||||
|
|
||||||
|
const DARK_THEMES = new Set(["ember", "midnight", "forest", "ash"]);
|
||||||
|
|
||||||
export function applyTheme(themeId) {
|
export function applyTheme(themeId) {
|
||||||
const html = document.documentElement;
|
const html = document.documentElement;
|
||||||
// Add a class to enable CSS transitions during theme switch
|
// Add a class to enable CSS transitions during theme switch
|
||||||
html.classList.add("theme-transitioning");
|
html.classList.add("theme-transitioning");
|
||||||
html.setAttribute("data-theme", themeId);
|
html.setAttribute("data-theme", themeId);
|
||||||
|
html.style.colorScheme = DARK_THEMES.has(themeId) ? "dark" : "light";
|
||||||
clearTimeout(themeTimeout);
|
clearTimeout(themeTimeout);
|
||||||
themeTimeout = setTimeout(() => {
|
themeTimeout = setTimeout(() => {
|
||||||
html.classList.remove("theme-transitioning");
|
html.classList.remove("theme-transitioning");
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ export const state = {
|
||||||
dialog: null, // { kind: 'mkdir'|'rename'|'delete', ... }
|
dialog: null, // { kind: 'mkdir'|'rename'|'delete', ... }
|
||||||
|
|
||||||
showSettingsDialog: false,
|
showSettingsDialog: false,
|
||||||
|
showSiteManager: false,
|
||||||
// Persisted (via get_pref/set_pref) + session-only (panel visibility)
|
// Persisted (via get_pref/set_pref) + session-only (panel visibility)
|
||||||
// preferences -- see js/components/settingsDialog.js.
|
// preferences -- see js/components/settingsDialog.js.
|
||||||
settings: {
|
settings: {
|
||||||
|
|
|
||||||
363
src/commands.rs
363
src/commands.rs
|
|
@ -4,13 +4,20 @@ use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
use crate::domain::connection::{ConnectionParams, Protocol};
|
use crate::domain::connection::{ConnectionParams, Protocol};
|
||||||
|
use crate::domain::error::AppError;
|
||||||
use crate::domain::file_entry::FileEntry;
|
use crate::domain::file_entry::FileEntry;
|
||||||
use crate::domain::site::Site;
|
use crate::domain::site::Site;
|
||||||
use crate::domain::transfer::{TaskState, TransferKind, TransferTask};
|
use crate::domain::transfer::{TaskState, TransferKind, TransferTask};
|
||||||
|
use crate::domain::window_state::WindowState;
|
||||||
use crate::fs::remote::RemoteRegistry;
|
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::storage::db::{self, HistoryRow};
|
||||||
use crate::transfer::queue::TransferQueue;
|
use crate::transfer::queue::TransferQueue;
|
||||||
|
|
||||||
|
|
@ -19,12 +26,16 @@ pub struct AppState {
|
||||||
pub registry: Arc<RemoteRegistry>,
|
pub registry: Arc<RemoteRegistry>,
|
||||||
pub queue: TransferQueue,
|
pub queue: TransferQueue,
|
||||||
pub max_concurrent: Arc<AtomicU32>,
|
pub max_concurrent: Arc<AtomicU32>,
|
||||||
|
pub auto_clear_secs: Arc<AtomicU32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Connections ---------------------------------------------------------
|
// --- Connections ---------------------------------------------------------
|
||||||
|
|
||||||
#[tauri::command]
|
#[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 registry = state.registry.clone();
|
||||||
let db = state.db.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 {
|
let fs: Arc<dyn RemoteFs> = match params.protocol {
|
||||||
Protocol::Sftp => {
|
Protocol::Sftp => {
|
||||||
if let Some(key_path) = ¶ms.key_path {
|
if let Some(key_path) = ¶ms.key_path {
|
||||||
Arc::new(
|
Arc::new(SftpClient::connect_key(
|
||||||
SftpClient::connect_key(
|
¶ms.host,
|
||||||
¶ms.host,
|
params.port,
|
||||||
params.port,
|
¶ms.username,
|
||||||
¶ms.username,
|
key_path,
|
||||||
key_path,
|
params.password.as_deref(),
|
||||||
params.password.as_deref(),
|
)?)
|
||||||
)
|
|
||||||
.map_err(|e| e.to_string())?,
|
|
||||||
)
|
|
||||||
} else if let Some(password) = ¶ms.password {
|
} else if let Some(password) = ¶ms.password {
|
||||||
Arc::new(
|
Arc::new(SftpClient::connect_password(
|
||||||
SftpClient::connect_password(¶ms.host, params.port, ¶ms.username, password)
|
¶ms.host,
|
||||||
.map_err(|e| e.to_string())?,
|
params.port,
|
||||||
)
|
¶ms.username,
|
||||||
|
password,
|
||||||
|
)?)
|
||||||
} else {
|
} else {
|
||||||
Arc::new(
|
Arc::new(SftpClient::connect_auto(
|
||||||
SftpClient::connect_auto(¶ms.host, params.port, ¶ms.username)
|
¶ms.host,
|
||||||
.map_err(|e| e.to_string())?,
|
params.port,
|
||||||
)
|
¶ms.username,
|
||||||
|
)?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Protocol::Ftp => {
|
Protocol::Ftp => {
|
||||||
let password = params
|
let password = params.password.clone().ok_or_else(|| {
|
||||||
.password
|
AppError::InvalidInput("Password is required for FTP connections.".into())
|
||||||
.clone()
|
})?;
|
||||||
.ok_or("Password is required for FTP connections.")?;
|
|
||||||
Arc::new(
|
Arc::new(
|
||||||
FtpClient::connect(¶ms.host, params.port, ¶ms.username, &password)
|
FtpClient::connect(¶ms.host, params.port, ¶ms.username, &password).await?,
|
||||||
.await
|
)
|
||||||
.map_err(|e| e.to_string())?,
|
}
|
||||||
|
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);
|
registry.insert(params.id.clone(), fs);
|
||||||
|
|
@ -104,11 +119,11 @@ pub fn disconnect(state: State<'_, AppState>, connection_id: String) {
|
||||||
|
|
||||||
// --- Remote file ops -------------------------------------------------------
|
// --- 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
|
state
|
||||||
.registry
|
.registry
|
||||||
.get(connection_id)
|
.get(connection_id)
|
||||||
.ok_or_else(|| "connection not found".to_string())
|
.ok_or_else(|| AppError::NotFound("connection not found".into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -116,11 +131,8 @@ pub async fn remote_list(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
path: String,
|
path: String,
|
||||||
) -> Result<Vec<FileEntry>, String> {
|
) -> Result<Vec<FileEntry>, AppError> {
|
||||||
get_remote(&state, &connection_id)?
|
Ok(get_remote(&state, &connection_id)?.list(&path).await?)
|
||||||
.list(&path)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -128,11 +140,8 @@ pub async fn remote_stat(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
path: String,
|
path: String,
|
||||||
) -> Result<FileEntry, String> {
|
) -> Result<FileEntry, AppError> {
|
||||||
get_remote(&state, &connection_id)?
|
Ok(get_remote(&state, &connection_id)?.stat(&path).await?)
|
||||||
.stat(&path)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -140,11 +149,8 @@ pub async fn remote_mkdir(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
path: String,
|
path: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), AppError> {
|
||||||
get_remote(&state, &connection_id)?
|
Ok(get_remote(&state, &connection_id)?.mkdir(&path).await?)
|
||||||
.mkdir(&path)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -153,11 +159,10 @@ pub async fn remote_rename(
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
from: String,
|
from: String,
|
||||||
to: String,
|
to: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), AppError> {
|
||||||
get_remote(&state, &connection_id)?
|
Ok(get_remote(&state, &connection_id)?
|
||||||
.rename(&from, &to)
|
.rename(&from, &to)
|
||||||
.await
|
.await?)
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -165,18 +170,15 @@ pub async fn remote_delete(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
path: String,
|
path: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), AppError> {
|
||||||
get_remote(&state, &connection_id)?
|
Ok(get_remote(&state, &connection_id)?.delete(&path).await?)
|
||||||
.delete(&path)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Local file ops --------------------------------------------------------
|
// --- Local file ops --------------------------------------------------------
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn local_list(path: String) -> Result<Vec<FileEntry>, String> {
|
pub fn local_list(path: String) -> Result<Vec<FileEntry>, AppError> {
|
||||||
crate::fs::local::list(&path).map_err(|e| e.to_string())
|
Ok(crate::fs::local::list(&path)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -185,28 +187,28 @@ pub fn local_home_dir() -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn local_mkdir(path: String) -> Result<(), String> {
|
pub fn local_mkdir(path: String) -> Result<(), AppError> {
|
||||||
crate::fs::local::mkdir(&path).map_err(|e| e.to_string())
|
Ok(crate::fs::local::mkdir(&path)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn local_rename(from: String, to: String) -> Result<(), String> {
|
pub fn local_rename(from: String, to: String) -> Result<(), AppError> {
|
||||||
crate::fs::local::rename(&from, &to).map_err(|e| e.to_string())
|
Ok(crate::fs::local::rename(&from, &to)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn local_delete(path: String) -> Result<(), String> {
|
pub fn local_delete(path: String) -> Result<(), AppError> {
|
||||||
crate::fs::local::delete(&path).map_err(|e| e.to_string())
|
Ok(crate::fs::local::delete(&path)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn local_open(path: String) -> Result<(), String> {
|
pub fn local_open(path: String) -> Result<(), AppError> {
|
||||||
crate::fs::local::open(&path).map_err(|e| e.to_string())
|
Ok(crate::fs::local::open(&path)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn local_move_into(src_path: String, dest_dir: String) -> Result<(), String> {
|
pub fn local_move_into(src_path: String, dest_dir: String) -> Result<(), AppError> {
|
||||||
crate::fs::local::move_into(&src_path, &dest_dir).map_err(|e| e.to_string())
|
Ok(crate::fs::local::move_into(&src_path, &dest_dir)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Transfers --------------------------------------------------------------
|
// --- Transfers --------------------------------------------------------------
|
||||||
|
|
@ -222,7 +224,14 @@ pub fn enqueue_transfer(
|
||||||
file_name: String,
|
file_name: String,
|
||||||
total_bytes: u64,
|
total_bytes: u64,
|
||||||
) -> String {
|
) -> 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();
|
let id = task.id.clone();
|
||||||
state.queue.push(task);
|
state.queue.push(task);
|
||||||
id
|
id
|
||||||
|
|
@ -264,51 +273,79 @@ pub fn set_max_concurrent(state: State<'_, AppState>, n: u32) {
|
||||||
// --- Sites / bookmarks / history / settings ---------------------------------
|
// --- Sites / bookmarks / history / settings ---------------------------------
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn list_sites(state: State<'_, AppState>) -> Result<Vec<Site>, String> {
|
pub fn list_sites(state: State<'_, AppState>) -> Result<Vec<Site>, AppError> {
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
let conn = state
|
||||||
db::get_sites(&conn).map_err(|e| e.to_string())
|
.db
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
|
Ok(db::get_sites(&conn)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn save_site(state: State<'_, AppState>, site: Site) -> Result<(), String> {
|
pub fn save_site(state: State<'_, AppState>, site: Site) -> Result<(), AppError> {
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
let conn = state
|
||||||
db::save_site(&conn, &site).map_err(|e| e.to_string())
|
.db
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
|
Ok(db::save_site(&conn, &site)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn delete_site(state: State<'_, AppState>, id: String) -> Result<(), String> {
|
pub fn delete_site(state: State<'_, AppState>, id: String) -> Result<(), AppError> {
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
let conn = state
|
||||||
db::delete_site(&conn, &id).map_err(|e| e.to_string())
|
.db
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
|
Ok(db::delete_site(&conn, &id)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn list_bookmarks(state: State<'_, AppState>) -> Result<Vec<(i64, String, String)>, String> {
|
pub fn list_bookmarks(state: State<'_, AppState>) -> Result<Vec<(i64, String, String)>, AppError> {
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
let conn = state
|
||||||
db::get_bookmarks(&conn).map_err(|e| e.to_string())
|
.db
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
|
Ok(db::get_bookmarks(&conn)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn add_bookmark(state: State<'_, AppState>, name: String, path: String) -> Result<i64, String> {
|
pub fn add_bookmark(
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
state: State<'_, AppState>,
|
||||||
db::add_bookmark(&conn, &name, &path).map_err(|e| e.to_string())
|
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]
|
#[tauri::command]
|
||||||
pub fn remove_bookmark(state: State<'_, AppState>, id: i64) -> Result<(), String> {
|
pub fn remove_bookmark(state: State<'_, AppState>, id: i64) -> Result<(), AppError> {
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
let conn = state
|
||||||
db::remove_bookmark(&conn, id).map_err(|e| e.to_string())
|
.db
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
|
Ok(db::remove_bookmark(&conn, id)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn list_history(state: State<'_, AppState>) -> Result<Vec<HistoryRow>, String> {
|
pub fn list_history(state: State<'_, AppState>) -> Result<Vec<HistoryRow>, AppError> {
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
let conn = state
|
||||||
db::get_history(&conn).map_err(|e| e.to_string())
|
.db
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
|
Ok(db::get_history(&conn)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn clear_history(state: State<'_, AppState>) -> Result<(), String> {
|
pub fn clear_history(state: State<'_, AppState>) -> Result<(), AppError> {
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
let conn = state
|
||||||
db::clear_history(&conn).map_err(|e| e.to_string())
|
.db
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
|
Ok(db::clear_history(&conn)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -317,32 +354,47 @@ pub fn find_history_conn_id(
|
||||||
host: String,
|
host: String,
|
||||||
port: u16,
|
port: u16,
|
||||||
username: String,
|
username: String,
|
||||||
) -> Result<Option<String>, String> {
|
) -> Result<Option<String>, AppError> {
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
let conn = state
|
||||||
db::find_history_conn_id(&conn, &host, port, &username).map_err(|e| e.to_string())
|
.db
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
|
Ok(db::find_history_conn_id(&conn, &host, port, &username)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_pref(state: State<'_, AppState>, key: String) -> Result<Option<String>, String> {
|
pub fn get_pref(state: State<'_, AppState>, key: String) -> Result<Option<String>, AppError> {
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
let conn = state
|
||||||
|
.db
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
Ok(db::get_setting(&conn, &key))
|
Ok(db::get_setting(&conn, &key))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn set_pref(state: State<'_, AppState>, key: String, value: String) -> Result<(), String> {
|
pub fn set_pref(state: State<'_, AppState>, key: String, value: String) -> Result<(), AppError> {
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
if key == "auto_clear_completed_secs" && let Ok(secs) = value.parse::<u32>() {
|
||||||
db::set_setting(&conn, &key, &value).map_err(|e| e.to_string())
|
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 в БД).
|
/// Удалить пароль из сохранённого сайта (очистить поле password в БД).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn delete_password(state: State<'_, AppState>, site_id: String) -> Result<(), String> {
|
pub fn delete_password(state: State<'_, AppState>, site_id: String) -> Result<(), AppError> {
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
let conn = state
|
||||||
|
.db
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE sites SET password = NULL WHERE id = ?1",
|
"UPDATE sites SET password = NULL WHERE id = ?1",
|
||||||
rusqlite::params![site_id],
|
rusqlite::params![site_id],
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -381,5 +433,110 @@ pub fn platform_info() -> PlatformInfo {
|
||||||
} else {
|
} else {
|
||||||
(None, None)
|
(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,
|
Connecting,
|
||||||
Error(String),
|
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 modified: Option<i64>, // unix timestamp
|
||||||
pub permissions: Option<String>,
|
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 connection;
|
||||||
|
pub mod error;
|
||||||
pub mod file_entry;
|
pub mod file_entry;
|
||||||
pub mod site;
|
pub mod site;
|
||||||
pub mod transfer;
|
pub mod transfer;
|
||||||
|
pub mod window_state;
|
||||||
|
|
|
||||||
|
|
@ -15,3 +15,56 @@ pub struct Site {
|
||||||
pub folder: Option<String>,
|
pub folder: Option<String>,
|
||||||
pub note: 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
|
(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)?;
|
fs::rename(src_path, dest)?;
|
||||||
Ok(())
|
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);
|
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",
|
"common.cancel",
|
||||||
[
|
[
|
||||||
"Cancel", "Отмена", "Cancelar", "Annuler", "Abbrechen", "Annulla", "Cancelar",
|
"Cancel",
|
||||||
"Anuluj", "取消", "キャンセル", "취소", "İptal",
|
"Отмена",
|
||||||
|
"Cancelar",
|
||||||
|
"Annuler",
|
||||||
|
"Abbrechen",
|
||||||
|
"Annulla",
|
||||||
|
"Cancelar",
|
||||||
|
"Anuluj",
|
||||||
|
"取消",
|
||||||
|
"キャンセル",
|
||||||
|
"취소",
|
||||||
|
"İptal",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|
@ -20,8 +30,18 @@ pub const ENTRIES: &[Entry] = &[
|
||||||
(
|
(
|
||||||
"common.delete",
|
"common.delete",
|
||||||
[
|
[
|
||||||
"Delete", "Удалить", "Eliminar", "Supprimer", "Löschen", "Elimina", "Excluir",
|
"Delete",
|
||||||
"Usuń", "删除", "削除", "삭제", "Sil",
|
"Удалить",
|
||||||
|
"Eliminar",
|
||||||
|
"Supprimer",
|
||||||
|
"Löschen",
|
||||||
|
"Elimina",
|
||||||
|
"Excluir",
|
||||||
|
"Usuń",
|
||||||
|
"删除",
|
||||||
|
"削除",
|
||||||
|
"삭제",
|
||||||
|
"Sil",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|
@ -44,29 +64,69 @@ pub const ENTRIES: &[Entry] = &[
|
||||||
(
|
(
|
||||||
"common.edit",
|
"common.edit",
|
||||||
[
|
[
|
||||||
"Edit", "Изменить", "Editar", "Modifier", "Bearbeiten", "Modifica", "Editar",
|
"Edit",
|
||||||
"Edytuj", "编辑", "編集", "편집", "Düzenle",
|
"Изменить",
|
||||||
|
"Editar",
|
||||||
|
"Modifier",
|
||||||
|
"Bearbeiten",
|
||||||
|
"Modifica",
|
||||||
|
"Editar",
|
||||||
|
"Edytuj",
|
||||||
|
"编辑",
|
||||||
|
"編集",
|
||||||
|
"편집",
|
||||||
|
"Düzenle",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"common.save",
|
"common.save",
|
||||||
[
|
[
|
||||||
"Save", "Сохранить", "Guardar", "Enregistrer", "Speichern", "Salva", "Salvar",
|
"Save",
|
||||||
"Zapisz", "保存", "保存", "저장", "Kaydet",
|
"Сохранить",
|
||||||
|
"Guardar",
|
||||||
|
"Enregistrer",
|
||||||
|
"Speichern",
|
||||||
|
"Salva",
|
||||||
|
"Salvar",
|
||||||
|
"Zapisz",
|
||||||
|
"保存",
|
||||||
|
"保存",
|
||||||
|
"저장",
|
||||||
|
"Kaydet",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"common.open",
|
"common.open",
|
||||||
[
|
[
|
||||||
"Open", "Открыть", "Abrir", "Ouvrir", "Öffnen", "Apri", "Abrir", "Otwórz", "打开",
|
"Open",
|
||||||
"開く", "열기", "Aç",
|
"Открыть",
|
||||||
|
"Abrir",
|
||||||
|
"Ouvrir",
|
||||||
|
"Öffnen",
|
||||||
|
"Apri",
|
||||||
|
"Abrir",
|
||||||
|
"Otwórz",
|
||||||
|
"打开",
|
||||||
|
"開く",
|
||||||
|
"열기",
|
||||||
|
"Aç",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"common.close",
|
"common.close",
|
||||||
[
|
[
|
||||||
"Close", "Закрыть", "Cerrar", "Fermer", "Schließen", "Chiudi", "Fechar", "Zamknij",
|
"Close",
|
||||||
"关闭", "閉じる", "닫기", "Kapat",
|
"Закрыть",
|
||||||
|
"Cerrar",
|
||||||
|
"Fermer",
|
||||||
|
"Schließen",
|
||||||
|
"Chiudi",
|
||||||
|
"Fechar",
|
||||||
|
"Zamknij",
|
||||||
|
"关闭",
|
||||||
|
"閉じる",
|
||||||
|
"닫기",
|
||||||
|
"Kapat",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|
@ -106,8 +166,18 @@ pub const ENTRIES: &[Entry] = &[
|
||||||
(
|
(
|
||||||
"common.connect",
|
"common.connect",
|
||||||
[
|
[
|
||||||
"Connect", "Подключить", "Conectar", "Connecter", "Verbinden", "Connetti",
|
"Connect",
|
||||||
"Conectar", "Połącz", "连接", "接続", "연결", "Bağlan",
|
"Подключить",
|
||||||
|
"Conectar",
|
||||||
|
"Connecter",
|
||||||
|
"Verbinden",
|
||||||
|
"Connetti",
|
||||||
|
"Conectar",
|
||||||
|
"Połącz",
|
||||||
|
"连接",
|
||||||
|
"接続",
|
||||||
|
"연결",
|
||||||
|
"Bağlan",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|
@ -130,8 +200,18 @@ pub const ENTRIES: &[Entry] = &[
|
||||||
(
|
(
|
||||||
"common.host",
|
"common.host",
|
||||||
[
|
[
|
||||||
"Host", "Хост", "Host", "Hôte", "Host", "Host", "Host", "Host", "主机", "ホスト",
|
"Host",
|
||||||
"호스트", "Sunucu",
|
"Хост",
|
||||||
|
"Host",
|
||||||
|
"Hôte",
|
||||||
|
"Host",
|
||||||
|
"Host",
|
||||||
|
"Host",
|
||||||
|
"Host",
|
||||||
|
"主机",
|
||||||
|
"ホスト",
|
||||||
|
"호스트",
|
||||||
|
"Sunucu",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|
@ -154,43 +234,103 @@ pub const ENTRIES: &[Entry] = &[
|
||||||
(
|
(
|
||||||
"common.password",
|
"common.password",
|
||||||
[
|
[
|
||||||
"Password", "Пароль", "Contraseña", "Mot de passe", "Passwort", "Password", "Senha",
|
"Password",
|
||||||
"Hasło", "密码", "パスワード", "비밀번호", "Parola",
|
"Пароль",
|
||||||
|
"Contraseña",
|
||||||
|
"Mot de passe",
|
||||||
|
"Passwort",
|
||||||
|
"Password",
|
||||||
|
"Senha",
|
||||||
|
"Hasło",
|
||||||
|
"密码",
|
||||||
|
"パスワード",
|
||||||
|
"비밀번호",
|
||||||
|
"Parola",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"common.port",
|
"common.port",
|
||||||
[
|
[
|
||||||
"Port", "Порт", "Puerto", "Port", "Port", "Porta", "Porta", "Port", "端口", "ポート",
|
"Port",
|
||||||
"포트", "Port",
|
"Порт",
|
||||||
|
"Puerto",
|
||||||
|
"Port",
|
||||||
|
"Port",
|
||||||
|
"Porta",
|
||||||
|
"Porta",
|
||||||
|
"Port",
|
||||||
|
"端口",
|
||||||
|
"ポート",
|
||||||
|
"포트",
|
||||||
|
"Port",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"common.label",
|
"common.label",
|
||||||
[
|
[
|
||||||
"Label", "Метка", "Etiqueta", "Étiquette", "Bezeichnung", "Etichetta", "Rótulo",
|
"Label",
|
||||||
"Etykieta", "标签", "ラベル", "라벨", "Etiket",
|
"Метка",
|
||||||
|
"Etiqueta",
|
||||||
|
"Étiquette",
|
||||||
|
"Bezeichnung",
|
||||||
|
"Etichetta",
|
||||||
|
"Rótulo",
|
||||||
|
"Etykieta",
|
||||||
|
"标签",
|
||||||
|
"ラベル",
|
||||||
|
"라벨",
|
||||||
|
"Etiket",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"common.refresh",
|
"common.refresh",
|
||||||
[
|
[
|
||||||
"Refresh", "Обновить", "Actualizar", "Actualiser", "Aktualisieren", "Aggiorna",
|
"Refresh",
|
||||||
"Atualizar", "Odśwież", "刷新", "更新", "새로고침", "Yenile",
|
"Обновить",
|
||||||
|
"Actualizar",
|
||||||
|
"Actualiser",
|
||||||
|
"Aktualisieren",
|
||||||
|
"Aggiorna",
|
||||||
|
"Atualizar",
|
||||||
|
"Odśwież",
|
||||||
|
"刷新",
|
||||||
|
"更新",
|
||||||
|
"새로고침",
|
||||||
|
"Yenile",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"common.upload",
|
"common.upload",
|
||||||
[
|
[
|
||||||
"Upload", "Загрузить", "Subir", "Envoyer", "Hochladen", "Carica", "Enviar",
|
"Upload",
|
||||||
"Prześlij", "上传", "アップロード", "업로드", "Yükle",
|
"Загрузить",
|
||||||
|
"Subir",
|
||||||
|
"Envoyer",
|
||||||
|
"Hochladen",
|
||||||
|
"Carica",
|
||||||
|
"Enviar",
|
||||||
|
"Prześlij",
|
||||||
|
"上传",
|
||||||
|
"アップロード",
|
||||||
|
"업로드",
|
||||||
|
"Yükle",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"common.download",
|
"common.download",
|
||||||
[
|
[
|
||||||
"Download", "Скачать", "Descargar", "Télécharger", "Herunterladen", "Scarica",
|
"Download",
|
||||||
"Baixar", "Pobierz", "下载", "ダウンロード", "다운로드", "İndir",
|
"Скачать",
|
||||||
|
"Descargar",
|
||||||
|
"Télécharger",
|
||||||
|
"Herunterladen",
|
||||||
|
"Scarica",
|
||||||
|
"Baixar",
|
||||||
|
"Pobierz",
|
||||||
|
"下载",
|
||||||
|
"ダウンロード",
|
||||||
|
"다운로드",
|
||||||
|
"İndir",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|
@ -213,15 +353,35 @@ pub const ENTRIES: &[Entry] = &[
|
||||||
(
|
(
|
||||||
"common.history",
|
"common.history",
|
||||||
[
|
[
|
||||||
"History", "История", "Historial", "Historique", "Verlauf", "Cronologia",
|
"History",
|
||||||
"Histórico", "Historia", "历史记录", "履歴", "기록", "Geçmiş",
|
"История",
|
||||||
|
"Historial",
|
||||||
|
"Historique",
|
||||||
|
"Verlauf",
|
||||||
|
"Cronologia",
|
||||||
|
"Histórico",
|
||||||
|
"Historia",
|
||||||
|
"历史记录",
|
||||||
|
"履歴",
|
||||||
|
"기록",
|
||||||
|
"Geçmiş",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"common.search",
|
"common.search",
|
||||||
[
|
[
|
||||||
"Search", "Поиск", "Buscar", "Rechercher", "Suchen", "Cerca", "Pesquisar", "Szukaj",
|
"Search",
|
||||||
"搜索", "検索", "검색", "Ara",
|
"Поиск",
|
||||||
|
"Buscar",
|
||||||
|
"Rechercher",
|
||||||
|
"Suchen",
|
||||||
|
"Cerca",
|
||||||
|
"Pesquisar",
|
||||||
|
"Szukaj",
|
||||||
|
"搜索",
|
||||||
|
"検索",
|
||||||
|
"검색",
|
||||||
|
"Ara",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|
@ -233,7 +393,17 @@ pub const ENTRIES: &[Entry] = &[
|
||||||
(
|
(
|
||||||
"common.no",
|
"common.no",
|
||||||
[
|
[
|
||||||
"No", "Нет", "No", "Non", "Nein", "No", "Não", "Nie", "否", "いいえ", "아니요",
|
"No",
|
||||||
|
"Нет",
|
||||||
|
"No",
|
||||||
|
"Non",
|
||||||
|
"Nein",
|
||||||
|
"No",
|
||||||
|
"Não",
|
||||||
|
"Nie",
|
||||||
|
"否",
|
||||||
|
"いいえ",
|
||||||
|
"아니요",
|
||||||
"Hayır",
|
"Hayır",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,18 @@ pub const ENTRIES: &[Entry] = &[
|
||||||
(
|
(
|
||||||
"dialogs.browse",
|
"dialogs.browse",
|
||||||
[
|
[
|
||||||
"Browse…", "Обзор…", "Examinar…", "Parcourir…", "Durchsuchen…", "Sfoglia…",
|
"Browse…",
|
||||||
"Procurar…", "Przeglądaj…", "浏览…", "参照…", "찾아보기…", "Gözat…",
|
"Обзор…",
|
||||||
|
"Examinar…",
|
||||||
|
"Parcourir…",
|
||||||
|
"Durchsuchen…",
|
||||||
|
"Sfoglia…",
|
||||||
|
"Procurar…",
|
||||||
|
"Przeglądaj…",
|
||||||
|
"浏览…",
|
||||||
|
"参照…",
|
||||||
|
"찾아보기…",
|
||||||
|
"Gözat…",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,18 @@ pub const ENTRIES: &[Entry] = &[
|
||||||
(
|
(
|
||||||
"panels.local_label",
|
"panels.local_label",
|
||||||
[
|
[
|
||||||
"LOCAL", "ЛОКАЛЬНО", "LOCAL", "LOCAL", "LOKAL", "LOCALE", "LOCAL", "LOKALNE", "本地",
|
"LOCAL",
|
||||||
"ローカル", "로컬", "YEREL",
|
"ЛОКАЛЬНО",
|
||||||
|
"LOCAL",
|
||||||
|
"LOCAL",
|
||||||
|
"LOKAL",
|
||||||
|
"LOCALE",
|
||||||
|
"LOCAL",
|
||||||
|
"LOKALNE",
|
||||||
|
"本地",
|
||||||
|
"ローカル",
|
||||||
|
"로컬",
|
||||||
|
"YEREL",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|
@ -94,29 +104,69 @@ pub const ENTRIES: &[Entry] = &[
|
||||||
(
|
(
|
||||||
"panels.col_size",
|
"panels.col_size",
|
||||||
[
|
[
|
||||||
"Size", "Размер", "Tamaño", "Taille", "Größe", "Dimensione", "Tamanho", "Rozmiar",
|
"Size",
|
||||||
"大小", "サイズ", "크기", "Boyut",
|
"Размер",
|
||||||
|
"Tamaño",
|
||||||
|
"Taille",
|
||||||
|
"Größe",
|
||||||
|
"Dimensione",
|
||||||
|
"Tamanho",
|
||||||
|
"Rozmiar",
|
||||||
|
"大小",
|
||||||
|
"サイズ",
|
||||||
|
"크기",
|
||||||
|
"Boyut",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"panels.type_dir",
|
"panels.type_dir",
|
||||||
[
|
[
|
||||||
"Dir", "Папка", "Carpeta", "Dossier", "Ordner", "Cartella", "Pasta", "Folder",
|
"Dir",
|
||||||
"文件夹", "フォルダ", "폴더", "Klasör",
|
"Папка",
|
||||||
|
"Carpeta",
|
||||||
|
"Dossier",
|
||||||
|
"Ordner",
|
||||||
|
"Cartella",
|
||||||
|
"Pasta",
|
||||||
|
"Folder",
|
||||||
|
"文件夹",
|
||||||
|
"フォルダ",
|
||||||
|
"폴더",
|
||||||
|
"Klasör",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"panels.type_file",
|
"panels.type_file",
|
||||||
[
|
[
|
||||||
"File", "Файл", "Archivo", "Fichier", "Datei", "File", "Arquivo", "Plik", "文件",
|
"File",
|
||||||
"ファイル", "파일", "Dosya",
|
"Файл",
|
||||||
|
"Archivo",
|
||||||
|
"Fichier",
|
||||||
|
"Datei",
|
||||||
|
"File",
|
||||||
|
"Arquivo",
|
||||||
|
"Plik",
|
||||||
|
"文件",
|
||||||
|
"ファイル",
|
||||||
|
"파일",
|
||||||
|
"Dosya",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"panels.type_link",
|
"panels.type_link",
|
||||||
[
|
[
|
||||||
"Link", "Ссылка", "Enlace", "Lien", "Link", "Collegamento", "Link", "Link", "链接",
|
"Link",
|
||||||
"リンク", "링크", "Bağlantı",
|
"Ссылка",
|
||||||
|
"Enlace",
|
||||||
|
"Lien",
|
||||||
|
"Link",
|
||||||
|
"Collegamento",
|
||||||
|
"Link",
|
||||||
|
"Link",
|
||||||
|
"链接",
|
||||||
|
"リンク",
|
||||||
|
"링크",
|
||||||
|
"Bağlantı",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|
@ -139,8 +189,18 @@ pub const ENTRIES: &[Entry] = &[
|
||||||
(
|
(
|
||||||
"panels.sidebar.drives",
|
"panels.sidebar.drives",
|
||||||
[
|
[
|
||||||
"DRIVES", "ДИСКИ", "UNIDADES", "DISQUES", "LAUFWERKE", "UNITÀ", "UNIDADES", "DYSKI",
|
"DRIVES",
|
||||||
"磁盘", "ドライブ", "드라이브", "SÜRÜCÜLER",
|
"ДИСКИ",
|
||||||
|
"UNIDADES",
|
||||||
|
"DISQUES",
|
||||||
|
"LAUFWERKE",
|
||||||
|
"UNITÀ",
|
||||||
|
"UNIDADES",
|
||||||
|
"DYSKI",
|
||||||
|
"磁盘",
|
||||||
|
"ドライブ",
|
||||||
|
"드라이브",
|
||||||
|
"SÜRÜCÜLER",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|
@ -197,8 +257,18 @@ pub const ENTRIES: &[Entry] = &[
|
||||||
(
|
(
|
||||||
"panels.sidebar.qa_home",
|
"panels.sidebar.qa_home",
|
||||||
[
|
[
|
||||||
"Home", "Домой", "Inicio", "Accueil", "Start", "Home", "Início", "Dom", "主目录",
|
"Home",
|
||||||
"ホーム", "홈", "Ana Sayfa",
|
"Домой",
|
||||||
|
"Inicio",
|
||||||
|
"Accueil",
|
||||||
|
"Start",
|
||||||
|
"Home",
|
||||||
|
"Início",
|
||||||
|
"Dom",
|
||||||
|
"主目录",
|
||||||
|
"ホーム",
|
||||||
|
"홈",
|
||||||
|
"Ana Sayfa",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|
@ -221,8 +291,18 @@ pub const ENTRIES: &[Entry] = &[
|
||||||
(
|
(
|
||||||
"panels.sidebar.qa_documents",
|
"panels.sidebar.qa_documents",
|
||||||
[
|
[
|
||||||
"Documents", "Документы", "Documentos", "Documents", "Dokumente", "Documenti",
|
"Documents",
|
||||||
"Documentos", "Dokumenty", "文稿", "書類", "문서", "Belgeler",
|
"Документы",
|
||||||
|
"Documentos",
|
||||||
|
"Documents",
|
||||||
|
"Dokumente",
|
||||||
|
"Documenti",
|
||||||
|
"Documentos",
|
||||||
|
"Dokumenty",
|
||||||
|
"文稿",
|
||||||
|
"書類",
|
||||||
|
"문서",
|
||||||
|
"Belgeler",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|
@ -245,22 +325,52 @@ pub const ENTRIES: &[Entry] = &[
|
||||||
(
|
(
|
||||||
"panels.sidebar.qa_pictures",
|
"panels.sidebar.qa_pictures",
|
||||||
[
|
[
|
||||||
"Pictures", "Изображения", "Imágenes", "Images", "Bilder", "Immagini", "Imagens",
|
"Pictures",
|
||||||
"Obrazy", "图片", "ピクチャ", "사진", "Resimler",
|
"Изображения",
|
||||||
|
"Imágenes",
|
||||||
|
"Images",
|
||||||
|
"Bilder",
|
||||||
|
"Immagini",
|
||||||
|
"Imagens",
|
||||||
|
"Obrazy",
|
||||||
|
"图片",
|
||||||
|
"ピクチャ",
|
||||||
|
"사진",
|
||||||
|
"Resimler",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"panels.sidebar.qa_music",
|
"panels.sidebar.qa_music",
|
||||||
[
|
[
|
||||||
"Music", "Музыка", "Música", "Musique", "Musik", "Musica", "Música", "Muzyka",
|
"Music",
|
||||||
"音乐", "ミュージック", "음악", "Müzik",
|
"Музыка",
|
||||||
|
"Música",
|
||||||
|
"Musique",
|
||||||
|
"Musik",
|
||||||
|
"Musica",
|
||||||
|
"Música",
|
||||||
|
"Muzyka",
|
||||||
|
"音乐",
|
||||||
|
"ミュージック",
|
||||||
|
"음악",
|
||||||
|
"Müzik",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"panels.sidebar.qa_videos",
|
"panels.sidebar.qa_videos",
|
||||||
[
|
[
|
||||||
"Videos", "Видео", "Vídeos", "Vidéos", "Videos", "Video", "Vídeos", "Filmy", "影片",
|
"Videos",
|
||||||
"ムービー", "동영상", "Videolar",
|
"Видео",
|
||||||
|
"Vídeos",
|
||||||
|
"Vidéos",
|
||||||
|
"Videos",
|
||||||
|
"Video",
|
||||||
|
"Vídeos",
|
||||||
|
"Filmy",
|
||||||
|
"影片",
|
||||||
|
"ムービー",
|
||||||
|
"동영상",
|
||||||
|
"Videolar",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,18 @@ pub const ENTRIES: &[Entry] = &[
|
||||||
(
|
(
|
||||||
"settings.nav.group_general",
|
"settings.nav.group_general",
|
||||||
[
|
[
|
||||||
"GENERAL", "ОБЩЕЕ", "GENERAL", "GÉNÉRAL", "ALLGEMEIN", "GENERALE", "GERAL", "OGÓLNE",
|
"GENERAL",
|
||||||
"通用", "一般", "일반", "GENEL",
|
"ОБЩЕЕ",
|
||||||
|
"GENERAL",
|
||||||
|
"GÉNÉRAL",
|
||||||
|
"ALLGEMEIN",
|
||||||
|
"GENERALE",
|
||||||
|
"GERAL",
|
||||||
|
"OGÓLNE",
|
||||||
|
"通用",
|
||||||
|
"一般",
|
||||||
|
"일반",
|
||||||
|
"GENEL",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|
@ -65,15 +75,35 @@ pub const ENTRIES: &[Entry] = &[
|
||||||
(
|
(
|
||||||
"settings.nav.group_about",
|
"settings.nav.group_about",
|
||||||
[
|
[
|
||||||
"ABOUT", "О ПРОГРАММЕ", "ACERCA DE", "À PROPOS", "ÜBER", "INFORMAZIONI", "SOBRE",
|
"ABOUT",
|
||||||
"O PROGRAMIE", "关于", "情報", "정보", "HAKKINDA",
|
"О ПРОГРАММЕ",
|
||||||
|
"ACERCA DE",
|
||||||
|
"À PROPOS",
|
||||||
|
"ÜBER",
|
||||||
|
"INFORMAZIONI",
|
||||||
|
"SOBRE",
|
||||||
|
"O PROGRAMIE",
|
||||||
|
"关于",
|
||||||
|
"情報",
|
||||||
|
"정보",
|
||||||
|
"HAKKINDA",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"settings.nav.general",
|
"settings.nav.general",
|
||||||
[
|
[
|
||||||
"General", "Общие", "General", "Général", "Allgemein", "Generale", "Geral", "Ogólne",
|
"General",
|
||||||
"通用", "一般", "일반", "Genel",
|
"Общие",
|
||||||
|
"General",
|
||||||
|
"Général",
|
||||||
|
"Allgemein",
|
||||||
|
"Generale",
|
||||||
|
"Geral",
|
||||||
|
"Ogólne",
|
||||||
|
"通用",
|
||||||
|
"一般",
|
||||||
|
"일반",
|
||||||
|
"Genel",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|
@ -113,8 +143,18 @@ pub const ENTRIES: &[Entry] = &[
|
||||||
(
|
(
|
||||||
"settings.nav.history",
|
"settings.nav.history",
|
||||||
[
|
[
|
||||||
"History", "История", "Historial", "Historique", "Verlauf", "Cronologia",
|
"History",
|
||||||
"Histórico", "Historia", "历史记录", "履歴", "기록", "Geçmiş",
|
"История",
|
||||||
|
"Historial",
|
||||||
|
"Historique",
|
||||||
|
"Verlauf",
|
||||||
|
"Cronologia",
|
||||||
|
"Histórico",
|
||||||
|
"Historia",
|
||||||
|
"历史记录",
|
||||||
|
"履歴",
|
||||||
|
"기록",
|
||||||
|
"Geçmiş",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|
@ -154,8 +194,18 @@ pub const ENTRIES: &[Entry] = &[
|
||||||
(
|
(
|
||||||
"settings.nav.about",
|
"settings.nav.about",
|
||||||
[
|
[
|
||||||
"About", "О программе", "Acerca de", "À propos", "Über", "Informazioni", "Sobre",
|
"About",
|
||||||
"O programie", "关于", "情報", "정보", "Hakkında",
|
"О программе",
|
||||||
|
"Acerca de",
|
||||||
|
"À propos",
|
||||||
|
"Über",
|
||||||
|
"Informazioni",
|
||||||
|
"Sobre",
|
||||||
|
"O programie",
|
||||||
|
"关于",
|
||||||
|
"情報",
|
||||||
|
"정보",
|
||||||
|
"Hakkında",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// ── General ──────────────────────────────────────────────────────────
|
// ── General ──────────────────────────────────────────────────────────
|
||||||
|
|
@ -340,8 +390,17 @@ pub const ENTRIES: &[Entry] = &[
|
||||||
(
|
(
|
||||||
"settings.appearance.sidebar",
|
"settings.appearance.sidebar",
|
||||||
[
|
[
|
||||||
"Sidebar", "Боковая панель", "Barra lateral", "Barre latérale", "Seitenleiste",
|
"Sidebar",
|
||||||
"Barra laterale", "Barra lateral", "Pasek boczny", "侧边栏", "サイドバー", "사이드바",
|
"Боковая панель",
|
||||||
|
"Barra lateral",
|
||||||
|
"Barre latérale",
|
||||||
|
"Seitenleiste",
|
||||||
|
"Barra laterale",
|
||||||
|
"Barra lateral",
|
||||||
|
"Pasek boczny",
|
||||||
|
"侧边栏",
|
||||||
|
"サイドバー",
|
||||||
|
"사이드바",
|
||||||
"Kenar Çubuğu",
|
"Kenar Çubuğu",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -383,8 +442,18 @@ pub const ENTRIES: &[Entry] = &[
|
||||||
(
|
(
|
||||||
"settings.connections.sites",
|
"settings.connections.sites",
|
||||||
[
|
[
|
||||||
"Sites", "Сайты", "Sitios", "Sites", "Seiten", "Siti", "Sites", "Serwery", "站点",
|
"Sites",
|
||||||
"サイト", "사이트", "Siteler",
|
"Сайты",
|
||||||
|
"Sitios",
|
||||||
|
"Sites",
|
||||||
|
"Seiten",
|
||||||
|
"Siti",
|
||||||
|
"Sites",
|
||||||
|
"Serwery",
|
||||||
|
"站点",
|
||||||
|
"サイト",
|
||||||
|
"사이트",
|
||||||
|
"Siteler",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|
@ -441,8 +510,18 @@ pub const ENTRIES: &[Entry] = &[
|
||||||
(
|
(
|
||||||
"settings.connections.bookmarks_title",
|
"settings.connections.bookmarks_title",
|
||||||
[
|
[
|
||||||
"Bookmarks", "Закладки", "Marcadores", "Favoris", "Lesezeichen", "Preferiti",
|
"Bookmarks",
|
||||||
"Favoritos", "Zakładki", "书签", "ブックマーク", "북마크", "Yer İmleri",
|
"Закладки",
|
||||||
|
"Marcadores",
|
||||||
|
"Favoris",
|
||||||
|
"Lesezeichen",
|
||||||
|
"Preferiti",
|
||||||
|
"Favoritos",
|
||||||
|
"Zakładki",
|
||||||
|
"书签",
|
||||||
|
"ブックマーク",
|
||||||
|
"북마크",
|
||||||
|
"Yer İmleri",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|
@ -534,9 +613,18 @@ pub const ENTRIES: &[Entry] = &[
|
||||||
(
|
(
|
||||||
"settings.history.delete_all",
|
"settings.history.delete_all",
|
||||||
[
|
[
|
||||||
"Delete All", "Удалить всё", "Eliminar todo", "Tout supprimer", "Alles löschen",
|
"Delete All",
|
||||||
"Elimina tutto", "Excluir tudo", "Usuń wszystko", "全部删除", "すべて削除",
|
"Удалить всё",
|
||||||
"모두 삭제", "Tümünü Sil",
|
"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",
|
"settings.transfers.never",
|
||||||
[
|
[
|
||||||
"Never", "Никогда", "Nunca", "Jamais", "Nie", "Mai", "Nunca", "Nigdy", "从不", "しない",
|
"Never",
|
||||||
"안 함", "Asla",
|
"Никогда",
|
||||||
|
"Nunca",
|
||||||
|
"Jamais",
|
||||||
|
"Nie",
|
||||||
|
"Mai",
|
||||||
|
"Nunca",
|
||||||
|
"Nigdy",
|
||||||
|
"从不",
|
||||||
|
"しない",
|
||||||
|
"안 함",
|
||||||
|
"Asla",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|
@ -792,22 +890,51 @@ pub const ENTRIES: &[Entry] = &[
|
||||||
(
|
(
|
||||||
"settings.about.version",
|
"settings.about.version",
|
||||||
[
|
[
|
||||||
"Version", "Версия", "Versión", "Version", "Version", "Versione", "Versão", "Wersja",
|
"Version",
|
||||||
"版本", "バージョン", "버전", "Sürüm",
|
"Версия",
|
||||||
|
"Versión",
|
||||||
|
"Version",
|
||||||
|
"Version",
|
||||||
|
"Versione",
|
||||||
|
"Versão",
|
||||||
|
"Wersja",
|
||||||
|
"版本",
|
||||||
|
"バージョン",
|
||||||
|
"버전",
|
||||||
|
"Sürüm",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"settings.about.license",
|
"settings.about.license",
|
||||||
[
|
[
|
||||||
"License", "Лицензия", "Licencia", "Licence", "Lizenz", "Licenza", "Licença",
|
"License",
|
||||||
"Licencja", "许可证", "ライセンス", "라이선스", "Lisans",
|
"Лицензия",
|
||||||
|
"Licencia",
|
||||||
|
"Licence",
|
||||||
|
"Lizenz",
|
||||||
|
"Licenza",
|
||||||
|
"Licença",
|
||||||
|
"Licencja",
|
||||||
|
"许可证",
|
||||||
|
"ライセンス",
|
||||||
|
"라이선스",
|
||||||
|
"Lisans",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"settings.about.database",
|
"settings.about.database",
|
||||||
[
|
[
|
||||||
"Database", "База данных", "Base de datos", "Base de données", "Datenbank",
|
"Database",
|
||||||
"Database", "Banco de dados", "Baza danych", "数据库", "データベース", "데이터베이스",
|
"База данных",
|
||||||
|
"Base de datos",
|
||||||
|
"Base de données",
|
||||||
|
"Datenbank",
|
||||||
|
"Database",
|
||||||
|
"Banco de dados",
|
||||||
|
"Baza danych",
|
||||||
|
"数据库",
|
||||||
|
"データベース",
|
||||||
|
"데이터베이스",
|
||||||
"Veritabanı",
|
"Veritabanı",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,18 @@ pub const ENTRIES: &[Entry] = &[
|
||||||
(
|
(
|
||||||
"toolbar.queue_label",
|
"toolbar.queue_label",
|
||||||
[
|
[
|
||||||
"Queue", "Очередь", "Cola", "File d'attente", "Warteschlange", "Coda", "Fila",
|
"Queue",
|
||||||
"Kolejka", "队列", "キュー", "대기열", "Kuyruk",
|
"Очередь",
|
||||||
|
"Cola",
|
||||||
|
"File d'attente",
|
||||||
|
"Warteschlange",
|
||||||
|
"Coda",
|
||||||
|
"Fila",
|
||||||
|
"Kolejka",
|
||||||
|
"队列",
|
||||||
|
"キュー",
|
||||||
|
"대기열",
|
||||||
|
"Kuyruk",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|
@ -165,8 +175,18 @@ pub const ENTRIES: &[Entry] = &[
|
||||||
(
|
(
|
||||||
"queue.state_queued",
|
"queue.state_queued",
|
||||||
[
|
[
|
||||||
"Queued", "В очереди", "En cola", "En attente", "Wartend", "In coda", "Na fila",
|
"Queued",
|
||||||
"W kolejce", "排队中", "待機中", "대기 중", "Sırada",
|
"В очереди",
|
||||||
|
"En cola",
|
||||||
|
"En attente",
|
||||||
|
"Wartend",
|
||||||
|
"In coda",
|
||||||
|
"Na fila",
|
||||||
|
"W kolejce",
|
||||||
|
"排队中",
|
||||||
|
"待機中",
|
||||||
|
"대기 중",
|
||||||
|
"Sırada",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|
@ -206,29 +226,69 @@ pub const ENTRIES: &[Entry] = &[
|
||||||
(
|
(
|
||||||
"queue.state_complete",
|
"queue.state_complete",
|
||||||
[
|
[
|
||||||
"Complete", "Готово", "Completado", "Terminé", "Fertig", "Completato", "Concluído",
|
"Complete",
|
||||||
"Gotowe", "已完成", "完了", "완료", "Tamamlandı",
|
"Готово",
|
||||||
|
"Completado",
|
||||||
|
"Terminé",
|
||||||
|
"Fertig",
|
||||||
|
"Completato",
|
||||||
|
"Concluído",
|
||||||
|
"Gotowe",
|
||||||
|
"已完成",
|
||||||
|
"完了",
|
||||||
|
"완료",
|
||||||
|
"Tamamlandı",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"queue.state_failed",
|
"queue.state_failed",
|
||||||
[
|
[
|
||||||
"Failed", "Ошибка", "Fallido", "Échec", "Fehlgeschlagen", "Non riuscito", "Falhou",
|
"Failed",
|
||||||
"Niepowodzenie", "失败", "失敗", "실패", "Başarısız",
|
"Ошибка",
|
||||||
|
"Fallido",
|
||||||
|
"Échec",
|
||||||
|
"Fehlgeschlagen",
|
||||||
|
"Non riuscito",
|
||||||
|
"Falhou",
|
||||||
|
"Niepowodzenie",
|
||||||
|
"失败",
|
||||||
|
"失敗",
|
||||||
|
"실패",
|
||||||
|
"Başarısız",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"queue.state_paused",
|
"queue.state_paused",
|
||||||
[
|
[
|
||||||
"Paused", "Приостановлено", "Pausado", "En pause", "Pausiert", "In pausa", "Pausado",
|
"Paused",
|
||||||
"Wstrzymane", "已暂停", "一時停止", "일시 중지", "Duraklatıldı",
|
"Приостановлено",
|
||||||
|
"Pausado",
|
||||||
|
"En pause",
|
||||||
|
"Pausiert",
|
||||||
|
"In pausa",
|
||||||
|
"Pausado",
|
||||||
|
"Wstrzymane",
|
||||||
|
"已暂停",
|
||||||
|
"一時停止",
|
||||||
|
"일시 중지",
|
||||||
|
"Duraklatıldı",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"queue.state_cancelled",
|
"queue.state_cancelled",
|
||||||
[
|
[
|
||||||
"Cancelled", "Отменено", "Cancelado", "Annulé", "Abgebrochen", "Annullato",
|
"Cancelled",
|
||||||
"Cancelado", "Anulowane", "已取消", "キャンセル済み", "취소됨", "İptal edildi",
|
"Отменено",
|
||||||
|
"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;
|
pub mod transfer;
|
||||||
|
|
||||||
use crate::commands::AppState;
|
use crate::commands::AppState;
|
||||||
|
use crate::domain::window_state::WindowState;
|
||||||
use crate::fs::remote::RemoteRegistry;
|
use crate::fs::remote::RemoteRegistry;
|
||||||
use crate::transfer::queue::TransferQueue;
|
use crate::transfer::queue::TransferQueue;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::AtomicU32;
|
use std::sync::atomic::AtomicU32;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
const DEFAULT_MAX_CONCURRENT: u32 = 2;
|
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 registry = Arc::new(RemoteRegistry::default());
|
||||||
let queue = TransferQueue::default();
|
let queue = TransferQueue::default();
|
||||||
|
|
||||||
|
|
@ -47,15 +58,54 @@ pub fn run() {
|
||||||
registry: registry.clone(),
|
registry: registry.clone(),
|
||||||
queue: queue.clone(),
|
queue: queue.clone(),
|
||||||
max_concurrent: max_concurrent.clone(),
|
max_concurrent: max_concurrent.clone(),
|
||||||
|
auto_clear_secs: auto_clear_secs.clone(),
|
||||||
})
|
})
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
|
let handle = app.handle().clone();
|
||||||
transfer::worker::spawn_worker(
|
transfer::worker::spawn_worker(
|
||||||
queue.clone(),
|
queue.clone(),
|
||||||
registry.clone(),
|
registry.clone(),
|
||||||
tauri::async_runtime::handle().inner().clone(),
|
tauri::async_runtime::handle().inner().clone(),
|
||||||
max_concurrent.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(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
|
@ -94,6 +144,9 @@ pub fn run() {
|
||||||
commands::delete_password,
|
commands::delete_password,
|
||||||
commands::app_data_dir,
|
commands::app_data_dir,
|
||||||
commands::platform_info,
|
commands::platform_info,
|
||||||
|
commands::save_window_state,
|
||||||
|
commands::load_window_state,
|
||||||
|
commands::new_window,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("failed to start Wherry");
|
.expect("failed to start Wherry");
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,10 @@ use std::str::FromStr;
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
use tokio::sync::Mutex;
|
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};
|
use crate::domain::file_entry::{EntryKind, FileEntry};
|
||||||
|
|
||||||
|
|
@ -14,6 +17,36 @@ pub struct FtpClient {
|
||||||
pub conn: Mutex<Option<AsyncFtpStream>>,
|
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 {
|
impl FtpClient {
|
||||||
pub async fn connect(host: &str, port: u16, user: &str, pass: &str) -> Result<Self> {
|
pub async fn connect(host: &str, port: u16, user: &str, pass: &str) -> Result<Self> {
|
||||||
let addr = format!("{}:{}", host, port);
|
let addr = format!("{}:{}", host, port);
|
||||||
|
|
@ -28,30 +61,24 @@ impl FtpClient {
|
||||||
conn: Mutex::new(Some(stream)),
|
conn: Mutex::new(Some(stream)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn parse_list_entry(line: &str) -> Option<FileEntry> {
|
impl FtpsClient {
|
||||||
let file = FtpListFile::from_str(line).ok()?;
|
pub async fn connect(host: &str, port: u16, user: &str, pass: &str) -> Result<Self> {
|
||||||
let kind = if file.is_directory() {
|
let addr = format!("{}:{}", host, port);
|
||||||
EntryKind::Dir
|
let stream = AsyncNativeTlsFtpStream::connect(&addr)
|
||||||
} else if file.is_symlink() {
|
.await
|
||||||
EntryKind::Symlink
|
.map_err(|e| anyhow::anyhow!("FTPS connect failed: {}", e))?;
|
||||||
} else {
|
let mut stream = stream
|
||||||
EntryKind::File
|
.into_secure(AsyncNativeTlsConnector::from(TlsConnector::new()), host)
|
||||||
};
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("FTPS TLS upgrade failed: {}", e))?;
|
||||||
let modified = file
|
stream
|
||||||
.modified()
|
.login(user, pass)
|
||||||
.duration_since(SystemTime::UNIX_EPOCH)
|
.await
|
||||||
.ok()
|
.map_err(|e| anyhow::anyhow!("FTPS login failed: {}", e))?;
|
||||||
.map(|d| d.as_secs() as i64);
|
Ok(Self {
|
||||||
|
conn: Mutex::new(Some(stream)),
|
||||||
Some(FileEntry {
|
|
||||||
name: file.name().to_string(),
|
|
||||||
path: file.name().to_string(),
|
|
||||||
kind,
|
|
||||||
size: Some(file.size() as u64),
|
|
||||||
modified,
|
|
||||||
permissions: None,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,125 +2,132 @@
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use async_trait::async_trait;
|
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::domain::file_entry::{EntryKind, FileEntry};
|
||||||
use crate::protocols::{ProgressAction, RemoteFs};
|
use crate::protocols::{ProgressAction, RemoteFs};
|
||||||
|
|
||||||
#[async_trait]
|
macro_rules! impl_remote_fs {
|
||||||
impl RemoteFs for FtpClient {
|
($client:ty, $label:literal, $upload:ident, $download:ident) => {
|
||||||
async fn list(&self, path: &str) -> Result<Vec<FileEntry>> {
|
#[async_trait]
|
||||||
let mut guard = self.conn.lock().await;
|
impl RemoteFs for $client {
|
||||||
let conn = guard
|
async fn list(&self, path: &str) -> Result<Vec<FileEntry>> {
|
||||||
.as_mut()
|
let mut guard = self.conn.lock().await;
|
||||||
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
let conn = guard
|
||||||
let lines = conn
|
.as_mut()
|
||||||
.list(Some(path))
|
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||||
.await
|
let lines = conn
|
||||||
.map_err(|e| anyhow::anyhow!("FTP list failed: {}", e))?;
|
.list(Some(path))
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!(concat!($label, " list failed: {}"), e))?;
|
||||||
|
|
||||||
let mut entries: Vec<FileEntry> = lines
|
let mut entries: Vec<FileEntry> = lines
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|line| Self::parse_list_entry(line))
|
.filter_map(|line| parse_list_entry(line))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
entries.sort_by(|a, b| match (&a.kind, &b.kind) {
|
entries.sort_by(|a, b| match (&a.kind, &b.kind) {
|
||||||
(EntryKind::Dir, EntryKind::Dir) => a.name.cmp(&b.name),
|
(EntryKind::Dir, EntryKind::Dir) => a.name.cmp(&b.name),
|
||||||
(EntryKind::Dir, _) => std::cmp::Ordering::Less,
|
(EntryKind::Dir, _) => std::cmp::Ordering::Less,
|
||||||
(_, EntryKind::Dir) => std::cmp::Ordering::Greater,
|
(_, EntryKind::Dir) => std::cmp::Ordering::Greater,
|
||||||
_ => a.name.cmp(&b.name),
|
_ => a.name.cmp(&b.name),
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn upload_with_progress(
|
async fn upload_with_progress(
|
||||||
&self,
|
&self,
|
||||||
local: &str,
|
local: &str,
|
||||||
remote: &str,
|
remote: &str,
|
||||||
on_progress: Option<Box<dyn Fn(u64) -> ProgressAction + Send>>,
|
on_progress: Option<Box<dyn Fn(u64) -> ProgressAction + Send>>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut guard = self.conn.lock().await;
|
let mut guard = self.conn.lock().await;
|
||||||
let conn = guard
|
let conn = guard
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||||
transfer::upload(conn, local, remote, on_progress).await
|
transfer::$upload(conn, local, remote, on_progress).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn download_with_progress(
|
async fn download_with_progress(
|
||||||
&self,
|
&self,
|
||||||
remote: &str,
|
remote: &str,
|
||||||
local: &str,
|
local: &str,
|
||||||
on_progress: Option<Box<dyn Fn(u64) -> ProgressAction + Send>>,
|
on_progress: Option<Box<dyn Fn(u64) -> ProgressAction + Send>>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut guard = self.conn.lock().await;
|
let mut guard = self.conn.lock().await;
|
||||||
let conn = guard
|
let conn = guard
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||||
transfer::download(conn, remote, local, on_progress).await
|
transfer::$download(conn, remote, local, on_progress).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn mkdir(&self, path: &str) -> Result<()> {
|
async fn mkdir(&self, path: &str) -> Result<()> {
|
||||||
let mut guard = self.conn.lock().await;
|
let mut guard = self.conn.lock().await;
|
||||||
let conn = guard
|
let conn = guard
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||||
conn.mkdir(path)
|
conn.mkdir(path)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("FTP mkdir failed: {}", e))?;
|
.map_err(|e| anyhow::anyhow!(concat!($label, " mkdir failed: {}"), e))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn rename(&self, from: &str, to: &str) -> Result<()> {
|
async fn rename(&self, from: &str, to: &str) -> Result<()> {
|
||||||
let mut guard = self.conn.lock().await;
|
let mut guard = self.conn.lock().await;
|
||||||
let conn = guard
|
let conn = guard
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||||
conn.rename(from, to)
|
conn.rename(from, to)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("FTP rename failed: {}", e))?;
|
.map_err(|e| anyhow::anyhow!(concat!($label, " rename failed: {}"), e))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete(&self, path: &str) -> Result<()> {
|
async fn delete(&self, path: &str) -> Result<()> {
|
||||||
let mut guard = self.conn.lock().await;
|
let mut guard = self.conn.lock().await;
|
||||||
let conn = guard
|
let conn = guard
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
.ok_or_else(|| anyhow::anyhow!("not connected"))?;
|
||||||
if conn.rm(path).await.is_err() {
|
if conn.rm(path).await.is_err() {
|
||||||
conn.rmdir(path)
|
conn.rmdir(path)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("FTP delete failed: {}", e))?;
|
.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).
|
//! Чанкованная загрузка/выгрузка по FTP с колбэком прогресса (pause/cancel).
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use futures_lite::io::{AsyncReadExt, AsyncWriteExt};
|
use futures_lite::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
use suppaftp::AsyncFtpStream;
|
use suppaftp::{AsyncFtpStream, AsyncNativeTlsFtpStream};
|
||||||
use tokio::fs::File;
|
use tokio::fs::File;
|
||||||
use tokio::io::{AsyncReadExt as TokioReadExt, AsyncWriteExt as TokioWriteExt};
|
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>>;
|
type ProgressCb = Option<Box<dyn Fn(u64) -> ProgressAction + Send>>;
|
||||||
|
|
||||||
/// Проверяет действие прогресса; возвращает Err для pause/cancel (прерывает цикл).
|
|
||||||
fn check(cb: &ProgressCb, total: u64) -> Result<()> {
|
fn check(cb: &ProgressCb, total: u64) -> Result<()> {
|
||||||
if let Some(cb) = cb {
|
if let Some(cb) = cb {
|
||||||
match cb(total) {
|
match cb(total) {
|
||||||
|
|
@ -23,79 +22,86 @@ fn check(cb: &ProgressCb, total: u64) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn upload(
|
macro_rules! gen_transfer {
|
||||||
conn: &mut AsyncFtpStream,
|
($upload:ident, $download:ident, $stream:ty) => {
|
||||||
local: &str,
|
pub(super) async fn $upload(
|
||||||
remote: &str,
|
conn: &mut $stream,
|
||||||
on_progress: ProgressCb,
|
local: &str,
|
||||||
) -> Result<()> {
|
remote: &str,
|
||||||
let mut file = File::open(local)
|
on_progress: ProgressCb,
|
||||||
.await
|
) -> Result<()> {
|
||||||
.map_err(|e| anyhow::anyhow!("cannot open local file: {}", e))?;
|
let mut file = File::open(local)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("cannot open local file: {}", e))?;
|
||||||
|
|
||||||
let mut stream = conn
|
let mut dstream = conn
|
||||||
.put_with_stream(remote)
|
.put_with_stream(remote)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("FTP upload stream failed: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("FTP upload stream failed: {}", e))?;
|
||||||
|
|
||||||
let mut buf = vec![0u8; CHUNK_SIZE];
|
let mut buf = vec![0u8; CHUNK_SIZE];
|
||||||
let mut total = 0u64;
|
let mut total = 0u64;
|
||||||
loop {
|
loop {
|
||||||
let n = file
|
let n = file
|
||||||
.read(&mut buf)
|
.read(&mut buf)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("FTP upload read failed: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("FTP upload read failed: {}", e))?;
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
break;
|
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)
|
pub(super) async fn $download(
|
||||||
.await
|
conn: &mut $stream,
|
||||||
.map_err(|e| anyhow::anyhow!("FTP upload finalize failed: {}", e))?;
|
remote: &str,
|
||||||
Ok(())
|
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(
|
gen_transfer!(upload_ftp, download_ftp, AsyncFtpStream);
|
||||||
conn: &mut AsyncFtpStream,
|
gen_transfer!(upload_ftps, download_ftps, AsyncNativeTlsFtpStream);
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
pub mod ftp;
|
pub mod ftp;
|
||||||
|
pub use ftp::FtpsClient;
|
||||||
pub mod sftp;
|
pub mod sftp;
|
||||||
|
|
||||||
use crate::domain::file_entry::FileEntry;
|
use crate::domain::file_entry::FileEntry;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! Таблица закладок локальных папок.
|
//! Таблица закладок локальных папок.
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use rusqlite::{Connection, params};
|
use rusqlite::{params, Connection};
|
||||||
|
|
||||||
pub fn get_bookmarks(conn: &Connection) -> Result<Vec<(i64, String, String)>> {
|
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")?;
|
let mut stmt = conn.prepare("SELECT id, name, path FROM bookmarks ORDER BY id")?;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! Таблица истории подключений (последние 20 целей, уникальных по host/port/user).
|
//! Таблица истории подключений (последние 20 целей, уникальных по host/port/user).
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use rusqlite::{Connection, params};
|
use rusqlite::{params, Connection};
|
||||||
|
|
||||||
use super::{protocol_from_str, protocol_to_str};
|
use super::{protocol_from_str, protocol_to_str};
|
||||||
use crate::domain::connection::Protocol;
|
use crate::domain::connection::Protocol;
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ mod sites;
|
||||||
|
|
||||||
pub use bookmarks::{add_bookmark, get_bookmarks, remove_bookmark};
|
pub use bookmarks::{add_bookmark, get_bookmarks, remove_bookmark};
|
||||||
pub use history::{
|
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 settings::{get_bool, get_setting, get_u32, set_bool, set_setting, set_u32};
|
||||||
pub use sites::{delete_site, get_sites, save_site};
|
pub use sites::{delete_site, get_sites, save_site};
|
||||||
|
|
@ -97,3 +97,158 @@ pub fn init_tables(conn: &Connection) -> Result<()> {
|
||||||
|
|
||||||
Ok(())
|
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 для того, что должно переживать перезапуск (в отличие
|
//! диалогом Settings для того, что должно переживать перезапуск (в отличие
|
||||||
//! от видимости панелей, которая остаётся только на время сессии).
|
//! от видимости панелей, которая остаётся только на время сессии).
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use rusqlite::{Connection, params};
|
use rusqlite::{params, Connection};
|
||||||
|
|
||||||
pub fn get_setting(conn: &Connection, key: &str) -> Option<String> {
|
pub fn get_setting(conn: &Connection, key: &str) -> Option<String> {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! Таблица сохранённых серверов (Sites).
|
//! Таблица сохранённых серверов (Sites).
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use rusqlite::{Connection, params};
|
use rusqlite::{params, Connection};
|
||||||
|
|
||||||
use super::{protocol_from_str, protocol_to_str};
|
use super::{protocol_from_str, protocol_to_str};
|
||||||
use crate::domain::site::Site;
|
use crate::domain::site::Site;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
"withGlobalTauri": true,
|
"withGlobalTauri": true,
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
|
"label": "main",
|
||||||
"title": "Wherry",
|
"title": "Wherry",
|
||||||
"width": 1280,
|
"width": 1280,
|
||||||
"height": 800,
|
"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
|
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);
|
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::Arc;
|
||||||
use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
|
||||||
|
use std::sync::Mutex;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tauri::{AppHandle, Emitter};
|
use tauri::{AppHandle, Emitter};
|
||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
|
|
@ -69,9 +71,12 @@ pub fn spawn_worker(
|
||||||
registry: Arc<RemoteRegistry>,
|
registry: Arc<RemoteRegistry>,
|
||||||
rt_handle: tokio::runtime::Handle,
|
rt_handle: tokio::runtime::Handle,
|
||||||
max_concurrent: Arc<AtomicU32>,
|
max_concurrent: Arc<AtomicU32>,
|
||||||
|
auto_clear_secs: Arc<AtomicU32>,
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
) {
|
) {
|
||||||
let in_flight = Arc::new(AtomicUsize::new(0));
|
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 {
|
rt_handle.spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
let limit = max_concurrent.load(Ordering::Relaxed).max(1) as usize;
|
let limit = max_concurrent.load(Ordering::Relaxed).max(1) as usize;
|
||||||
|
|
@ -91,11 +96,27 @@ pub fn spawn_worker(
|
||||||
let registry = registry.clone();
|
let registry = registry.clone();
|
||||||
let guard_counter = in_flight.clone();
|
let guard_counter = in_flight.clone();
|
||||||
let app = app.clone();
|
let app = app.clone();
|
||||||
|
let completed_at = completed_at.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _guard = InFlightGuard(guard_counter);
|
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;
|
sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -107,6 +128,7 @@ async fn run_transfer(
|
||||||
queue: TransferQueue,
|
queue: TransferQueue,
|
||||||
registry: Arc<RemoteRegistry>,
|
registry: Arc<RemoteRegistry>,
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
|
completed_at: Arc<Mutex<HashMap<String, Instant>>>,
|
||||||
) {
|
) {
|
||||||
let fs = match registry.get(&task.connection_id) {
|
let fs = match registry.get(&task.connection_id) {
|
||||||
Some(fs) => fs,
|
Some(fs) => fs,
|
||||||
|
|
@ -153,7 +175,11 @@ async fn run_transfer(
|
||||||
};
|
};
|
||||||
if throttle.lock().unwrap().should_emit() {
|
if throttle.lock().unwrap().should_emit() {
|
||||||
queue_for_progress.update_progress(&task_id_for_progress, transferred, speed);
|
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
|
ProgressAction::Continue
|
||||||
}));
|
}));
|
||||||
|
|
@ -173,6 +199,7 @@ async fn run_transfer(
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
queue.update_state(&task.id, TaskState::Completed);
|
queue.update_state(&task.id, TaskState::Completed);
|
||||||
queue.update_progress(&task.id, task.total_bytes, 0);
|
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_progress(&app, &queue, &task.id);
|
||||||
emit_state(&app, &task.id, TaskState::Completed);
|
emit_state(&app, &task.id, TaskState::Completed);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue