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
|
|
@ -414,8 +414,8 @@
|
|||
flex: 1;
|
||||
}
|
||||
.dialog-close {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: transparent;
|
||||
|
|
@ -541,6 +541,10 @@
|
|||
cursor: pointer;
|
||||
min-width: 180px;
|
||||
}
|
||||
.settings-select option {
|
||||
background: var(--bg-panel);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.settings-select:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -305,8 +305,8 @@
|
|||
color: var(--text-primary);
|
||||
}
|
||||
.tab-close {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
|
|
@ -314,13 +314,15 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.tab-close:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: var(--text-primary);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* localPane.js/remotePane.js render straight into this element (there's no
|
||||
|
|
@ -708,17 +710,17 @@ html[data-platform="linux"] ::-webkit-scrollbar {
|
|||
}
|
||||
.pane-split-btn,
|
||||
.pane-close-btn {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-hint);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
font-size: 13px;
|
||||
padding: 0;
|
||||
}
|
||||
.pane-split-btn:hover,
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ function build() {
|
|||
<div class="dialog-header">
|
||||
<span class="icon icon--important">${iconMarkup("serverSquare", 16)}</span>
|
||||
<span class="dialog-title">${t("connect.title")}</span>
|
||||
<button type="button" class="dialog-close" data-action="close">${iconMarkup("close", 13)}</button>
|
||||
<button type="button" class="dialog-close" data-action="close">${iconMarkup("close", 16)}</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="segmented" data-role="protocol">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
// Reusable file listing table shared by the Local and Remote panes — mirrors
|
||||
// the old src/ui/panels/file_pane module (table.rs/row.rs/sort.rs collapsed
|
||||
// into one file since there's no split-module pressure in JS).
|
||||
import { t } from "../i18n.js";
|
||||
import { iconMarkup, fileIconFor } from "../icons.js";
|
||||
import { formatSize, formatTime } from "../format.js";
|
||||
|
|
@ -8,6 +5,11 @@ import { makeDraggable, makeDropTarget } from "../dnd.js";
|
|||
import { showContextMenu } from "./contextMenu.js";
|
||||
import { escapeHtml } from "../dom.js";
|
||||
|
||||
const ROW_HEIGHT = 32;
|
||||
const OVERSCAN = 10;
|
||||
|
||||
let rafId = null;
|
||||
|
||||
function sortEntries(entries, sort) {
|
||||
const dotdotIdx = entries.findIndex((e) => e.name === "..");
|
||||
const dotdot = dotdotIdx >= 0 ? entries[dotdotIdx] : null;
|
||||
|
|
@ -57,29 +59,64 @@ function sortHeaderHtml(label, col, sort) {
|
|||
return `<button type="button" class="sort-header ${active ? "active" : ""}" data-col="${col}">${label}${arrow}</button>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* opts:
|
||||
* - entries: FileEntry[]
|
||||
* - selected: string[] (names of selected entries)
|
||||
* - anchor: string|null (name shift-click range selection extends from)
|
||||
* - sort: { col, dir }
|
||||
* - onSort(nextSort)
|
||||
* - onSelect(names, anchor)
|
||||
* - onOpen(name)
|
||||
* - dragPayload(entry) => payload|null
|
||||
* - onDropOnEntry(entry, payload) only called for dir/".." rows
|
||||
* - onHoverOpen(entry) spring-loaded open: fires after a drag lingers on
|
||||
* a dir/".." row for HOVER_OPEN_DELAY, without
|
||||
* ending the drag -- only called for dir/".." rows
|
||||
* - contextMenuItems(entry, selectedEntries) => menu items for showContextMenu, or null to skip
|
||||
*/
|
||||
export function renderFileTable(container, opts) {
|
||||
const scrollPos = container.querySelector(".file-table-body")?.scrollTop ?? 0;
|
||||
function computeVisibleRange(scrollTop, viewportHeight, totalItems) {
|
||||
const start = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
|
||||
const end = Math.min(totalItems, Math.ceil((scrollTop + viewportHeight) / ROW_HEIGHT) + OVERSCAN);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function getSignature(sorted, sort) {
|
||||
const len = sorted.length;
|
||||
if (len === 0) return `0:${sort.col}:${sort.dir}`;
|
||||
const mid = Math.min(len >> 1, len - 1);
|
||||
return `${len}:${sort.col}:${sort.dir}:${sorted[0].name}:${sorted[mid].name}:${sorted[len - 1].name}`;
|
||||
}
|
||||
|
||||
function initStructure(container) {
|
||||
let cache = container._fileTableCache;
|
||||
if (cache) return cache;
|
||||
|
||||
container.innerHTML = "";
|
||||
container.classList.add("file-table-wrap");
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "file-table-header";
|
||||
container.appendChild(header);
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "file-table-body";
|
||||
container.appendChild(body);
|
||||
|
||||
const spacer = document.createElement("div");
|
||||
spacer.style.position = "relative";
|
||||
body.appendChild(spacer);
|
||||
|
||||
cache = {
|
||||
header,
|
||||
body,
|
||||
spacer,
|
||||
lastSignature: null,
|
||||
sorted: [],
|
||||
selectableNames: [],
|
||||
opts: null,
|
||||
};
|
||||
container._fileTableCache = cache;
|
||||
|
||||
body.addEventListener("scroll", () => {
|
||||
if (rafId) return;
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null;
|
||||
const c = container._fileTableCache;
|
||||
if (c && c.sorted.length > 0) {
|
||||
updateVisibleRows(c);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return cache;
|
||||
}
|
||||
|
||||
function updateHeader(header, opts) {
|
||||
header.innerHTML = `
|
||||
${sortHeaderHtml(t("panels.colName"), "name", opts.sort)}
|
||||
${sortHeaderHtml(t("panels.colModified"), "modified", opts.sort)}
|
||||
|
|
@ -96,81 +133,141 @@ export function renderFileTable(container, opts) {
|
|||
opts.onSort(next);
|
||||
});
|
||||
});
|
||||
container.appendChild(header);
|
||||
}
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "file-table-body";
|
||||
container.appendChild(body);
|
||||
function createRowElement(entry, index, cache) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "file-row";
|
||||
row.dataset.index = index;
|
||||
|
||||
const sorted = sortEntries(opts.entries, opts.sort);
|
||||
const selectableNames = sorted.filter((e) => e.name !== "..").map((e) => e.name);
|
||||
const current = cache.opts;
|
||||
if (current.selected.includes(entry.name)) row.classList.add("selected");
|
||||
|
||||
for (const entry of sorted) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "file-row";
|
||||
if (opts.selected.includes(entry.name)) row.classList.add("selected");
|
||||
row.style.position = "absolute";
|
||||
row.style.top = index * ROW_HEIGHT + "px";
|
||||
row.style.left = "0";
|
||||
row.style.right = "0";
|
||||
row.style.height = ROW_HEIGHT + "px";
|
||||
|
||||
const [iconName, iconColor] = rowIcon(entry);
|
||||
const nameCol = entry.name === ".." ? "text-hint" : "text-primary";
|
||||
const [iconName, iconColor] = rowIcon(entry);
|
||||
const nameCol = entry.name === ".." ? "text-hint" : "text-primary";
|
||||
|
||||
row.innerHTML = `
|
||||
<div class="file-row-name">
|
||||
<span class="icon ${iconColor}">${iconMarkup(iconName, 15)}</span>
|
||||
<span class="${nameCol}">${escapeHtml(entry.name)}</span>
|
||||
</div>
|
||||
<div class="file-row-modified mono">${formatTime(entry.modified)}</div>
|
||||
<div class="file-row-type">${typeLabel(entry)}</div>
|
||||
<div class="file-row-size mono">${formatSize(entry.size)}</div>
|
||||
`;
|
||||
row.innerHTML = `
|
||||
<div class="file-row-name">
|
||||
<span class="icon ${iconColor}">${iconMarkup(iconName, 15)}</span>
|
||||
<span class="${nameCol}">${escapeHtml(entry.name)}</span>
|
||||
</div>
|
||||
<div class="file-row-modified mono">${formatTime(entry.modified)}</div>
|
||||
<div class="file-row-type">${typeLabel(entry)}</div>
|
||||
<div class="file-row-size mono">${formatSize(entry.size)}</div>
|
||||
`;
|
||||
|
||||
row.addEventListener("click", (e) => {
|
||||
if (entry.name === "..") {
|
||||
opts.onSelect([], null);
|
||||
return;
|
||||
}
|
||||
const idx = selectableNames.indexOf(entry.name);
|
||||
if (e.shiftKey && opts.anchor && selectableNames.includes(opts.anchor)) {
|
||||
const anchorIdx = selectableNames.indexOf(opts.anchor);
|
||||
const [lo, hi] = anchorIdx < idx ? [anchorIdx, idx] : [idx, anchorIdx];
|
||||
opts.onSelect(selectableNames.slice(lo, hi + 1), opts.anchor);
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
const next = new Set(opts.selected);
|
||||
if (next.has(entry.name)) next.delete(entry.name);
|
||||
else next.add(entry.name);
|
||||
opts.onSelect(selectableNames.filter((n) => next.has(n)), entry.name);
|
||||
} else {
|
||||
opts.onSelect([entry.name], entry.name);
|
||||
row.addEventListener("click", (e) => {
|
||||
const opts = cache.opts;
|
||||
if (entry.name === "..") {
|
||||
opts.onSelect([], null);
|
||||
return;
|
||||
}
|
||||
const idx = cache.selectableNames.indexOf(entry.name);
|
||||
if (e.shiftKey && opts.anchor && cache.selectableNames.includes(opts.anchor)) {
|
||||
const anchorIdx = cache.selectableNames.indexOf(opts.anchor);
|
||||
const [lo, hi] = anchorIdx < idx ? [anchorIdx, idx] : [idx, anchorIdx];
|
||||
opts.onSelect(cache.selectableNames.slice(lo, hi + 1), opts.anchor);
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
const next = new Set(opts.selected);
|
||||
if (next.has(entry.name)) next.delete(entry.name);
|
||||
else next.add(entry.name);
|
||||
opts.onSelect(cache.selectableNames.filter((n) => next.has(n)), entry.name);
|
||||
} else {
|
||||
opts.onSelect([entry.name], entry.name);
|
||||
}
|
||||
});
|
||||
row.addEventListener("dblclick", () => cache.opts.onOpen(entry.name));
|
||||
|
||||
if (entry.name !== "..") {
|
||||
row.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
const opts = cache.opts;
|
||||
let selection = opts.selected;
|
||||
if (!selection.includes(entry.name)) {
|
||||
selection = [entry.name];
|
||||
opts.onSelect(selection, entry.name);
|
||||
}
|
||||
const selectedEntries = cache.sorted.filter((en) => selection.includes(en.name));
|
||||
const items = opts.contextMenuItems?.(entry, selectedEntries);
|
||||
if (items) showContextMenu(e.clientX, e.clientY, items);
|
||||
});
|
||||
row.addEventListener("dblclick", () => opts.onOpen(entry.name));
|
||||
|
||||
if (entry.name !== "..") {
|
||||
row.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
let selection = opts.selected;
|
||||
if (!selection.includes(entry.name)) {
|
||||
selection = [entry.name];
|
||||
opts.onSelect(selection, entry.name);
|
||||
}
|
||||
const selectedEntries = sorted.filter((en) => selection.includes(en.name));
|
||||
const items = opts.contextMenuItems?.(entry, selectedEntries);
|
||||
if (items) showContextMenu(e.clientX, e.clientY, items);
|
||||
});
|
||||
|
||||
const payload = opts.dragPayload?.(entry);
|
||||
if (payload) makeDraggable(row.querySelector(".file-row-name"), entry, payload);
|
||||
}
|
||||
|
||||
if (entry.kind === "dir" || entry.name === "..") {
|
||||
makeDropTarget(row, {
|
||||
onDrop: (payload) => opts.onDropOnEntry?.(entry, payload),
|
||||
onHoverChange: (hover) => row.classList.toggle("drag-hover", hover),
|
||||
onHoverHold: () => opts.onHoverOpen?.(entry),
|
||||
});
|
||||
}
|
||||
|
||||
body.appendChild(row);
|
||||
const payload = current.dragPayload?.(entry);
|
||||
if (payload) makeDraggable(row.querySelector(".file-row-name"), entry, payload);
|
||||
}
|
||||
|
||||
body.scrollTop = scrollPos;
|
||||
if (entry.kind === "dir" || entry.name === "..") {
|
||||
makeDropTarget(row, {
|
||||
onDrop: (payload) => cache.opts.onDropOnEntry?.(entry, payload),
|
||||
onHoverChange: (hover) => row.classList.toggle("drag-hover", hover),
|
||||
onHoverHold: () => cache.opts.onHoverOpen?.(entry),
|
||||
});
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function updateVisibleRows(cache) {
|
||||
const sorted = cache.sorted;
|
||||
if (sorted.length === 0) return;
|
||||
|
||||
const { start, end } = computeVisibleRange(
|
||||
cache.body.scrollTop,
|
||||
cache.body.clientHeight,
|
||||
sorted.length,
|
||||
);
|
||||
|
||||
const existing = cache.spacer.querySelectorAll(".file-row");
|
||||
for (const row of existing) {
|
||||
const idx = parseInt(row.dataset.index, 10);
|
||||
if (idx < start || idx >= end) {
|
||||
row.remove();
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = start; i < end; i++) {
|
||||
if (!cache.spacer.querySelector(`.file-row[data-index="${i}"]`)) {
|
||||
const row = createRowElement(sorted[i], i, cache);
|
||||
cache.spacer.appendChild(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateSelection(cache, selected) {
|
||||
const rows = cache.spacer.querySelectorAll(".file-row");
|
||||
for (const row of rows) {
|
||||
const idx = parseInt(row.dataset.index, 10);
|
||||
const entry = cache.sorted[idx];
|
||||
if (entry) {
|
||||
row.classList.toggle("selected", selected.includes(entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function renderFileTable(container, opts) {
|
||||
const cache = initStructure(container);
|
||||
cache.opts = opts;
|
||||
|
||||
updateHeader(cache.header, opts);
|
||||
|
||||
const sorted = sortEntries(opts.entries, opts.sort);
|
||||
const sig = getSignature(sorted, opts.sort);
|
||||
|
||||
if (sig !== cache.lastSignature) {
|
||||
cache.lastSignature = sig;
|
||||
cache.sorted = sorted;
|
||||
cache.selectableNames = sorted.filter((e) => e.name !== "..").map((e) => e.name);
|
||||
cache.spacer.style.height = sorted.length * ROW_HEIGHT + "px";
|
||||
cache.body.scrollTop = 0;
|
||||
cache.spacer.innerHTML = "";
|
||||
updateVisibleRows(cache);
|
||||
} else {
|
||||
updateSelection(cache, opts.selected);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ function createPaneCell(pane) {
|
|||
const closeBtn = document.createElement("button");
|
||||
closeBtn.className = "pane-close-btn";
|
||||
closeBtn.title = "Close";
|
||||
closeBtn.innerHTML = iconMarkup("close", 10);
|
||||
closeBtn.innerHTML = iconMarkup("close", 13);
|
||||
closeBtn.addEventListener("click", (e) => { e.stopPropagation(); closePane(pane.id); });
|
||||
header.appendChild(closeBtn);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
setLanguage,
|
||||
setTheme,
|
||||
} from "../settingsActions.js";
|
||||
import { openSiteManager } from "./siteManager.js";
|
||||
|
||||
const LANG_OPTIONS = [
|
||||
["en", "English"],
|
||||
|
|
@ -176,6 +177,7 @@ function sectionConnections() {
|
|||
<div class="settings-list-row-title">${escapeHtml(s.name)}</div>
|
||||
<div class="settings-list-row-sub">${escapeHtml(s.username)}@${escapeHtml(s.host)}:${s.port}</div>
|
||||
</div>
|
||||
<button type="button" class="btn icon-btn btn-ghost" data-action="edit-site" data-id="${s.id}" title="${t("siteManager.editSiteHover")}">${iconMarkup("pen", 13)}</button>
|
||||
<button type="button" class="btn icon-btn btn-ghost" data-action="delete-site" data-id="${s.id}" title="${t("settings.deleteSiteHover")}">${iconMarkup("trash", 14)}</button>
|
||||
</div>`,
|
||||
)
|
||||
|
|
@ -348,7 +350,7 @@ function build() {
|
|||
<div class="dialog-header">
|
||||
<span class="icon icon--important">${iconMarkup("settings", 16)}</span>
|
||||
<span class="dialog-title">${t("settings.title")}</span>
|
||||
<button type="button" class="dialog-close" data-action="close">${iconMarkup("close", 13)}</button>
|
||||
<button type="button" class="dialog-close" data-action="close">${iconMarkup("close", 16)}</button>
|
||||
</div>
|
||||
<div class="settings-body">
|
||||
<div class="settings-nav" data-role="nav"></div>
|
||||
|
|
@ -408,6 +410,11 @@ function wireContentActions(root) {
|
|||
});
|
||||
});
|
||||
|
||||
root.querySelectorAll("[data-action='edit-site']").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
openSiteManager(btn.dataset.id);
|
||||
});
|
||||
});
|
||||
root.querySelectorAll("[data-action='delete-site']").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
await deleteSite(btn.dataset.id);
|
||||
|
|
|
|||
374
frontend/js/components/siteManager.js
Normal file
374
frontend/js/components/siteManager.js
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
import { state, notify } from "../store.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { iconMarkup } from "../icons.js";
|
||||
import { escapeHtml } from "../dom.js";
|
||||
import { connectFromSite, PROTOCOL_LABELS } from "../connectActions.js";
|
||||
import * as ipc from "../ipc.js";
|
||||
|
||||
const PROTOCOLS = ["sftp", "ftp", "ftps"];
|
||||
const DEFAULT_PORTS = { sftp: 22, ftp: 21, ftps: 990 };
|
||||
|
||||
let overlayEl = null;
|
||||
let viewMode = "list";
|
||||
let form = emptyForm();
|
||||
let editingId = null;
|
||||
let saving = false;
|
||||
let deleteConfirmId = null;
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
name: "",
|
||||
protocol: "sftp",
|
||||
host: "",
|
||||
port: "",
|
||||
username: "",
|
||||
password: "",
|
||||
keyPath: "",
|
||||
folder: "",
|
||||
note: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function openSiteManager(siteId) {
|
||||
const site = state.sites.find((s) => s.id === siteId);
|
||||
if (!site) return;
|
||||
editingId = siteId;
|
||||
form = {
|
||||
name: site.name,
|
||||
protocol: site.protocol,
|
||||
host: site.host,
|
||||
port: String(site.port),
|
||||
username: site.username,
|
||||
password: site.password ?? "",
|
||||
keyPath: site.keyPath ?? "",
|
||||
folder: site.folder ?? "",
|
||||
note: site.note ?? "",
|
||||
};
|
||||
viewMode = "form";
|
||||
saving = false;
|
||||
deleteConfirmId = null;
|
||||
state.showSettingsDialog = false;
|
||||
state.showSiteManager = true;
|
||||
notify();
|
||||
}
|
||||
|
||||
export function openSiteManagerNew() {
|
||||
editingId = null;
|
||||
form = emptyForm();
|
||||
viewMode = "list";
|
||||
saving = false;
|
||||
deleteConfirmId = null;
|
||||
state.showSiteManager = true;
|
||||
notify();
|
||||
}
|
||||
|
||||
function close() {
|
||||
state.showSiteManager = false;
|
||||
overlayEl?.remove();
|
||||
overlayEl = null;
|
||||
notify();
|
||||
}
|
||||
|
||||
function switchToList() {
|
||||
viewMode = "list";
|
||||
deleteConfirmId = null;
|
||||
notify();
|
||||
}
|
||||
|
||||
async function saveCurrent() {
|
||||
if (saving) return;
|
||||
const name = form.name.trim();
|
||||
const host = form.host.trim();
|
||||
const username = form.username.trim();
|
||||
if (!name || !host || !username) return;
|
||||
|
||||
let port = DEFAULT_PORTS[form.protocol];
|
||||
if (form.port.trim()) {
|
||||
const parsed = Number(form.port);
|
||||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) return;
|
||||
port = parsed;
|
||||
}
|
||||
|
||||
saving = true;
|
||||
notify();
|
||||
|
||||
const site = {
|
||||
id: editingId || crypto.randomUUID(),
|
||||
name,
|
||||
protocol: form.protocol,
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
password: form.password || null,
|
||||
keyPath: form.keyPath || null,
|
||||
folder: form.folder || null,
|
||||
note: form.note || null,
|
||||
};
|
||||
|
||||
try {
|
||||
await ipc.saveSite(site);
|
||||
state.sites = await ipc.listSites();
|
||||
state.statusMessage = t("siteManager.saved");
|
||||
viewMode = "list";
|
||||
saving = false;
|
||||
notify();
|
||||
} catch {
|
||||
saving = false;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSiteAction(id) {
|
||||
const site = state.sites.find((s) => s.id === id);
|
||||
await ipc.deleteSite(id);
|
||||
await ipc.deletePassword(id);
|
||||
state.sites = state.sites.filter((s) => s.id !== id);
|
||||
if (site) state.statusMessage = `Removed ${site.name}`;
|
||||
notify();
|
||||
}
|
||||
|
||||
async function browseKeyFile() {
|
||||
try {
|
||||
const dialog = window.__TAURI__.dialog;
|
||||
const path = await dialog.open({ title: "Choose SSH key", multiple: false, directory: false });
|
||||
if (typeof path === "string") {
|
||||
form.keyPath = path;
|
||||
const input = overlayEl?.querySelector("[data-field=keyPath]");
|
||||
if (input) input.value = path;
|
||||
}
|
||||
} catch {
|
||||
/* dialog plugin unavailable / user cancelled */
|
||||
}
|
||||
}
|
||||
|
||||
function build() {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "overlay";
|
||||
overlay.addEventListener("mousedown", (e) => {
|
||||
if (e.target === overlay) close();
|
||||
});
|
||||
|
||||
const dialog = document.createElement("div");
|
||||
dialog.className = "dialog";
|
||||
dialog.style.width = "440px";
|
||||
dialog.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
dialog.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && viewMode === "form") saveCurrent();
|
||||
});
|
||||
|
||||
dialog.innerHTML = `
|
||||
<div class="dialog-header">
|
||||
<span class="icon icon--important">${iconMarkup("serverSquare", 16)}</span>
|
||||
<span class="dialog-title" data-role="title">${t("siteManager.title")}</span>
|
||||
<button type="button" class="dialog-close" data-action="close">${iconMarkup("close", 16)}</button>
|
||||
</div>
|
||||
<div class="dialog-body" data-role="body" style="max-height:60vh;overflow-y:auto"></div>
|
||||
`;
|
||||
|
||||
dialog.querySelector('[data-action="close"]').addEventListener("click", close);
|
||||
|
||||
overlay.appendChild(dialog);
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const title = overlayEl.querySelector('[data-role="title"]');
|
||||
title.textContent = t("siteManager.title");
|
||||
|
||||
const body = overlayEl.querySelector('[data-role="body"]');
|
||||
|
||||
let html = "";
|
||||
if (!state.sites.length) {
|
||||
html += `<div class="settings-empty">${t("siteManager.noSites")}</div>`;
|
||||
} else {
|
||||
html += state.sites.map(
|
||||
(s) => `
|
||||
<div class="settings-list-row">
|
||||
<div class="settings-list-row-main">
|
||||
<div class="settings-list-row-title">
|
||||
<span class="proto-badge" style="margin-right:6px">${PROTOCOL_LABELS[s.protocol] ?? s.protocol}</span>
|
||||
${escapeHtml(s.name)}
|
||||
</div>
|
||||
<div class="settings-list-row-sub">${escapeHtml(s.username)}@${escapeHtml(s.host)}:${s.port}</div>
|
||||
</div>
|
||||
<div class="settings-row" style="flex-shrink:0;gap:4px">
|
||||
<button type="button" class="btn icon-btn btn-ghost" data-action="sm-connect" data-id="${s.id}" title="${t("connect.connect")}">${iconMarkup("playCircle", 14)}</button>
|
||||
<button type="button" class="btn icon-btn btn-ghost" data-action="sm-edit" data-id="${s.id}" title="${t("siteManager.editSiteHover")}">${iconMarkup("pen", 13)}</button>
|
||||
<button type="button" class="btn icon-btn btn-ghost" data-action="sm-delete" data-id="${s.id}" title="${t("settings.deleteSiteHover")}">${iconMarkup("trash", 14)}</button>
|
||||
</div>
|
||||
</div>`,
|
||||
).join("");
|
||||
}
|
||||
|
||||
html += `
|
||||
<div style="margin-top:12px">
|
||||
<button type="button" class="btn btn-accent" data-action="sm-add" style="width:100%">
|
||||
${iconMarkup("addCircle", 14)}<span>${t("siteManager.addSite")}</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
body.innerHTML = html;
|
||||
|
||||
body.querySelectorAll("[data-action='sm-connect']").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const site = state.sites.find((s) => s.id === btn.dataset.id);
|
||||
if (site) {
|
||||
close();
|
||||
await connectFromSite(site);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
body.querySelectorAll("[data-action='sm-edit']").forEach((btn) => {
|
||||
btn.addEventListener("click", () => openSiteManager(btn.dataset.id));
|
||||
});
|
||||
|
||||
body.querySelectorAll("[data-action='sm-delete']").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
await deleteSiteAction(btn.dataset.id);
|
||||
if (overlayEl) renderList();
|
||||
});
|
||||
});
|
||||
|
||||
body.querySelector("[data-action='sm-add']").addEventListener("click", () => {
|
||||
editingId = null;
|
||||
form = emptyForm();
|
||||
viewMode = "form";
|
||||
notify();
|
||||
});
|
||||
}
|
||||
|
||||
function renderForm() {
|
||||
const title = overlayEl.querySelector('[data-role="title"]');
|
||||
title.textContent = editingId ? t("siteManager.editSite") : t("siteManager.addSite");
|
||||
|
||||
const body = overlayEl.querySelector('[data-role="body"]');
|
||||
|
||||
body.innerHTML = `
|
||||
<div class="field">
|
||||
<span class="field-label">${t("siteManager.name")}</span>
|
||||
<input class="field-input" data-field="name" type="text" value="${escapeHtml(form.name)}" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="field-label">${t("siteManager.protocol")}</span>
|
||||
<div class="segmented" data-role="protocol">
|
||||
${PROTOCOLS.map((p) => `<button type="button" class="segmented-item ${form.protocol === p ? "active" : ""}" data-protocol="${p}">${PROTOCOL_LABELS[p]}</button>`).join("")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="field-label">${t("connect.host")}</span>
|
||||
<input class="field-input" data-field="host" type="text" value="${escapeHtml(form.host)}" placeholder="example.com" />
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field" style="flex:1">
|
||||
<span class="field-label">${t("connect.username")}</span>
|
||||
<input class="field-input" data-field="username" type="text" value="${escapeHtml(form.username)}" />
|
||||
</div>
|
||||
<div class="field" style="width:80px">
|
||||
<span class="field-label">${t("connect.port")}</span>
|
||||
<input class="field-input" data-field="port" type="text" value="${escapeHtml(form.port)}" placeholder="${DEFAULT_PORTS[form.protocol]}" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="field-label">${t("connect.password")}</span>
|
||||
<div class="field-with-icon">
|
||||
<input class="field-input" data-field="password" type="password" value="${escapeHtml(form.password)}" />
|
||||
<button type="button" class="field-icon-btn" data-action="toggle-pass" title="Show/hide">${iconMarkup("eye", 15)}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-role="key-section" style="display:${form.protocol === "sftp" ? "block" : "none"}">
|
||||
<p class="field-hint">${t("siteManager.keyHint")}</p>
|
||||
<div class="field">
|
||||
<span class="field-label">${t("connect.keyFile")}</span>
|
||||
<div class="field-with-btn">
|
||||
<input class="field-input" data-field="keyPath" type="text" value="${escapeHtml(form.keyPath)}" />
|
||||
<button type="button" class="btn btn-ghost" data-action="browse" style="flex-shrink:0">${t("connect.browse")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="field-label">${t("siteManager.folder")}</span>
|
||||
<input class="field-input" data-field="folder" type="text" value="${escapeHtml(form.folder)}" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="field-label">${t("siteManager.note")}</span>
|
||||
<textarea class="field-input" data-field="note" rows="3" style="resize:vertical;padding:6px 10px;height:auto;line-height:1.4;font-family:inherit">${escapeHtml(form.note)}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="dialog-footer" style="margin-top:16px">
|
||||
<button type="button" class="btn btn-ghost" data-action="cancel">${t("common.cancel")}</button>
|
||||
<div class="spacer"></div>
|
||||
<button type="button" class="btn btn-accent" data-action="save" ${saving ? "disabled" : ""}>
|
||||
${saving ? t("siteManager.saving") : t("common.save")}
|
||||
</button>
|
||||
${saving ? '<span class="spinner" style="margin-left:8px"></span>' : ""}
|
||||
</div>
|
||||
`;
|
||||
|
||||
body.querySelector('[data-action="cancel"]').addEventListener("click", switchToList);
|
||||
body.querySelector('[data-action="save"]').addEventListener("click", saveCurrent);
|
||||
|
||||
body.querySelectorAll("[data-field]").forEach((input) => {
|
||||
input.addEventListener("input", () => {
|
||||
form[input.dataset.field] = input.value;
|
||||
});
|
||||
});
|
||||
|
||||
const passInput = body.querySelector('[data-field="password"]');
|
||||
const toggleBtn = body.querySelector('[data-action="toggle-pass"]');
|
||||
if (toggleBtn && passInput) {
|
||||
toggleBtn.addEventListener("click", () => {
|
||||
const showing = passInput.type === "text";
|
||||
passInput.type = showing ? "password" : "text";
|
||||
toggleBtn.innerHTML = iconMarkup(showing ? "eye" : "eyeClosed", 15);
|
||||
});
|
||||
}
|
||||
|
||||
const browseBtn = body.querySelector('[data-action="browse"]');
|
||||
if (browseBtn) browseBtn.addEventListener("click", browseKeyFile);
|
||||
|
||||
body.querySelectorAll("[data-protocol]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const proto = btn.dataset.protocol;
|
||||
form.protocol = proto;
|
||||
const portInput = body.querySelector('[data-field="port"]');
|
||||
if (!form.port) {
|
||||
portInput.placeholder = String(DEFAULT_PORTS[proto]);
|
||||
}
|
||||
body.querySelectorAll("[data-protocol]").forEach((b) => b.classList.toggle("active", b === btn));
|
||||
const keySection = body.querySelector('[data-role="key-section"]');
|
||||
if (keySection) keySection.style.display = proto === "sftp" ? "block" : "none";
|
||||
});
|
||||
});
|
||||
|
||||
setTimeout(() => body.querySelector('[data-field="name"]')?.focus(), 0);
|
||||
}
|
||||
|
||||
export function renderSiteManager() {
|
||||
if (!state.showSiteManager) {
|
||||
if (overlayEl) {
|
||||
overlayEl.remove();
|
||||
overlayEl = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!overlayEl) {
|
||||
overlayEl = build();
|
||||
document.body.appendChild(overlayEl);
|
||||
}
|
||||
|
||||
if (viewMode === "list") {
|
||||
renderList();
|
||||
} else {
|
||||
renderForm();
|
||||
}
|
||||
}
|
||||
|
|
@ -65,7 +65,7 @@ function createTab(id, label, status, closable) {
|
|||
const close = document.createElement("button");
|
||||
close.type = "button";
|
||||
close.className = "tab-close";
|
||||
close.innerHTML = iconMarkup("close", 10);
|
||||
close.innerHTML = iconMarkup("close", 13);
|
||||
close.title = "Close";
|
||||
close.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { state, notify, activeTab } from "../store.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { iconMarkup } from "../icons.js";
|
||||
import * as ipc from "../ipc.js";
|
||||
import { openNew } from "./connectDialog.js";
|
||||
import { toggle as toggleHistory } from "./historyPopup.js";
|
||||
import { openMkdir, openDelete, openRename } from "./opDialogs.js";
|
||||
|
|
@ -49,6 +50,7 @@ export function renderToolbar(container) {
|
|||
<button type="button" class="btn icon-btn btn-ghost" data-action="new-folder" title="${t("common.newFolder")} (${shortcutLabel("N", { shift: true })})" ${state.activePane === "remote" && !connected ? "disabled" : ""}>${iconMarkup("folderAdd", 16)}</button>
|
||||
<button type="button" class="btn icon-btn btn-ghost" data-action="delete" title="${t("common.delete")} (${isMac ? "⌘⌫" : "Del"})" ${currentSelection().length > 0 ? "" : "disabled"}>${iconMarkup("trash", 16)}</button>
|
||||
<button type="button" class="btn icon-btn btn-ghost" data-action="rename" title="${t("common.rename")} (${isMac ? "⏎" : "F2"})" ${currentSelection().length === 1 ? "" : "disabled"}>${iconMarkup("pen", 16)}</button>
|
||||
<button type="button" class="btn icon-btn btn-ghost" data-action="new-window" title="New Window">${iconMarkup("copy", 15)}</button>
|
||||
<div class="toolbar-spacer"></div>
|
||||
<button type="button" class="btn icon-btn btn-ghost" data-action="settings" title="${t("common.settings")} (${shortcutLabel(",")})">${iconMarkup("settings", 16)}</button>
|
||||
<button type="button" class="btn icon-btn btn-toggle ${state.showHistoryPopup ? "active" : ""}" data-action="history" data-history-toggle title="${t("common.history")}">${iconMarkup("history", 16)}</button>
|
||||
|
|
@ -90,6 +92,9 @@ export function renderToolbar(container) {
|
|||
state.opTarget = state.activePane;
|
||||
openRename(names[0]);
|
||||
});
|
||||
container.querySelector('[data-action="new-window"]').addEventListener("click", () => {
|
||||
ipc.newWindow();
|
||||
});
|
||||
container.querySelector('[data-action="history"]').addEventListener("click", (e) => {
|
||||
toggleHistory(e.currentTarget);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { iconMarkup } from "../icons.js";
|
|||
import { renderHistoryRows } from "./historyList.js";
|
||||
import { openNew } from "./connectDialog.js";
|
||||
import { openSettings } from "./settingsDialog.js";
|
||||
import { openSiteManagerNew } from "./siteManager.js";
|
||||
import { connectFromSite, PROTOCOL_LABELS } from "../connectActions.js";
|
||||
import { escapeHtml } from "../dom.js";
|
||||
import { shortcutLabel } from "../platform.js";
|
||||
|
|
@ -31,6 +32,9 @@ export function renderWelcome(container) {
|
|||
<div class="welcome-card" data-role="sites" style="display:none">
|
||||
<div class="welcome-card-title">${iconMarkup("star", 12)}<span>${t("welcome.savedSites")}</span></div>
|
||||
<div data-role="sites-list"></div>
|
||||
<div style="margin-top:6px;text-align:center">
|
||||
<button type="button" class="btn btn-ghost" data-action="manage-sites" style="font-size:11px;height:28px">${t("siteManager.manageSites")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="welcome-card" data-role="recent" style="display:none">
|
||||
<div class="welcome-card-title">${iconMarkup("history", 12)}<span>${t("welcome.recentConnections")}</span></div>
|
||||
|
|
@ -42,6 +46,7 @@ export function renderWelcome(container) {
|
|||
|
||||
container.querySelector('[data-action="new-connection"]').addEventListener("click", openNew);
|
||||
container.querySelector('[data-action="open-settings"]').addEventListener("click", openSettings);
|
||||
container.querySelector('[data-action="manage-sites"]')?.addEventListener("click", openSiteManagerNew);
|
||||
|
||||
updateWelcome(container);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,6 +112,22 @@ export default {
|
|||
deleteTitleMulti: "Delete {n} items?",
|
||||
deleteBody: "This can't be undone.",
|
||||
},
|
||||
siteManager: {
|
||||
title: "Site Manager",
|
||||
addSite: "Add Site",
|
||||
editSite: "Edit Site",
|
||||
name: "Name",
|
||||
protocol: "Protocol",
|
||||
keyHint: "Only used for SFTP connections.",
|
||||
folder: "Remote folder (optional)",
|
||||
note: "Note (optional)",
|
||||
saving: "Saving…",
|
||||
saved: "Site saved",
|
||||
confirmDelete: 'Delete "{name}"?',
|
||||
noSites: "No saved sites yet.",
|
||||
manageSites: "Manage Sites",
|
||||
editSiteHover: "Edit this site",
|
||||
},
|
||||
settings: {
|
||||
title: "Settings",
|
||||
navGeneral: "General",
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ export const setPref = (key, value) => invoke("set_pref", { key, value });
|
|||
export const deletePassword = (siteId) => invoke("delete_password", { siteId });
|
||||
export const appDataDir = () => invoke("app_data_dir");
|
||||
export const platformInfo = () => invoke("platform_info");
|
||||
export const newWindow = () => invoke("new_window");
|
||||
|
||||
// --- Events -------------------------------------------------------------------
|
||||
// Payloads here are snake_case on the wire (worker.rs's ProgressPayload/
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { renderConnectDialog } from "./components/connectDialog.js";
|
|||
import { renderHistoryPopup } from "./components/historyPopup.js";
|
||||
import { renderOpDialogs, closeDialog } from "./components/opDialogs.js";
|
||||
import { renderSettingsDialog } from "./components/settingsDialog.js";
|
||||
import { renderSiteManager } from "./components/siteManager.js";
|
||||
|
||||
const root = document.getElementById("screen-root");
|
||||
|
||||
|
|
@ -104,6 +105,7 @@ function render() {
|
|||
renderHistoryPopup();
|
||||
renderOpDialogs();
|
||||
renderSettingsDialog();
|
||||
renderSiteManager();
|
||||
}
|
||||
|
||||
// Settings → Appearance toggles — animated via CSS transitions.
|
||||
|
|
@ -129,6 +131,9 @@ window.addEventListener("keydown", (e) => {
|
|||
if (e.key !== "Escape") return;
|
||||
if (state.dialog) {
|
||||
closeDialog();
|
||||
} else if (state.showSiteManager) {
|
||||
state.showSiteManager = false;
|
||||
notify();
|
||||
} else if (state.showConnectDialog && !state.connectLoading) {
|
||||
state.showConnectDialog = false;
|
||||
notify();
|
||||
|
|
|
|||
|
|
@ -43,11 +43,14 @@ export async function loadSettings() {
|
|||
const themeTransitionMs = 300;
|
||||
let themeTimeout = null;
|
||||
|
||||
const DARK_THEMES = new Set(["ember", "midnight", "forest", "ash"]);
|
||||
|
||||
export function applyTheme(themeId) {
|
||||
const html = document.documentElement;
|
||||
// Add a class to enable CSS transitions during theme switch
|
||||
html.classList.add("theme-transitioning");
|
||||
html.setAttribute("data-theme", themeId);
|
||||
html.style.colorScheme = DARK_THEMES.has(themeId) ? "dark" : "light";
|
||||
clearTimeout(themeTimeout);
|
||||
themeTimeout = setTimeout(() => {
|
||||
html.classList.remove("theme-transitioning");
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ export const state = {
|
|||
dialog: null, // { kind: 'mkdir'|'rename'|'delete', ... }
|
||||
|
||||
showSettingsDialog: false,
|
||||
showSiteManager: false,
|
||||
// Persisted (via get_pref/set_pref) + session-only (panel visibility)
|
||||
// preferences -- see js/components/settingsDialog.js.
|
||||
settings: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue