Дезайн ыыыы

This commit is contained in:
loki5512344 2026-07-11 00:01:26 +03:00
parent 403d7086d4
commit e7c02c7feb
174 changed files with 15802 additions and 9725 deletions

View 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);
}
});
}

View 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);
}

View 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);
}

View 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;
}

View 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);
}
}

View 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,
});
}
}

View 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();
}

View 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);
}

View 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();
}

View 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;

View 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();
}

View 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;
}
}

View 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);
}

View 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);
}

View 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() {}

View 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);
}

View 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);
}