CH-J Server Managerمدیریت سرور از طریق SSH
منو
منبع منتشرشده

CH-J Server Manager

پوشه‌ها و فایل‌های یک نسخهٔ مشخص از برنامه را مرور کنید.

دانلود ZIP منبع
CH-J / Server Manager 377 فایل
core-20261019T111616Z-160a00bfcddc افزوده‌شده: · ساخته‌شده:
index.js افزوده‌شده: تاریخ ثبت نشده است ۵۴٫۱ KB · 630 خط
کد دانلود فایل
1"use strict";
3const api = window.chjPlugin;
4const byId = (id) => document.getElementById(id);
5const elements = Object.fromEntries([
6 "pluginVersion", "closeButton", "sessionSelect", "refreshSessionsButton", "rootButton", "upButton", "refreshButton", "breadcrumbs", "pathForm", "pathInput",
7 "newFileButton", "mkdirButton", "uploadButton", "downloadButton", "zipButton", "tarGzButton", "tarButton", "filterInput", "selectAllCheckbox", "filesBody",
8 "busyOverlay", "busyLabel", "status", "selectionInfo", "editor", "editorPath", "editorTarget", "editorPrivilege", "editorBackup", "editorLanguage", "editorSize", "editorDirty", "editorHost", "editorPosition",
9 "downloadEditorButton", "saveEditorButton", "closeEditorButton", "contextMenu",
10 "transfers", "transfersToggle", "transferSummary", "transferOverall", "transferOverallText", "transferList", "pauseAllButton", "resumeAllButton", "cancelAllButton",
11 "pauseSelectedButton", "resumeSelectedButton", "cancelSelectedButton", "clearFinishedButton", "concurrencySelect", "autoRetryCheckbox", "verifyCheckbox",
12 "sudoDialog", "sudoReason", "sudoError", "sudoPasswordRow", "sudoAccount", "sudoPassword", "sudoRemember", "sudoConfirm",
13 "planDialog", "planTitle", "planSummary", "planConflicts", "planConflictText", "planConflictList", "planSkipped", "planSudo", "planPasswordRow", "planPassword", "planRemember", "planError", "planSkip", "planOverwrite", "planStart"
14].map((id) => [id, byId(id)]));
16const state = {
17 sessions: [], currentPath: "/", entries: [], visibleEntries: [], selected: new Set(), anchorPath: null, listPrivileged: false,
18 sortKey: "name", sortDirection: 1, busy: false, monaco: null, editor: null, editorPath: null, savedText: "", monacoPromise: null,
19 edit: null, sudoAccepted: new Set(), transfers: new Map(), transferSummary: null, transferSelected: new Set(), transferNodes: new Map(), renderQueued: false
20};
22window.MonacoEnvironment = { getWorkerUrl: () => "vendor/monaco/vs/base/worker/workerMain.js" };
24// Server error codes ← concrete, user-facing reasons (the backend keeps stable codes).
25const ERROR_TEXT = {
26 FILE_PERMISSION_DENIED: "Účet SSH nemá k této operaci oprávnění souborového systému.",
27 FILE_METADATA_PERMISSION: "Původního vlastníka, skupinu nebo atributy souboru nelze bez oprávnění správce zachovat.",
28 FILE_NOT_FOUND: "Soubor nebo adresář neexistuje.",
29 FILE_EXISTS: "Cílová položka již existuje.",
30 FILE_CONFLICT: "Soubor byl mezitím změněn jiným procesem (obsah, práva nebo cíl odkazu). Uložení bylo zastaveno, původní soubor zůstal beze změny.",
31 FILE_HARDLINK_UNSUPPORTED: "Soubor má více pevných odkazů (hardlink). Nahrazení novým inode by přerušilo sdílení obsahu s ostatními odkazy, proto se automaticky neprovádí.",
32 FILE_UNSAFE_TYPE_OR_LINK: "Cíl není běžný soubor (např. zařízení, FIFO nebo nečekaný odkaz).",
33 FILE_UNSAFE_PATH: "Cesta obsahuje nebezpečnou nebo neplatnou součást (např. smyčku symbolických odkazů).",
34 FILE_SYMLINK_TARGET_MISSING: "Symbolický odkaz vede na neexistující cíl.",
35 FILE_SYMLINK_LOOP: "Symbolické odkazy tvoří smyčku.",
36 FILE_UNSAFE_PRIVILEGED_DIRECTORY: "Některý nadřazený adresář je zapisovatelný pro všechny bez sticky bitu; privilegovaná operace byla odmítnuta.",
37 FILE_UNSAFE_WORKSPACE: "Soukromý pracovní adresář účtu není bezpečný (vlastník/práva). Zkontrolujte domovský adresář.",
38 FILE_METADATA_UNSUPPORTED: "Důležitá metadata (ACL, xattr, SELinux, IMA/EVM) nelze bezpečně převzít. Operace byla zastavena.",
39 FILE_NO_SPACE: "Na cílovém souborovém systému není dost místa. Původní soubor zůstal nedotčen.",
40 FILE_READ_ONLY_FILESYSTEM: "Souborový systém je připojen jen pro čtení.",
41 FILE_TOO_LARGE: "Soubor překračuje limit editoru 25 MiB.",
42 FILE_DEPENDENCY_UNAVAILABLE: "Na serveru chybí python3, který je nutný pro bezpečné uložení se zachováním metadat.",
43 FILE_LINUX_REQUIRED: "Bezpečné ukládání je podporováno jen na linuxových serverech.",
44 FEATURE_PLATFORM_UNSUPPORTED: "Na tomto serveru (Windows) není editor ani privilegovaná správa souborů k dispozici; přenosy nových souborů a stahování fungují přes SFTP.",
45 FILE_RESULT_UNKNOWN: "Výsledek operace nelze potvrdit (např. přerušené spojení). Obsah editoru zůstal zachován a na serveru je obnovitelná kopie.",
46 FILE_UPLOAD_TIMEOUT: "Nahrání pracovní kopie vypršelo.",
47 FILE_PROTECTED_PATH: "Systémový adresář nelze odstranit ani přejmenovat.",
48 FILE_DIRECTORY_NOT_EMPTY: "Adresář není prázdný.",
49 FILE_BUSY: "Soubor je právě používán.",
50 FILE_VERIFY_FAILED: "Kontrolní součet přenesených dat nesouhlasí se zdrojem.",
51 FILE_SOURCE_CHANGED: "Zdrojový soubor se během přenosu změnil. Spusťte přenos znovu od začátku.",
52 FILE_RESUME_MISMATCH: "Uložená část souboru neodpovídá zdroji, nelze bezpečně navázat. Spusťte přenos znovu od začátku.",
53 SUDO_PASSWORD_REQUIRED: "Operace vyžaduje heslo sudo.",
54 SUDO_PASSWORD_INCORRECT: "Heslo sudo bylo odmítnuto.",
55 SUDO_NOT_PERMITTED: "Účet nemá oprávnění používat sudo pro tuto operaci.",
56 SUDO_TTY_REQUIRED: "Politika sudo vyžaduje terminál (requiretty); CH-J ji neobchází.",
57 SUDO_AUTH_FAILED: "Ověření sudo selhalo nebo je účet uzamčen.",
58 SUDO_UNAVAILABLE: "Na serveru není sudo a účet není root.",
59 SESSION_NOT_CONNECTED: "SSH relace není připojena.",
60 TRANSFER_STALLED: "Po dobu 60 s nebyla potvrzena žádná data (timeout).",
61 APP_RESTARTED: "Aplikace byla během přenosu ukončena.",
62 LOCAL_NO_SPACE: "Na lokálním disku není dost místa.",
63 TRANSFER_PLAN_EXPIRED: "Výběr vypršel, vyberte soubory znovu."
64};
65const NEEDS_SUDO = new Set(["FILE_PERMISSION_DENIED", "FILE_METADATA_PERMISSION", "SUDO_PASSWORD_REQUIRED"]);
66const STATE_TEXT = { waiting: "Čeká ve frontě", connecting: "Připojování", transferring: "Přenos probíhá", paused: "Pozastaveno", interrupted: "Přerušeno",
67 reconnecting: "Obnovování spojení", verifying: "Ověřování dat", finalizing: "Dokončování na serveru", completed: "Dokončeno", failed: "Chyba", cancelled: "Zrušeno" };
68const FINALIZE_TEXT = { pending: "čeká", running: "probíhá", done: "dokončeno", unknown: "nepotvrzeno – ověří se při obnovení", failed: "selhalo" };
69const RUNNING = new Set(["connecting", "transferring", "verifying", "finalizing"]);
70const FINAL = new Set(["completed", "failed", "cancelled"]);
72function errorCode(error) { return error?.code && /^[A-Z_]+$/.test(error.code) ? error.code : (/(?:^|: )([A-Z][A-Z0-9_]{3,63}):/.exec(String(error?.message || ""))?.[1] || ""); }
73function errorText(error) {
74 const code = errorCode(error);
75 if (ERROR_TEXT[code]) return `${ERROR_TEXT[code]} (${code})`;
76 return String(error?.message || error || "Neznámá chyba.").replace(/^Error invoking remote method '[^']+': (Error: )?/, "");
78function normalizePath(value) {
79 const raw = String(value || "").trim();
80 if (!raw.startsWith("/")) throw new Error("Cesta musí začínat znakem /.");
81 const parts = [];
82 for (const part of raw.split("/")) { if (!part || part === ".") continue; if (part === "..") parts.pop(); else parts.push(part); }
83 return `/${parts.join("/")}`;
85function joinPath(base, name) { return normalizePath(`${base === "/" ? "" : base}/${name}`); }
86function parentPath(value) { const parts = normalizePath(value).split("/").filter(Boolean); parts.pop(); return `/${parts.join("/")}`; }
87function selectedSessionId() { return elements.sessionSelect.value; }
88function currentSession() { return state.sessions.find((session) => session.sessionId === selectedSessionId()); }
89function selectedEntries() { return state.entries.filter((entry) => state.selected.has(entry.path)); }
90function bytes(value) { const units = ["B", "KiB", "MiB", "GiB", "TiB"]; let amount = Number(value || 0); let unit = 0; while (amount >= 1024 && unit < units.length - 1) { amount /= 1024; unit += 1; } return `${amount.toLocaleString("cs-CZ", { maximumFractionDigits: 1 })} ${units[unit]}`; }
91function rate(value) { return value > 0 ? `${bytes(value)}/s` : "—"; }
92function duration(seconds) {
93 if (seconds === null || seconds === undefined || !Number.isFinite(seconds)) return "Výpočet času…";
94 const s = Math.max(0, Math.round(seconds));
95 if (s < 60) return `přibližně ${s} s`;
96 if (s < 3600) return `přibližně ${Math.floor(s / 60)} min ${s % 60} s`;
97 return `přibližně ${Math.floor(s / 3600)} h ${Math.floor((s % 3600) / 60)} min`;
99function setStatus(message, error = false) { elements.status.textContent = message; elements.status.classList.toggle("error", error); }
100function validName(value) { return Boolean(value && !/[\/\0\r\n]/.test(value) && value !== "." && value !== ".."); }
102function setBusy(value, label = "Pracuji…") {
103 state.busy = value; elements.busyOverlay.hidden = !value; elements.busyLabel.textContent = label;
104 for (const element of [elements.sessionSelect, elements.refreshSessionsButton, elements.rootButton, elements.upButton, elements.refreshButton, elements.newFileButton, elements.mkdirButton, elements.uploadButton]) element.disabled = value || (!selectedSessionId() && element !== elements.sessionSelect && element !== elements.refreshSessionsButton);
105 updateActions();
108function updateActions() {
109 const selected = selectedEntries(); const enabled = Boolean(selectedSessionId()) && !state.busy;
110 elements.downloadButton.disabled = !enabled || !selected.length;
111 for (const button of [elements.zipButton, elements.tarGzButton, elements.tarButton]) button.disabled = !enabled || !selected.length;
112 elements.selectAllCheckbox.checked = Boolean(state.visibleEntries.length) && state.visibleEntries.every((entry) => state.selected.has(entry.path));
113 elements.selectAllCheckbox.indeterminate = state.visibleEntries.some((entry) => state.selected.has(entry.path)) && !elements.selectAllCheckbox.checked;
114 elements.selectionInfo.textContent = `${state.visibleEntries.length} položek · ${selected.length} vybráno${selected.length ? ` · ${bytes(selected.reduce((sum, entry) => sum + (entry.type === "directory" ? 0 : entry.size), 0))}` : ""}`;
117// ---- privilege prompts ------------------------------------------------------------------------
119async function privileges() {
120 try { return await api.files.privileges(selectedSessionId()); }
121 catch { return { mode: "unknown", username: currentSession()?.username || "" }; }
124// Offers the shared sudo mechanism; resolves to options for the retried call or null.
125function askSudo(reason, { error = "", privilege = null } = {}) {
126 return new Promise((resolve) => {
127 const needsPassword = !privilege || privilege.mode === "sudo-password" || privilege.mode === "unknown";
128 elements.sudoReason.textContent = reason;
129 elements.sudoError.hidden = !error; elements.sudoError.textContent = error;
130 elements.sudoAccount.textContent = privilege?.username || currentSession()?.username || "";
131 elements.sudoPasswordRow.hidden = !needsPassword || privilege?.sudoCached === true;
132 elements.sudoPassword.value = "";
133 const close = () => {
134 elements.sudoDialog.removeEventListener("close", close);
135 const ok = elements.sudoDialog.returnValue === "ok";
136 const password = elements.sudoPassword.value;
137 elements.sudoPassword.value = "";
138 resolve(ok ? { sudo: true, ...(password ? { sudoPassword: password } : {}), rememberSudo: elements.sudoRemember.checked } : null);
139 };
140 elements.sudoDialog.addEventListener("close", close);
141 elements.sudoDialog.returnValue = "";
142 elements.sudoDialog.showModal();
143 if (!elements.sudoPasswordRow.hidden) elements.sudoPassword.focus();
144 });
147// Runs action(options); on insufficient permissions offers sudo (password re-asked on rejection).
148async function withPrivilege(reason, action, initial = {}) {
149 let options = initial;
150 const sessionId = selectedSessionId();
151 for (let attempt = 0; attempt < 5; attempt += 1) {
152 try { return { value: await action(options), options }; }
153 catch (error) {
154 const code = errorCode(error);
155 if (!NEEDS_SUDO.has(code) && code !== "SUDO_PASSWORD_INCORRECT") throw error;
156 // After the user approved sudo (remembered in Main memory) for this session, retry silently once.
157 if (!options.sudo && state.sudoAccepted.has(sessionId) && code !== "SUDO_PASSWORD_REQUIRED") { options = { ...initial, sudo: true }; continue; }
158 const privilege = await privileges();
159 if (privilege.mode === "root" && code !== "SUDO_PASSWORD_INCORRECT") throw error; // Root sees the real filesystem error.
160 if (privilege.mode === "none") throw Object.assign(new Error(`SUDO_NOT_PERMITTED: ${ERROR_TEXT[code] || ""}`), { code: privilege.sudoInstalled ? "SUDO_NOT_PERMITTED" : "SUDO_UNAVAILABLE" });
161 const answer = await askSudo(reason, { privilege: code === "SUDO_PASSWORD_INCORRECT" || code === "SUDO_PASSWORD_REQUIRED" ? { ...privilege, mode: "sudo-password", sudoCached: false } : privilege,
162 error: code === "SUDO_PASSWORD_INCORRECT" ? ERROR_TEXT.SUDO_PASSWORD_INCORRECT : options.sudo ? errorText(error) : "" });
163 if (!answer) throw Object.assign(new Error("Operace byla zrušena."), { code: "CANCELED" });
164 if (answer.rememberSudo) state.sudoAccepted.add(sessionId); else state.sudoAccepted.delete(sessionId);
165 options = { ...initial, ...answer };
166 }
167 }
168 throw new Error("Sudo autorizace se nezdařila.");
171// ---- directory view ---------------------------------------------------------------------------
173function renderBreadcrumbs() {
174 const fragment = document.createDocumentFragment(); let current = "";
175 const append = (label, target) => { const button = document.createElement("button"); button.type = "button"; button.className = "crumb"; button.textContent = label; button.addEventListener("click", () => loadDirectory(target)); fragment.append(button); };
176 append("/", "/");
177 for (const part of state.currentPath.split("/").filter(Boolean)) { const separator = document.createElement("span"); separator.className = "crumb-sep"; separator.textContent = "›"; fragment.append(separator); current += `/${part}`; append(part, current); }
178 elements.breadcrumbs.replaceChildren(fragment); elements.breadcrumbs.scrollLeft = elements.breadcrumbs.scrollWidth;
181function compareEntries(left, right) {
182 const leftDir = left.type === "directory" || left.targetType === "directory"; const rightDir = right.type === "directory" || right.targetType === "directory";
183 if (leftDir && !rightDir) return -1;
184 if (rightDir && !leftDir) return 1;
185 const key = state.sortKey; let result;
186 if (key === "size") result = left.size - right.size;
187 else if (key === "modifiedAt") result = Date.parse(left.modifiedAt || 0) - Date.parse(right.modifiedAt || 0);
188 else result = String(left[key] || "").localeCompare(String(right[key] || ""), "cs", { numeric: true, sensitivity: "base" });
189 return result * state.sortDirection;
192function typeLabel(item) {
193 if (item.type === "directory") return "složka";
194 if (item.type === "symlink") return item.targetType === "directory" ? "odkaz ← složka" : item.targetType === "missing" ? "odkaz (neplatný)" : "odkaz ← soubor";
195 return item.name.split(".").pop()?.toUpperCase() || "soubor";
198function renderEntries() {
199 const query = elements.filterInput.value.trim().toLocaleLowerCase("cs");
200 state.visibleEntries = state.entries.filter((entry) => !query || entry.name.toLocaleLowerCase("cs").includes(query)).sort(compareEntries);
201 const fragment = document.createDocumentFragment();
202 for (const item of state.visibleEntries) {
203 const isDir = item.type === "directory" || item.targetType === "directory";
204 const row = document.createElement("tr"); row.dataset.path = item.path; row.dataset.type = item.type; row.classList.toggle("selected", state.selected.has(item.path));
205 const checkCell = document.createElement("td"); const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.checked = state.selected.has(item.path); checkbox.dataset.select = item.path; checkCell.append(checkbox); row.append(checkCell);
206 const name = document.createElement("td"); name.className = "file-name"; const icon = document.createElement("span"); icon.className = "file-icon"; icon.textContent = item.type === "directory" ? "📁" : item.type === "symlink" ? (isDir ? "↖📁" : "↖") : "📄"; name.append(icon, document.createTextNode(item.name)); row.append(name);
207 for (const value of [typeLabel(item), isDir ? "—" : bytes(item.size), item.modifiedAt ? new Date(item.modifiedAt).toLocaleString("cs-CZ") : "—"]) { const cell = document.createElement("td"); cell.className = "file-meta"; cell.textContent = value; row.append(cell); }
208 const actions = document.createElement("td"); const wrap = document.createElement("div"); wrap.className = "row-actions"; for (const [label, action] of [[isDir ? "Otevřít" : "Upravit", "open"], ["⋯", "menu"]]) { const button = document.createElement("button"); button.type = "button"; button.className = "row-action"; button.textContent = label; button.dataset.rowAction = action; button.dataset.path = item.path; wrap.append(button); } actions.append(wrap); row.append(actions); fragment.append(row);
209 }
210 if (!state.visibleEntries.length) { const row = document.createElement("tr"); const cell = document.createElement("td"); cell.colSpan = 6; cell.className = "empty"; cell.textContent = query ? "Filtru neodpovídá žádná položka." : "Adresář je prázdný."; row.append(cell); fragment.append(row); }
211 elements.filesBody.replaceChildren(fragment); updateActions();
214function selectPath(remotePath, options = {}) {
215 const index = state.visibleEntries.findIndex((entry) => entry.path === remotePath);
216 if (options.range && state.anchorPath) {
217 const anchor = state.visibleEntries.findIndex((entry) => entry.path === state.anchorPath); if (anchor >= 0 && index >= 0) for (let position = Math.min(anchor, index); position <= Math.max(anchor, index); position += 1) state.selected.add(state.visibleEntries[position].path);
218 } else if (options.toggle) { if (state.selected.has(remotePath)) state.selected.delete(remotePath); else state.selected.add(remotePath); state.anchorPath = remotePath; }
219 else { state.selected = new Set([remotePath]); state.anchorPath = remotePath; }
220 renderEntries();
223async function loadSessions() {
224 const previous = selectedSessionId(); state.sessions = (await api.sessions.list()).filter((session) => session.state === "connected");
225 elements.sessionSelect.replaceChildren(new Option(state.sessions.length ? "Vyberte relaci…" : "Žádná připojená relace", ""));
226 for (const session of state.sessions) elements.sessionSelect.add(new Option(`${session.label} — ${session.username}@${session.host}`, session.sessionId));
227 elements.sessionSelect.value = state.sessions.some((session) => session.sessionId === previous) ? previous : (state.sessions[0]?.sessionId || ""); setBusy(false);
230async function loadDirectory(requested = state.currentPath) {
231 if (!selectedSessionId() || state.busy) return; const target = normalizePath(requested); setBusy(true, `Načítám ${target}…`); hideContextMenu();
232 try {
233 const { value: entries, options } = await withPrivilege(`Adresář ${target} nelze číst pod účtem SSH. Zobrazit jeho obsah se sudo?`,
234 (opts) => api.files.list(selectedSessionId(), target, opts));
235 state.listPrivileged = options.sudo === true;
236 state.currentPath = target; state.entries = entries; state.selected.clear(); state.anchorPath = null; elements.pathInput.value = target; renderBreadcrumbs(); renderEntries();
237 setStatus(`Načteno ${entries.length} položek z ${target}${state.listPrivileged ? " (sudo)" : ""}.`);
238 }
239 catch (error) { if (errorCode(error) !== "CANCELED") setStatus(errorText(error), true); }
240 finally { setBusy(false); }
243async function mutate(label, reason, action) {
244 if (state.busy) return; setBusy(true, label);
245 try { const { value: result } = await withPrivilege(reason, action); if (result?.canceled) { setStatus("Operace byla zrušena."); return; } setBusy(false); await loadDirectory(state.currentPath); }
246 catch (error) { setStatus(errorCode(error) === "CANCELED" ? "Operace byla zrušena." : errorText(error), errorCode(error) !== "CANCELED"); }
247 finally { setBusy(false); }
250// ---- Monaco editor ----------------------------------------------------------------------------
252function languageFor(remotePath) {
253 const extension = remotePath.toLowerCase().split(".").pop();
254 return ({ js: "javascript", mjs: "javascript", cjs: "javascript", ts: "typescript", tsx: "typescript", json: "json", html: "html", htm: "html", css: "css", scss: "scss", less: "less", md: "markdown", sh: "shell", bash: "shell", zsh: "shell", py: "python", go: "go", java: "java", c: "c", h: "c", cpp: "cpp", hpp: "cpp", rs: "rust", php: "php", sql: "sql", xml: "xml", yaml: "yaml", yml: "yaml", ini: "ini", toml: "ini", conf: "ini", dockerfile: "dockerfile" })[extension] || (remotePath.toLowerCase().endsWith("dockerfile") ? "dockerfile" : "plaintext");
257function ensureMonaco() {
258 if (state.monaco) return Promise.resolve(state.monaco); if (state.monacoPromise) return state.monacoPromise;
259 state.monacoPromise = new Promise((resolve, reject) => { const amd = window.require; if (!amd?.config) return reject(new Error("Monaco AMD loader není dostupný.")); amd.config({ paths: { vs: "vendor/monaco/vs" }, preferScriptTags: true }); amd(["vs/editor/editor.main"], () => { if (!window.monaco?.editor) return reject(new Error("Monaco Editor se nepodařilo inicializovat.")); state.monaco = window.monaco; resolve(window.monaco); }, reject); }).catch((error) => { state.monacoPromise = null; throw error; });
260 return state.monacoPromise;
263function updateDirtyState() { const dirty = Boolean(state.editor && state.editorPath && state.editor.getValue() !== state.savedText); elements.editorDirty.hidden = !dirty; return dirty; }
265function renderEditorMeta() {
266 const edit = state.edit;
267 elements.editorPrivilege.hidden = !edit?.privileged;
268 elements.editorPrivilege.textContent = edit?.privileged ? "sudo" : "";
269 elements.editorTarget.hidden = !edit?.link;
270 elements.editorTarget.textContent = edit?.link ? `↖ Symbolický odkaz – upravuje se cílový soubor ${edit.resolvedPath}; odkaz zůstane zachován.` : "";
273async function saveEditor() {
274 if (!state.editorPath || !state.editor || state.busy || !state.edit) return; setBusy(true, "Ukládám soubor…");
275 const text = state.editor.getValue();
276 const edit = state.edit;
277 const base = { editId: edit.editId, ...(elements.editorBackup.checked ? { backup: true } : {}), ...(edit.privileged || !edit.directSave ? { sudo: true } : {}) };
278 try {
279 const { value: result, options } = await withPrivilege(`Uložení ${state.editorPath} vyžaduje oprávnění správce. Soubor se uloží atomicky se zachováním vlastníka, práv, ACL, xattr a SELinux kontextu.`, async (opts) => {
280 const outcome = await api.files.saveText(selectedSessionId(), state.editorPath, text, { ...base, ...opts });
281 if (!outcome.ok) throw Object.assign(new Error(`${outcome.error.code}: ${outcome.error.message}`), { code: outcome.error.code, detail: outcome.error });
282 return outcome.value;
283 }, base.sudo ? { sudo: true } : {});
284 if (options.sudo) { edit.privileged = true; renderEditorMeta(); }
285 state.savedText = text; elements.editorSize.textContent = bytes(result.size); updateDirtyState();
286 setStatus(`Atomicky uloženo ${bytes(result.size)}${result.link ? ` do cíle odkazu ${result.resolvedPath}` : ""}${options.sudo ? " (sudo)" : ""}${result.backupPath ? ` · záloha ${result.backupPath}` : ""}${result.recoveryAvailable ? " · pracovní kopie zůstala k dispozici" : ""}.`);
287 } catch (error) {
288 const code = errorCode(error);
289 if (code === "CANCELED") setStatus("Uložení bylo zrušeno; změny zůstávají v editoru.");
290 else if (code === "FILE_CONFLICT") {
291 setStatus(errorText(error), true);
292 if (window.confirm(`${ERROR_TEXT.FILE_CONFLICT}\n\nNačíst aktuální verzi ze serveru? Vaše neuložené změny budou zahozeny.\n(Zrušit = ponechat změny v editoru)`)) { state.savedText = state.editor.getValue(); setBusy(false); await openText(state.editorPath, { force: true }); }
293 } else setStatus(`${errorText(error)}${error.detail?.recoveryId ? ` Kopie pro obnovu: ${error.detail.recoveryId}.` : ""}`, true);
294 } finally { setBusy(false); }
297async function openText(remotePath, { force = false } = {}) {
298 if (state.busy) return;
299 if (!force && state.editorPath && updateDirtyState() && !window.confirm("Otevřený soubor obsahuje neuložené změny. Zahodit je a otevřít jiný soubor?")) return;
300 setBusy(true, `Otevírám ${remotePath}…`);
301 try {
302 const [{ value: result, options }, monaco] = await Promise.all([
303 withPrivilege(`Soubor ${remotePath} nelze číst pod účtem SSH. Otevřít jej se sudo?`, (opts) => api.files.readText(selectedSessionId(), remotePath, opts)),
304 ensureMonaco()
305 ]);
306 const language = languageFor(result.resolvedPath || remotePath);
307 if (state.edit?.editId) void api.files.closeText(state.edit.editId).catch(() => {});
308 if (!state.editor) {
309 state.editor = monaco.editor.create(elements.editorHost, { value: result.text, language, theme: "vs-dark", automaticLayout: true, fontSize: 13, lineHeight: 20, minimap: { enabled: true }, smoothScrolling: true, bracketPairColorization: { enabled: true }, guides: { bracketPairs: true }, renderWhitespace: "selection", formatOnPaste: true, scrollBeyondLastLine: false });
310 state.editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => void saveEditor());
311 state.editor.onDidChangeModelContent(updateDirtyState); state.editor.onDidChangeCursorPosition((event) => { elements.editorPosition.textContent = `Řádek ${event.position.lineNumber}, sloupec ${event.position.column}`; });
312 } else { state.editor.setValue(result.text); monaco.editor.setModelLanguage(state.editor.getModel(), language); }
313 state.edit = { editId: result.editId, resolvedPath: result.resolvedPath || result.path, link: result.link === true, directSave: result.directSave !== false, privileged: options.sudo === true };
314 state.editorPath = result.path; state.savedText = result.text; elements.editorPath.textContent = result.path; elements.editorPath.title = result.path; elements.editorLanguage.textContent = language; elements.editorSize.textContent = bytes(result.size);
315 elements.editorBackup.checked = false; renderEditorMeta(); updateDirtyState(); elements.editor.hidden = false; state.editor.focus();
316 setStatus(`Otevřen ${remotePath}${result.link ? ` (odkaz na ${result.resolvedPath})` : ""}${options.sudo ? " se sudo" : ""}${!result.directSave && !options.sudo ? " · uložení bude vyžadovat sudo" : ""}.`);
317 } catch (error) { if (errorCode(error) !== "CANCELED") setStatus(errorText(error), true); }
318 finally { setBusy(false); }
321function closeEditor() {
322 if (!elements.editor.hidden && updateDirtyState() && !window.confirm("Soubor obsahuje neuložené změny. Opravdu zavřít editor?")) return;
323 if (state.edit?.editId) void api.files.closeText(state.edit.editId).catch(() => {});
324 elements.editor.hidden = true; state.editorPath = null; state.savedText = ""; state.edit = null;
327// ---- transfers --------------------------------------------------------------------------------
329function showPlan(plan) {
330 return new Promise((resolve) => {
331 const upload = plan.direction === "upload";
332 elements.planTitle.textContent = upload ? "Nahrání na server" : "Stažení ze serveru";
333 elements.planSummary.textContent = `${plan.files} souborů${plan.directories ? ` a ${plan.directories} složek` : ""}, celkem ${bytes(plan.bytes)} ← ${plan.destination}`;
334 elements.planConflicts.hidden = !plan.conflictCount;
335 elements.planConflictText.textContent = `${plan.conflictCount} cílových souborů již existuje. Přepsání zachová jejich vlastníka, práva, ACL a atributy a proběhne atomicky až po úplném ověřeném přenosu.`;
336 elements.planConflictList.replaceChildren(...plan.conflicts.map((item) => { const li = document.createElement("li"); li.textContent = item; return li; }));
337 elements.planSkipped.hidden = !plan.skippedCount;
338 elements.planSkipped.textContent = `${plan.skippedCount} položek bude vynecháno (symbolické odkazy, speciální soubory nebo kolize se složkou).`;
339 elements.planSudo.hidden = !plan.requiresSudo;
340 elements.planPasswordRow.hidden = !plan.sudoPasswordRequired;
341 elements.planPassword.value = "";
342 elements.planError.hidden = !plan.error; elements.planError.textContent = plan.error || "";
343 elements.planSkip.hidden = !plan.conflictCount; elements.planOverwrite.hidden = !plan.conflictCount; elements.planStart.hidden = Boolean(plan.conflictCount);
344 const close = () => {
345 elements.planDialog.removeEventListener("close", close);
346 const choice = elements.planDialog.returnValue;
347 const password = elements.planPassword.value; elements.planPassword.value = "";
348 resolve(["start", "skip", "overwrite"].includes(choice) ? { conflict: choice === "start" ? "fail" : choice, ...(plan.requiresSudo ? { sudo: true, rememberSudo: elements.planRemember.checked } : {}), ...(password ? { sudoPassword: password } : {}) } : null);
349 };
350 elements.planDialog.addEventListener("close", close);
351 elements.planDialog.returnValue = "";
352 elements.planDialog.showModal();
353 if (plan.requiresSudo && plan.sudoPasswordRequired) elements.planPassword.focus();
354 });
357async function runPlan(plan) {
358 if (!plan || plan.canceled) { setStatus("Výběr byl zrušen."); return; }
359 if (!plan.files && !plan.directories) { setStatus("Výběr neobsahuje žádné přenositelné soubory."); return; }
360 for (let attempt = 0; attempt < 4; attempt += 1) {
361 const choice = await showPlan(plan);
362 if (!choice) { setStatus("Přenos nebyl spuštěn."); return; }
363 try {
364 const started = await api.files.transfers.start(plan.planId, choice);
365 setStatus(`Zařazeno ${started.jobs} přenosů${started.skipped ? `, přeskočeno ${started.skipped}` : ""}.`);
366 setTransfersCollapsed(false);
367 return;
368 } catch (error) {
369 const code = errorCode(error);
370 if (code === "SUDO_PASSWORD_INCORRECT" || code === "SUDO_PASSWORD_REQUIRED") { plan = { ...plan, sudoPasswordRequired: true, error: errorText(error) }; continue; }
371 setStatus(errorText(error), true); return;
372 }
373 }
376async function uploadSelection() {
377 if (state.busy || !selectedSessionId()) return;
378 if (!api.files.transfers) { await mutate("Nahrávám soubory a složky…", "", () => api.files.upload(selectedSessionId(), state.currentPath)); return; }
379 setBusy(true, "Připravuji nahrání…");
380 let plan;
381 try { plan = await api.files.transfers.planUpload(selectedSessionId(), state.currentPath); }
382 catch (error) { setStatus(errorText(error), true); }
383 finally { setBusy(false); }
384 if (plan) await runPlan(plan);
387async function downloadSelection(paths = selectedEntries().map((item) => item.path)) {
388 if (!paths.length || state.busy) return;
389 if (!api.files.transfers) {
390 const items = selectedEntries(); setBusy(true, `Stahuji ${items.length} položek…`);
391 try { const result = items.length === 1 && items[0].type !== "directory" ? await api.files.download(selectedSessionId(), items[0].path) : await api.files.downloadMany(selectedSessionId(), items.map((item) => item.path)); if (!result.canceled) setStatus(`Staženo ${result.entries || 1} položek, ${bytes(result.size)}.`); }
392 catch (error) { setStatus(errorText(error), true); }
393 finally { setBusy(false); }
394 return;
395 }
396 let plan;
397 try {
398 plan = (await withPrivilege("Vybrané položky nelze číst pod účtem SSH. Stáhnout je se sudo? Chráněné soubory se nejprve zkopírují do soukromého dočasného adresáře účtu.",
399 (opts) => api.files.transfers.planDownload(selectedSessionId(), paths, opts), state.listPrivileged ? { sudo: true } : {})).value;
400 } catch (error) { if (errorCode(error) !== "CANCELED") setStatus(errorText(error), true); return; }
401 await runPlan(plan);
404async function archiveSelection(format) {
405 const items = selectedEntries(); if (!items.length || state.busy) return; setBusy(true, `Vytvářím ${format.toUpperCase()} archiv…`);
406 try { const result = await api.files.downloadArchive(selectedSessionId(), items.map((item) => item.path), format); if (!result.canceled) setStatus(`Archiv ${format.toUpperCase()} uložen: ${bytes(result.size)}, ${result.entries} položek.`); }
407 catch (error) { setStatus(errorText(error), true); }
408 finally { setBusy(false); }
411function setTransfersCollapsed(collapsed) {
412 elements.transfers.classList.toggle("collapsed", collapsed);
413 elements.transfersToggle.setAttribute("aria-expanded", String(!collapsed));
414 try { localStorage.setItem("chj.fm.transfersCollapsed", collapsed ? "1" : "0"); } catch {}
417function applyTransferUpdate(update) {
418 if (update.full) state.transfers = new Map();
419 for (const id of update.removed || []) { state.transfers.delete(id); state.transferSelected.delete(id); }
420 for (const job of update.jobs || []) state.transfers.set(job.id, job);
421 if (update.summary) state.transferSummary = update.summary;
422 if (update.settings) {
423 elements.concurrencySelect.value = String(update.settings.maxConcurrent);
424 elements.autoRetryCheckbox.checked = update.settings.autoRetry;
425 elements.verifyCheckbox.checked = update.settings.verify;
426 }
427 queueTransferRender();
430function queueTransferRender() {
431 if (state.renderQueued) return;
432 state.renderQueued = true;
433 requestAnimationFrame(() => { state.renderQueued = false; renderTransfers(); });
436function jobStatusText(job) {
437 let text = STATE_TEXT[job.state] || job.state;
438 if (job.state === "reconnecting" && job.nextRetryAt) text += ` (pokus ${job.attempts}, za ${Math.max(0, Math.ceil((job.nextRetryAt - Date.now()) / 1000))} s)`;
439 if (job.authRequired) text += " – vyžaduje sudo";
440 return text;
443function buildTransferNode(job) {
444 const node = document.createElement("div"); node.className = "transfer"; node.dataset.id = job.id;
445 node.innerHTML = '<input type="checkbox" aria-label="Vybrat přenos"><div class="t-main"><div class="t-title"><strong></strong><span class="t-direction"></span><span class="t-badge" hidden>sudo</span></div><div class="t-paths"></div><div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100"><span></span></div><div class="t-stats"></div><div class="t-error" hidden></div></div><div class="t-actions"></div>';
446 return node;
449function actionButton(label, action, id, extra = "") { const button = document.createElement("button"); button.type = "button"; button.className = `button compact ${extra}`; button.textContent = label; button.dataset.transferAction = action; button.dataset.id = id; return button; }
451function updateTransferNode(node, job) {
452 node.classList.toggle("selected", state.transferSelected.has(job.id));
453 node.querySelector("input").checked = state.transferSelected.has(job.id);
454 node.querySelector(".t-title strong").textContent = job.name;
455 node.querySelector(".t-direction").textContent = `${job.direction === "upload" ? "Upload ←" : "Download ←"} ${job.target}`;
456 node.querySelector(".t-badge").hidden = job.mode !== "privileged";
457 node.querySelector(".t-paths").textContent = `Zdroj: ${job.source} · Cíl: ${job.target}${job.resolvedTarget && job.resolvedTarget !== job.target ? ` (cíl odkazu ${job.resolvedTarget})` : ""}`;
458 const bar = node.querySelector(".progress");
459 const value = job.state === "completed" ? 100 : job.percent;
460 bar.classList.toggle("indeterminate", value === null && RUNNING.has(job.state));
461 bar.classList.toggle("done", job.state === "completed"); bar.classList.toggle("error", job.state === "failed" || job.state === "cancelled");
462 bar.classList.toggle("paused", ["paused", "interrupted", "reconnecting"].includes(job.state));
463 bar.querySelector("span").style.width = `${value ?? 0}%`;
464 if (value === null) bar.removeAttribute("aria-valuenow"); else bar.setAttribute("aria-valuenow", String(value));
465 const stats = [];
466 stats.push(`<b>${value === null ? (job.size === 0 ? "0 B" : "—") : `${value.toLocaleString("cs-CZ")} %`}</b>`);
467 stats.push(`${bytes(job.transferred)} / ${job.size === null ? "neznámá velikost" : bytes(job.size)}`);
468 if (job.state === "transferring") { stats.push(`Rychlost: ${rate(job.speed)}`); stats.push(`Zbývá: ${duration(job.eta)}`); }
469 if (job.averageSpeed) stats.push(`Průměr: ${rate(job.averageSpeed)}`);
470 stats.push(`Stav: <span class="t-state ${job.state}">${escapeHtml(jobStatusText(job))}</span>`);
471 if (job.direction === "upload" || job.mode === "privileged") stats.push(`Dokončení na cíli: ${FINALIZE_TEXT[job.finalize.state] || job.finalize.state}${job.finalize.state === "done" && job.finalize.metadataPreserved ? " · metadata zachována" : ""}`);
472 if (job.verification) stats.push(`Ověření: ${job.verification.method === "sha256" ? (job.verification.matched ? "SHA-256 shoda" : "SHA-256") : "jen velikost"}`);
473 node.querySelector(".t-stats").innerHTML = stats.join("<span></span>");
474 const errorNode = node.querySelector(".t-error");
475 errorNode.hidden = !job.error || job.state === "completed"; errorNode.textContent = job.error ? errorText({ code: job.error.code, message: job.error.message }) : "";
476 const actions = node.querySelector(".t-actions"); const buttons = [];
477 if (job.actions.pause) buttons.push(actionButton("⏸ Pozastavit", "pause", job.id));
478 if (job.actions.resume) buttons.push(actionButton(job.state === "interrupted" ? (job.authRequired ? "🔑 Pokračovat se sudo" : "↻ Obnovit") : "▶ Pokračovat", "resume", job.id));
479 if (job.actions.overwrite) buttons.push(actionButton("Přepsat cíl", "overwrite", job.id));
480 if (job.actions.retry) buttons.push(actionButton("⟲ Znovu od začátku", "retry", job.id, "ghost"));
481 if (job.actions.cancel) buttons.push(actionButton("✕ Zrušit", "cancel", job.id, "danger"));
482 if (job.actions.remove) buttons.push(actionButton("Odebrat", "remove", job.id, "ghost"));
483 const signature = buttons.map((button) => button.dataset.transferAction + button.textContent).join("|");
484 if (actions.dataset.signature !== signature) { actions.replaceChildren(...buttons); actions.dataset.signature = signature; }
487function escapeHtml(value) { return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]); }
489function renderTransfers() {
490 const jobs = [...state.transfers.values()];
491 const summary = state.transferSummary;
492 if (!jobs.length || !summary) {
493 elements.transferSummary.textContent = "Žádné přenosy.";
494 elements.transferOverall.querySelector("span").style.width = "0%"; elements.transferOverallText.textContent = "";
495 elements.transferList.replaceChildren(Object.assign(document.createElement("div"), { className: "transfer-empty", textContent: "Nahrané a stažené soubory se zobrazí zde i po přepnutí obrazovky nebo restartu aplikace." }));
496 state.transferNodes.clear();
497 } else {
498 const parts = [`${summary.total} souborů`, `${summary.completed} dokončeno`, `${summary.active} aktivní`, `${summary.waiting} čeká`];
499 if (summary.paused) parts.push(`${summary.paused} pozastaveno`);
500 if (summary.interrupted) parts.push(`${summary.interrupted} přerušeno`);
501 if (summary.failed) parts.push(`${summary.failed} s chybou`);
502 if (summary.cancelled) parts.push(`${summary.cancelled} zrušeno`);
503 elements.transferSummary.textContent = parts.join(" · ");
504 const bar = elements.transferOverall;
505 bar.classList.toggle("indeterminate", summary.percent === null && summary.active > 0);
506 bar.classList.toggle("done", summary.completed === summary.total - summary.cancelled && summary.total > summary.cancelled);
507 bar.querySelector("span").style.width = `${summary.percent ?? 0}%`;
508 elements.transferOverallText.textContent = `${summary.percent === null ? "—" : `${summary.percent.toLocaleString("cs-CZ")} %`} · ${bytes(summary.transferredBytes)} / ${summary.sizesKnown ? bytes(summary.totalBytes) : "neznámá velikost"}${summary.active ? ` · ${rate(summary.speed)} · Zbývá: ${duration(summary.eta)}` : ""}`;
509 const rank = (job) => RUNNING.has(job.state) ? 0 : ["waiting", "reconnecting"].includes(job.state) ? 1 : ["paused", "interrupted"].includes(job.state) ? 2 : job.state === "failed" ? 3 : 4;
510 const ordered = jobs.sort((a, b) => rank(a) - rank(b) || (rank(a) === 4 ? (b.finishedAt || 0) - (a.finishedAt || 0) : a.createdAt - b.createdAt)).slice(0, 400);
511 const keep = new Set(ordered.map((job) => job.id));
512 for (const [id, node] of state.transferNodes) if (!keep.has(id)) { node.remove(); state.transferNodes.delete(id); }
513 const empty = elements.transferList.querySelector(".transfer-empty"); if (empty) empty.remove();
514 let previous = null;
515 for (const job of ordered) {
516 let node = state.transferNodes.get(job.id);
517 if (!node) { node = buildTransferNode(job); state.transferNodes.set(job.id, node); }
518 updateTransferNode(node, job);
519 const expected = previous ? previous.nextSibling : elements.transferList.firstChild;
520 if (expected !== node) elements.transferList.insertBefore(node, expected);
521 previous = node;
522 }
523 }
524 const selected = [...state.transferSelected].map((id) => state.transfers.get(id)).filter(Boolean);
525 elements.pauseSelectedButton.disabled = !selected.some((job) => job.actions.pause);
526 elements.resumeSelectedButton.disabled = !selected.some((job) => job.actions.resume);
527 elements.cancelSelectedButton.disabled = !selected.some((job) => job.actions.cancel);
528 elements.pauseAllButton.disabled = !jobs.some((job) => job.actions.pause);
529 elements.resumeAllButton.disabled = !jobs.some((job) => job.actions.resume);
530 elements.cancelAllButton.disabled = !jobs.some((job) => job.actions.cancel);
531 elements.clearFinishedButton.disabled = !jobs.some((job) => job.actions.remove);
534async function transferAction(action, ids) {
535 const list = ids === "all" ? [...state.transfers.values()] : ids.map((id) => state.transfers.get(id)).filter(Boolean);
536 try {
537 if (action === "pause") await api.files.transfers.pause(ids);
538 else if (action === "cancel") {
539 if (!window.confirm(`Definitivně zrušit ${list.filter((job) => job.actions.cancel).length} přenosů? Dočasná data vytvořená aplikací budou odstraněna, existující cílové soubory zůstanou beze změny.`)) return;
540 await api.files.transfers.cancel(ids);
541 } else if (action === "remove") await api.files.transfers.remove(ids);
542 else if (action === "retry") {
543 if (!window.confirm("Přenést soubor znovu od začátku? Dosud přenesená část bude zahozena.")) return;
544 const options = await transferAuthorization(list);
545 if (options !== null) await api.files.transfers.retry(ids, options);
546 } else if (action === "overwrite") {
547 if (!window.confirm("Cíl byl mezitím změněn nebo již existuje. Přesto jej nahradit přeneseným souborem (se zachováním jeho metadat)?")) return;
548 const options = await transferAuthorization(list);
549 if (options !== null) await api.files.transfers.resume(ids, { ...options, overwrite: true });
550 } else if (action === "resume") {
551 const options = await transferAuthorization(list);
552 if (options === null) return;
553 await api.files.transfers.resume(ids, options);
554 }
555 } catch (error) { setStatus(errorText(error), true); }
558// Interrupted jobs that need sudo get a password prompt before resuming.
559async function transferAuthorization(jobs) {
560 if (!jobs.some((job) => job.authRequired)) return {};
561 const privilege = await privileges();
562 if (privilege.mode === "none") { setStatus(ERROR_TEXT.SUDO_NOT_PERMITTED, true); return null; }
563 const codes = new Set(jobs.map((job) => job.error?.code));
564 return askSudo("Dokončení přenosu vyžaduje oprávnění správce (zápis do chráněného adresáře nebo čtení chráněného souboru).", {
565 privilege: codes.has("SUDO_PASSWORD_REQUIRED") || codes.has("SUDO_PASSWORD_INCORRECT") ? { ...privilege, mode: "sudo-password", sudoCached: false } : privilege,
566 error: codes.has("SUDO_PASSWORD_INCORRECT") ? ERROR_TEXT.SUDO_PASSWORD_INCORRECT : ""
567 });
570async function initTransfers() {
571 if (!api.files?.transfers) { elements.transfers.hidden = true; return; }
572 try { setTransfersCollapsed(localStorage.getItem("chj.fm.transfersCollapsed") === "1"); } catch {}
573 api.files.transfers.onUpdate(applyTransferUpdate);
574 const current = await api.files.transfers.list();
575 applyTransferUpdate({ ...current, full: true });
576 setInterval(() => { if ([...state.transfers.values()].some((job) => job.state === "reconnecting" || job.state === "transferring")) queueTransferRender(); }, 1000);
579// ---- remaining actions ------------------------------------------------------------------------
581async function openSelection() { const item = selectedEntries()[0]; if (!item) return; if (item.type === "directory" || item.targetType === "directory") await loadDirectory(item.path); else await openText(item.path); }
582async function renameSelection() { const item = selectedEntries()[0]; if (!item || selectedEntries().length !== 1) return; const name = window.prompt("Nový název:", item.name); if (!validName(name) || name === item.name) return; await mutate("Přejmenovávám…", `Přejmenování ${item.path} vyžaduje oprávnění správce.`, (opts) => api.files.rename(selectedSessionId(), item.path, joinPath(parentPath(item.path), name), opts)); }
583async function removeSelection() { const items = selectedEntries(); if (!items.length || !window.confirm(`Opravdu trvale smazat ${items.length} vybraných položek včetně obsahu vybraných složek?`)) return; await mutate("Mažu vybrané položky…", "Smazání vybraných položek vyžaduje oprávnění správce.", (opts) => api.files.removeMany(selectedSessionId(), items.map((item) => item.path), true, opts)); }
585function hideContextMenu() { elements.contextMenu.hidden = true; }
586function showContextMenu(event, remotePath) { event.preventDefault(); if (remotePath && !state.selected.has(remotePath)) selectPath(remotePath); elements.contextMenu.querySelector('[data-context="rename"]').disabled = state.selected.size !== 1; elements.contextMenu.querySelector('[data-context="open"]').disabled = state.selected.size !== 1; elements.contextMenu.hidden = false; const rect = elements.contextMenu.getBoundingClientRect(); elements.contextMenu.style.left = `${Math.max(6, Math.min(event.clientX, window.innerWidth - rect.width - 6))}px`; elements.contextMenu.style.top = `${Math.max(6, Math.min(event.clientY, window.innerHeight - rect.height - 6))}px`; }
588elements.closeButton.addEventListener("click", () => { if (state.editorPath && updateDirtyState() && !window.confirm("Editor obsahuje neuložené změny. Opravdu zavřít File Manager? Probíhající přenosy pokračují na pozadí.")) return; api.close(); });
589elements.refreshSessionsButton.addEventListener("click", async () => { await loadSessions(); await loadDirectory(state.currentPath); });
590elements.sessionSelect.addEventListener("change", () => { setBusy(false); state.listPrivileged = false; void loadDirectory("/"); });
591elements.pathForm.addEventListener("submit", (event) => { event.preventDefault(); void loadDirectory(elements.pathInput.value); });
592elements.rootButton.addEventListener("click", () => loadDirectory("/")); elements.upButton.addEventListener("click", () => loadDirectory(parentPath(state.currentPath))); elements.refreshButton.addEventListener("click", () => loadDirectory(state.currentPath));
593elements.filterInput.addEventListener("input", renderEntries);
594elements.selectAllCheckbox.addEventListener("change", () => { for (const item of state.visibleEntries) { if (elements.selectAllCheckbox.checked) state.selected.add(item.path); else state.selected.delete(item.path); } renderEntries(); });
595for (const button of document.querySelectorAll("button.sort")) button.addEventListener("click", () => { const key = button.dataset.sort; if (state.sortKey === key) state.sortDirection *= -1; else { state.sortKey = key; state.sortDirection = 1; } for (const other of document.querySelectorAll("button.sort")) { other.classList.toggle("active", other === button); other.querySelector("span").textContent = other === button ? (state.sortDirection > 0 ? "↑" : "↓") : ""; } renderEntries(); });
596elements.newFileButton.addEventListener("click", () => { const name = window.prompt("Název nového souboru:", "novy-soubor.txt"); if (!validName(name)) return; const target = joinPath(state.currentPath, name); void mutate("Vytvářím soubor…", `Vytvoření ${target} vyžaduje oprávnění správce.`, (opts) => api.files.createFile(selectedSessionId(), target, opts)); });
597elements.mkdirButton.addEventListener("click", () => { const name = window.prompt("Název nové složky:"); if (!validName(name)) return; const target = joinPath(state.currentPath, name); void mutate("Vytvářím složku…", `Vytvoření ${target} vyžaduje oprávnění správce.`, (opts) => api.files.mkdir(selectedSessionId(), target, opts)); });
598elements.uploadButton.addEventListener("click", () => void uploadSelection());
599elements.downloadButton.addEventListener("click", () => void downloadSelection()); elements.zipButton.addEventListener("click", () => archiveSelection("zip")); elements.tarGzButton.addEventListener("click", () => archiveSelection("tar.gz")); elements.tarButton.addEventListener("click", () => archiveSelection("tar"));
600elements.filesBody.addEventListener("click", (event) => { const action = event.target.closest("button[data-row-action]"); if (action) { selectPath(action.dataset.path); if (action.dataset.rowAction === "open") void openSelection(); else showContextMenu(event, action.dataset.path); return; } const row = event.target.closest("tr[data-path]"); if (!row) return; selectPath(row.dataset.path, { toggle: event.metaKey || event.ctrlKey || event.target.matches("input[type=checkbox]"), range: event.shiftKey }); });
601elements.filesBody.addEventListener("dblclick", (event) => { const row = event.target.closest("tr[data-path]"); if (!row) return; selectPath(row.dataset.path); void openSelection(); }); elements.filesBody.addEventListener("contextmenu", (event) => { const row = event.target.closest("tr[data-path]"); showContextMenu(event, row?.dataset.path); });
602elements.contextMenu.addEventListener("click", (event) => { const action = event.target.closest("button[data-context]")?.dataset.context; hideContextMenu(); if (action === "open") void openSelection(); if (action === "download") void downloadSelection(); if (action === "rename") void renameSelection(); if (action === "remove") void removeSelection(); if (["zip", "tar", "tar.gz"].includes(action)) void archiveSelection(action); }); document.addEventListener("click", (event) => { if (!event.target.closest("#contextMenu") && !event.target.closest('[data-row-action="menu"]')) hideContextMenu(); });
603elements.saveEditorButton.addEventListener("click", () => void saveEditor()); elements.closeEditorButton.addEventListener("click", closeEditor); elements.downloadEditorButton.addEventListener("click", () => { if (state.editorPath) void downloadSelection([state.editorPath]); });
604elements.transfersToggle.addEventListener("click", () => setTransfersCollapsed(!elements.transfers.classList.contains("collapsed")));
605elements.transferList.addEventListener("click", (event) => {
606 const button = event.target.closest("button[data-transfer-action]");
607 if (button) { void transferAction(button.dataset.transferAction, [button.dataset.id]); return; }
608 const node = event.target.closest(".transfer"); if (!node) return;
609 if (state.transferSelected.has(node.dataset.id)) state.transferSelected.delete(node.dataset.id); else state.transferSelected.add(node.dataset.id);
610 queueTransferRender();
611});
612elements.pauseAllButton.addEventListener("click", () => void transferAction("pause", "all"));
613elements.resumeAllButton.addEventListener("click", () => void transferAction("resume", [...state.transfers.values()].filter((job) => job.actions.resume).map((job) => job.id)));
614elements.cancelAllButton.addEventListener("click", () => void transferAction("cancel", "all"));
615elements.pauseSelectedButton.addEventListener("click", () => void transferAction("pause", [...state.transferSelected]));
616elements.resumeSelectedButton.addEventListener("click", () => void transferAction("resume", [...state.transferSelected]));
617elements.cancelSelectedButton.addEventListener("click", () => void transferAction("cancel", [...state.transferSelected]));
618elements.clearFinishedButton.addEventListener("click", () => void transferAction("remove", [...state.transfers.values()].filter((job) => job.actions.remove).map((job) => job.id)));
619for (const element of [elements.concurrencySelect, elements.autoRetryCheckbox, elements.verifyCheckbox]) element.addEventListener("change", () => {
620 void api.files.transfers?.updateSettings({ maxConcurrent: Number(elements.concurrencySelect.value), autoRetry: elements.autoRetryCheckbox.checked, verify: elements.verifyCheckbox.checked }).catch((error) => setStatus(errorText(error), true));
621});
622window.addEventListener("beforeunload", (event) => { if (state.editorPath && updateDirtyState()) { event.preventDefault(); event.returnValue = ""; } });
623window.addEventListener("keydown", (event) => { if (event.key === "Escape") { if (!elements.contextMenu.hidden) hideContextMenu(); else if (!elements.editor.hidden && !elements.sudoDialog.open) closeEditor(); return; } if (!elements.editor.hidden || event.target.matches("input,select,textarea") || elements.sudoDialog.open || elements.planDialog.open) return; if (event.key === "F5") { event.preventDefault(); void loadDirectory(state.currentPath); } if (event.key === "F2") { event.preventDefault(); void renameSelection(); } if (event.key === "Delete") { event.preventDefault(); void removeSelection(); } if (event.key === "Enter") { event.preventDefault(); void openSelection(); } if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "a") { event.preventDefault(); state.selected = new Set(state.visibleEntries.map((entry) => entry.path)); renderEntries(); } if (event.key === "Backspace") { event.preventDefault(); void loadDirectory(parentPath(state.currentPath)); } });
625(async () => {
626 const info = await api.getInfo(); elements.pluginVersion.textContent = `${info.name} v${info.version} · ${info.publisher}`;
627 if (!api.files?.downloadArchive || !api.files?.downloadMany || !api.files?.createFile || !api.files?.removeMany) { for (const element of document.querySelectorAll("main button, main input, main select")) element.disabled = true; setStatus("Tento build pluginu vyžaduje nový Core s rozšířeným files.* API. Nejdříve aktualizujte aplikaci.", true); return; }
628 await initTransfers().catch((error) => setStatus(errorText(error), true));
629 await loadSessions(); await loadDirectory("/");
630})().catch((error) => setStatus(errorText(error), true));
SHA-25664cf50d11fbba52b82f676491eabd1276fc4c43d758673db52a79c5aa54271d2

CH-J Proprietary Software License 1.14 · کد منبع تحت CH-J Proprietary Software License 1.14 ارائه می‌شود. در دسترس بودن آن شرایط مجوز را تغییر نمی‌دهد و حق اضافه‌ای اعطا نمی‌کند.

SHA-256 بایگانیa9605c0870a223b6a8542bb0705edd51d8b04f3069582653417a8397fabcc09a
فایل‌ها