diff --git a/.cargo/config.toml b/.cargo/config.toml
new file mode 100644
index 0000000..39dc687
--- /dev/null
+++ b/.cargo/config.toml
@@ -0,0 +1,2 @@
+[build]
+jobs = 6
diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml
index 9fd45e0..64560bb 100644
--- a/.github/workflows/rust.yml
+++ b/.github/workflows/rust.yml
@@ -10,13 +10,74 @@ env:
CARGO_TERM_COLOR: always
jobs:
- build:
-
+ lint:
runs-on: ubuntu-latest
-
steps:
- - uses: actions/checkout@v4
- - name: Build
- run: cargo build --verbose
- - name: Run tests
- run: cargo test --verbose
+ - uses: actions/checkout@v4
+ - name: Install system dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y \
+ libwebkit2gtk-4.1-dev \
+ libjavascriptcoregtk-4.1-dev \
+ libsoup-3.0-dev \
+ libgtk-3-dev \
+ libappindicator3-dev \
+ librsvg2-dev \
+ patchelf \
+ libssl-dev \
+ libdbus-1-dev
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ components: clippy, rustfmt
+ - uses: Swatinem/rust-cache@v2
+ - name: Check formatting
+ run: cargo fmt --check
+ - name: Lint with clippy
+ run: cargo clippy -- -D warnings
+
+ build:
+ runs-on: ubuntu-latest
+ needs: [lint]
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install system dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y \
+ libwebkit2gtk-4.1-dev \
+ libjavascriptcoregtk-4.1-dev \
+ libsoup-3.0-dev \
+ libgtk-3-dev \
+ libappindicator3-dev \
+ librsvg2-dev \
+ patchelf \
+ libssl-dev \
+ libdbus-1-dev
+ - uses: dtolnay/rust-toolchain@stable
+ - uses: Swatinem/rust-cache@v2
+ - name: Build (release)
+ run: cargo build --release
+
+ test:
+ runs-on: ubuntu-latest
+ needs: [build]
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install system dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y \
+ libwebkit2gtk-4.1-dev \
+ libjavascriptcoregtk-4.1-dev \
+ libsoup-3.0-dev \
+ libgtk-3-dev \
+ libappindicator3-dev \
+ librsvg2-dev \
+ patchelf \
+ libssl-dev \
+ libdbus-1-dev
+ - uses: dtolnay/rust-toolchain@stable
+ - uses: Swatinem/rust-cache@v2
+ - name: Run tests
+ run: cargo test
diff --git a/README.md b/README.md
index 5eab2fc..bf7eb70 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,11 @@
FTP/SFTP клиент на Tauri 2: бэкенд на Rust, фронтенд — vanilla HTML/CSS/JS.
+
diff --git a/frontend/js/components/fileTable.js b/frontend/js/components/fileTable.js
index d5c738c..3e89668 100644
--- a/frontend/js/components/fileTable.js
+++ b/frontend/js/components/fileTable.js
@@ -1,6 +1,3 @@
-// Reusable file listing table shared by the Local and Remote panes — mirrors
-// the old src/ui/panels/file_pane module (table.rs/row.rs/sort.rs collapsed
-// into one file since there's no split-module pressure in JS).
import { t } from "../i18n.js";
import { iconMarkup, fileIconFor } from "../icons.js";
import { formatSize, formatTime } from "../format.js";
@@ -8,6 +5,11 @@ import { makeDraggable, makeDropTarget } from "../dnd.js";
import { showContextMenu } from "./contextMenu.js";
import { escapeHtml } from "../dom.js";
+const ROW_HEIGHT = 32;
+const OVERSCAN = 10;
+
+let rafId = null;
+
function sortEntries(entries, sort) {
const dotdotIdx = entries.findIndex((e) => e.name === "..");
const dotdot = dotdotIdx >= 0 ? entries[dotdotIdx] : null;
@@ -57,29 +59,64 @@ function sortHeaderHtml(label, col, sort) {
return ``;
}
-/**
- * opts:
- * - entries: FileEntry[]
- * - selected: string[] (names of selected entries)
- * - anchor: string|null (name shift-click range selection extends from)
- * - sort: { col, dir }
- * - onSort(nextSort)
- * - onSelect(names, anchor)
- * - onOpen(name)
- * - dragPayload(entry) => payload|null
- * - onDropOnEntry(entry, payload) only called for dir/".." rows
- * - onHoverOpen(entry) spring-loaded open: fires after a drag lingers on
- * a dir/".." row for HOVER_OPEN_DELAY, without
- * ending the drag -- only called for dir/".." rows
- * - contextMenuItems(entry, selectedEntries) => menu items for showContextMenu, or null to skip
- */
-export function renderFileTable(container, opts) {
- const scrollPos = container.querySelector(".file-table-body")?.scrollTop ?? 0;
+function computeVisibleRange(scrollTop, viewportHeight, totalItems) {
+ const start = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
+ const end = Math.min(totalItems, Math.ceil((scrollTop + viewportHeight) / ROW_HEIGHT) + OVERSCAN);
+ return { start, end };
+}
+
+function getSignature(sorted, sort) {
+ const len = sorted.length;
+ if (len === 0) return `0:${sort.col}:${sort.dir}`;
+ const mid = Math.min(len >> 1, len - 1);
+ return `${len}:${sort.col}:${sort.dir}:${sorted[0].name}:${sorted[mid].name}:${sorted[len - 1].name}`;
+}
+
+function initStructure(container) {
+ let cache = container._fileTableCache;
+ if (cache) return cache;
+
container.innerHTML = "";
container.classList.add("file-table-wrap");
const header = document.createElement("div");
header.className = "file-table-header";
+ container.appendChild(header);
+
+ const body = document.createElement("div");
+ body.className = "file-table-body";
+ container.appendChild(body);
+
+ const spacer = document.createElement("div");
+ spacer.style.position = "relative";
+ body.appendChild(spacer);
+
+ cache = {
+ header,
+ body,
+ spacer,
+ lastSignature: null,
+ sorted: [],
+ selectableNames: [],
+ opts: null,
+ };
+ container._fileTableCache = cache;
+
+ body.addEventListener("scroll", () => {
+ if (rafId) return;
+ rafId = requestAnimationFrame(() => {
+ rafId = null;
+ const c = container._fileTableCache;
+ if (c && c.sorted.length > 0) {
+ updateVisibleRows(c);
+ }
+ });
+ });
+
+ return cache;
+}
+
+function updateHeader(header, opts) {
header.innerHTML = `
${sortHeaderHtml(t("panels.colName"), "name", opts.sort)}
${sortHeaderHtml(t("panels.colModified"), "modified", opts.sort)}
@@ -96,81 +133,141 @@ export function renderFileTable(container, opts) {
opts.onSort(next);
});
});
- container.appendChild(header);
+}
- const body = document.createElement("div");
- body.className = "file-table-body";
- container.appendChild(body);
+function createRowElement(entry, index, cache) {
+ const row = document.createElement("div");
+ row.className = "file-row";
+ row.dataset.index = index;
- const sorted = sortEntries(opts.entries, opts.sort);
- const selectableNames = sorted.filter((e) => e.name !== "..").map((e) => e.name);
+ const current = cache.opts;
+ if (current.selected.includes(entry.name)) row.classList.add("selected");
- for (const entry of sorted) {
- const row = document.createElement("div");
- row.className = "file-row";
- if (opts.selected.includes(entry.name)) row.classList.add("selected");
+ row.style.position = "absolute";
+ row.style.top = index * ROW_HEIGHT + "px";
+ row.style.left = "0";
+ row.style.right = "0";
+ row.style.height = ROW_HEIGHT + "px";
- const [iconName, iconColor] = rowIcon(entry);
- const nameCol = entry.name === ".." ? "text-hint" : "text-primary";
+ const [iconName, iconColor] = rowIcon(entry);
+ const nameCol = entry.name === ".." ? "text-hint" : "text-primary";
- row.innerHTML = `
-
- ${iconMarkup(iconName, 15)}
- ${escapeHtml(entry.name)}
-
-
${formatTime(entry.modified)}
-
${typeLabel(entry)}
-
${formatSize(entry.size)}
- `;
+ row.innerHTML = `
+
+ ${iconMarkup(iconName, 15)}
+ ${escapeHtml(entry.name)}
+
+
${formatTime(entry.modified)}
+
${typeLabel(entry)}
+
${formatSize(entry.size)}
+ `;
- row.addEventListener("click", (e) => {
- if (entry.name === "..") {
- opts.onSelect([], null);
- return;
- }
- const idx = selectableNames.indexOf(entry.name);
- if (e.shiftKey && opts.anchor && selectableNames.includes(opts.anchor)) {
- const anchorIdx = selectableNames.indexOf(opts.anchor);
- const [lo, hi] = anchorIdx < idx ? [anchorIdx, idx] : [idx, anchorIdx];
- opts.onSelect(selectableNames.slice(lo, hi + 1), opts.anchor);
- } else if (e.ctrlKey || e.metaKey) {
- const next = new Set(opts.selected);
- if (next.has(entry.name)) next.delete(entry.name);
- else next.add(entry.name);
- opts.onSelect(selectableNames.filter((n) => next.has(n)), entry.name);
- } else {
- opts.onSelect([entry.name], entry.name);
+ row.addEventListener("click", (e) => {
+ const opts = cache.opts;
+ if (entry.name === "..") {
+ opts.onSelect([], null);
+ return;
+ }
+ const idx = cache.selectableNames.indexOf(entry.name);
+ if (e.shiftKey && opts.anchor && cache.selectableNames.includes(opts.anchor)) {
+ const anchorIdx = cache.selectableNames.indexOf(opts.anchor);
+ const [lo, hi] = anchorIdx < idx ? [anchorIdx, idx] : [idx, anchorIdx];
+ opts.onSelect(cache.selectableNames.slice(lo, hi + 1), opts.anchor);
+ } else if (e.ctrlKey || e.metaKey) {
+ const next = new Set(opts.selected);
+ if (next.has(entry.name)) next.delete(entry.name);
+ else next.add(entry.name);
+ opts.onSelect(cache.selectableNames.filter((n) => next.has(n)), entry.name);
+ } else {
+ opts.onSelect([entry.name], entry.name);
+ }
+ });
+ row.addEventListener("dblclick", () => cache.opts.onOpen(entry.name));
+
+ if (entry.name !== "..") {
+ row.addEventListener("contextmenu", (e) => {
+ e.preventDefault();
+ const opts = cache.opts;
+ let selection = opts.selected;
+ if (!selection.includes(entry.name)) {
+ selection = [entry.name];
+ opts.onSelect(selection, entry.name);
}
+ const selectedEntries = cache.sorted.filter((en) => selection.includes(en.name));
+ const items = opts.contextMenuItems?.(entry, selectedEntries);
+ if (items) showContextMenu(e.clientX, e.clientY, items);
});
- row.addEventListener("dblclick", () => opts.onOpen(entry.name));
- if (entry.name !== "..") {
- row.addEventListener("contextmenu", (e) => {
- e.preventDefault();
- let selection = opts.selected;
- if (!selection.includes(entry.name)) {
- selection = [entry.name];
- opts.onSelect(selection, entry.name);
- }
- const selectedEntries = sorted.filter((en) => selection.includes(en.name));
- const items = opts.contextMenuItems?.(entry, selectedEntries);
- if (items) showContextMenu(e.clientX, e.clientY, items);
- });
-
- const payload = opts.dragPayload?.(entry);
- if (payload) makeDraggable(row.querySelector(".file-row-name"), entry, payload);
- }
-
- if (entry.kind === "dir" || entry.name === "..") {
- makeDropTarget(row, {
- onDrop: (payload) => opts.onDropOnEntry?.(entry, payload),
- onHoverChange: (hover) => row.classList.toggle("drag-hover", hover),
- onHoverHold: () => opts.onHoverOpen?.(entry),
- });
- }
-
- body.appendChild(row);
+ const payload = current.dragPayload?.(entry);
+ if (payload) makeDraggable(row.querySelector(".file-row-name"), entry, payload);
}
- body.scrollTop = scrollPos;
+ if (entry.kind === "dir" || entry.name === "..") {
+ makeDropTarget(row, {
+ onDrop: (payload) => cache.opts.onDropOnEntry?.(entry, payload),
+ onHoverChange: (hover) => row.classList.toggle("drag-hover", hover),
+ onHoverHold: () => cache.opts.onHoverOpen?.(entry),
+ });
+ }
+
+ return row;
+}
+
+function updateVisibleRows(cache) {
+ const sorted = cache.sorted;
+ if (sorted.length === 0) return;
+
+ const { start, end } = computeVisibleRange(
+ cache.body.scrollTop,
+ cache.body.clientHeight,
+ sorted.length,
+ );
+
+ const existing = cache.spacer.querySelectorAll(".file-row");
+ for (const row of existing) {
+ const idx = parseInt(row.dataset.index, 10);
+ if (idx < start || idx >= end) {
+ row.remove();
+ }
+ }
+
+ for (let i = start; i < end; i++) {
+ if (!cache.spacer.querySelector(`.file-row[data-index="${i}"]`)) {
+ const row = createRowElement(sorted[i], i, cache);
+ cache.spacer.appendChild(row);
+ }
+ }
+}
+
+function updateSelection(cache, selected) {
+ const rows = cache.spacer.querySelectorAll(".file-row");
+ for (const row of rows) {
+ const idx = parseInt(row.dataset.index, 10);
+ const entry = cache.sorted[idx];
+ if (entry) {
+ row.classList.toggle("selected", selected.includes(entry.name));
+ }
+ }
+}
+
+export function renderFileTable(container, opts) {
+ const cache = initStructure(container);
+ cache.opts = opts;
+
+ updateHeader(cache.header, opts);
+
+ const sorted = sortEntries(opts.entries, opts.sort);
+ const sig = getSignature(sorted, opts.sort);
+
+ if (sig !== cache.lastSignature) {
+ cache.lastSignature = sig;
+ cache.sorted = sorted;
+ cache.selectableNames = sorted.filter((e) => e.name !== "..").map((e) => e.name);
+ cache.spacer.style.height = sorted.length * ROW_HEIGHT + "px";
+ cache.body.scrollTop = 0;
+ cache.spacer.innerHTML = "";
+ updateVisibleRows(cache);
+ } else {
+ updateSelection(cache, opts.selected);
+ }
}
diff --git a/frontend/js/components/paneLayout.js b/frontend/js/components/paneLayout.js
index 2e959b9..5fb5fb5 100644
--- a/frontend/js/components/paneLayout.js
+++ b/frontend/js/components/paneLayout.js
@@ -64,7 +64,7 @@ function createPaneCell(pane) {
const closeBtn = document.createElement("button");
closeBtn.className = "pane-close-btn";
closeBtn.title = "Close";
- closeBtn.innerHTML = iconMarkup("close", 10);
+ closeBtn.innerHTML = iconMarkup("close", 13);
closeBtn.addEventListener("click", (e) => { e.stopPropagation(); closePane(pane.id); });
header.appendChild(closeBtn);
}
diff --git a/frontend/js/components/settingsDialog.js b/frontend/js/components/settingsDialog.js
index 4d90902..e82a970 100644
--- a/frontend/js/components/settingsDialog.js
+++ b/frontend/js/components/settingsDialog.js
@@ -22,6 +22,7 @@ import {
setLanguage,
setTheme,
} from "../settingsActions.js";
+import { openSiteManager } from "./siteManager.js";
const LANG_OPTIONS = [
["en", "English"],
@@ -176,6 +177,7 @@ function sectionConnections() {
${escapeHtml(s.name)}
${escapeHtml(s.username)}@${escapeHtml(s.host)}:${s.port}
+
`,
)
@@ -348,7 +350,7 @@ function build() {
@@ -408,6 +410,11 @@ function wireContentActions(root) {
});
});
+ root.querySelectorAll("[data-action='edit-site']").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ openSiteManager(btn.dataset.id);
+ });
+ });
root.querySelectorAll("[data-action='delete-site']").forEach((btn) => {
btn.addEventListener("click", async () => {
await deleteSite(btn.dataset.id);
diff --git a/frontend/js/components/siteManager.js b/frontend/js/components/siteManager.js
new file mode 100644
index 0000000..258e45a
--- /dev/null
+++ b/frontend/js/components/siteManager.js
@@ -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 = `
+
+
+ `;
+
+ 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 += `
${t("siteManager.noSites")}
`;
+ } else {
+ html += state.sites.map(
+ (s) => `
+
+
+
+ ${PROTOCOL_LABELS[s.protocol] ?? s.protocol}
+ ${escapeHtml(s.name)}
+
+
${escapeHtml(s.username)}@${escapeHtml(s.host)}:${s.port}
+
+
+
+
+
+
+
`,
+ ).join("");
+ }
+
+ html += `
+
+
+
+ `;
+
+ 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 = `
+
+ ${t("siteManager.name")}
+
+
+
+
+
${t("siteManager.protocol")}
+
+ ${PROTOCOLS.map((p) => ``).join("")}
+
+
+
+
+ ${t("connect.host")}
+
+
+
+
+
+
+
${t("connect.password")}
+
+
+
+
+
+
+
+
${t("siteManager.keyHint")}
+
+
${t("connect.keyFile")}
+
+
+
+
+
+
+
+
+ ${t("siteManager.folder")}
+
+
+
+
+ ${t("siteManager.note")}
+
+
+
+
+ `;
+
+ 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();
+ }
+}
diff --git a/frontend/js/components/tabs.js b/frontend/js/components/tabs.js
index 07ff07a..07d1ee3 100644
--- a/frontend/js/components/tabs.js
+++ b/frontend/js/components/tabs.js
@@ -65,7 +65,7 @@ function createTab(id, label, status, closable) {
const close = document.createElement("button");
close.type = "button";
close.className = "tab-close";
- close.innerHTML = iconMarkup("close", 10);
+ close.innerHTML = iconMarkup("close", 13);
close.title = "Close";
close.addEventListener("click", (e) => {
e.stopPropagation();
diff --git a/frontend/js/components/toolbar.js b/frontend/js/components/toolbar.js
index 5dade7a..4cbb010 100644
--- a/frontend/js/components/toolbar.js
+++ b/frontend/js/components/toolbar.js
@@ -1,6 +1,7 @@
import { state, notify, activeTab } from "../store.js";
import { t } from "../i18n.js";
import { iconMarkup } from "../icons.js";
+import * as ipc from "../ipc.js";
import { openNew } from "./connectDialog.js";
import { toggle as toggleHistory } from "./historyPopup.js";
import { openMkdir, openDelete, openRename } from "./opDialogs.js";
@@ -49,6 +50,7 @@ export function renderToolbar(container) {
+
@@ -90,6 +92,9 @@ export function renderToolbar(container) {
state.opTarget = state.activePane;
openRename(names[0]);
});
+ container.querySelector('[data-action="new-window"]').addEventListener("click", () => {
+ ipc.newWindow();
+ });
container.querySelector('[data-action="history"]').addEventListener("click", (e) => {
toggleHistory(e.currentTarget);
});
diff --git a/frontend/js/components/welcome.js b/frontend/js/components/welcome.js
index e8aa64f..60c5185 100644
--- a/frontend/js/components/welcome.js
+++ b/frontend/js/components/welcome.js
@@ -4,6 +4,7 @@ import { iconMarkup } from "../icons.js";
import { renderHistoryRows } from "./historyList.js";
import { openNew } from "./connectDialog.js";
import { openSettings } from "./settingsDialog.js";
+import { openSiteManagerNew } from "./siteManager.js";
import { connectFromSite, PROTOCOL_LABELS } from "../connectActions.js";
import { escapeHtml } from "../dom.js";
import { shortcutLabel } from "../platform.js";
@@ -31,6 +32,9 @@ export function renderWelcome(container) {
${iconMarkup("star", 12)}${t("welcome.savedSites")}
+
+
+
${iconMarkup("history", 12)}${t("welcome.recentConnections")}
@@ -42,6 +46,7 @@ export function renderWelcome(container) {
container.querySelector('[data-action="new-connection"]').addEventListener("click", openNew);
container.querySelector('[data-action="open-settings"]').addEventListener("click", openSettings);
+ container.querySelector('[data-action="manage-sites"]')?.addEventListener("click", openSiteManagerNew);
updateWelcome(container);
}
diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js
index a267f21..c68fc38 100644
--- a/frontend/js/i18n/en.js
+++ b/frontend/js/i18n/en.js
@@ -112,6 +112,22 @@ export default {
deleteTitleMulti: "Delete {n} items?",
deleteBody: "This can't be undone.",
},
+ siteManager: {
+ title: "Site Manager",
+ addSite: "Add Site",
+ editSite: "Edit Site",
+ name: "Name",
+ protocol: "Protocol",
+ keyHint: "Only used for SFTP connections.",
+ folder: "Remote folder (optional)",
+ note: "Note (optional)",
+ saving: "Saving…",
+ saved: "Site saved",
+ confirmDelete: 'Delete "{name}"?',
+ noSites: "No saved sites yet.",
+ manageSites: "Manage Sites",
+ editSiteHover: "Edit this site",
+ },
settings: {
title: "Settings",
navGeneral: "General",
diff --git a/frontend/js/ipc.js b/frontend/js/ipc.js
index e99984b..a00cee5 100644
--- a/frontend/js/ipc.js
+++ b/frontend/js/ipc.js
@@ -85,6 +85,7 @@ export const setPref = (key, value) => invoke("set_pref", { key, value });
export const deletePassword = (siteId) => invoke("delete_password", { siteId });
export const appDataDir = () => invoke("app_data_dir");
export const platformInfo = () => invoke("platform_info");
+export const newWindow = () => invoke("new_window");
// --- Events -------------------------------------------------------------------
// Payloads here are snake_case on the wire (worker.rs's ProgressPayload/
diff --git a/frontend/js/main.js b/frontend/js/main.js
index 1141bec..accfed8 100644
--- a/frontend/js/main.js
+++ b/frontend/js/main.js
@@ -15,6 +15,7 @@ import { renderConnectDialog } from "./components/connectDialog.js";
import { renderHistoryPopup } from "./components/historyPopup.js";
import { renderOpDialogs, closeDialog } from "./components/opDialogs.js";
import { renderSettingsDialog } from "./components/settingsDialog.js";
+import { renderSiteManager } from "./components/siteManager.js";
const root = document.getElementById("screen-root");
@@ -104,6 +105,7 @@ function render() {
renderHistoryPopup();
renderOpDialogs();
renderSettingsDialog();
+ renderSiteManager();
}
// Settings → Appearance toggles — animated via CSS transitions.
@@ -129,6 +131,9 @@ window.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
if (state.dialog) {
closeDialog();
+ } else if (state.showSiteManager) {
+ state.showSiteManager = false;
+ notify();
} else if (state.showConnectDialog && !state.connectLoading) {
state.showConnectDialog = false;
notify();
diff --git a/frontend/js/settingsActions.js b/frontend/js/settingsActions.js
index 767651e..f96ef1b 100644
--- a/frontend/js/settingsActions.js
+++ b/frontend/js/settingsActions.js
@@ -43,11 +43,14 @@ export async function loadSettings() {
const themeTransitionMs = 300;
let themeTimeout = null;
+const DARK_THEMES = new Set(["ember", "midnight", "forest", "ash"]);
+
export function applyTheme(themeId) {
const html = document.documentElement;
// Add a class to enable CSS transitions during theme switch
html.classList.add("theme-transitioning");
html.setAttribute("data-theme", themeId);
+ html.style.colorScheme = DARK_THEMES.has(themeId) ? "dark" : "light";
clearTimeout(themeTimeout);
themeTimeout = setTimeout(() => {
html.classList.remove("theme-transitioning");
diff --git a/frontend/js/store.js b/frontend/js/store.js
index 40dceb3..fa56eda 100644
--- a/frontend/js/store.js
+++ b/frontend/js/store.js
@@ -66,6 +66,7 @@ export const state = {
dialog: null, // { kind: 'mkdir'|'rename'|'delete', ... }
showSettingsDialog: false,
+ showSiteManager: false,
// Persisted (via get_pref/set_pref) + session-only (panel visibility)
// preferences -- see js/components/settingsDialog.js.
settings: {
diff --git a/src/commands.rs b/src/commands.rs
index 3ad2d81..f47fd5b 100644
--- a/src/commands.rs
+++ b/src/commands.rs
@@ -4,13 +4,20 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use tauri::State;
+use tauri::Manager;
use crate::domain::connection::{ConnectionParams, Protocol};
+use crate::domain::error::AppError;
use crate::domain::file_entry::FileEntry;
use crate::domain::site::Site;
use crate::domain::transfer::{TaskState, TransferKind, TransferTask};
+use crate::domain::window_state::WindowState;
use crate::fs::remote::RemoteRegistry;
-use crate::protocols::{RemoteFs, ftp::FtpClient, sftp::SftpClient};
+use crate::protocols::{
+ RemoteFs,
+ ftp::{FtpClient, FtpsClient},
+ sftp::SftpClient,
+};
use crate::storage::db::{self, HistoryRow};
use crate::transfer::queue::TransferQueue;
@@ -19,12 +26,16 @@ pub struct AppState {
pub registry: Arc
,
pub queue: TransferQueue,
pub max_concurrent: Arc,
+ pub auto_clear_secs: Arc,
}
// --- Connections ---------------------------------------------------------
#[tauri::command]
-pub async fn connect(state: State<'_, AppState>, params: ConnectionParams) -> Result {
+pub async fn connect(
+ state: State<'_, AppState>,
+ params: ConnectionParams,
+) -> Result {
let registry = state.registry.clone();
let db = state.db.clone();
@@ -44,40 +55,44 @@ pub async fn connect(state: State<'_, AppState>, params: ConnectionParams) -> Re
let fs: Arc = match params.protocol {
Protocol::Sftp => {
if let Some(key_path) = ¶ms.key_path {
- Arc::new(
- SftpClient::connect_key(
- ¶ms.host,
- params.port,
- ¶ms.username,
- key_path,
- params.password.as_deref(),
- )
- .map_err(|e| e.to_string())?,
- )
+ Arc::new(SftpClient::connect_key(
+ ¶ms.host,
+ params.port,
+ ¶ms.username,
+ key_path,
+ params.password.as_deref(),
+ )?)
} else if let Some(password) = ¶ms.password {
- Arc::new(
- SftpClient::connect_password(¶ms.host, params.port, ¶ms.username, password)
- .map_err(|e| e.to_string())?,
- )
+ Arc::new(SftpClient::connect_password(
+ ¶ms.host,
+ params.port,
+ ¶ms.username,
+ password,
+ )?)
} else {
- Arc::new(
- SftpClient::connect_auto(¶ms.host, params.port, ¶ms.username)
- .map_err(|e| e.to_string())?,
- )
+ Arc::new(SftpClient::connect_auto(
+ ¶ms.host,
+ params.port,
+ ¶ms.username,
+ )?)
}
}
Protocol::Ftp => {
- let password = params
- .password
- .clone()
- .ok_or("Password is required for FTP connections.")?;
+ let password = params.password.clone().ok_or_else(|| {
+ AppError::InvalidInput("Password is required for FTP connections.".into())
+ })?;
Arc::new(
- FtpClient::connect(¶ms.host, params.port, ¶ms.username, &password)
- .await
- .map_err(|e| e.to_string())?,
+ FtpClient::connect(¶ms.host, params.port, ¶ms.username, &password).await?,
+ )
+ }
+ Protocol::Ftps => {
+ let password = params.password.clone().ok_or_else(|| {
+ AppError::InvalidInput("Password is required for FTPS connections.".into())
+ })?;
+ Arc::new(
+ FtpsClient::connect(¶ms.host, params.port, ¶ms.username, &password).await?,
)
}
- Protocol::Ftps => return Err("FTPS not yet implemented".into()),
};
registry.insert(params.id.clone(), fs);
@@ -104,11 +119,11 @@ pub fn disconnect(state: State<'_, AppState>, connection_id: String) {
// --- Remote file ops -------------------------------------------------------
-fn get_remote(state: &AppState, connection_id: &str) -> Result, String> {
+fn get_remote(state: &AppState, connection_id: &str) -> Result, AppError> {
state
.registry
.get(connection_id)
- .ok_or_else(|| "connection not found".to_string())
+ .ok_or_else(|| AppError::NotFound("connection not found".into()))
}
#[tauri::command]
@@ -116,11 +131,8 @@ pub async fn remote_list(
state: State<'_, AppState>,
connection_id: String,
path: String,
-) -> Result, String> {
- get_remote(&state, &connection_id)?
- .list(&path)
- .await
- .map_err(|e| e.to_string())
+) -> Result, AppError> {
+ Ok(get_remote(&state, &connection_id)?.list(&path).await?)
}
#[tauri::command]
@@ -128,11 +140,8 @@ pub async fn remote_stat(
state: State<'_, AppState>,
connection_id: String,
path: String,
-) -> Result {
- get_remote(&state, &connection_id)?
- .stat(&path)
- .await
- .map_err(|e| e.to_string())
+) -> Result {
+ Ok(get_remote(&state, &connection_id)?.stat(&path).await?)
}
#[tauri::command]
@@ -140,11 +149,8 @@ pub async fn remote_mkdir(
state: State<'_, AppState>,
connection_id: String,
path: String,
-) -> Result<(), String> {
- get_remote(&state, &connection_id)?
- .mkdir(&path)
- .await
- .map_err(|e| e.to_string())
+) -> Result<(), AppError> {
+ Ok(get_remote(&state, &connection_id)?.mkdir(&path).await?)
}
#[tauri::command]
@@ -153,11 +159,10 @@ pub async fn remote_rename(
connection_id: String,
from: String,
to: String,
-) -> Result<(), String> {
- get_remote(&state, &connection_id)?
+) -> Result<(), AppError> {
+ Ok(get_remote(&state, &connection_id)?
.rename(&from, &to)
- .await
- .map_err(|e| e.to_string())
+ .await?)
}
#[tauri::command]
@@ -165,18 +170,15 @@ pub async fn remote_delete(
state: State<'_, AppState>,
connection_id: String,
path: String,
-) -> Result<(), String> {
- get_remote(&state, &connection_id)?
- .delete(&path)
- .await
- .map_err(|e| e.to_string())
+) -> Result<(), AppError> {
+ Ok(get_remote(&state, &connection_id)?.delete(&path).await?)
}
// --- Local file ops --------------------------------------------------------
#[tauri::command]
-pub fn local_list(path: String) -> Result, String> {
- crate::fs::local::list(&path).map_err(|e| e.to_string())
+pub fn local_list(path: String) -> Result, AppError> {
+ Ok(crate::fs::local::list(&path)?)
}
#[tauri::command]
@@ -185,28 +187,28 @@ pub fn local_home_dir() -> String {
}
#[tauri::command]
-pub fn local_mkdir(path: String) -> Result<(), String> {
- crate::fs::local::mkdir(&path).map_err(|e| e.to_string())
+pub fn local_mkdir(path: String) -> Result<(), AppError> {
+ Ok(crate::fs::local::mkdir(&path)?)
}
#[tauri::command]
-pub fn local_rename(from: String, to: String) -> Result<(), String> {
- crate::fs::local::rename(&from, &to).map_err(|e| e.to_string())
+pub fn local_rename(from: String, to: String) -> Result<(), AppError> {
+ Ok(crate::fs::local::rename(&from, &to)?)
}
#[tauri::command]
-pub fn local_delete(path: String) -> Result<(), String> {
- crate::fs::local::delete(&path).map_err(|e| e.to_string())
+pub fn local_delete(path: String) -> Result<(), AppError> {
+ Ok(crate::fs::local::delete(&path)?)
}
#[tauri::command]
-pub fn local_open(path: String) -> Result<(), String> {
- crate::fs::local::open(&path).map_err(|e| e.to_string())
+pub fn local_open(path: String) -> Result<(), AppError> {
+ Ok(crate::fs::local::open(&path)?)
}
#[tauri::command]
-pub fn local_move_into(src_path: String, dest_dir: String) -> Result<(), String> {
- crate::fs::local::move_into(&src_path, &dest_dir).map_err(|e| e.to_string())
+pub fn local_move_into(src_path: String, dest_dir: String) -> Result<(), AppError> {
+ Ok(crate::fs::local::move_into(&src_path, &dest_dir)?)
}
// --- Transfers --------------------------------------------------------------
@@ -222,7 +224,14 @@ pub fn enqueue_transfer(
file_name: String,
total_bytes: u64,
) -> String {
- let task = TransferTask::new(kind, connection_id, local_path, remote_path, file_name, total_bytes);
+ let task = TransferTask::new(
+ kind,
+ connection_id,
+ local_path,
+ remote_path,
+ file_name,
+ total_bytes,
+ );
let id = task.id.clone();
state.queue.push(task);
id
@@ -264,51 +273,79 @@ pub fn set_max_concurrent(state: State<'_, AppState>, n: u32) {
// --- Sites / bookmarks / history / settings ---------------------------------
#[tauri::command]
-pub fn list_sites(state: State<'_, AppState>) -> Result, String> {
- let conn = state.db.lock().map_err(|e| e.to_string())?;
- db::get_sites(&conn).map_err(|e| e.to_string())
+pub fn list_sites(state: State<'_, AppState>) -> Result, AppError> {
+ let conn = state
+ .db
+ .lock()
+ .map_err(|e| AppError::Internal(e.to_string()))?;
+ Ok(db::get_sites(&conn)?)
}
#[tauri::command]
-pub fn save_site(state: State<'_, AppState>, site: Site) -> Result<(), String> {
- let conn = state.db.lock().map_err(|e| e.to_string())?;
- db::save_site(&conn, &site).map_err(|e| e.to_string())
+pub fn save_site(state: State<'_, AppState>, site: Site) -> Result<(), AppError> {
+ let conn = state
+ .db
+ .lock()
+ .map_err(|e| AppError::Internal(e.to_string()))?;
+ Ok(db::save_site(&conn, &site)?)
}
#[tauri::command]
-pub fn delete_site(state: State<'_, AppState>, id: String) -> Result<(), String> {
- let conn = state.db.lock().map_err(|e| e.to_string())?;
- db::delete_site(&conn, &id).map_err(|e| e.to_string())
+pub fn delete_site(state: State<'_, AppState>, id: String) -> Result<(), AppError> {
+ let conn = state
+ .db
+ .lock()
+ .map_err(|e| AppError::Internal(e.to_string()))?;
+ Ok(db::delete_site(&conn, &id)?)
}
#[tauri::command]
-pub fn list_bookmarks(state: State<'_, AppState>) -> Result, String> {
- let conn = state.db.lock().map_err(|e| e.to_string())?;
- db::get_bookmarks(&conn).map_err(|e| e.to_string())
+pub fn list_bookmarks(state: State<'_, AppState>) -> Result, AppError> {
+ let conn = state
+ .db
+ .lock()
+ .map_err(|e| AppError::Internal(e.to_string()))?;
+ Ok(db::get_bookmarks(&conn)?)
}
#[tauri::command]
-pub fn add_bookmark(state: State<'_, AppState>, name: String, path: String) -> Result {
- let conn = state.db.lock().map_err(|e| e.to_string())?;
- db::add_bookmark(&conn, &name, &path).map_err(|e| e.to_string())
+pub fn add_bookmark(
+ state: State<'_, AppState>,
+ name: String,
+ path: String,
+) -> Result {
+ let conn = state
+ .db
+ .lock()
+ .map_err(|e| AppError::Internal(e.to_string()))?;
+ Ok(db::add_bookmark(&conn, &name, &path)?)
}
#[tauri::command]
-pub fn remove_bookmark(state: State<'_, AppState>, id: i64) -> Result<(), String> {
- let conn = state.db.lock().map_err(|e| e.to_string())?;
- db::remove_bookmark(&conn, id).map_err(|e| e.to_string())
+pub fn remove_bookmark(state: State<'_, AppState>, id: i64) -> Result<(), AppError> {
+ let conn = state
+ .db
+ .lock()
+ .map_err(|e| AppError::Internal(e.to_string()))?;
+ Ok(db::remove_bookmark(&conn, id)?)
}
#[tauri::command]
-pub fn list_history(state: State<'_, AppState>) -> Result, String> {
- let conn = state.db.lock().map_err(|e| e.to_string())?;
- db::get_history(&conn).map_err(|e| e.to_string())
+pub fn list_history(state: State<'_, AppState>) -> Result, AppError> {
+ let conn = state
+ .db
+ .lock()
+ .map_err(|e| AppError::Internal(e.to_string()))?;
+ Ok(db::get_history(&conn)?)
}
#[tauri::command]
-pub fn clear_history(state: State<'_, AppState>) -> Result<(), String> {
- let conn = state.db.lock().map_err(|e| e.to_string())?;
- db::clear_history(&conn).map_err(|e| e.to_string())
+pub fn clear_history(state: State<'_, AppState>) -> Result<(), AppError> {
+ let conn = state
+ .db
+ .lock()
+ .map_err(|e| AppError::Internal(e.to_string()))?;
+ Ok(db::clear_history(&conn)?)
}
#[tauri::command]
@@ -317,32 +354,47 @@ pub fn find_history_conn_id(
host: String,
port: u16,
username: String,
-) -> Result