Дезайн ыыыы
This commit is contained in:
parent
403d7086d4
commit
e7c02c7feb
174 changed files with 15802 additions and 9725 deletions
33
frontend/js/components/breadcrumbs.js
Normal file
33
frontend/js/components/breadcrumbs.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/** Renders a `/`-separated path as clickable breadcrumb segments into `container`. */
|
||||
export function renderBreadcrumbs(container, path, onNavigate) {
|
||||
container.innerHTML = "";
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
|
||||
const root = document.createElement("button");
|
||||
root.type = "button";
|
||||
root.className = "breadcrumb-seg";
|
||||
root.textContent = "/";
|
||||
root.addEventListener("click", () => onNavigate("/"));
|
||||
container.appendChild(root);
|
||||
|
||||
let acc = "";
|
||||
parts.forEach((part, i) => {
|
||||
acc += `/${part}`;
|
||||
const isLast = i === parts.length - 1;
|
||||
const seg = document.createElement(isLast ? "span" : "button");
|
||||
seg.className = `breadcrumb-seg ${isLast ? "current" : ""}`;
|
||||
seg.textContent = part;
|
||||
if (!isLast) {
|
||||
const target = acc;
|
||||
seg.type = "button";
|
||||
seg.addEventListener("click", () => onNavigate(target));
|
||||
}
|
||||
container.appendChild(seg);
|
||||
if (!isLast) {
|
||||
const sep = document.createElement("span");
|
||||
sep.className = "breadcrumb-sep";
|
||||
sep.textContent = "/";
|
||||
container.appendChild(sep);
|
||||
}
|
||||
});
|
||||
}
|
||||
233
frontend/js/components/connectDialog.js
Normal file
233
frontend/js/components/connectDialog.js
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
// "New Connection" modal — src/ui/dialogs/connection/{view,form}.rs equivalent.
|
||||
import { state, notify } from "../store.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { iconMarkup } from "../icons.js";
|
||||
import { buildParamsFromForm, spawnConnect, DEFAULT_PORTS } from "../connectActions.js";
|
||||
import { escapeHtml as escapeAttr } from "../dom.js";
|
||||
|
||||
const PROTOCOLS = ["sftp", "ftp", "ftps"];
|
||||
const PROTOCOL_LABELS = { sftp: "SFTP", ftp: "FTP", ftps: "FTPS" };
|
||||
|
||||
let form = emptyForm();
|
||||
let overlayEl = null;
|
||||
let lastOpenSig = null;
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
protocol: "sftp",
|
||||
host: "",
|
||||
port: "",
|
||||
username: "",
|
||||
password: "",
|
||||
keyPath: "",
|
||||
label: "",
|
||||
};
|
||||
}
|
||||
|
||||
/** Called by the toolbar "New Connection" button and the welcome screen CTA. */
|
||||
export function openNew() {
|
||||
form = emptyForm();
|
||||
state.connectError = "";
|
||||
state.showConnectDialog = true;
|
||||
notify();
|
||||
}
|
||||
|
||||
/** Called from history "Edit" — prefills everything. */
|
||||
export function openForEdit(entry) {
|
||||
form = {
|
||||
protocol: entry.protocol,
|
||||
host: entry.host,
|
||||
port: String(entry.port),
|
||||
username: entry.username,
|
||||
password: "",
|
||||
keyPath: entry.keyPath ?? "",
|
||||
label: "",
|
||||
};
|
||||
state.connectError = "";
|
||||
state.showConnectDialog = true;
|
||||
notify();
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (state.connectLoading) return;
|
||||
state.showConnectDialog = false;
|
||||
notify();
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (state.connectLoading) return;
|
||||
const result = buildParamsFromForm(form);
|
||||
if (!result.ok) {
|
||||
state.connectError = t(result.error);
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
await spawnConnect(result.params);
|
||||
}
|
||||
|
||||
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.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
|
||||
dialog.innerHTML = `
|
||||
<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>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<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 class="field">
|
||||
<span class="field-label">${t("connect.host")}</span>
|
||||
<input class="field-input" data-field="host" type="text" value="${escapeAttr(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="${escapeAttr(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="${escapeAttr(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="${escapeAttr(form.password)}" placeholder="" />
|
||||
<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("connect.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="${escapeAttr(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("connect.label")}</span>
|
||||
<input class="field-input" data-field="label" type="text" value="${escapeAttr(form.label)}" />
|
||||
</div>
|
||||
|
||||
<div data-role="error"></div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn btn-ghost" data-action="close">${t("common.cancel")}</button>
|
||||
<div class="spacer"></div>
|
||||
<button type="button" class="btn btn-accent" data-action="submit">${t("connect.connect")}</button>
|
||||
<span data-role="spinner" style="margin-left:8px"></span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
overlay.appendChild(dialog);
|
||||
|
||||
dialog.querySelectorAll('[data-action="close"]').forEach((el) =>
|
||||
el.addEventListener("click", close),
|
||||
);
|
||||
dialog.querySelector('[data-action="submit"]').addEventListener("click", submit);
|
||||
dialog.querySelector('[data-action="browse"]').addEventListener("click", browseKeyFile);
|
||||
|
||||
dialog.querySelectorAll("[data-field]").forEach((input) => {
|
||||
input.addEventListener("input", () => {
|
||||
form[input.dataset.field] = input.value;
|
||||
});
|
||||
});
|
||||
|
||||
const passInput = dialog.querySelector('[data-field="password"]');
|
||||
dialog.querySelector('[data-action="toggle-pass"]').addEventListener("click", (e) => {
|
||||
const showing = passInput.type === "text";
|
||||
passInput.type = showing ? "password" : "text";
|
||||
e.currentTarget.innerHTML = iconMarkup(showing ? "eye" : "eyeClosed", 15);
|
||||
});
|
||||
|
||||
dialog.querySelectorAll("[data-protocol]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const proto = btn.dataset.protocol;
|
||||
form.protocol = proto;
|
||||
if (!form.port) {
|
||||
dialog.querySelector('[data-field="port"]').placeholder = String(DEFAULT_PORTS[proto]);
|
||||
}
|
||||
dialog.querySelectorAll("[data-protocol]").forEach((b) => b.classList.toggle("active", b === btn));
|
||||
dialog.querySelector('[data-role="key-section"]').style.display = proto === "sftp" ? "block" : "none";
|
||||
});
|
||||
});
|
||||
|
||||
dialog.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") submit();
|
||||
});
|
||||
|
||||
return { overlay, dialog };
|
||||
}
|
||||
|
||||
function updateDynamic(dialog) {
|
||||
const errRole = dialog.querySelector('[data-role="error"]');
|
||||
errRole.innerHTML = state.connectError
|
||||
? `<div class="error-box">${iconMarkup("dangerTriangle", 14)}<span>${escapeAttr(state.connectError)}</span></div>`
|
||||
: "";
|
||||
|
||||
const submitBtn = dialog.querySelector('[data-action="submit"]');
|
||||
submitBtn.disabled = state.connectLoading;
|
||||
submitBtn.textContent = state.connectLoading ? t("connect.connecting") : t("connect.connect");
|
||||
const cancelBtn = dialog.querySelector('[data-action="close"]');
|
||||
cancelBtn.disabled = state.connectLoading;
|
||||
|
||||
dialog.querySelector('[data-role="spinner"]').innerHTML = state.connectLoading
|
||||
? '<span class="spinner"></span>'
|
||||
: "";
|
||||
}
|
||||
|
||||
export function renderConnectDialog() {
|
||||
const sig = JSON.stringify([state.showConnectDialog]);
|
||||
if (!state.showConnectDialog) {
|
||||
if (overlayEl) {
|
||||
overlayEl.remove();
|
||||
overlayEl = null;
|
||||
}
|
||||
lastOpenSig = sig;
|
||||
return;
|
||||
}
|
||||
|
||||
if (sig !== lastOpenSig || !overlayEl) {
|
||||
if (overlayEl) overlayEl.remove();
|
||||
const { overlay, dialog } = build();
|
||||
document.body.appendChild(overlay);
|
||||
overlayEl = overlay;
|
||||
overlayEl._dialog = dialog;
|
||||
setTimeout(() => dialog.querySelector('[data-field="host"]').focus(), 0);
|
||||
lastOpenSig = sig;
|
||||
}
|
||||
|
||||
updateDynamic(overlayEl._dialog);
|
||||
}
|
||||
58
frontend/js/components/contextMenu.js
Normal file
58
frontend/js/components/contextMenu.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { iconMarkup } from "../icons.js";
|
||||
|
||||
let openMenuEl = null;
|
||||
|
||||
function closeOpenMenu() {
|
||||
if (openMenuEl) {
|
||||
openMenuEl.remove();
|
||||
openMenuEl = null;
|
||||
document.removeEventListener("mousedown", onOutsideClick, true);
|
||||
document.removeEventListener("keydown", onKeydown, true);
|
||||
}
|
||||
}
|
||||
|
||||
function onOutsideClick(e) {
|
||||
if (openMenuEl && !openMenuEl.contains(e.target)) closeOpenMenu();
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (e.key === "Escape") closeOpenMenu();
|
||||
}
|
||||
|
||||
/**
|
||||
* items: Array<{ label, icon?, danger?, onClick } | 'sep'>
|
||||
*/
|
||||
export function showContextMenu(x, y, items) {
|
||||
closeOpenMenu();
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "context-menu";
|
||||
for (const item of items) {
|
||||
if (item === "sep") {
|
||||
const sep = document.createElement("div");
|
||||
sep.className = "context-menu-sep";
|
||||
menu.appendChild(sep);
|
||||
continue;
|
||||
}
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "context-menu-item" + (item.danger ? " danger" : "");
|
||||
btn.innerHTML = `${item.icon ? iconMarkup(item.icon, 14) : ""}<span>${item.label}</span>`;
|
||||
btn.addEventListener("click", () => {
|
||||
closeOpenMenu();
|
||||
item.onClick();
|
||||
});
|
||||
menu.appendChild(btn);
|
||||
}
|
||||
document.body.appendChild(menu);
|
||||
|
||||
const rect = menu.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
menu.style.left = `${Math.min(x, vw - rect.width - 8)}px`;
|
||||
menu.style.top = `${Math.min(y, vh - rect.height - 8)}px`;
|
||||
|
||||
openMenuEl = menu;
|
||||
setTimeout(() => {
|
||||
document.addEventListener("mousedown", onOutsideClick, true);
|
||||
document.addEventListener("keydown", onKeydown, true);
|
||||
}, 0);
|
||||
}
|
||||
176
frontend/js/components/fileTable.js
Normal file
176
frontend/js/components/fileTable.js
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
// 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";
|
||||
import { makeDraggable, makeDropTarget } from "../dnd.js";
|
||||
import { showContextMenu } from "./contextMenu.js";
|
||||
import { escapeHtml } from "../dom.js";
|
||||
|
||||
function sortEntries(entries, sort) {
|
||||
const dotdotIdx = entries.findIndex((e) => e.name === "..");
|
||||
const dotdot = dotdotIdx >= 0 ? entries[dotdotIdx] : null;
|
||||
const rest = entries.filter((e) => e.name !== "..");
|
||||
|
||||
rest.sort((a, b) => {
|
||||
const aDir = a.kind === "dir";
|
||||
const bDir = b.kind === "dir";
|
||||
if (aDir !== bDir) return aDir ? -1 : 1;
|
||||
|
||||
let cmp = 0;
|
||||
switch (sort.col) {
|
||||
case "size":
|
||||
cmp = (a.size ?? 0) - (b.size ?? 0);
|
||||
break;
|
||||
case "kind":
|
||||
cmp = a.kind.localeCompare(b.kind);
|
||||
break;
|
||||
case "modified":
|
||||
cmp = (a.modified ?? 0) - (b.modified ?? 0);
|
||||
break;
|
||||
default:
|
||||
cmp = a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||
}
|
||||
return sort.dir === "desc" ? -cmp : cmp;
|
||||
});
|
||||
|
||||
return dotdot ? [dotdot, ...rest] : rest;
|
||||
}
|
||||
|
||||
function rowIcon(entry) {
|
||||
if (entry.name === "..") return ["arrowUp", "icon--inactive"];
|
||||
if (entry.kind === "dir") return ["folder", "icon--important"];
|
||||
if (entry.kind === "symlink") return ["link", "icon--important"];
|
||||
return fileIconFor(entry.name);
|
||||
}
|
||||
|
||||
function typeLabel(entry) {
|
||||
if (entry.kind === "dir") return t("panels.typeDir");
|
||||
if (entry.kind === "symlink") return t("panels.typeLink");
|
||||
return t("panels.typeFile");
|
||||
}
|
||||
|
||||
function sortHeaderHtml(label, col, sort) {
|
||||
const active = sort.col === col;
|
||||
const arrow = active ? (sort.dir === "asc" ? " ↑" : " ↓") : "";
|
||||
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;
|
||||
container.innerHTML = "";
|
||||
container.classList.add("file-table-wrap");
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "file-table-header";
|
||||
header.innerHTML = `
|
||||
${sortHeaderHtml(t("panels.colName"), "name", opts.sort)}
|
||||
${sortHeaderHtml(t("panels.colModified"), "modified", opts.sort)}
|
||||
${sortHeaderHtml(t("panels.colType"), "kind", opts.sort)}
|
||||
${sortHeaderHtml(t("panels.colSize"), "size", opts.sort)}
|
||||
`;
|
||||
header.querySelectorAll(".sort-header").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const col = btn.dataset.col;
|
||||
const next =
|
||||
opts.sort.col === col
|
||||
? { col, dir: opts.sort.dir === "asc" ? "desc" : "asc" }
|
||||
: { col, dir: "asc" };
|
||||
opts.onSort(next);
|
||||
});
|
||||
});
|
||||
container.appendChild(header);
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "file-table-body";
|
||||
container.appendChild(body);
|
||||
|
||||
const sorted = sortEntries(opts.entries, opts.sort);
|
||||
const selectableNames = sorted.filter((e) => e.name !== "..").map((e) => e.name);
|
||||
|
||||
for (const entry of sorted) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "file-row";
|
||||
if (opts.selected.includes(entry.name)) row.classList.add("selected");
|
||||
|
||||
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.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("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);
|
||||
}
|
||||
|
||||
body.scrollTop = scrollPos;
|
||||
}
|
||||
47
frontend/js/components/historyList.js
Normal file
47
frontend/js/components/historyList.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// Shared "recent connections" row renderer used by both the welcome screen
|
||||
// and the toolbar's History popover.
|
||||
import { iconMarkup } from "../icons.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { showContextMenu } from "./contextMenu.js";
|
||||
import { reconnectFromHistory, saveHistoryAsSite } from "../connectActions.js";
|
||||
import { openForEdit } from "./connectDialog.js";
|
||||
|
||||
/** entries: HistoryEntry[]; rowClass defaults to "recent-row". */
|
||||
export function renderHistoryRows(container, entries, { rowClass = "recent-row", onAfterClick } = {}) {
|
||||
container.innerHTML = "";
|
||||
for (const entry of entries) {
|
||||
const row = document.createElement("div");
|
||||
row.className = rowClass;
|
||||
row.innerHTML = `
|
||||
<span class="icon">${iconMarkup("serverSquare", 13)}</span>
|
||||
<span class="recent-label">${entry.username}@${entry.host}:${entry.port}</span>
|
||||
<span class="recent-time">${entry.connectedAt}</span>
|
||||
`;
|
||||
row.addEventListener("click", () => {
|
||||
reconnectFromHistory(entry);
|
||||
onAfterClick?.();
|
||||
});
|
||||
row.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
showContextMenu(e.clientX, e.clientY, [
|
||||
{
|
||||
label: t("common.edit"),
|
||||
icon: "pen",
|
||||
onClick: () => {
|
||||
openForEdit(entry);
|
||||
onAfterClick?.();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("common.save"),
|
||||
icon: "star",
|
||||
onClick: () => {
|
||||
saveHistoryAsSite(entry);
|
||||
onAfterClick?.();
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
container.appendChild(row);
|
||||
}
|
||||
}
|
||||
68
frontend/js/components/historyPopup.js
Normal file
68
frontend/js/components/historyPopup.js
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { state, notify } from "../store.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { iconMarkup } from "../icons.js";
|
||||
import { renderHistoryRows } from "./historyList.js";
|
||||
|
||||
let popoverEl = null;
|
||||
let pendingAnchorRect = null;
|
||||
|
||||
function close() {
|
||||
state.showHistoryPopup = false;
|
||||
notify();
|
||||
}
|
||||
|
||||
export function toggle(anchorEl) {
|
||||
// Measure the anchor before notify() -- the toolbar re-renders itself
|
||||
// synchronously inside notify() (its computeSig() includes
|
||||
// showHistoryPopup), which discards and recreates this exact button. A
|
||||
// rect read after that point, or against a stale element reference, would
|
||||
// measure a detached node and yield an all-zero rect.
|
||||
pendingAnchorRect = anchorEl.getBoundingClientRect();
|
||||
state.showHistoryPopup = !state.showHistoryPopup;
|
||||
notify();
|
||||
if (state.showHistoryPopup) positionNear(pendingAnchorRect);
|
||||
}
|
||||
|
||||
function positionNear(rect) {
|
||||
if (!popoverEl || !rect) return;
|
||||
popoverEl.style.top = `${rect.bottom + 6}px`;
|
||||
popoverEl.style.right = `${window.innerWidth - rect.right}px`;
|
||||
}
|
||||
|
||||
function onOutsideClick(e) {
|
||||
if (popoverEl && !popoverEl.contains(e.target) && !e.target.closest("[data-history-toggle]")) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
export function renderHistoryPopup() {
|
||||
if (!state.showHistoryPopup) {
|
||||
if (popoverEl) {
|
||||
document.removeEventListener("mousedown", onOutsideClick, true);
|
||||
popoverEl.remove();
|
||||
popoverEl = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!popoverEl) {
|
||||
popoverEl = document.createElement("div");
|
||||
popoverEl.className = "popover";
|
||||
document.body.appendChild(popoverEl);
|
||||
setTimeout(() => document.addEventListener("mousedown", onOutsideClick, true), 0);
|
||||
}
|
||||
|
||||
popoverEl.innerHTML = `
|
||||
<div class="popover-header">
|
||||
<span class="icon">${iconMarkup("serverSquare", 12)}</span>
|
||||
<span>${t("history.recentConnections")}</span>
|
||||
</div>
|
||||
${state.history.length === 0 ? `<div class="popover-empty">${t("history.noHistory")}</div>` : '<div class="popover-list" data-role="list"></div>'}
|
||||
`;
|
||||
|
||||
if (state.history.length > 0) {
|
||||
renderHistoryRows(popoverEl.querySelector('[data-role="list"]'), state.history, {
|
||||
rowClass: "popover-row",
|
||||
onAfterClick: close,
|
||||
});
|
||||
}
|
||||
}
|
||||
140
frontend/js/components/localPane.js
Normal file
140
frontend/js/components/localPane.js
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { state, notify, activeTab } from "../store.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { iconMarkup } from "../icons.js";
|
||||
import { formatSize } from "../format.js";
|
||||
import { renderFileTable } from "./fileTable.js";
|
||||
import { renderBreadcrumbs } from "./breadcrumbs.js";
|
||||
import { makeDropTarget } from "../dnd.js";
|
||||
import { showContextMenu } from "./contextMenu.js";
|
||||
import { openRename, openDelete } from "./opDialogs.js";
|
||||
import {
|
||||
navigateLocal,
|
||||
openLocalEntry,
|
||||
addBookmark,
|
||||
handleDropOnLocal,
|
||||
queueUpload,
|
||||
queueUploadMany,
|
||||
} from "../fsActions.js";
|
||||
|
||||
export function renderLocalPane(container) {
|
||||
container.innerHTML = `
|
||||
<div class="path-bar">
|
||||
<span class="path-bar-label">${t("panels.localLabel")}</span>
|
||||
<div class="breadcrumbs" data-role="breadcrumbs"></div>
|
||||
</div>
|
||||
<div data-role="table" class="file-table-wrap"></div>
|
||||
<div class="pane-footer">
|
||||
<span data-role="count" class="text-dim" style="font-size:11px"></span>
|
||||
<span data-role="size" class="text-hint" style="font-size:11px"></span>
|
||||
<div class="pane-footer-right">
|
||||
<button type="button" class="btn icon-btn btn-ghost" data-action="bookmark" title="${t("panels.bookmarkThisFolder")}">${iconMarkup("star", 13)}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.querySelector('[data-action="bookmark"]').addEventListener("click", () => {
|
||||
const path = state.local.path;
|
||||
const name = path.split("/").filter(Boolean).pop() ?? "Bookmark";
|
||||
addBookmark(name, path);
|
||||
});
|
||||
|
||||
makeDropTarget(container, {
|
||||
onDrop: (payload) => handleDropOnLocal(payload, state.local.path),
|
||||
onHoverChange: (hover) => container.classList.toggle("drag-over", hover),
|
||||
});
|
||||
|
||||
updateLocalPane(container);
|
||||
}
|
||||
|
||||
export function updateLocalPane(container) {
|
||||
renderBreadcrumbs(container.querySelector('[data-role="breadcrumbs"]'), state.local.path || "/", navigateLocal);
|
||||
|
||||
const tab = activeTab();
|
||||
const connected = tab?.status === "connected";
|
||||
|
||||
renderFileTable(container.querySelector('[data-role="table"]'), {
|
||||
entries: state.local.entries,
|
||||
selected: state.local.selected,
|
||||
anchor: state.local.selectAnchor,
|
||||
sort: state.local.sort,
|
||||
onSort: (next) => {
|
||||
state.local.sort = next;
|
||||
notify();
|
||||
},
|
||||
onSelect: (names, anchor) => {
|
||||
state.local.selected = names;
|
||||
state.local.selectAnchor = anchor;
|
||||
state.activePane = "local";
|
||||
notify();
|
||||
},
|
||||
onOpen: (name) => openLocalEntry(name),
|
||||
onHoverOpen: (entry) => openLocalEntry(entry.name),
|
||||
dragPayload: (entry) => {
|
||||
if (entry.name === "..") return null;
|
||||
const group =
|
||||
state.local.selected.includes(entry.name) && state.local.selected.length > 1
|
||||
? state.local.entries.filter((e) => state.local.selected.includes(e.name))
|
||||
: [entry];
|
||||
return {
|
||||
source: "local",
|
||||
items: group.map((e) => ({ path: e.path, name: e.name, kind: e.kind })),
|
||||
};
|
||||
},
|
||||
onDropOnEntry: (entry, payload) => handleDropOnLocal(payload, entry.path),
|
||||
contextMenuItems: (entry, selectedEntries) => {
|
||||
if (selectedEntries.length > 1) {
|
||||
const files = selectedEntries.filter((e) => e.kind === "file");
|
||||
return [
|
||||
{
|
||||
label: t("common.delete") + ` (${selectedEntries.length})`,
|
||||
icon: "trash",
|
||||
danger: true,
|
||||
onClick: () => openDeleteLocal(selectedEntries),
|
||||
},
|
||||
...(files.length > 0 && connected
|
||||
? [
|
||||
"sep",
|
||||
{
|
||||
label: t("panels.uploadToServer") + ` (${files.length})`,
|
||||
icon: "upload",
|
||||
onClick: () => queueUploadMany(tab, files),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ label: t("common.open"), icon: "folder", onClick: () => openLocalEntry(entry.name) },
|
||||
{ label: t("common.rename"), icon: "pen", onClick: () => openRenameLocal(entry) },
|
||||
{ label: t("common.delete"), icon: "trash", danger: true, onClick: () => openDeleteLocal([entry]) },
|
||||
"sep",
|
||||
...(entry.kind === "dir"
|
||||
? [{ label: t("panels.addToBookmarks"), icon: "star", onClick: () => addBookmark(entry.name, entry.path) }]
|
||||
: []),
|
||||
{ label: t("common.copyPath"), icon: "copy", onClick: () => copyPath(entry.path) },
|
||||
...(entry.kind === "file" && connected
|
||||
? ["sep", { label: t("panels.uploadToServer"), icon: "upload", onClick: () => queueUpload(tab, entry) }]
|
||||
: []),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
const count = state.local.entries.filter((e) => e.name !== "..").length;
|
||||
const totalSize = state.local.entries.reduce((sum, e) => sum + (e.size ?? 0), 0);
|
||||
container.querySelector('[data-role="count"]').textContent = t("panels.countItems", { n: count });
|
||||
container.querySelector('[data-role="size"]').textContent = totalSize > 0 ? `(${formatSize(totalSize)})` : "";
|
||||
}
|
||||
|
||||
function openRenameLocal(entry) {
|
||||
state.opTarget = "local";
|
||||
openRename(entry.name);
|
||||
}
|
||||
function openDeleteLocal(entries) {
|
||||
state.opTarget = "local";
|
||||
openDelete(entries.map((e) => e.name));
|
||||
}
|
||||
function copyPath(path) {
|
||||
navigator.clipboard?.writeText(path);
|
||||
state.statusMessage = t("panels.pathCopied");
|
||||
notify();
|
||||
}
|
||||
158
frontend/js/components/opDialogs.js
Normal file
158
frontend/js/components/opDialogs.js
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
// New Folder / Rename / Delete-confirm modals — shared by local and remote
|
||||
// panes, applied to whichever side is `state.opTarget`.
|
||||
import { state, notify } from "../store.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { iconMarkup } from "../icons.js";
|
||||
import { doMkdir, doRename, doDelete } from "../fsActions.js";
|
||||
import { escapeHtml as escapeAttr } from "../dom.js";
|
||||
|
||||
let overlayEl = null;
|
||||
let lastSig = null;
|
||||
|
||||
export function openMkdir() {
|
||||
state.dialog = { kind: "mkdir", value: "" };
|
||||
notify();
|
||||
}
|
||||
|
||||
export function openRename(name) {
|
||||
state.dialog = { kind: "rename", oldName: name, value: name };
|
||||
notify();
|
||||
}
|
||||
|
||||
export function openDelete(names) {
|
||||
// Settings → General → "Confirm before delete" off skips the modal
|
||||
// entirely instead of just pre-answering it.
|
||||
if (!state.settings.confirmBeforeDelete) {
|
||||
doDelete(names);
|
||||
return;
|
||||
}
|
||||
state.dialog = { kind: "delete", names };
|
||||
notify();
|
||||
}
|
||||
|
||||
export function closeDialog() {
|
||||
state.dialog = null;
|
||||
notify();
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const d = state.dialog;
|
||||
if (!d) return;
|
||||
if (d.kind === "mkdir") {
|
||||
const name = d.value.trim();
|
||||
if (!name) return;
|
||||
closeDialog();
|
||||
await doMkdir(name);
|
||||
} else if (d.kind === "rename") {
|
||||
const name = d.value.trim();
|
||||
if (!name || name === d.oldName) {
|
||||
closeDialog();
|
||||
return;
|
||||
}
|
||||
closeDialog();
|
||||
await doRename(d.oldName, name);
|
||||
} else if (d.kind === "delete") {
|
||||
closeDialog();
|
||||
await doDelete(d.names);
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
function buildMkdirOrRename(d) {
|
||||
const isRename = d.kind === "rename";
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "overlay";
|
||||
overlay.addEventListener("mousedown", (e) => {
|
||||
if (e.target === overlay) closeDialog();
|
||||
});
|
||||
const dialog = document.createElement("div");
|
||||
dialog.className = "dialog dialog-sm";
|
||||
dialog.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
dialog.innerHTML = `
|
||||
<div class="dialog-header">
|
||||
<span class="icon icon--important">${iconMarkup(isRename ? "pen" : "folderAdd", 15)}</span>
|
||||
<span class="dialog-title">${isRename ? t("dialogs.renameTitle") : t("dialogs.newFolderTitle")}</span>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<input class="field-input" data-field="value" type="text" value="${escapeAttr(d.value)}"
|
||||
placeholder="${isRename ? t("dialogs.renamePlaceholder") : t("dialogs.newFolderPlaceholder")}" />
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn btn-ghost" data-action="close">${t("common.cancel")}</button>
|
||||
<div class="spacer"></div>
|
||||
<button type="button" class="btn btn-accent" data-action="submit">${t("common.ok")}</button>
|
||||
</div>
|
||||
`;
|
||||
overlay.appendChild(dialog);
|
||||
dialog.querySelector('[data-action="close"]').addEventListener("click", closeDialog);
|
||||
dialog.querySelector('[data-action="submit"]').addEventListener("click", submit);
|
||||
const input = dialog.querySelector('[data-field="value"]');
|
||||
input.addEventListener("input", () => {
|
||||
state.dialog.value = input.value;
|
||||
});
|
||||
dialog.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") submit();
|
||||
});
|
||||
document.body.appendChild(overlay);
|
||||
setTimeout(() => {
|
||||
input.focus();
|
||||
if (isRename) {
|
||||
const dot = input.value.lastIndexOf(".");
|
||||
input.setSelectionRange(0, dot > 0 ? dot : input.value.length);
|
||||
} else {
|
||||
input.select();
|
||||
}
|
||||
}, 0);
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function buildDelete(d) {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "overlay";
|
||||
overlay.addEventListener("mousedown", (e) => {
|
||||
if (e.target === overlay) closeDialog();
|
||||
});
|
||||
const dialog = document.createElement("div");
|
||||
dialog.className = "dialog dialog-sm";
|
||||
dialog.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
const title =
|
||||
d.names.length === 1
|
||||
? t("dialogs.deleteTitle", { name: escapeAttr(d.names[0]) })
|
||||
: t("dialogs.deleteTitleMulti", { n: d.names.length });
|
||||
dialog.innerHTML = `
|
||||
<div class="dialog-header">
|
||||
<span class="icon text-red">${iconMarkup("dangerTriangle", 15)}</span>
|
||||
<span class="dialog-title">${title}</span>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<p class="dialog-text">${t("dialogs.deleteBody")}</p>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn btn-ghost" data-action="close">${t("common.cancel")}</button>
|
||||
<div class="spacer"></div>
|
||||
<button type="button" class="btn btn-danger" data-action="submit">${t("common.delete")}</button>
|
||||
</div>
|
||||
`;
|
||||
overlay.appendChild(dialog);
|
||||
dialog.querySelector('[data-action="close"]').addEventListener("click", closeDialog);
|
||||
dialog.querySelector('[data-action="submit"]').addEventListener("click", submit);
|
||||
document.body.appendChild(overlay);
|
||||
return overlay;
|
||||
}
|
||||
|
||||
export function renderOpDialogs() {
|
||||
const sig = JSON.stringify(
|
||||
state.dialog ? { kind: state.dialog.kind, names: state.dialog.names, oldName: state.dialog.oldName } : null,
|
||||
);
|
||||
if (sig === lastSig) return;
|
||||
lastSig = sig;
|
||||
|
||||
if (overlayEl) {
|
||||
overlayEl.remove();
|
||||
overlayEl = null;
|
||||
}
|
||||
if (!state.dialog) return;
|
||||
|
||||
overlayEl =
|
||||
state.dialog.kind === "delete" ? buildDelete(state.dialog) : buildMkdirOrRename(state.dialog);
|
||||
}
|
||||
319
frontend/js/components/paneLayout.js
Normal file
319
frontend/js/components/paneLayout.js
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
// Split-pane layout — tabs dragged from tab bar into panes create splits.
|
||||
// Direction-aware: drop left edge = split left, right edge = split right,
|
||||
// center = replace. Panes can also be reordered by dragging headers.
|
||||
import { state, notify } from "../store.js";
|
||||
import { renderLocalPane, updateLocalPane } from "./localPane.js";
|
||||
import { renderRemotePane, updateRemotePane } from "./remotePane.js";
|
||||
import { iconMarkup } from "../icons.js";
|
||||
import { HOVER_OPEN_DELAY, getDraggedTabId } from "../dnd.js";
|
||||
|
||||
const paneRefs = new Map();
|
||||
|
||||
function paneKind(p) { return p.tabId ?? "local"; }
|
||||
function paneLabel(p) { return p.tabId === null ? "Local" : (state.tabs.find((t) => t.id === p.tabId)?.label ?? "?"); }
|
||||
|
||||
/* Render the full pane grid */
|
||||
export function renderPanes(container) {
|
||||
container.innerHTML = "";
|
||||
paneRefs.clear();
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "pane-grid";
|
||||
wrap.style.cssText = `display:grid;grid-template-columns:${buildGrid()};flex:1;min-height:0;`;
|
||||
wrap.addEventListener("dragover", (e) => { e.preventDefault(); });
|
||||
wrap.addEventListener("drop", (e) => {
|
||||
// Drop on empty area (after last pane) → append
|
||||
e.preventDefault();
|
||||
const tabId = e.dataTransfer.getData("tab-id");
|
||||
if (!tabId) return;
|
||||
stopDropHighlight();
|
||||
addPane(parsedTabId(tabId), state.layout.panes.length);
|
||||
});
|
||||
container.appendChild(wrap);
|
||||
|
||||
for (const pane of state.layout.panes) {
|
||||
const cell = createPaneCell(pane);
|
||||
wrap.appendChild(cell);
|
||||
}
|
||||
}
|
||||
|
||||
function createPaneCell(pane) {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "pane-cell";
|
||||
cell.dataset.paneId = pane.id;
|
||||
|
||||
// Edge drop zones
|
||||
const zones = ["left","right","top","bottom","center","swap"].map((z) => {
|
||||
const d = document.createElement("div");
|
||||
d.className = `pane-zone pane-zone--${z}`;
|
||||
d.dataset.zone = z;
|
||||
return d;
|
||||
});
|
||||
|
||||
// Header
|
||||
const header = document.createElement("div");
|
||||
header.className = "pane-header";
|
||||
header.draggable = true;
|
||||
header.dataset.paneId = pane.id;
|
||||
header.innerHTML = `<span class="pane-label">${paneLabel(pane)}</span>`;
|
||||
header.addEventListener("dragstart", onHeaderDragStart);
|
||||
header.addEventListener("dragend", onHeaderDragEnd);
|
||||
|
||||
// Close btn
|
||||
if (state.layout.panes.length > 1) {
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.className = "pane-close-btn";
|
||||
closeBtn.title = "Close";
|
||||
closeBtn.innerHTML = iconMarkup("close", 10);
|
||||
closeBtn.addEventListener("click", (e) => { e.stopPropagation(); closePane(pane.id); });
|
||||
header.appendChild(closeBtn);
|
||||
}
|
||||
|
||||
// Body
|
||||
const body = document.createElement("div");
|
||||
body.className = "pane-body";
|
||||
|
||||
cell.appendChild(header);
|
||||
cell.appendChild(body);
|
||||
zones.forEach((z) => cell.appendChild(z));
|
||||
|
||||
// Drag events on the cell
|
||||
cell.addEventListener("dragover", onPaneDragOver);
|
||||
cell.addEventListener("dragleave", onPaneDragLeave);
|
||||
cell.addEventListener("drop", onPaneDrop);
|
||||
|
||||
// Render file table
|
||||
if (pane.tabId === null) {
|
||||
renderLocalPane(body);
|
||||
} else {
|
||||
const tab = state.tabs.find((t) => t.id === pane.tabId);
|
||||
if (tab) renderRemotePane(body, tab);
|
||||
}
|
||||
paneRefs.set(pane.id, { kind: paneKind(pane), bodyEl: body });
|
||||
|
||||
return cell;
|
||||
}
|
||||
|
||||
function buildGrid() {
|
||||
const n = state.layout.panes.length;
|
||||
if (n <= 1) return "1fr";
|
||||
return state.layout.panes.map(() => "1fr").join(" ");
|
||||
}
|
||||
|
||||
/* In-place update (avoids full rebuild on every tick) */
|
||||
export function updatePanes(container) {
|
||||
const wrap = container.querySelector(".pane-grid");
|
||||
if (!wrap) return renderPanes(container);
|
||||
const cells = wrap.querySelectorAll(".pane-cell");
|
||||
if (cells.length !== state.layout.panes.length) return renderPanes(container);
|
||||
const ids = Array.from(cells).map((c) => c.dataset.paneId);
|
||||
const expected = state.layout.panes.map((p) => p.id);
|
||||
if (ids.join(",") !== expected.join(",")) return renderPanes(container);
|
||||
|
||||
wrap.style.gridTemplateColumns = buildGrid();
|
||||
|
||||
for (const pane of state.layout.panes) {
|
||||
const ref = paneRefs.get(pane.id);
|
||||
if (!ref || ref.kind !== paneKind(pane)) return renderPanes(container);
|
||||
const cell = wrap.querySelector(`[data-pane-id="${pane.id}"]`);
|
||||
if (cell) {
|
||||
const label = cell.querySelector(".pane-label");
|
||||
if (label) label.textContent = paneLabel(pane);
|
||||
}
|
||||
if (pane.tabId === null) updateLocalPane(ref.bodyEl);
|
||||
else { const tab = state.tabs.find((t) => t.id === pane.tabId); if (tab) updateRemotePane(ref.bodyEl, tab); }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drag & Drop: tab bar → pane ─────────────────────────────────────────
|
||||
|
||||
/* Tab bar tabs become draggable — set data "tab-id" on dragstart. Pane cells
|
||||
get visual drop zones (left/right/top/bottom/center), but two rules keep
|
||||
it from feeling random:
|
||||
1. The pane already showing the dragged tab never offers itself as a
|
||||
target -- there's nowhere sensible to put a tab inside its own pane.
|
||||
2. Every other pane needs a beat of sustained hover (HOVER_OPEN_DELAY)
|
||||
before it "opens" as a drop target. Zones appearing the instant the
|
||||
pointer crosses into a pane read as jumpy; the pause makes hovering
|
||||
read as an intentional choice instead. */
|
||||
|
||||
let activeZone = null; // "<paneId>-<zone>" of the currently shown zone overlay
|
||||
let holdTimer = null; // pending "open" timer for the pane below
|
||||
let holdPaneId = null; // pane the hold timer is counting for
|
||||
let openPaneId = null; // pane that passed the hold and is now a live drop target
|
||||
|
||||
function computeZone(cell, clientX, clientY) {
|
||||
const rect = cell.getBoundingClientRect();
|
||||
const x = (clientX - rect.left) / rect.width;
|
||||
const y = (clientY - rect.top) / rect.height;
|
||||
if (x < 0.22) return "left";
|
||||
if (x > 0.78) return "right";
|
||||
if (y < 0.22) return "top";
|
||||
if (y > 0.78) return "bottom";
|
||||
return "center";
|
||||
}
|
||||
|
||||
function showZone(cell, zone) {
|
||||
const key = `${cell.dataset.paneId}-${zone}`;
|
||||
if (activeZone === key) return;
|
||||
document.querySelectorAll(".pane-cell.drop-active").forEach((c) => {
|
||||
if (c !== cell) {
|
||||
c.classList.remove("drop-active");
|
||||
delete c.dataset.dropZone;
|
||||
}
|
||||
});
|
||||
activeZone = key;
|
||||
cell.classList.add("drop-active");
|
||||
cell.dataset.dropZone = zone;
|
||||
}
|
||||
|
||||
function clearHold() {
|
||||
clearTimeout(holdTimer);
|
||||
holdTimer = null;
|
||||
holdPaneId = null;
|
||||
}
|
||||
|
||||
function stopDropHighlight() {
|
||||
document.querySelectorAll(".pane-cell").forEach((c) => {
|
||||
c.classList.remove("drop-active", "drop-pending");
|
||||
delete c.dataset.dropZone;
|
||||
});
|
||||
activeZone = null;
|
||||
}
|
||||
|
||||
function resetPaneDrag() {
|
||||
clearHold();
|
||||
openPaneId = null;
|
||||
stopDropHighlight();
|
||||
}
|
||||
|
||||
function onPaneDragOver(e) {
|
||||
if (!e.dataTransfer.types.includes("tab-id")) return;
|
||||
const cell = e.currentTarget;
|
||||
const paneId = cell.dataset.paneId;
|
||||
const pane = state.layout.panes.find((p) => p.id === paneId);
|
||||
|
||||
// Dragging a tab over the pane that already shows it -- never a valid
|
||||
// target. Stop the event here so it doesn't bubble to the grid wrap's
|
||||
// unconditional preventDefault(), which would otherwise still allow a
|
||||
// drop (routed to the "append at end" handler) at this pointer position.
|
||||
if (pane && pane.tabId === getDraggedTabId()) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
|
||||
if (openPaneId === paneId) {
|
||||
showZone(cell, computeZone(cell, e.clientX, e.clientY));
|
||||
return;
|
||||
}
|
||||
|
||||
if (holdPaneId !== paneId) {
|
||||
clearHold();
|
||||
holdPaneId = paneId;
|
||||
cell.classList.add("drop-pending");
|
||||
holdTimer = setTimeout(() => {
|
||||
holdTimer = null;
|
||||
cell.classList.remove("drop-pending");
|
||||
openPaneId = paneId;
|
||||
showZone(cell, computeZone(cell, e.clientX, e.clientY));
|
||||
}, HOVER_OPEN_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
function onPaneDragLeave(e) {
|
||||
const cell = e.currentTarget;
|
||||
if (cell.contains(e.relatedTarget)) return;
|
||||
cell.classList.remove("drop-pending");
|
||||
if (holdPaneId === cell.dataset.paneId) clearHold();
|
||||
if (openPaneId === cell.dataset.paneId) {
|
||||
openPaneId = null;
|
||||
cell.classList.remove("drop-active");
|
||||
delete cell.dataset.dropZone;
|
||||
activeZone = null;
|
||||
}
|
||||
}
|
||||
|
||||
function onPaneDrop(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const tabId = e.dataTransfer.getData("tab-id");
|
||||
const cell = e.currentTarget;
|
||||
const paneId = cell.dataset.paneId;
|
||||
const wasOpen = openPaneId === paneId;
|
||||
const zone = cell.dataset.dropZone || "center";
|
||||
resetPaneDrag();
|
||||
if (!tabId || !wasOpen) return; // released before this pane "opened" -- ignore
|
||||
|
||||
const idx = state.layout.panes.findIndex((p) => p.id === paneId);
|
||||
if (idx === -1) return;
|
||||
const tid = parsedTabId(tabId);
|
||||
|
||||
switch (zone) {
|
||||
case "left": addPane(tid, idx); break;
|
||||
case "right": addPane(tid, idx + 1); break;
|
||||
case "top": addPane(tid, idx + 1); break; // in flat layout, left/right suffices
|
||||
case "bottom": addPane(tid, idx + 1); break; // top/bottom = same as right
|
||||
case "center": // replace this pane (but don't add duplicate)
|
||||
if (!state.layout.panes.some((p) => p.tabId === tid && p.id !== paneId)) {
|
||||
state.layout.panes[idx].tabId = tid;
|
||||
}
|
||||
break;
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
// A drag can end (dropped, cancelled, Escaped) outside every pane cell --
|
||||
// clean up hold/zone state unconditionally so it never gets stuck.
|
||||
window.addEventListener("dragend", resetPaneDrag);
|
||||
|
||||
// ── Header drag (reorder) ───────────────────────────────────────────────
|
||||
|
||||
let headerDragId = null;
|
||||
function onHeaderDragStart(e) {
|
||||
headerDragId = e.currentTarget.dataset.paneId;
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", headerDragId);
|
||||
e.target.closest(".pane-cell")?.classList.add("pane-dragging");
|
||||
}
|
||||
function onHeaderDragEnd(e) {
|
||||
e.target.closest(".pane-cell")?.classList.remove("pane-dragging");
|
||||
stopDropHighlight();
|
||||
headerDragId = null;
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function parsedTabId(raw) {
|
||||
return raw === "local" ? null : raw;
|
||||
}
|
||||
|
||||
function addPane(tabId, index) {
|
||||
// If this tab is already in another pane, remove it
|
||||
const existing = state.layout.panes.findIndex((p) => p.tabId === tabId);
|
||||
if (existing !== -1) {
|
||||
state.layout.panes.splice(existing, 1);
|
||||
if (existing < index) index--;
|
||||
}
|
||||
state.layout.panes.splice(index, 0, { id: `pane-${Date.now()}`, tabId });
|
||||
}
|
||||
|
||||
function closePane(paneId) {
|
||||
if (state.layout.panes.length <= 1) return;
|
||||
state.layout.panes = state.layout.panes.filter((p) => p.id !== paneId);
|
||||
notify();
|
||||
}
|
||||
|
||||
// ── Public API: open a tab in a pane from outside ───────────────────────
|
||||
export function openInPane(tabId) {
|
||||
// If already visible, do nothing (it's already in some pane)
|
||||
if (state.layout.panes.some((p) => p.tabId === tabId)) return;
|
||||
|
||||
// Replace the first pane (single-pane UX — click = switch, not split)
|
||||
// Splitting only happens via drag-and-drop into edge zones.
|
||||
state.layout.panes[0].tabId = tabId;
|
||||
notify();
|
||||
}
|
||||
130
frontend/js/components/queuePanel.js
Normal file
130
frontend/js/components/queuePanel.js
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { state, notify } from "../store.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { iconMarkup } from "../icons.js";
|
||||
import { formatSize, taskStateKind, taskStateDetail, progressPct } from "../format.js";
|
||||
import * as ipc from "../ipc.js";
|
||||
import { escapeHtml } from "../dom.js";
|
||||
|
||||
const STATE_LABEL_KEY = {
|
||||
queued: "queue.stateQueued",
|
||||
completed: "queue.stateComplete",
|
||||
failed: "queue.stateFailed",
|
||||
paused: "queue.statePaused",
|
||||
cancelled: "queue.stateCancelled",
|
||||
retrying: "queue.stateRetrying",
|
||||
};
|
||||
|
||||
function stateBadge(task) {
|
||||
const kind = taskStateKind(task.state);
|
||||
if (kind === "running") {
|
||||
if (task.speed) return { text: `${formatSize(task.speed)}/s`, cls: "text-green" };
|
||||
const verb = task.kind === "upload" ? "queue.stateUploading" : "queue.stateDownloading";
|
||||
return { text: t(verb), cls: "text-accent" };
|
||||
}
|
||||
const cls = { completed: "text-green", failed: "text-red", paused: "text-yellow" }[kind] ?? "text-hint";
|
||||
return { text: t(STATE_LABEL_KEY[kind] ?? "queue.stateQueued"), cls };
|
||||
}
|
||||
|
||||
function taskRow(task) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "queue-task";
|
||||
const kind = taskStateKind(task.state);
|
||||
const pct = progressPct(task);
|
||||
const badge = stateBadge(task);
|
||||
const err = kind === "failed" ? taskStateDetail(task.state) : "";
|
||||
|
||||
row.innerHTML = `
|
||||
<span class="icon icon--important">${iconMarkup(task.kind === "upload" ? "upload" : "download", 15)}</span>
|
||||
<span class="queue-task-name" ${err ? `title="${escapeAttr(err)}"` : ""}>${escapeHtml(task.fileName)}</span>
|
||||
<div class="progress"><div class="progress-fill ${kind}" style="width:${pct}%"></div></div>
|
||||
<span class="queue-task-pct mono">${pct.toFixed(0)}%</span>
|
||||
<span class="queue-task-state ${badge.cls}">${badge.text}</span>
|
||||
<div class="queue-task-actions" data-role="actions"></div>
|
||||
`;
|
||||
|
||||
const actions = row.querySelector('[data-role="actions"]');
|
||||
if (kind === "running") {
|
||||
actions.appendChild(iconActionBtn("pauseCircle", () => ipc.pauseTask(task.id)));
|
||||
actions.appendChild(iconActionBtn("closeCircle", () => ipc.cancelTask(task.id)));
|
||||
} else if (kind === "paused" || kind === "queued") {
|
||||
actions.appendChild(iconActionBtn("playCircle", () => ipc.resumeTask(task.id)));
|
||||
actions.appendChild(iconActionBtn("closeCircle", () => ipc.cancelTask(task.id)));
|
||||
} else {
|
||||
actions.appendChild(iconActionBtn("closeCircle", () => ipc.removeTask(task.id)));
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function iconActionBtn(icon, onClick) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "btn icon-btn btn-ghost";
|
||||
btn.style.width = "22px";
|
||||
btn.style.height = "22px";
|
||||
btn.innerHTML = iconMarkup(icon, 13);
|
||||
btn.addEventListener("click", onClick);
|
||||
return btn;
|
||||
}
|
||||
|
||||
let lastSig = null;
|
||||
|
||||
export function renderQueuePanel(container) {
|
||||
const tasks = state.queueTasks;
|
||||
const pending = tasks.filter((tk) => ["running", "queued"].includes(taskStateKind(tk.state)));
|
||||
const n = pending.length > 0 ? pending.length : tasks.length;
|
||||
const expanded = state.showQueue && tasks.length > 0;
|
||||
|
||||
container.classList.toggle("expanded", expanded);
|
||||
|
||||
const aggSpeed = tasks
|
||||
.filter((tk) => taskStateKind(tk.state) === "running")
|
||||
.reduce((sum, tk) => sum + (tk.speed ?? 0), 0);
|
||||
const done = tasks.filter((tk) => taskStateKind(tk.state) === "completed").length;
|
||||
const failed = tasks.filter((tk) => taskStateKind(tk.state) === "failed").length;
|
||||
|
||||
let rightHtml = "";
|
||||
if (aggSpeed > 0) {
|
||||
rightHtml = `<span class="text-green mono">${formatSize(aggSpeed)}/s</span>`;
|
||||
} else if (tasks.length > 0) {
|
||||
rightHtml = `<span class="text-dim">${t("queue.doneSuffix", { done, total: tasks.length })}</span>`;
|
||||
if (failed > 0) rightHtml += `<span class="text-red" style="margin-left:8px">${t("queue.failedSuffix", { n: failed })}</span>`;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="queue-header" data-role="header">
|
||||
<span class="icon">${iconMarkup(state.showQueue ? "altArrowDown" : "altArrowUp", 13)}</span>
|
||||
<span class="queue-header-title">${t("queue.title", { n })}</span>
|
||||
<div class="queue-header-right">${rightHtml}</div>
|
||||
</div>
|
||||
<div class="queue-list" data-role="list"></div>
|
||||
`;
|
||||
|
||||
container.querySelector('[data-role="header"]').addEventListener("click", () => {
|
||||
state.showQueue = !state.showQueue;
|
||||
notify();
|
||||
});
|
||||
|
||||
const list = container.querySelector('[data-role="list"]');
|
||||
if (tasks.length === 0) {
|
||||
list.innerHTML = `<div class="popover-empty">${t("queue.empty")}</div>`;
|
||||
} else {
|
||||
for (const task of tasks) list.appendChild(taskRow(task));
|
||||
}
|
||||
|
||||
lastSig = computeSig();
|
||||
}
|
||||
|
||||
function computeSig() {
|
||||
return JSON.stringify([
|
||||
state.showQueue,
|
||||
state.queueTasks.map((tk) => [tk.id, tk.transferredBytes, tk.speed, JSON.stringify(tk.state)]),
|
||||
]);
|
||||
}
|
||||
|
||||
export function updateQueuePanelIfNeeded(container) {
|
||||
if (computeSig() === lastSig) return;
|
||||
renderQueuePanel(container);
|
||||
}
|
||||
|
||||
const escapeAttr = escapeHtml;
|
||||
145
frontend/js/components/remotePane.js
Normal file
145
frontend/js/components/remotePane.js
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { state, notify } from "../store.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { iconMarkup } from "../icons.js";
|
||||
import { formatSize } from "../format.js";
|
||||
import { renderFileTable } from "./fileTable.js";
|
||||
import { renderBreadcrumbs } from "./breadcrumbs.js";
|
||||
import { makeDropTarget } from "../dnd.js";
|
||||
import { openRename, openDelete } from "./opDialogs.js";
|
||||
import {
|
||||
navigateRemote,
|
||||
openRemoteEntry,
|
||||
handleDropOnRemote,
|
||||
refreshRemote,
|
||||
queueDownload,
|
||||
queueDownloadMany,
|
||||
} from "../fsActions.js";
|
||||
|
||||
const STATUS_ICON = { connected: "●", connecting: "◐", error: "×", disconnected: "○" };
|
||||
const STATUS_CLASS = { connected: "connected", connecting: "connecting", error: "error", disconnected: "disconnected" };
|
||||
|
||||
export function renderRemotePane(container, tab) {
|
||||
container.innerHTML = `
|
||||
<div class="path-bar">
|
||||
<span class="path-bar-label">${t("panels.remoteLabel", { host: tab.params.host })}</span>
|
||||
<span class="status-dot ${STATUS_CLASS[tab.status] ?? "disconnected"}">${STATUS_ICON[tab.status] ?? "○"}</span>
|
||||
<div class="breadcrumbs" data-role="breadcrumbs"></div>
|
||||
<div class="path-bar-right" data-role="loading"></div>
|
||||
</div>
|
||||
<div data-role="table" class="file-table-wrap"></div>
|
||||
<div class="pane-footer">
|
||||
<span data-role="count" class="text-dim" style="font-size:11px"></span>
|
||||
<span data-role="size" class="text-hint" style="font-size:11px"></span>
|
||||
<div class="pane-footer-right">
|
||||
<button type="button" class="btn icon-btn btn-ghost" data-action="refresh" title="${t("common.refresh")}">${iconMarkup("refresh", 13)}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.querySelector('[data-action="refresh"]').addEventListener("click", () => {
|
||||
if (!tab.loading) refreshRemote(tab);
|
||||
});
|
||||
|
||||
makeDropTarget(container, {
|
||||
onDrop: (payload) => handleDropOnRemote(tab, payload, tab.remotePath),
|
||||
onHoverChange: (hover) => container.classList.toggle("drag-over", hover),
|
||||
});
|
||||
|
||||
updateRemotePane(container, tab);
|
||||
}
|
||||
|
||||
export function updateRemotePane(container, tab) {
|
||||
renderBreadcrumbs(container.querySelector('[data-role="breadcrumbs"]'), tab.remotePath || "/", (path) =>
|
||||
navigateRemote(tab, path),
|
||||
);
|
||||
|
||||
container.querySelector('[data-role="loading"]').innerHTML = tab.loading
|
||||
? `<span>${t("panels.loading")}</span><span class="spinner"></span>`
|
||||
: "";
|
||||
|
||||
renderFileTable(container.querySelector('[data-role="table"]'), {
|
||||
entries: tab.remoteEntries,
|
||||
selected: tab.remoteSelected,
|
||||
anchor: tab.remoteSelectAnchor,
|
||||
sort: tab.sort,
|
||||
onSort: (next) => {
|
||||
tab.sort = next;
|
||||
notify();
|
||||
},
|
||||
onSelect: (names, anchor) => {
|
||||
tab.remoteSelected = names;
|
||||
tab.remoteSelectAnchor = anchor;
|
||||
state.activePane = "remote";
|
||||
notify();
|
||||
},
|
||||
onOpen: (name) => openRemoteEntry(tab, name),
|
||||
onHoverOpen: (entry) => openRemoteEntry(tab, entry.name),
|
||||
dragPayload: (entry) => {
|
||||
if (entry.name === ".." || tab.status !== "connected") return null;
|
||||
const group =
|
||||
tab.remoteSelected.includes(entry.name) && tab.remoteSelected.length > 1
|
||||
? tab.remoteEntries.filter((e) => tab.remoteSelected.includes(e.name))
|
||||
: [entry];
|
||||
return {
|
||||
source: "remote",
|
||||
connectionId: tab.id,
|
||||
items: group.map((e) => ({ path: e.path, name: e.name, kind: e.kind })),
|
||||
};
|
||||
},
|
||||
onDropOnEntry: (entry, payload) => handleDropOnRemote(tab, payload, entry.path),
|
||||
contextMenuItems: (entry, selectedEntries) => {
|
||||
if (selectedEntries.length > 1) {
|
||||
const files = selectedEntries.filter((e) => e.kind === "file");
|
||||
return [
|
||||
{
|
||||
label: t("common.delete") + ` (${selectedEntries.length})`,
|
||||
icon: "trash",
|
||||
danger: true,
|
||||
onClick: () => openDeleteRemote(selectedEntries),
|
||||
},
|
||||
...(files.length > 0
|
||||
? [
|
||||
"sep",
|
||||
{
|
||||
label: t("panels.downloadToLocal") + ` (${files.length})`,
|
||||
icon: "download",
|
||||
onClick: () => queueDownloadMany(tab, files),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ label: t("common.open"), icon: "folder", onClick: () => openRemoteEntry(tab, entry.name) },
|
||||
{ label: t("common.rename"), icon: "pen", onClick: () => openRenameRemote(entry) },
|
||||
{ label: t("common.delete"), icon: "trash", danger: true, onClick: () => openDeleteRemote([entry]) },
|
||||
"sep",
|
||||
{ label: t("common.copyPath"), icon: "copy", onClick: () => copyPath(entry.path) },
|
||||
...(entry.kind === "file"
|
||||
? [{ label: t("panels.downloadToLocal"), icon: "download", onClick: () => queueDownload(tab, entry) }]
|
||||
: []),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
const count = tab.remoteEntries.filter((e) => e.name !== "..").length;
|
||||
const totalSize = tab.remoteEntries.reduce((sum, e) => sum + (e.size ?? 0), 0);
|
||||
container.querySelector('[data-role="count"]').textContent = tab.loading
|
||||
? t("panels.loading")
|
||||
: t("panels.countItems", { n: count });
|
||||
container.querySelector('[data-role="size"]').textContent = totalSize > 0 ? `(${formatSize(totalSize)})` : "";
|
||||
}
|
||||
|
||||
function openRenameRemote(entry) {
|
||||
state.opTarget = "remote";
|
||||
openRename(entry.name);
|
||||
}
|
||||
function openDeleteRemote(entries) {
|
||||
state.opTarget = "remote";
|
||||
openDelete(entries.map((e) => e.name));
|
||||
}
|
||||
function copyPath(path) {
|
||||
navigator.clipboard?.writeText(path);
|
||||
state.statusMessage = t("panels.pathCopied");
|
||||
notify();
|
||||
}
|
||||
496
frontend/js/components/settingsDialog.js
Normal file
496
frontend/js/components/settingsDialog.js
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
// Settings dialog — nav list left, section content right. Mirrors the old
|
||||
// src/ui/app/settings/{general,connections,transfers,about}.rs sections
|
||||
// (Security folded in here too). Unlike connectDialog/opDialogs there are no
|
||||
// free-text inputs in here, so the whole content pane can just be rebuilt
|
||||
// from scratch on every render -- no focus/caret state to preserve.
|
||||
import { state, notify } from "../store.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { iconMarkup } from "../icons.js";
|
||||
import { escapeHtml } from "../dom.js";
|
||||
import {
|
||||
setConfirmBeforeDelete,
|
||||
useCurrentFolderAsDefault,
|
||||
resetDefaultFolder,
|
||||
setMaxConcurrent,
|
||||
setAutoClearCompletedSecs,
|
||||
togglePanel,
|
||||
clearHistory,
|
||||
deleteSite,
|
||||
forgetPassword,
|
||||
forgetAllPasswords,
|
||||
revealDatabase,
|
||||
setLanguage,
|
||||
setTheme,
|
||||
} from "../settingsActions.js";
|
||||
|
||||
const LANG_OPTIONS = [
|
||||
["en", "English"],
|
||||
["ru", "Русский"],
|
||||
["es", "Español"],
|
||||
["fr", "Français"],
|
||||
["de", "Deutsch"],
|
||||
["it", "Italiano"],
|
||||
["pt", "Português"],
|
||||
["pl", "Polski"],
|
||||
["zh", "简体中文"],
|
||||
["ja", "日本語"],
|
||||
["ko", "한국어"],
|
||||
["tr", "Türkçe"],
|
||||
];
|
||||
|
||||
const SECTIONS = [
|
||||
{ id: "general", icon: "settings", labelKey: "settings.navGeneral" },
|
||||
{ id: "appearance", icon: "monitor", labelKey: "settings.navAppearance" },
|
||||
{ id: "connections", icon: "serverSquare", labelKey: "settings.navConnections" },
|
||||
{ id: "history", icon: "history", labelKey: "settings.navHistory" },
|
||||
{ id: "security", icon: "shieldKeyhole", labelKey: "settings.navSecurity" },
|
||||
{ id: "transfers", icon: "clock", labelKey: "settings.navTransfers" },
|
||||
{ id: "about", icon: "documentText", labelKey: "settings.navAbout" },
|
||||
];
|
||||
|
||||
let overlayEl = null;
|
||||
let activeSection = "general";
|
||||
let historyClearConfirm = false;
|
||||
|
||||
export function openSettings() {
|
||||
activeSection = "general";
|
||||
historyClearConfirm = false;
|
||||
state.showSettingsDialog = true;
|
||||
notify();
|
||||
}
|
||||
|
||||
function close() {
|
||||
state.showSettingsDialog = false;
|
||||
notify();
|
||||
}
|
||||
|
||||
function switchSection(id) {
|
||||
activeSection = id;
|
||||
historyClearConfirm = false;
|
||||
renderContent();
|
||||
renderNav();
|
||||
}
|
||||
|
||||
// A plain <span>, not a <button> -- a labelable descendant (button, input)
|
||||
// inside a clickable wrapper gets a second, forwarded click event from the
|
||||
// browser itself on top of the one the wrapper's own listener already
|
||||
// handles, which silently double-toggles state right back to where it
|
||||
// started. Since the wrapper handles the click, the indicator only needs to
|
||||
// be visual.
|
||||
function switchToggle(active) {
|
||||
return `<span class="switch ${active ? "on" : ""}" data-role="switch"></span>`;
|
||||
}
|
||||
|
||||
// --- section renderers -------------------------------------------------------
|
||||
|
||||
function sectionGeneral() {
|
||||
const folder = state.settings.defaultLocalFolder || t("settings.homeDirectory");
|
||||
return `
|
||||
<div class="settings-section-title">${t("settings.navGeneral")}</div>
|
||||
<div class="settings-block">
|
||||
<div class="settings-check-row" data-action="toggle-confirm-delete">
|
||||
${switchToggle(state.settings.confirmBeforeDelete)}
|
||||
<div>
|
||||
<div class="settings-label">${t("settings.confirmDelete")}</div>
|
||||
<div class="settings-hint">${t("settings.confirmDeleteHint")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-block">
|
||||
<div class="settings-label">${t("settings.language")}</div>
|
||||
<div class="settings-hint">${t("settings.languageHint")}</div>
|
||||
<div class="settings-row" style="margin-top:8px">
|
||||
<select class="settings-select" data-action="set-language">
|
||||
${LANG_OPTIONS.map(([code, native]) =>
|
||||
`<option value="${code}" ${code === state.settings.language ? "selected" : ""}>${native}</option>`
|
||||
).join("")}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-block">
|
||||
<div class="settings-label">${t("settings.startupFolder")}</div>
|
||||
<div class="settings-hint">${t("settings.startupFolderHint")}</div>
|
||||
<div class="settings-hint mono" style="margin-top:8px;color:var(--text-dim)">${escapeHtml(folder)}</div>
|
||||
<div class="settings-row" style="margin-top:10px">
|
||||
<button type="button" class="btn btn-ghost" data-action="use-current-folder">${t("settings.useCurrentFolder")}</button>
|
||||
<button type="button" class="btn btn-ghost" data-action="reset-folder">${t("settings.resetToHome")}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function sectionAppearance() {
|
||||
const currentTheme = state.settings.theme;
|
||||
const themes = [
|
||||
["ember", "settings.themeEmber"],
|
||||
["frost", "settings.themeFrost"],
|
||||
["midnight", "settings.themeMidnight"],
|
||||
["forest", "settings.themeForest"],
|
||||
["dawn", "settings.themeDawn"],
|
||||
["ash", "settings.themeAsh"],
|
||||
];
|
||||
|
||||
const toggles = [
|
||||
["showToolbar", "settings.toolbar"],
|
||||
["showSidebar", "settings.sidebar"],
|
||||
["showStatusBar", "settings.statusBar"],
|
||||
["showQueuePanel", "settings.transferQueue"],
|
||||
];
|
||||
return `
|
||||
<div class="settings-section-title">${t("settings.navAppearance")}</div>
|
||||
<div class="settings-block">
|
||||
<div class="settings-label">${t("settings.theme")}</div>
|
||||
<div class="settings-hint" style="margin-bottom:10px">${t("settings.themeHint")}</div>
|
||||
<div class="theme-grid" data-action="set-theme">
|
||||
${themes
|
||||
.map(
|
||||
([id, labelKey]) => `
|
||||
<button type="button" class="theme-card ${id === currentTheme ? "active" : ""}" data-theme-id="${id}">
|
||||
<div class="theme-swatch theme-swatch--${id}"></div>
|
||||
<span class="theme-card-label">${t(labelKey)}</span>
|
||||
</button>`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-block">
|
||||
${toggles
|
||||
.map(
|
||||
([key, labelKey]) => `
|
||||
<div class="settings-check-row" style="margin-bottom:10px" data-panel-toggle="${key}">
|
||||
${switchToggle(state.settings[key])}
|
||||
<div class="settings-label">${t(labelKey)}</div>
|
||||
</div>`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function sectionConnections() {
|
||||
const sites = state.sites
|
||||
.map(
|
||||
(s) => `
|
||||
<div class="settings-list-row">
|
||||
<div class="settings-list-row-main">
|
||||
<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="delete-site" data-id="${s.id}" title="${t("settings.deleteSiteHover")}">${iconMarkup("trash", 14)}</button>
|
||||
</div>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
const bookmarks = state.sidebar.bookmarks
|
||||
.map(
|
||||
(b) => `
|
||||
<div class="settings-list-row">
|
||||
<div class="settings-list-row-main">
|
||||
<div class="settings-list-row-title">${escapeHtml(b.name)}</div>
|
||||
<div class="settings-list-row-sub">${escapeHtml(b.path)}</div>
|
||||
</div>
|
||||
<button type="button" class="btn icon-btn btn-ghost" data-action="delete-bookmark" data-id="${b.id}" title="${t("panels.sidebarRemoveBookmark")}">${iconMarkup("trash", 14)}</button>
|
||||
</div>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<div class="settings-section-title">${t("settings.sites")}</div>
|
||||
<div class="settings-block">
|
||||
${state.sites.length ? sites : `<div class="settings-empty">${t("settings.noSites")}</div>`}
|
||||
</div>
|
||||
<div class="settings-block">
|
||||
<div class="settings-label">${t("settings.bookmarksTitle")}</div>
|
||||
${state.sidebar.bookmarks.length ? bookmarks : `<div class="settings-empty">${t("settings.noBookmarks")}</div>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function sectionHistory() {
|
||||
const rows = state.history
|
||||
.map(
|
||||
(h) => `
|
||||
<div class="settings-list-row">
|
||||
<div class="settings-list-row-main">
|
||||
<div class="settings-list-row-title">${escapeHtml(h.username)}@${escapeHtml(h.host)}:${h.port}</div>
|
||||
</div>
|
||||
<div class="settings-list-row-sub">${escapeHtml(h.connectedAt)}</div>
|
||||
</div>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
const footer = state.history.length
|
||||
? historyClearConfirm
|
||||
? `
|
||||
<div class="settings-hint" style="margin-top:12px">${t("settings.confirmClear")}</div>
|
||||
<div class="settings-row" style="margin-top:8px">
|
||||
<button type="button" class="btn btn-danger" data-action="confirm-clear-history">${t("settings.deleteAll")}</button>
|
||||
<button type="button" class="btn btn-ghost" data-action="cancel-clear-history">${t("common.cancel")}</button>
|
||||
</div>`
|
||||
: `<button type="button" class="btn btn-ghost" style="margin-top:12px;color:var(--red)" data-action="clear-history">${t("settings.clearAll")}</button>`
|
||||
: "";
|
||||
|
||||
return `
|
||||
<div class="settings-section-title">${t("settings.historyTitle")}</div>
|
||||
<div class="settings-block">
|
||||
${state.history.length ? rows : `<div class="settings-empty">${t("settings.historyEmpty")}</div>`}
|
||||
${footer}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function sectionSecurity() {
|
||||
const rows = state.sites
|
||||
.map(
|
||||
(s) => `
|
||||
<div class="settings-list-row">
|
||||
<div class="settings-list-row-main">
|
||||
<div class="settings-list-row-title">${escapeHtml(s.name)}</div>
|
||||
</div>
|
||||
<button type="button" class="btn icon-btn btn-ghost" data-action="forget-password" data-id="${s.id}" title="${t("settings.forgetPasswordHover")}">${iconMarkup("lockPassword", 13)}</button>
|
||||
</div>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<div class="settings-section-title">${t("settings.navSecurity")}</div>
|
||||
<div class="settings-hint">${t("settings.securityHint")}</div>
|
||||
<div class="settings-block">
|
||||
${state.sites.length ? rows : `<div class="settings-empty">${t("settings.noConnections")}</div>`}
|
||||
</div>
|
||||
<div class="settings-block">
|
||||
<button type="button" class="btn btn-ghost" style="color:var(--red)" data-action="forget-all">${t("settings.forgetAll")}</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function segmented(options, current, action) {
|
||||
return `
|
||||
<div class="segmented" data-action="${action}">
|
||||
${options
|
||||
.map(
|
||||
([label, value]) =>
|
||||
`<button type="button" class="segmented-item ${value === current ? "active" : ""}" data-value="${value}">${label}</button>`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function sectionTransfers() {
|
||||
return `
|
||||
<div class="settings-section-title">${t("settings.navTransfers")}</div>
|
||||
<div class="settings-block">
|
||||
<div class="settings-label">${t("settings.concurrentTitle")}</div>
|
||||
<div class="settings-hint">${t("settings.concurrentHint")}</div>
|
||||
<div style="margin-top:8px;width:220px">
|
||||
${segmented(
|
||||
[1, 2, 3, 4, 5, 6].map((n) => [String(n), n]),
|
||||
state.settings.maxConcurrent,
|
||||
"set-max-concurrent",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-block">
|
||||
<div class="settings-label">${t("settings.autoRemoveTitle")}</div>
|
||||
<div class="settings-hint">${t("settings.autoRemoveHint")}</div>
|
||||
<div style="margin-top:8px;width:260px">
|
||||
${segmented(
|
||||
[
|
||||
[t("settings.never"), 0],
|
||||
[t("settings.tenSec"), 10],
|
||||
[t("settings.thirtySec"), 30],
|
||||
[t("settings.oneMin"), 60],
|
||||
],
|
||||
state.settings.autoClearCompletedSecs,
|
||||
"set-auto-clear",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function sectionAbout() {
|
||||
return `
|
||||
<div class="settings-section-title">${t("settings.navAbout")}</div>
|
||||
<div class="settings-block">
|
||||
<div class="settings-value-row"><span class="k">${t("settings.version")}</span><span class="v">0.1.0</span></div>
|
||||
<div class="settings-value-row"><span class="k">${t("settings.license")}</span><span class="v">GPL-3.0-only</span></div>
|
||||
<div class="settings-value-row"><span class="k">${t("settings.database")}</span><span class="v" data-role="db-path">…</span></div>
|
||||
<button type="button" class="btn btn-ghost" style="margin-top:10px" data-action="reveal-db">${t("settings.revealInFolder")}</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const RENDERERS = {
|
||||
general: sectionGeneral,
|
||||
appearance: sectionAppearance,
|
||||
connections: sectionConnections,
|
||||
history: sectionHistory,
|
||||
security: sectionSecurity,
|
||||
transfers: sectionTransfers,
|
||||
about: sectionAbout,
|
||||
};
|
||||
|
||||
// --- shell + wiring ------------------------------------------------------------
|
||||
|
||||
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-settings";
|
||||
dialog.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
dialog.innerHTML = `
|
||||
<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>
|
||||
</div>
|
||||
<div class="settings-body">
|
||||
<div class="settings-nav" data-role="nav"></div>
|
||||
<div class="settings-content" data-role="content"></div>
|
||||
</div>
|
||||
`;
|
||||
dialog.querySelector('[data-action="close"]').addEventListener("click", close);
|
||||
overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function renderNav() {
|
||||
const nav = overlayEl.querySelector('[data-role="nav"]');
|
||||
nav.innerHTML = SECTIONS.map(
|
||||
(s) => `
|
||||
<button type="button" class="settings-nav-item ${s.id === activeSection ? "active" : ""}" data-section="${s.id}">
|
||||
${iconMarkup(s.icon, 14)}<span>${t(s.labelKey)}</span>
|
||||
</button>`,
|
||||
).join("");
|
||||
nav.querySelectorAll("[data-section]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => switchSection(btn.dataset.section));
|
||||
});
|
||||
}
|
||||
|
||||
function renderContent() {
|
||||
const content = overlayEl.querySelector('[data-role="content"]');
|
||||
content.innerHTML = RENDERERS[activeSection]();
|
||||
wireContentActions(content);
|
||||
}
|
||||
|
||||
function wireContentActions(root) {
|
||||
root.querySelector("[data-action='toggle-confirm-delete']")?.addEventListener("click", () => {
|
||||
setConfirmBeforeDelete(!state.settings.confirmBeforeDelete);
|
||||
renderContent();
|
||||
});
|
||||
root.querySelector("[data-action='use-current-folder']")?.addEventListener("click", async () => {
|
||||
await useCurrentFolderAsDefault();
|
||||
renderContent();
|
||||
});
|
||||
root.querySelector("[data-action='reset-folder']")?.addEventListener("click", async () => {
|
||||
await resetDefaultFolder();
|
||||
renderContent();
|
||||
});
|
||||
|
||||
root.querySelectorAll("[data-action='set-theme'] .theme-card").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
setTheme(el.dataset.themeId);
|
||||
renderContent();
|
||||
});
|
||||
});
|
||||
|
||||
root.querySelectorAll("[data-panel-toggle]").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
togglePanel(el.dataset.panelToggle);
|
||||
renderContent();
|
||||
});
|
||||
});
|
||||
|
||||
root.querySelectorAll("[data-action='delete-site']").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
await deleteSite(btn.dataset.id);
|
||||
renderContent();
|
||||
});
|
||||
});
|
||||
root.querySelectorAll("[data-action='delete-bookmark']").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const { removeBookmark } = await import("../fsActions.js");
|
||||
await removeBookmark(Number(btn.dataset.id));
|
||||
renderContent();
|
||||
});
|
||||
});
|
||||
|
||||
root.querySelector("[data-action='clear-history']")?.addEventListener("click", () => {
|
||||
historyClearConfirm = true;
|
||||
renderContent();
|
||||
});
|
||||
root.querySelector("[data-action='cancel-clear-history']")?.addEventListener("click", () => {
|
||||
historyClearConfirm = false;
|
||||
renderContent();
|
||||
});
|
||||
root.querySelector("[data-action='confirm-clear-history']")?.addEventListener("click", async () => {
|
||||
await clearHistory();
|
||||
historyClearConfirm = false;
|
||||
renderContent();
|
||||
});
|
||||
|
||||
root.querySelectorAll("[data-action='forget-password']").forEach((btn) => {
|
||||
btn.addEventListener("click", () => forgetPassword(btn.dataset.id));
|
||||
});
|
||||
root.querySelector("[data-action='forget-all']")?.addEventListener("click", forgetAllPasswords);
|
||||
|
||||
root.querySelectorAll("[data-action='set-max-concurrent'] .segmented-item").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
setMaxConcurrent(Number(btn.dataset.value));
|
||||
renderContent();
|
||||
});
|
||||
});
|
||||
root.querySelectorAll("[data-action='set-auto-clear'] .segmented-item").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
setAutoClearCompletedSecs(Number(btn.dataset.value));
|
||||
renderContent();
|
||||
});
|
||||
});
|
||||
|
||||
root.querySelector("[data-action='reveal-db']")?.addEventListener("click", revealDatabase);
|
||||
|
||||
root.querySelector("[data-action='set-language']")?.addEventListener("change", (e) => {
|
||||
setLanguage(e.target.value);
|
||||
// Re-render everything so all strings update immediately
|
||||
renderNav();
|
||||
renderContent();
|
||||
// After re-render, re-select the correct option
|
||||
const sel = overlayEl.querySelector("[data-action='set-language']");
|
||||
if (sel) sel.value = state.settings.language;
|
||||
});
|
||||
|
||||
const dbPathEl = root.querySelector('[data-role="db-path"]');
|
||||
if (dbPathEl) {
|
||||
import("../ipc.js").then((ipc) =>
|
||||
ipc.appDataDir().then((dir) => {
|
||||
dbPathEl.textContent = `${dir}/wherry.db`;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let wasOpen = false;
|
||||
|
||||
export function renderSettingsDialog() {
|
||||
if (!state.showSettingsDialog) {
|
||||
if (overlayEl) {
|
||||
overlayEl.remove();
|
||||
overlayEl = null;
|
||||
}
|
||||
wasOpen = false;
|
||||
return;
|
||||
}
|
||||
if (!wasOpen) {
|
||||
overlayEl = build();
|
||||
renderNav();
|
||||
renderContent();
|
||||
wasOpen = true;
|
||||
}
|
||||
}
|
||||
96
frontend/js/components/sidebar.js
Normal file
96
frontend/js/components/sidebar.js
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { state } from "../store.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { iconMarkup, QUICK_ACCESS_ICONS } from "../icons.js";
|
||||
import { showContextMenu } from "./contextMenu.js";
|
||||
import { navigateLocal, removeBookmark } from "../fsActions.js";
|
||||
|
||||
let lastSig = null;
|
||||
|
||||
function item(icon, label, active, onClick) {
|
||||
const row = document.createElement("div");
|
||||
row.className = `row ${active ? "active" : ""}`;
|
||||
row.innerHTML = `<span class="icon">${iconMarkup(icon, 15)}</span><span style="margin-left:8px;font-size:12.5px">${label}</span>`;
|
||||
row.addEventListener("click", onClick);
|
||||
return row;
|
||||
}
|
||||
|
||||
export function renderSidebar(container) {
|
||||
container.innerHTML = "";
|
||||
|
||||
const logo = document.createElement("div");
|
||||
logo.className = "sidebar-logo";
|
||||
logo.innerHTML = `<span class="sidebar-logo-badge">W</span><span class="sidebar-logo-text">Wherry</span>`;
|
||||
container.appendChild(logo);
|
||||
|
||||
const qaTitle = document.createElement("div");
|
||||
qaTitle.className = "sidebar-section-title";
|
||||
qaTitle.textContent = t("panels.localLabel");
|
||||
container.appendChild(qaTitle);
|
||||
|
||||
for (const qa of state.sidebar.quickAccess) {
|
||||
if (!qa.path) continue;
|
||||
const active = state.local.path === qa.path;
|
||||
container.appendChild(
|
||||
item(QUICK_ACCESS_ICONS[qa.name] ?? "folder", qa.name, active, () => navigateLocal(qa.path)),
|
||||
);
|
||||
}
|
||||
|
||||
const divider1 = document.createElement("div");
|
||||
divider1.className = "sidebar-divider";
|
||||
container.appendChild(divider1);
|
||||
|
||||
const drivesTitle = document.createElement("div");
|
||||
drivesTitle.className = "sidebar-section-title";
|
||||
drivesTitle.textContent = t("panels.sidebarDrives");
|
||||
container.appendChild(drivesTitle);
|
||||
|
||||
container.appendChild(
|
||||
item("ssd", t("panels.sidebarRoot"), state.local.path === "/", () => navigateLocal("/")),
|
||||
);
|
||||
|
||||
if (state.sidebar.bookmarks.length > 0) {
|
||||
const divider2 = document.createElement("div");
|
||||
divider2.className = "sidebar-divider";
|
||||
container.appendChild(divider2);
|
||||
|
||||
const bmTitle = document.createElement("div");
|
||||
bmTitle.className = "sidebar-section-title";
|
||||
bmTitle.textContent = t("panels.sidebarBookmarks");
|
||||
container.appendChild(bmTitle);
|
||||
|
||||
for (const bm of state.sidebar.bookmarks) {
|
||||
const row = item("star", bm.name, state.local.path === bm.path, () => navigateLocal(bm.path));
|
||||
row.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
showContextMenu(e.clientX, e.clientY, [
|
||||
{ label: t("panels.sidebarRemoveBookmark"), icon: "trash", danger: true, onClick: () => removeBookmark(bm.id) },
|
||||
]);
|
||||
});
|
||||
container.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
const spacer = document.createElement("div");
|
||||
spacer.className = "sidebar-spacer";
|
||||
container.appendChild(spacer);
|
||||
|
||||
const footer = document.createElement("div");
|
||||
footer.className = "sidebar-footer";
|
||||
footer.textContent = "Wherry v0.1";
|
||||
container.appendChild(footer);
|
||||
|
||||
lastSig = computeSig();
|
||||
}
|
||||
|
||||
function computeSig() {
|
||||
return JSON.stringify([
|
||||
state.local.path,
|
||||
state.sidebar.bookmarks.map((b) => `${b.id}:${b.path}`),
|
||||
state.sidebar.quickAccess.map((q) => q.path),
|
||||
]);
|
||||
}
|
||||
|
||||
export function updateSidebarIfNeeded(container) {
|
||||
if (computeSig() === lastSig) return;
|
||||
renderSidebar(container);
|
||||
}
|
||||
43
frontend/js/components/statusBar.js
Normal file
43
frontend/js/components/statusBar.js
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { state } from "../store.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { iconMarkup } from "../icons.js";
|
||||
import { formatSize, taskStateKind } from "../format.js";
|
||||
import { escapeHtml } from "../dom.js";
|
||||
|
||||
let lastSig = null;
|
||||
|
||||
export function renderStatusBar(container) {
|
||||
const aggSpeed = state.queueTasks
|
||||
.filter((tk) => taskStateKind(tk.state) === "running")
|
||||
.reduce((sum, tk) => sum + (tk.speed ?? 0), 0);
|
||||
|
||||
const connected = state.tabs.filter((tb) => tb.status === "connected").length;
|
||||
const total = state.tabs.length;
|
||||
|
||||
container.innerHTML = `
|
||||
<span data-role="status">${escapeHtml(state.statusMessage)}</span>
|
||||
<div class="status-bar-right">
|
||||
${aggSpeed > 0 ? `<span class="text-green mono">${formatSize(aggSpeed)}/s</span>` : ""}
|
||||
${
|
||||
total > 0
|
||||
? `<span class="${connected > 0 ? "text-green" : "text-hint"}">${connected > 0 ? iconMarkup("lockPassword", 11) : ""} ${connected}/${total}</span>`
|
||||
: `<span class="text-hint">${t("status.notConnected")}</span>`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
|
||||
lastSig = computeSig();
|
||||
}
|
||||
|
||||
function computeSig() {
|
||||
return JSON.stringify([
|
||||
state.statusMessage,
|
||||
state.tabs.map((tb) => tb.status),
|
||||
state.queueTasks.map((tk) => tk.speed),
|
||||
]);
|
||||
}
|
||||
|
||||
export function updateStatusBarIfNeeded(container) {
|
||||
if (computeSig() === lastSig) return;
|
||||
renderStatusBar(container);
|
||||
}
|
||||
80
frontend/js/components/tabs.js
Normal file
80
frontend/js/components/tabs.js
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { state, notify } from "../store.js";
|
||||
import { iconMarkup } from "../icons.js";
|
||||
import { closeTab } from "../fsActions.js";
|
||||
import { openInPane } from "./paneLayout.js";
|
||||
import { escapeHtml } from "../dom.js";
|
||||
import { setDraggedTabId } from "../dnd.js";
|
||||
|
||||
const STATUS_COLOR = { connected:"text-green", connecting:"text-yellow", error:"text-red", disconnected:"text-hint" };
|
||||
const STATUS_DOT = { connected:"●", connecting:"◐", error:"×", disconnected:"○" };
|
||||
|
||||
export function renderTabBar(container) {
|
||||
container.innerHTML = "";
|
||||
|
||||
// Local tab — always present, not closable
|
||||
const localTab = createTab("local", "Local", null, false);
|
||||
container.appendChild(localTab);
|
||||
|
||||
for (const tab of state.tabs) {
|
||||
const el = createTab(tab.id, tab.label, tab.status, true);
|
||||
container.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
function createTab(id, label, status, closable) {
|
||||
const el = document.createElement("button");
|
||||
el.type = "button";
|
||||
el.className = "tab";
|
||||
el.draggable = true;
|
||||
|
||||
const inLayout = state.layout.panes.some((p) => p.tabId === (id === "local" ? null : id));
|
||||
if (inLayout) el.classList.add("active");
|
||||
|
||||
if (id !== "local" && status) {
|
||||
el.innerHTML = `<span class="${STATUS_COLOR[status] ?? "text-hint"}">${STATUS_DOT[status] ?? "○"}</span>`;
|
||||
}
|
||||
el.innerHTML += `<span>${escapeHtml(label)}</span>`;
|
||||
|
||||
// Drag — send tab-id
|
||||
el.addEventListener("dragstart", (e) => {
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("tab-id", id);
|
||||
el.classList.add("tab-dragging");
|
||||
setDraggedTabId(id === "local" ? null : id);
|
||||
});
|
||||
el.addEventListener("dragend", () => {
|
||||
el.classList.remove("tab-dragging");
|
||||
// Cleanup drop highlights
|
||||
document.querySelectorAll(".pane-cell.drop-active").forEach((c) => c.classList.remove("drop-active"));
|
||||
setDraggedTabId(undefined);
|
||||
});
|
||||
|
||||
// Click
|
||||
el.addEventListener("click", () => {
|
||||
openInPane(id === "local" ? null : id);
|
||||
if (id !== "local") {
|
||||
state.lastRemoteTabId = id;
|
||||
state.activePane = "remote";
|
||||
} else {
|
||||
state.activePane = "local";
|
||||
}
|
||||
});
|
||||
|
||||
// Close
|
||||
if (closable) {
|
||||
const close = document.createElement("button");
|
||||
close.type = "button";
|
||||
close.className = "tab-close";
|
||||
close.innerHTML = iconMarkup("close", 10);
|
||||
close.title = "Close";
|
||||
close.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
closeTab(id);
|
||||
});
|
||||
el.appendChild(close);
|
||||
}
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
export function renderActivePane() {}
|
||||
118
frontend/js/components/toolbar.js
Normal file
118
frontend/js/components/toolbar.js
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { state, notify, activeTab } from "../store.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { iconMarkup } from "../icons.js";
|
||||
import { openNew } from "./connectDialog.js";
|
||||
import { toggle as toggleHistory } from "./historyPopup.js";
|
||||
import { openMkdir, openDelete, openRename } from "./opDialogs.js";
|
||||
import { openSettings } from "./settingsDialog.js";
|
||||
import { queueUploadMany, queueDownloadMany, refreshLocal, refreshRemote } from "../fsActions.js";
|
||||
import { shortcutLabel, isMac } from "../platform.js";
|
||||
|
||||
let lastSig = null;
|
||||
|
||||
function pendingCount() {
|
||||
return state.queueTasks.filter((tk) => {
|
||||
const s = tk.state;
|
||||
const kind = typeof s === "string" ? s : Object.keys(s)[0];
|
||||
return kind === "running" || kind === "queued";
|
||||
}).length;
|
||||
}
|
||||
|
||||
function currentSelection() {
|
||||
if (state.activePane === "remote") {
|
||||
return activeTab()?.remoteSelected ?? [];
|
||||
}
|
||||
return state.local.selected;
|
||||
}
|
||||
|
||||
export function renderToolbar(container) {
|
||||
const tab = activeTab();
|
||||
const connected = tab?.status === "connected";
|
||||
|
||||
container.innerHTML = `
|
||||
<button type="button" class="btn btn-accent-dim" data-action="new-connection" title="${t("toolbar.newConnection")} (${shortcutLabel("N")})">
|
||||
${iconMarkup("addCircle", 15)}<span>${t("toolbar.newConnection")}</span>
|
||||
</button>
|
||||
<div class="btn-sep"></div>
|
||||
<button type="button" class="btn btn-ghost" data-action="upload" ${connected && state.local.selected.length > 0 ? "" : "disabled"}>
|
||||
${iconMarkup("upload", 15)}<span>${t("common.upload")}</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" data-action="download" ${connected && tab?.remoteSelected.length > 0 ? "" : "disabled"}>
|
||||
${iconMarkup("download", 15)}<span>${t("common.download")}</span>
|
||||
</button>
|
||||
<div class="btn-sep"></div>
|
||||
<button type="button" class="btn btn-toggle ${state.showQueue ? "active" : ""}" data-action="toggle-queue">
|
||||
${iconMarkup("clock", 15)}<span>${t("toolbar.queueLabel")}${pendingCount() > 0 ? ` ${pendingCount()}` : ""}</span>
|
||||
</button>
|
||||
<div class="btn-sep"></div>
|
||||
<button type="button" class="btn icon-btn btn-ghost" data-action="refresh" title="${t("common.refresh")} (${shortcutLabel("R")})" ${state.activePane === "remote" && !connected ? "disabled" : ""}>${iconMarkup("refresh", 16)}</button>
|
||||
<button type="button" class="btn icon-btn btn-ghost" data-action="new-folder" title="${t("common.newFolder")} (${shortcutLabel("N", { shift: true })})" ${state.activePane === "remote" && !connected ? "disabled" : ""}>${iconMarkup("folderAdd", 16)}</button>
|
||||
<button type="button" class="btn icon-btn btn-ghost" data-action="delete" title="${t("common.delete")} (${isMac ? "⌘⌫" : "Del"})" ${currentSelection().length > 0 ? "" : "disabled"}>${iconMarkup("trash", 16)}</button>
|
||||
<button type="button" class="btn icon-btn btn-ghost" data-action="rename" title="${t("common.rename")} (${isMac ? "⏎" : "F2"})" ${currentSelection().length === 1 ? "" : "disabled"}>${iconMarkup("pen", 16)}</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>
|
||||
`;
|
||||
|
||||
container.querySelector('[data-action="new-connection"]').addEventListener("click", openNew);
|
||||
container.querySelector('[data-action="settings"]').addEventListener("click", openSettings);
|
||||
container.querySelector('[data-action="upload"]').addEventListener("click", () => {
|
||||
const t2 = activeTab();
|
||||
const entries = state.local.entries.filter((e) => state.local.selected.includes(e.name));
|
||||
if (t2 && entries.length > 0) queueUploadMany(t2, entries);
|
||||
});
|
||||
container.querySelector('[data-action="download"]').addEventListener("click", () => {
|
||||
const t2 = activeTab();
|
||||
const entries = t2?.remoteEntries.filter((e) => t2.remoteSelected.includes(e.name)) ?? [];
|
||||
if (t2 && entries.length > 0) queueDownloadMany(t2, entries);
|
||||
});
|
||||
container.querySelector('[data-action="toggle-queue"]').addEventListener("click", () => {
|
||||
state.showQueue = !state.showQueue;
|
||||
notify();
|
||||
});
|
||||
container.querySelector('[data-action="refresh"]').addEventListener("click", () => {
|
||||
if (state.activePane === "remote" && tab) refreshRemote(tab);
|
||||
else refreshLocal();
|
||||
});
|
||||
container.querySelector('[data-action="new-folder"]').addEventListener("click", () => {
|
||||
state.opTarget = state.activePane;
|
||||
openMkdir();
|
||||
});
|
||||
container.querySelector('[data-action="delete"]').addEventListener("click", () => {
|
||||
const names = currentSelection();
|
||||
if (names.length === 0) return;
|
||||
state.opTarget = state.activePane;
|
||||
openDelete(names);
|
||||
});
|
||||
container.querySelector('[data-action="rename"]').addEventListener("click", () => {
|
||||
const names = currentSelection();
|
||||
if (names.length !== 1) return;
|
||||
state.opTarget = state.activePane;
|
||||
openRename(names[0]);
|
||||
});
|
||||
container.querySelector('[data-action="history"]').addEventListener("click", (e) => {
|
||||
toggleHistory(e.currentTarget);
|
||||
});
|
||||
|
||||
lastSig = computeSig();
|
||||
}
|
||||
|
||||
function computeSig() {
|
||||
const tab = activeTab();
|
||||
return JSON.stringify([
|
||||
state.activeTabId,
|
||||
state.activePane,
|
||||
tab?.status,
|
||||
tab?.remoteSelected,
|
||||
state.local.selected,
|
||||
state.showQueue,
|
||||
state.showHistoryPopup,
|
||||
pendingCount(),
|
||||
]);
|
||||
}
|
||||
|
||||
export function updateToolbarIfNeeded(container) {
|
||||
const sig = computeSig();
|
||||
if (sig === lastSig) return;
|
||||
renderToolbar(container);
|
||||
}
|
||||
87
frontend/js/components/welcome.js
Normal file
87
frontend/js/components/welcome.js
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { state } from "../store.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { iconMarkup } from "../icons.js";
|
||||
import { renderHistoryRows } from "./historyList.js";
|
||||
import { openNew } from "./connectDialog.js";
|
||||
import { openSettings } from "./settingsDialog.js";
|
||||
import { connectFromSite, PROTOCOL_LABELS } from "../connectActions.js";
|
||||
import { escapeHtml } from "../dom.js";
|
||||
import { shortcutLabel } from "../platform.js";
|
||||
|
||||
export function renderWelcome(container) {
|
||||
container.innerHTML = `
|
||||
<div class="welcome">
|
||||
<div class="welcome-hero">
|
||||
<div class="welcome-logo">${iconMarkup("folder", 34)}</div>
|
||||
<div class="welcome-title">Wherry</div>
|
||||
<div class="welcome-subtitle">${t("welcome.subtitle")}</div>
|
||||
</div>
|
||||
<div class="welcome-actions">
|
||||
<button type="button" class="btn btn-accent welcome-cta" data-action="new-connection">
|
||||
${iconMarkup("addCircle", 15)}<span>${t("welcome.newConnection")}</span>
|
||||
<kbd class="kbd kbd-on-accent">${shortcutLabel("N")}</kbd>
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" data-action="open-settings">
|
||||
${iconMarkup("settings", 15)}<span>${t("common.settings")}</span>
|
||||
<kbd class="kbd">${shortcutLabel(",")}</kbd>
|
||||
</button>
|
||||
</div>
|
||||
<div class="welcome-status" data-role="status"></div>
|
||||
<div class="welcome-columns">
|
||||
<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>
|
||||
<div class="welcome-card" data-role="recent" style="display:none">
|
||||
<div class="welcome-card-title">${iconMarkup("history", 12)}<span>${t("welcome.recentConnections")}</span></div>
|
||||
<div data-role="recent-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.querySelector('[data-action="new-connection"]').addEventListener("click", openNew);
|
||||
container.querySelector('[data-action="open-settings"]').addEventListener("click", openSettings);
|
||||
|
||||
updateWelcome(container);
|
||||
}
|
||||
|
||||
function renderSiteRows(container, sites) {
|
||||
container.innerHTML = "";
|
||||
for (const site of sites) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "recent-row";
|
||||
row.innerHTML = `
|
||||
<span class="icon icon--important">${iconMarkup("serverSquare", 13)}</span>
|
||||
<span class="recent-label">${escapeHtml(site.name)}</span>
|
||||
<span class="proto-badge">${PROTOCOL_LABELS[site.protocol] ?? site.protocol}</span>
|
||||
`;
|
||||
row.addEventListener("click", () => connectFromSite(site));
|
||||
container.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
export function updateWelcome(container) {
|
||||
const statusRole = container.querySelector('[data-role="status"]');
|
||||
if (!statusRole) return;
|
||||
|
||||
if (state.connectLoading) {
|
||||
statusRole.innerHTML = '<span class="spinner"></span>';
|
||||
} else if (state.connectError) {
|
||||
statusRole.innerHTML = `<span class="text-red" style="font-size:11.5px">${escapeHtml(state.connectError)}</span>`;
|
||||
} else {
|
||||
statusRole.innerHTML = "";
|
||||
}
|
||||
|
||||
const sitesWrap = container.querySelector('[data-role="sites"]');
|
||||
const sitesList = container.querySelector('[data-role="sites-list"]');
|
||||
const sites = state.sites.slice(0, 6);
|
||||
sitesWrap.style.display = sites.length ? "" : "none";
|
||||
if (sites.length) renderSiteRows(sitesList, sites);
|
||||
|
||||
const recentWrap = container.querySelector('[data-role="recent"]');
|
||||
const recentList = container.querySelector('[data-role="recent-list"]');
|
||||
const rows = state.history.slice(0, 6);
|
||||
recentWrap.style.display = rows.length ? "" : "none";
|
||||
if (rows.length) renderHistoryRows(recentList, rows);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue