"use strict"; const api = window.chjPlugin; const i18n = window.chjPluginI18n.createI18n("cs"); const t = i18n.t; let endedSessionId = ""; let sessionsLoading = false; // Guard every continuation, including sudo retries and dialog answers, against a server switch. async function sessionResult(promise, token) { const value = await promise; if (token !== state.generation) { if (value?.editId) void api.files.closeText(value.editId).catch(() => {}); throw new Error("CHJ_SESSION_CHANGED"); } return value; } // Marks a catalog key that is translated with t() only when it is shown. const k = (text) => text; const byId = (id) => document.getElementById(id); const elements = Object.fromEntries([ "pluginVersion", "closeButton", "sessionSelect", "refreshSessionsButton", "rootButton", "upButton", "refreshButton", "breadcrumbs", "pathForm", "pathInput", "newFileButton", "mkdirButton", "uploadButton", "downloadButton", "zipButton", "tarGzButton", "tarButton", "filterInput", "selectAllCheckbox", "filesBody", "busyOverlay", "busyLabel", "status", "selectionInfo", "editor", "editorPath", "editorTarget", "editorPrivilege", "editorBackup", "editorLanguage", "editorSize", "editorDirty", "editorHost", "editorPosition", "downloadEditorButton", "saveEditorButton", "closeEditorButton", "contextMenu", "transfers", "transfersToggle", "transferSummary", "transferOverall", "transferOverallText", "transferList", "pauseAllButton", "resumeAllButton", "cancelAllButton", "pauseSelectedButton", "resumeSelectedButton", "cancelSelectedButton", "clearFinishedButton", "concurrencySelect", "autoRetryCheckbox", "verifyCheckbox", "sudoDialog", "sudoReason", "sudoError", "sudoPasswordRow", "sudoAccount", "sudoPassword", "sudoRemember", "sudoConfirm", "planDialog", "planTitle", "planSummary", "planConflicts", "planConflictText", "planConflictList", "planSkipped", "planSudo", "planPasswordRow", "planPassword", "planRemember", "planError", "planSkip", "planOverwrite", "planStart" ].map((id) => [id, byId(id)])); const state = { generation: 0, sessionId: "", sessions: [], currentPath: "/", entries: [], visibleEntries: [], selected: new Set(), anchorPath: null, listPrivileged: false, sortKey: "name", sortDirection: 1, busy: false, monaco: null, editor: null, editorPath: null, savedText: "", monacoPromise: null, edit: null, sudoAccepted: new Set(), transfers: new Map(), transferSessionNames: new Map(), transferSummary: null, transferSelected: new Set(), transferNodes: new Map(), renderQueued: false }; window.MonacoEnvironment = { getWorkerUrl: () => "vendor/monaco/vs/base/worker/workerMain.js" }; // Server error codes → concrete, user-facing reasons (the backend keeps stable codes). const ERROR_TEXT = { FILE_PERMISSION_DENIED: k("Účet SSH nemá k této operaci oprávnění souborového systému."), FILE_METADATA_PERMISSION: k("Původního vlastníka, skupinu nebo atributy souboru nelze bez oprávnění správce zachovat."), FILE_NOT_FOUND: k("Soubor nebo adresář neexistuje."), FILE_EXISTS: k("Cílová položka již existuje."), FILE_CONFLICT: k("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."), FILE_HARDLINK_UNSUPPORTED: k("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í."), FILE_UNSAFE_TYPE_OR_LINK: k("Cíl není běžný soubor (např. zařízení, FIFO nebo nečekaný odkaz)."), FILE_UNSAFE_PATH: k("Cesta obsahuje nebezpečnou nebo neplatnou součást (např. smyčku symbolických odkazů)."), FILE_SYMLINK_TARGET_MISSING: k("Symbolický odkaz vede na neexistující cíl."), FILE_SYMLINK_LOOP: k("Symbolické odkazy tvoří smyčku."), FILE_UNSAFE_PRIVILEGED_DIRECTORY: k("Některý nadřazený adresář je zapisovatelný pro všechny bez sticky bitu; privilegovaná operace byla odmítnuta."), FILE_UNSAFE_WORKSPACE: k("Soukromý pracovní adresář účtu není bezpečný (vlastník/práva). Zkontrolujte domovský adresář."), FILE_METADATA_UNSUPPORTED: k("Důležitá metadata (ACL, xattr, SELinux, IMA/EVM) nelze bezpečně převzít. Operace byla zastavena."), FILE_NO_SPACE: k("Na cílovém souborovém systému není dost místa. Původní soubor zůstal nedotčen."), FILE_READ_ONLY_FILESYSTEM: k("Souborový systém je připojen jen pro čtení."), FILE_TOO_LARGE: k("Soubor překračuje limit editoru 25 MiB."), FILE_DEPENDENCY_UNAVAILABLE: k("Na serveru chybí python3, který je nutný pro bezpečné uložení se zachováním metadat."), FILE_LINUX_REQUIRED: k("Bezpečné ukládání je podporováno jen na linuxových serverech."), FEATURE_PLATFORM_UNSUPPORTED: k("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."), FILE_RESULT_UNKNOWN: k("Výsledek operace nelze potvrdit (např. přerušené spojení). Obsah editoru zůstal zachován a na serveru je obnovitelná kopie."), FILE_UPLOAD_TIMEOUT: k("Nahrání pracovní kopie vypršelo."), FILE_PROTECTED_PATH: k("Systémový adresář nelze odstranit ani přejmenovat."), FILE_DIRECTORY_NOT_EMPTY: k("Adresář není prázdný."), FILE_BUSY: k("Soubor je právě používán."), FILE_VERIFY_FAILED: k("Kontrolní součet přenesených dat nesouhlasí se zdrojem."), FILE_SOURCE_CHANGED: k("Zdrojový soubor se během přenosu změnil. Spusťte přenos znovu od začátku."), FILE_RESUME_MISMATCH: k("Uložená část souboru neodpovídá zdroji, nelze bezpečně navázat. Spusťte přenos znovu od začátku."), SUDO_PASSWORD_REQUIRED: k("Operace vyžaduje heslo sudo."), SUDO_PASSWORD_INCORRECT: k("Heslo sudo bylo odmítnuto."), SUDO_NOT_PERMITTED: k("Účet nemá oprávnění používat sudo pro tuto operaci."), SUDO_TTY_REQUIRED: k("Politika sudo vyžaduje terminál (requiretty); CH-J ji neobchází."), SUDO_AUTH_FAILED: k("Ověření sudo selhalo nebo je účet uzamčen."), SUDO_UNAVAILABLE: k("Na serveru není sudo a účet není root."), SESSION_NOT_CONNECTED: k("SSH relace není připojena."), TRANSFER_STALLED: k("Po dobu 60 s nebyla potvrzena žádná data (timeout)."), APP_RESTARTED: k("Aplikace byla během přenosu ukončena."), LOCAL_NO_SPACE: k("Na lokálním disku není dost místa."), TRANSFER_HOST_KEY_CHANGED: k("SSH klíč serveru se od začátku přenosu změnil, může jít o jiný stroj. Pokud je to stejný server, spusťte nový přenos."), TRANSFER_SAME_PATH: k("Zdroj a cíl jsou stejná složka."), TRANSFER_INTO_ITSELF: k("Složku nelze kopírovat do ní samotné."), TRANSFER_INVALID_SELECTION: k("Vyberte položky ke kopírování a dva různé panely."), LOCAL_PERMISSION_DENIED: k("K této lokální položce nemáte oprávnění."), LOCAL_OPERATION_FAILED: k("Lokální operace selhala."), TRANSFER_PLAN_SERVER_CHANGED: k("Výběr vypršel, vyberte soubory znovu."), TRANSFER_PLAN_EXPIRED: k("Výběr vypršel, vyberte soubory znovu.") }; const MOVE_KEPT_TEXT = { unverified: k("kontrolní součet nebyl potvrzen"), "source-changed": k("zdroj se mezitím změnil"), "source-gone": k("zdroj už neexistuje"), "source-disconnected": k("zdrojová relace není připojena"), "remove-failed": k("zdroj nelze odstranit") }; const NEEDS_SUDO = new Set(["FILE_PERMISSION_DENIED", "FILE_METADATA_PERMISSION", "SUDO_PASSWORD_REQUIRED"]); const STATE_TEXT = { waiting: k("Čeká ve frontě"), connecting: k("Připojování"), transferring: k("Přenos probíhá"), paused: k("Pozastaveno"), interrupted: k("Přerušeno"), reconnecting: k("Obnovování spojení"), verifying: k("Ověřování dat"), finalizing: k("Dokončování na serveru"), completed: k("Dokončeno"), failed: k("Chyba"), cancelled: k("Zrušeno") }; const FINALIZE_TEXT = { pending: k("čeká"), running: k("probíhá"), done: k("dokončeno"), unknown: k("nepotvrzeno – ověří se při obnovení"), failed: k("selhalo") }; const RUNNING = new Set(["connecting", "transferring", "verifying", "finalizing"]); const FINAL = new Set(["completed", "failed", "cancelled"]); function errorCode(error) { return error?.code && /^[A-Z_]+$/.test(error.code) ? error.code : (/(?:^|: )([A-Z][A-Z0-9_]{3,63}):/.exec(String(error?.message || ""))?.[1] || ""); } function errorText(error) { const code = errorCode(error); if (ERROR_TEXT[code]) return `${t(ERROR_TEXT[code])} (${code})`; return String(error?.message || error || t("Neznámá chyba.")).replace(/^Error invoking remote method '[^']+': (Error: )?/, ""); } function normalizePath(value) { const raw = String(value || "").trim(); if (!raw.startsWith("/")) throw new Error(t("Cesta musí začínat znakem /.")); const parts = []; for (const part of raw.split("/")) { if (!part || part === ".") continue; if (part === "..") parts.pop(); else parts.push(part); } return `/${parts.join("/")}`; } function joinPath(base, name) { return normalizePath(`${base === "/" ? "" : base}/${name}`); } function parentPath(value) { const parts = normalizePath(value).split("/").filter(Boolean); parts.pop(); return `/${parts.join("/")}`; } function selectedSessionId() { return state.sessionId; } function currentSession() { return state.sessions.find((session) => session.sessionId === selectedSessionId()); } function selectedEntries() { return state.entries.filter((entry) => state.selected.has(entry.path)); } function 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(i18n.locale(), { maximumFractionDigits: 1 })} ${units[unit]}`; } function rate(value) { return value > 0 ? `${bytes(value)}/s` : "—"; } function duration(seconds) { if (seconds === null || seconds === undefined || !Number.isFinite(seconds)) return t("Výpočet času…"); const s = Math.max(0, Math.round(seconds)); if (s < 60) return t("přibližně {s} s", { s }); if (s < 3600) return t("přibližně {m} min {s} s", { m: Math.floor(s / 60), s: s % 60 }); return t("přibližně {h} h {m} min", { h: Math.floor(s / 3600), m: Math.floor((s % 3600) / 60) }); } function setStatus(message, error = false) { elements.status.textContent = message; elements.status.classList.toggle("error", error); } function validName(value) { return Boolean(value && !/[\/\0\r\n]/.test(value) && value !== "." && value !== ".."); } // In-page: the system WebViews do not all implement the browser's own prompt. function askName(title, initial = "") { return new Promise((resolve) => { const dialog = byId("nameDialog"), input = byId("nameInput"); byId("nameTitle").textContent = title; input.value = initial; const close = () => { dialog.removeEventListener("close", close); resolve(dialog.returnValue === "ok" && validName(input.value.trim()) ? input.value.trim() : null); }; dialog.addEventListener("close", close); dialog.returnValue = ""; dialog.showModal(); input.focus(); const dot = initial.lastIndexOf("."); input.setSelectionRange(0, dot > 0 ? dot : initial.length); }); } function setBusy(value, label = t("Pracuji…")) { state.busy = value; elements.busyOverlay.hidden = !value; elements.busyLabel.textContent = label; 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); updateActions(); } function updateActions() { const selected = selectedEntries(); const enabled = Boolean(selectedSessionId()) && !state.busy; elements.downloadButton.disabled = !enabled || !selected.length; for (const button of [elements.zipButton, elements.tarGzButton, elements.tarButton]) button.disabled = !enabled || !selected.length; elements.selectAllCheckbox.checked = Boolean(state.visibleEntries.length) && state.visibleEntries.every((entry) => state.selected.has(entry.path)); elements.selectAllCheckbox.indeterminate = state.visibleEntries.some((entry) => state.selected.has(entry.path)) && !elements.selectAllCheckbox.checked; elements.selectionInfo.textContent = `${t("{count} položek · {selected} vybráno", { count: state.visibleEntries.length, selected: selected.length })}${selected.length ? ` · ${bytes(selected.reduce((sum, entry) => sum + (entry.type === "directory" ? 0 : entry.size), 0))}` : ""}`; } // ---- privilege prompts ------------------------------------------------------------------------ async function privileges() { const requestGeneration = state.generation; try { return await sessionResult(api.files.privileges(selectedSessionId()), requestGeneration); } catch { if (requestGeneration !== state.generation) return; return { mode: "unknown", username: currentSession()?.username || "" }; } } // Offers the shared sudo mechanism; resolves to options for the retried call or null. function askSudo(reason, { error = "", privilege = null } = {}) { return new Promise((resolve) => { const needsPassword = !privilege || privilege.mode === "sudo-password" || privilege.mode === "unknown"; elements.sudoReason.textContent = reason; elements.sudoError.hidden = !error; elements.sudoError.textContent = error; elements.sudoAccount.textContent = privilege?.username || currentSession()?.username || ""; elements.sudoPasswordRow.hidden = !needsPassword || privilege?.sudoCached === true; elements.sudoPassword.value = ""; const close = () => { elements.sudoDialog.removeEventListener("close", close); const ok = elements.sudoDialog.returnValue === "ok"; const password = elements.sudoPassword.value; elements.sudoPassword.value = ""; resolve(ok ? { sudo: true, ...(password ? { sudoPassword: password } : {}), rememberSudo: elements.sudoRemember.checked } : null); }; elements.sudoDialog.addEventListener("close", close); elements.sudoDialog.returnValue = ""; elements.sudoDialog.showModal(); if (!elements.sudoPasswordRow.hidden) elements.sudoPassword.focus(); }); } // Runs action(options); on insufficient permissions offers sudo (password re-asked on rejection). async function withPrivilege(reason, action, initial = {}) { const requestGeneration = state.generation; try { let options = initial; const sessionId = selectedSessionId(); for (let attempt = 0; attempt < 5; attempt += 1) { try { return { value: await sessionResult(action(options), requestGeneration), options }; } catch (error) { if (requestGeneration !== state.generation) return; const code = errorCode(error); if (!NEEDS_SUDO.has(code) && code !== "SUDO_PASSWORD_INCORRECT") throw error; // After the user approved sudo (remembered in Main memory) for this session, retry silently once. if (!options.sudo && state.sudoAccepted.has(sessionId) && code !== "SUDO_PASSWORD_REQUIRED") { options = { ...initial, sudo: true }; continue; } const privilege = await sessionResult(privileges(), requestGeneration); if (privilege.mode === "root" && code !== "SUDO_PASSWORD_INCORRECT") throw error; // Root sees the real filesystem error. if (privilege.mode === "none") throw Object.assign(new Error(`SUDO_NOT_PERMITTED: ${ERROR_TEXT[code] ? t(ERROR_TEXT[code]) : ""}`), { code: privilege.sudoInstalled ? "SUDO_NOT_PERMITTED" : "SUDO_UNAVAILABLE" }); const answer = await sessionResult(askSudo(reason, { privilege: code === "SUDO_PASSWORD_INCORRECT" || code === "SUDO_PASSWORD_REQUIRED" ? { ...privilege, mode: "sudo-password", sudoCached: false } : privilege, error: code === "SUDO_PASSWORD_INCORRECT" ? t(ERROR_TEXT.SUDO_PASSWORD_INCORRECT) : options.sudo ? errorText(error) : "" }), requestGeneration); if (!answer) throw Object.assign(new Error(t("Operace byla zrušena.")), { code: "CANCELED" }); if (answer.rememberSudo) state.sudoAccepted.add(sessionId); else state.sudoAccepted.delete(sessionId); options = { ...initial, ...answer }; } } throw new Error(t("Sudo autorizace se nezdařila.")); } catch (error) { if (requestGeneration !== state.generation) return; throw error; } } // ---- directory view --------------------------------------------------------------------------- function renderBreadcrumbs() { const fragment = document.createDocumentFragment(); let current = ""; 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); }; append("/", "/"); 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); } elements.breadcrumbs.replaceChildren(fragment); elements.breadcrumbs.scrollLeft = elements.breadcrumbs.scrollWidth; } function compareEntries(left, right) { const leftDir = left.type === "directory" || left.targetType === "directory"; const rightDir = right.type === "directory" || right.targetType === "directory"; if (leftDir && !rightDir) return -1; if (rightDir && !leftDir) return 1; const key = state.sortKey; let result; if (key === "size") result = left.size - right.size; else if (key === "modifiedAt") result = Date.parse(left.modifiedAt || 0) - Date.parse(right.modifiedAt || 0); else result = String(left[key] || "").localeCompare(String(right[key] || ""), i18n.locale(), { numeric: true, sensitivity: "base" }); return result * state.sortDirection; } function typeLabel(item) { if (item.type === "directory") return t("složka"); if (item.type === "symlink") return item.targetType === "directory" ? t("odkaz → složka") : item.targetType === "missing" ? t("odkaz (neplatný)") : t("odkaz → soubor"); return item.name.split(".").pop()?.toUpperCase() || t("soubor"); } function renderEntries() { const query = elements.filterInput.value.trim().toLocaleLowerCase(i18n.locale()); state.visibleEntries = state.entries.filter((entry) => !query || entry.name.toLocaleLowerCase(i18n.locale()).includes(query)).sort(compareEntries); const fragment = document.createDocumentFragment(); for (const item of state.visibleEntries) { const isDir = item.type === "directory" || item.targetType === "directory"; const row = document.createElement("tr"); row.dataset.path = item.path; row.dataset.type = item.type; row.classList.toggle("selected", state.selected.has(item.path)); 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); 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); for (const value of [typeLabel(item), isDir ? "—" : bytes(item.size), item.modifiedAt ? new Date(item.modifiedAt).toLocaleString(i18n.locale()) : "—"]) { const cell = document.createElement("td"); cell.className = "file-meta"; cell.textContent = value; row.append(cell); } const actions = document.createElement("td"); const wrap = document.createElement("div"); wrap.className = "row-actions"; for (const [label, action] of [[isDir ? t("Otevřít") : 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); } if (!state.visibleEntries.length) { const row = document.createElement("tr"); const cell = document.createElement("td"); cell.colSpan = 6; cell.className = "empty"; cell.textContent = query ? t("Filtru neodpovídá žádná položka.") : t("Adresář je prázdný."); row.append(cell); fragment.append(row); } elements.filesBody.replaceChildren(fragment); updateActions(); } function selectPath(remotePath, options = {}) { const index = state.visibleEntries.findIndex((entry) => entry.path === remotePath); if (options.range && state.anchorPath) { 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); } else if (options.toggle) { if (state.selected.has(remotePath)) state.selected.delete(remotePath); else state.selected.add(remotePath); state.anchorPath = remotePath; } else { state.selected = new Set([remotePath]); state.anchorPath = remotePath; } renderEntries(); } async function loadSessions() { if (sessionsLoading) return; sessionsLoading = true; try { const sessions = (await api.sessions.list()).filter((session) => session.state === "connected"); const previous = state.sessionId; const next = api.sessions.reconcile(endedSessionId || previous, sessions); state.sessions = sessions; for (const session of sessions) state.transferSessionNames.set(session.sessionId, api.sessions.displayName(session)); elements.sessionSelect.replaceChildren(new Option(sessions.length ? t("Vyberte relaci…") : t("Žádná připojená relace"), "")); for (const session of sessions) elements.sessionSelect.add(new Option(api.sessions.displayName(session), session.sessionId)); elements.sessionSelect.value = next.id; if (next.reason === "gone") endedSessionId ||= previous; if (next.id !== previous) { resetSession(next.id, next.reason === "gone"); if (next.id) void loadDirectory("/"); } if (next.reason === "gone") setStatus(t("Relace skončila. Vyberte server."), true); if (next.reason === "choose" && !globalThis.chjPanels?.active()) api.sessions.offerChoice(elements.sessionSelect, sessions, t("Vyberte relaci…")); globalThis.chjPanels?.syncSessions(sessions); } finally { sessionsLoading = false; } } function resetSession(id, preserveDraft = false) { state.generation += 1; state.sessionId = id; if (!preserveDraft && state.edit?.editId) void api.files.closeText(state.edit.editId).catch(() => {}); for (const dialog of document.querySelectorAll("dialog[open]")) dialog.close(); elements.sudoPassword.value = ""; elements.planPassword.value = ""; if (!preserveDraft) { elements.editor.hidden = true; state.editor?.setValue(""); Object.assign(state, { editorPath: null, savedText: "", edit: null }); } elements.saveEditorButton.disabled = !id; elements.downloadEditorButton.disabled = !id; Object.assign(state, { currentPath: "/", entries: [], visibleEntries: [], selected: new Set(), anchorPath: null, listPrivileged: false }); state.sudoAccepted.clear(); state.transferSelected.clear(); elements.pathInput.value = "/"; elements.filterInput.value = ""; hideContextMenu(); renderBreadcrumbs(); renderEntries(); queueTransferRender(); setBusy(false); } async function loadDirectory(requested = state.currentPath) { const requestGeneration = state.generation; if (!selectedSessionId() || state.busy) return; const target = normalizePath(requested); setBusy(true, t("Načítám {path}…", { path: target })); hideContextMenu(); try { const { value: entries, options } = await sessionResult(withPrivilege(t("Adresář {path} nelze číst pod účtem SSH. Zobrazit jeho obsah se sudo?", { path: target }), (opts) => api.files.list(selectedSessionId(), target, opts)), requestGeneration); state.listPrivileged = options.sudo === true; state.currentPath = target; state.entries = entries; state.selected.clear(); state.anchorPath = null; elements.pathInput.value = target; renderBreadcrumbs(); renderEntries(); setStatus(`${t("Načteno {count} položek z {path}", { count: entries.length, path: target })}${state.listPrivileged ? " (sudo)" : ""}.`); } catch (error) { if (requestGeneration !== state.generation) return; if (errorCode(error) !== "CANCELED") setStatus(errorText(error), true); } finally { if (requestGeneration === state.generation) { setBusy(false);} } } async function mutate(label, reason, action) { const requestGeneration = state.generation; if (state.busy) return; setBusy(true, label); try { const { value: result } = await sessionResult(withPrivilege(reason, action), requestGeneration); if (result?.canceled) { setStatus(t("Operace byla zrušena.")); return; } setBusy(false); await sessionResult(loadDirectory(state.currentPath), requestGeneration); } catch (error) { if (requestGeneration !== state.generation) return; setStatus(errorCode(error) === "CANCELED" ? t("Operace byla zrušena.") : errorText(error), errorCode(error) !== "CANCELED"); } finally { if (requestGeneration === state.generation) { setBusy(false);} } } // ---- Monaco editor ---------------------------------------------------------------------------- function languageFor(remotePath) { const extension = remotePath.toLowerCase().split(".").pop(); 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"); } function ensureMonaco() { if (state.monaco) return Promise.resolve(state.monaco); if (state.monacoPromise) return state.monacoPromise; state.monacoPromise = new Promise((resolve, reject) => { const amd = window.require; if (!amd?.config) return reject(new Error(t("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(t("Monaco Editor se nepodařilo inicializovat."))); state.monaco = window.monaco; resolve(window.monaco); }, reject); }).catch((error) => { state.monacoPromise = null; throw error; }); return state.monacoPromise; } function updateDirtyState() { const dirty = Boolean(state.editor && state.editorPath && state.editor.getValue() !== state.savedText); elements.editorDirty.hidden = !dirty; return dirty; } function renderEditorMeta() { const edit = state.edit; elements.editorPrivilege.hidden = !edit?.privileged; elements.editorPrivilege.textContent = edit?.privileged ? "sudo" : ""; elements.editorTarget.hidden = !edit?.link; elements.editorTarget.textContent = edit?.link ? `↗ ${t("Symbolický odkaz – upravuje se cílový soubor {path}; odkaz zůstane zachován.", { path: edit.resolvedPath })}` : ""; } async function saveEditor() { const requestGeneration = state.generation; try { if (!state.editorPath || !state.editor || state.busy || !state.edit || state.edit.sessionId !== selectedSessionId()) return; setBusy(true, t("Ukládám soubor…")); const text = state.editor.getValue(); const edit = state.edit; const editorPath = state.editorPath; const base = { editId: edit.editId, ...(elements.editorBackup.checked ? { backup: true } : {}), ...(edit.privileged || !edit.directSave ? { sudo: true } : {}) }; try { const { value: result, options } = await sessionResult(withPrivilege(t("Uložení {path} vyžaduje oprávnění správce. Soubor se uloží atomicky se zachováním vlastníka, práv, ACL, xattr a SELinux kontextu.", { path: state.editorPath }), async (opts) => { const requestGeneration = state.generation; try { const outcome = await sessionResult(api.files.saveText(edit.sessionId, editorPath, text, { ...base, ...opts }), requestGeneration); if (!outcome.ok) throw Object.assign(new Error(`${outcome.error.code}: ${outcome.error.message}`), { code: outcome.error.code, detail: outcome.error }); return outcome.value; } catch (error) { if (requestGeneration !== state.generation) return; throw error; } }, base.sudo ? { sudo: true } : {}), requestGeneration); if (options.sudo) { edit.privileged = true; renderEditorMeta(); } state.savedText = text; elements.editorSize.textContent = bytes(result.size); updateDirtyState(); setStatus(`${t("Atomicky uloženo {size}", { size: bytes(result.size) })}${result.link ? ` ${t("do cíle odkazu {path}", { path: result.resolvedPath })}` : ""}${options.sudo ? " (sudo)" : ""}${result.backupPath ? ` · ${t("záloha {path}", { path: result.backupPath })}` : ""}${result.recoveryAvailable ? ` · ${t("pracovní kopie zůstala k dispozici")}` : ""}.`); } catch (error) { if (requestGeneration !== state.generation) return; const code = errorCode(error); if (code === "CANCELED") setStatus(t("Uložení bylo zrušeno; změny zůstávají v editoru.")); else if (code === "FILE_CONFLICT") { setStatus(errorText(error), true); if (window.confirm(`${t(ERROR_TEXT.FILE_CONFLICT)}\n\n${t("Načíst aktuální verzi ze serveru? Vaše neuložené změny budou zahozeny.")}\n${t("(Zrušit = ponechat změny v editoru)")}`)) { state.savedText = state.editor.getValue(); setBusy(false); await sessionResult(openText(state.editorPath, { force: true }), requestGeneration); } } else setStatus(`${errorText(error)}${error.detail?.recoveryId ? ` ${t("Kopie pro obnovu: {id}.", { id: error.detail.recoveryId })}` : ""}`, true); } finally { if (requestGeneration === state.generation) { setBusy(false);} } } catch (error) { if (requestGeneration !== state.generation) return; throw error; } } async function openText(remotePath, { force = false } = {}) { const requestGeneration = state.generation; if (state.busy) return; if (!force && state.editorPath && updateDirtyState() && !window.confirm(t("Otevřený soubor obsahuje neuložené změny. Zahodit je a otevřít jiný soubor?"))) return; setBusy(true, t("Otevírám {path}…", { path: remotePath })); try { const [{ value: result, options }, monaco] = await sessionResult(Promise.all([ withPrivilege(t("Soubor {path} nelze číst pod účtem SSH. Otevřít jej se sudo?", { path: remotePath }), (opts) => api.files.readText(selectedSessionId(), remotePath, opts)), ensureMonaco() ]), requestGeneration); const language = languageFor(result.resolvedPath || remotePath); if (state.edit?.editId) void api.files.closeText(state.edit.editId).catch(() => {}); if (!state.editor) { 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 }); state.editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => void saveEditor()); state.editor.onDidChangeModelContent(updateDirtyState); state.editor.onDidChangeCursorPosition((event) => { elements.editorPosition.textContent = t("Řádek {line}, sloupec {column}", { line: event.position.lineNumber, column: event.position.column }); }); } else { state.editor.setValue(result.text); monaco.editor.setModelLanguage(state.editor.getModel(), language); } state.edit = { sessionId: selectedSessionId(), editId: result.editId, resolvedPath: result.resolvedPath || result.path, link: result.link === true, directSave: result.directSave !== false, privileged: options.sudo === true }; 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); elements.saveEditorButton.disabled = false; elements.downloadEditorButton.disabled = false; elements.editorBackup.checked = false; renderEditorMeta(); updateDirtyState(); elements.editor.hidden = false; state.editor.focus(); setStatus(`${t("Otevřen {path}", { path: remotePath })}${result.link ? ` (${t("odkaz na {path}", { path: result.resolvedPath })})` : ""}${options.sudo ? ` ${t("se sudo")}` : ""}${!result.directSave && !options.sudo ? ` · ${t("uložení bude vyžadovat sudo")}` : ""}.`); } catch (error) { if (requestGeneration !== state.generation) return; if (errorCode(error) !== "CANCELED") setStatus(errorText(error), true); } finally { if (requestGeneration === state.generation) { setBusy(false);} } } function closeEditor() { if (!elements.editor.hidden && updateDirtyState() && !window.confirm(t("Soubor obsahuje neuložené změny. Opravdu zavřít editor?"))) return; if (state.edit?.editId) void api.files.closeText(state.edit.editId).catch(() => {}); elements.editor.hidden = true; state.editorPath = null; state.savedText = ""; state.edit = null; } // ---- transfers -------------------------------------------------------------------------------- function showPlan(plan) { return new Promise((resolve) => { const upload = plan.direction === "upload"; elements.planTitle.textContent = plan.title || `${api.sessions.displayName(currentSession())} — ${upload ? t("Nahrání na server") : t("Stažení ze serveru")}`; elements.planSummary.textContent = `${t("{files} souborů", { files: plan.files })}${plan.directories ? ` ${t("a {directories} složek", { directories: plan.directories })}` : ""}, ${t("celkem {size}", { size: bytes(plan.bytes) })} → ${plan.destination}`; elements.planConflicts.hidden = !plan.conflictCount; elements.planConflictText.textContent = t("{count} 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.", { count: plan.conflictCount }); elements.planConflictList.replaceChildren(...plan.conflicts.map((item) => { const li = document.createElement("li"); li.textContent = item; return li; })); elements.planSkipped.hidden = !plan.skippedCount; elements.planSkipped.textContent = t("{count} položek bude vynecháno (symbolické odkazy, speciální soubory nebo kolize se složkou).", { count: plan.skippedCount }); elements.planSudo.hidden = !plan.requiresSudo; elements.planPasswordRow.hidden = !plan.sudoPasswordRequired; elements.planPassword.value = ""; elements.planError.hidden = !plan.error; elements.planError.textContent = plan.error || ""; elements.planSkip.hidden = !plan.conflictCount; elements.planOverwrite.hidden = !plan.conflictCount; elements.planStart.hidden = Boolean(plan.conflictCount); const close = () => { elements.planDialog.removeEventListener("close", close); const choice = elements.planDialog.returnValue; const password = elements.planPassword.value; elements.planPassword.value = ""; resolve(["start", "skip", "overwrite"].includes(choice) ? { conflict: choice === "start" ? "fail" : choice, ...(plan.requiresSudo ? { sudo: true, rememberSudo: elements.planRemember.checked } : {}), ...(password ? { sudoPassword: password } : {}) } : null); }; elements.planDialog.addEventListener("close", close); elements.planDialog.returnValue = ""; elements.planDialog.showModal(); if (plan.requiresSudo && plan.sudoPasswordRequired) elements.planPassword.focus(); }); } async function runPlan(plan) { const requestGeneration = state.generation; try { if (!plan || plan.canceled) { setStatus(t("Výběr byl zrušen.")); return; } if (!plan.files && !plan.directories) { setStatus(t("Výběr neobsahuje žádné přenositelné soubory.")); return; } for (let attempt = 0; attempt < 4; attempt += 1) { const choice = await sessionResult(showPlan(plan), requestGeneration); if (!choice) { setStatus(t("Přenos nebyl spuštěn.")); return; } try { const started = await sessionResult(api.files.transfers.start(plan.planId, { ...choice, sessionId: selectedSessionId() }), requestGeneration); setStatus(`${t("Zařazeno {count} přenosů", { count: started.jobs })}${started.skipped ? `, ${t("přeskočeno {count}", { count: started.skipped })}` : ""}.`); setTransfersCollapsed(false); return; } catch (error) { if (requestGeneration !== state.generation) return; const code = errorCode(error); if (code === "SUDO_PASSWORD_INCORRECT" || code === "SUDO_PASSWORD_REQUIRED") { plan = { ...plan, sudoPasswordRequired: true, error: errorText(error) }; continue; } setStatus(errorText(error), true); return; } } } catch (error) { if (requestGeneration !== state.generation) return; throw error; } } async function uploadSelection() { const requestGeneration = state.generation; try { if (state.busy || !selectedSessionId()) return; if (!api.files.transfers) { await sessionResult(mutate(t("Nahrávám soubory a složky…"), "", () => api.files.upload(selectedSessionId(), state.currentPath)), requestGeneration); return; } setBusy(true, t("Připravuji nahrání…")); let plan; try { plan = await sessionResult(api.files.transfers.planUpload(selectedSessionId(), state.currentPath), requestGeneration); } catch (error) { if (requestGeneration !== state.generation) return; setStatus(errorText(error), true); } finally { if (requestGeneration === state.generation) { setBusy(false);} } if (plan) await sessionResult(runPlan(plan), requestGeneration); } catch (error) { if (requestGeneration !== state.generation) return; throw error; } } async function downloadSelection(paths = selectedEntries().map((item) => item.path)) { const requestGeneration = state.generation; try { if (!paths.length || state.busy) return; if (!api.files.transfers) { const items = selectedEntries(); setBusy(true, t("Stahuji {count} položek…", { count: items.length })); try { const result = items.length === 1 && items[0].type !== "directory" ? await sessionResult(api.files.download(selectedSessionId(), items[0].path), requestGeneration) : await sessionResult(api.files.downloadMany(selectedSessionId(), items.map((item) => item.path)), requestGeneration); if (!result.canceled) setStatus(t("Staženo {count} položek, {size}.", { count: result.entries || 1, size: bytes(result.size) })); } catch (error) { if (requestGeneration !== state.generation) return; setStatus(errorText(error), true); } finally { if (requestGeneration === state.generation) { setBusy(false);} } return; } let plan; try { plan = (await sessionResult(withPrivilege(t("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."), (opts) => api.files.transfers.planDownload(selectedSessionId(), paths, opts), state.listPrivileged ? { sudo: true } : {}), requestGeneration)).value; } catch (error) { if (requestGeneration !== state.generation) return; if (errorCode(error) !== "CANCELED") setStatus(errorText(error), true); return; } await sessionResult(runPlan(plan), requestGeneration); } catch (error) { if (requestGeneration !== state.generation) return; throw error; } } async function archiveSelection(format) { const requestGeneration = state.generation; const items = selectedEntries(); if (!items.length || state.busy) return; setBusy(true, t("Vytvářím {format} archiv…", { format: format.toUpperCase() })); try { const result = await sessionResult(api.files.downloadArchive(selectedSessionId(), items.map((item) => item.path), format), requestGeneration); if (!result.canceled) setStatus(t("Archiv {format} uložen: {size}, {count} položek.", { format: format.toUpperCase(), size: bytes(result.size), count: result.entries })); } catch (error) { if (requestGeneration !== state.generation) return; setStatus(errorText(error), true); } finally { if (requestGeneration === state.generation) { setBusy(false);} } } function setTransfersCollapsed(collapsed) { elements.transfers.classList.toggle("collapsed", collapsed); elements.transfersToggle.setAttribute("aria-expanded", String(!collapsed)); try { localStorage.setItem("chj.fm.transfersCollapsed", collapsed ? "1" : "0"); } catch {} } function applyTransferUpdate(update) { if (update.full) state.transfers = new Map(); for (const id of update.removed || []) { state.transfers.delete(id); state.transferSelected.delete(id); } for (const job of update.jobs || []) state.transfers.set(job.id, job); if (update.summary) state.transferSummary = update.summary; if (update.settings) { elements.concurrencySelect.value = String(update.settings.maxConcurrent); elements.autoRetryCheckbox.checked = update.settings.autoRetry; elements.verifyCheckbox.checked = update.settings.verify; } queueTransferRender(); if ((update.jobs || []).some((job) => job.state === "completed")) globalThis.chjPanels?.transfersFinished(); } function queueTransferRender() { if (state.renderQueued) return; state.renderQueued = true; requestAnimationFrame(() => { state.renderQueued = false; renderTransfers(); }); } function jobStatusText(job) { let text = STATE_TEXT[job.state] ? t(STATE_TEXT[job.state]) : job.state; if (job.state === "reconnecting" && job.nextRetryAt) text += ` (${t("pokus {attempt}, za {seconds} s", { attempt: job.attempts, seconds: Math.max(0, Math.ceil((job.nextRetryAt - Date.now()) / 1000)) })})`; if (job.authRequired) text += ` – ${t("vyžaduje sudo")}`; return text; } function buildTransferNode(job) { const node = document.createElement("div"); node.className = "transfer"; node.dataset.id = job.id; node.innerHTML = `