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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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