CH-J Toolspamamahala ng server sa pamamagitan ng SSH
Menu
Nailathalang source

CH-J Tools

I-browse ang mga direktoryo at file ng isang partikular na release ng application.

I-download ang source ZIP
CH-J / Server Manager 1704 file
0.1.2-beta Idinagdag: · Binuo:
index.js Idinagdag: Hindi naitala ang petsa 72.3 KB · 900 linya
Code I-download ang file
1"use strict";
3const api = window.chjPlugin;
4const i18n = window.chjPluginI18n.createI18n("cs");
5const t = i18n.t;
6let endedSessionId = "";
7let sessionsLoading = false;
9// Guard every continuation, including sudo retries and dialog answers, against a server switch.
10async function sessionResult(promise, token) {
11 const value = await promise;
12 if (token !== state.generation) {
13 if (value?.editId) void api.files.closeText(value.editId).catch(() => {});
14 throw new Error("CHJ_SESSION_CHANGED");
15 }
16 return value;
19// Marks a catalog key that is translated with t() only when it is shown.
20const k = (text) => text;
21const byId = (id) => document.getElementById(id);
22const elements = Object.fromEntries([
23 "pluginVersion", "closeButton", "sessionSelect", "refreshSessionsButton", "rootButton", "upButton", "refreshButton", "breadcrumbs", "pathForm", "pathInput",
24 "newFileButton", "mkdirButton", "uploadButton", "downloadButton", "zipButton", "tarGzButton", "tarButton", "filterInput", "selectAllCheckbox", "filesBody",
25 "busyOverlay", "busyLabel", "status", "selectionInfo", "editor", "editorPath", "editorTarget", "editorPrivilege", "editorBackup", "editorLanguage", "editorSize", "editorDirty", "editorHost", "editorPosition",
26 "downloadEditorButton", "saveEditorButton", "closeEditorButton", "contextMenu",
27 "transfers", "transfersToggle", "transferSummary", "transferOverall", "transferOverallText", "transferList", "pauseAllButton", "resumeAllButton", "cancelAllButton",
28 "pauseSelectedButton", "resumeSelectedButton", "cancelSelectedButton", "clearFinishedButton", "concurrencySelect", "autoRetryCheckbox", "verifyCheckbox",
29 "sudoDialog", "sudoReason", "sudoError", "sudoPasswordRow", "sudoAccount", "sudoPassword", "sudoRemember", "sudoConfirm",
30 "planDialog", "planTitle", "planSummary", "planConflicts", "planConflictText", "planConflictList", "planSkipped", "planSudo", "planPasswordRow", "planPassword", "planRemember", "planError", "planSkip", "planOverwrite", "planStart"
31].map((id) => [id, byId(id)]));
33const state = {
34 generation: 0, sessionId: "", sessions: [], currentPath: "/", entries: [], visibleEntries: [], selected: new Set(), anchorPath: null, listPrivileged: false,
35 sortKey: "name", sortDirection: 1, busy: false, monaco: null, editor: null, editorPath: null, savedText: "", monacoPromise: null,
36 edit: null, sudoAccepted: new Set(), transfers: new Map(), transferSessionNames: new Map(), transferSummary: null, transferSelected: new Set(), transferNodes: new Map(), renderQueued: false
37};
39window.MonacoEnvironment = { getWorkerUrl: () => "vendor/monaco/vs/base/worker/workerMain.js" };
41// Server error codes → concrete, user-facing reasons (the backend keeps stable codes).
42const ERROR_TEXT = {
43 FILE_PERMISSION_DENIED: k("Účet SSH nemá k této operaci oprávnění souborového systému."),
44 FILE_METADATA_PERMISSION: k("Původního vlastníka, skupinu nebo atributy souboru nelze bez oprávnění správce zachovat."),
45 FILE_NOT_FOUND: k("Soubor nebo adresář neexistuje."),
46 FILE_EXISTS: k("Cílová položka již existuje."),
47 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."),
48 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í."),
49 FILE_UNSAFE_TYPE_OR_LINK: k("Cíl není běžný soubor (např. zařízení, FIFO nebo nečekaný odkaz)."),
50 FILE_UNSAFE_PATH: k("Cesta obsahuje nebezpečnou nebo neplatnou součást (např. smyčku symbolických odkazů)."),
51 FILE_SYMLINK_TARGET_MISSING: k("Symbolický odkaz vede na neexistující cíl."),
52 FILE_SYMLINK_LOOP: k("Symbolické odkazy tvoří smyčku."),
53 FILE_UNSAFE_PRIVILEGED_DIRECTORY: k("Některý nadřazený adresář je zapisovatelný pro všechny bez sticky bitu; privilegovaná operace byla odmítnuta."),
54 FILE_UNSAFE_WORKSPACE: k("Soukromý pracovní adresář účtu není bezpečný (vlastník/práva). Zkontrolujte domovský adresář."),
55 FILE_METADATA_UNSUPPORTED: k("Důležitá metadata (ACL, xattr, SELinux, IMA/EVM) nelze bezpečně převzít. Operace byla zastavena."),
56 FILE_NO_SPACE: k("Na cílovém souborovém systému není dost místa. Původní soubor zůstal nedotčen."),
57 FILE_READ_ONLY_FILESYSTEM: k("Souborový systém je připojen jen pro čtení."),
58 FILE_TOO_LARGE: k("Soubor překračuje limit editoru 25 MiB."),
59 FILE_DEPENDENCY_UNAVAILABLE: k("Na serveru chybí python3, který je nutný pro bezpečné uložení se zachováním metadat."),
60 FILE_LINUX_REQUIRED: k("Bezpečné ukládání je podporováno jen na linuxových serverech."),
61 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."),
62 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."),
63 FILE_UPLOAD_TIMEOUT: k("Nahrání pracovní kopie vypršelo."),
64 FILE_PROTECTED_PATH: k("Systémový adresář nelze odstranit ani přejmenovat."),
65 FILE_DIRECTORY_NOT_EMPTY: k("Adresář není prázdný."),
66 FILE_BUSY: k("Soubor je právě používán."),
67 FILE_VERIFY_FAILED: k("Kontrolní součet přenesených dat nesouhlasí se zdrojem."),
68 FILE_SOURCE_CHANGED: k("Zdrojový soubor se během přenosu změnil. Spusťte přenos znovu od začátku."),
69 FILE_RESUME_MISMATCH: k("Uložená část souboru neodpovídá zdroji, nelze bezpečně navázat. Spusťte přenos znovu od začátku."),
70 SUDO_PASSWORD_REQUIRED: k("Operace vyžaduje heslo sudo."),
71 SUDO_PASSWORD_INCORRECT: k("Heslo sudo bylo odmítnuto."),
72 SUDO_NOT_PERMITTED: k("Účet nemá oprávnění používat sudo pro tuto operaci."),
73 SUDO_TTY_REQUIRED: k("Politika sudo vyžaduje terminál (requiretty); CH-J ji neobchází."),
74 SUDO_AUTH_FAILED: k("Ověření sudo selhalo nebo je účet uzamčen."),
75 SUDO_UNAVAILABLE: k("Na serveru není sudo a účet není root."),
76 SESSION_NOT_CONNECTED: k("SSH relace není připojena."),
77 TRANSFER_STALLED: k("Po dobu 60 s nebyla potvrzena žádná data (timeout)."),
78 APP_RESTARTED: k("Aplikace byla během přenosu ukončena."),
79 LOCAL_NO_SPACE: k("Na lokálním disku není dost místa."),
80 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."),
81 TRANSFER_SAME_PATH: k("Zdroj a cíl jsou stejná složka."),
82 TRANSFER_INTO_ITSELF: k("Složku nelze kopírovat do ní samotné."),
83 TRANSFER_INVALID_SELECTION: k("Vyberte položky ke kopírování a dva různé panely."),
84 LOCAL_PERMISSION_DENIED: k("K této lokální položce nemáte oprávnění."),
85 LOCAL_OPERATION_FAILED: k("Lokální operace selhala."),
86 TRANSFER_PLAN_SERVER_CHANGED: k("Výběr vypršel, vyberte soubory znovu."),
87 TRANSFER_PLAN_EXPIRED: k("Výběr vypršel, vyberte soubory znovu.")
88};
89const 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") };
90const NEEDS_SUDO = new Set(["FILE_PERMISSION_DENIED", "FILE_METADATA_PERMISSION", "SUDO_PASSWORD_REQUIRED"]);
91const 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"),
92 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") };
93const FINALIZE_TEXT = { pending: k("čeká"), running: k("probíhá"), done: k("dokončeno"), unknown: k("nepotvrzeno – ověří se při obnovení"), failed: k("selhalo") };
94const RUNNING = new Set(["connecting", "transferring", "verifying", "finalizing"]);
95const FINAL = new Set(["completed", "failed", "cancelled"]);
97function errorCode(error) { return error?.code && /^[A-Z_]+$/.test(error.code) ? error.code : (/(?:^|: )([A-Z][A-Z0-9_]{3,63}):/.exec(String(error?.message || ""))?.[1] || ""); }
98function errorText(error) {
99 const code = errorCode(error);
100 if (ERROR_TEXT[code]) return `${t(ERROR_TEXT[code])} (${code})`;
101 return String(error?.message || error || t("Neznámá chyba.")).replace(/^Error invoking remote method '[^']+': (Error: )?/, "");
103function normalizePath(value) {
104 const raw = String(value || "").trim();
105 if (!raw.startsWith("/")) throw new Error(t("Cesta musí začínat znakem /."));
106 const parts = [];
107 for (const part of raw.split("/")) { if (!part || part === ".") continue; if (part === "..") parts.pop(); else parts.push(part); }
108 return `/${parts.join("/")}`;
110function joinPath(base, name) { return normalizePath(`${base === "/" ? "" : base}/${name}`); }
111function parentPath(value) { const parts = normalizePath(value).split("/").filter(Boolean); parts.pop(); return `/${parts.join("/")}`; }
112function selectedSessionId() { return state.sessionId; }
113function currentSession() { return state.sessions.find((session) => session.sessionId === selectedSessionId()); }
114function selectedEntries() { return state.entries.filter((entry) => state.selected.has(entry.path)); }
115function 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]}`; }
116function rate(value) { return value > 0 ? `${bytes(value)}/s` : "—"; }
117function duration(seconds) {
118 if (seconds === null || seconds === undefined || !Number.isFinite(seconds)) return t("Výpočet času…");
119 const s = Math.max(0, Math.round(seconds));
120 if (s < 60) return t("přibližně {s} s", { s });
121 if (s < 3600) return t("přibližně {m} min {s} s", { m: Math.floor(s / 60), s: s % 60 });
122 return t("přibližně {h} h {m} min", { h: Math.floor(s / 3600), m: Math.floor((s % 3600) / 60) });
124function setStatus(message, error = false) { elements.status.textContent = message; elements.status.classList.toggle("error", error); }
125function validName(value) { return Boolean(value && !/[\/\0\r\n]/.test(value) && value !== "." && value !== ".."); }
127// In-page: the system WebViews do not all implement the browser's own prompt.
128function askName(title, initial = "") {
129 return new Promise((resolve) => {
130 const dialog = byId("nameDialog"), input = byId("nameInput");
131 byId("nameTitle").textContent = title;
132 input.value = initial;
133 const close = () => {
134 dialog.removeEventListener("close", close);
135 resolve(dialog.returnValue === "ok" && validName(input.value.trim()) ? input.value.trim() : null);
136 };
137 dialog.addEventListener("close", close);
138 dialog.returnValue = "";
139 dialog.showModal();
140 input.focus();
141 const dot = initial.lastIndexOf(".");
142 input.setSelectionRange(0, dot > 0 ? dot : initial.length);
143 });
146function setBusy(value, label = t("Pracuji…")) {
147 state.busy = value; elements.busyOverlay.hidden = !value; elements.busyLabel.textContent = label;
148 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);
149 updateActions();
152function updateActions() {
153 const selected = selectedEntries(); const enabled = Boolean(selectedSessionId()) && !state.busy;
154 elements.downloadButton.disabled = !enabled || !selected.length;
155 for (const button of [elements.zipButton, elements.tarGzButton, elements.tarButton]) button.disabled = !enabled || !selected.length;
156 elements.selectAllCheckbox.checked = Boolean(state.visibleEntries.length) && state.visibleEntries.every((entry) => state.selected.has(entry.path));
157 elements.selectAllCheckbox.indeterminate = state.visibleEntries.some((entry) => state.selected.has(entry.path)) && !elements.selectAllCheckbox.checked;
158 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))}` : ""}`;
161// ---- privilege prompts ------------------------------------------------------------------------
163async function privileges() {
164 const requestGeneration = state.generation;
165 try { return await sessionResult(api.files.privileges(selectedSessionId()), requestGeneration); }
166 catch { if (requestGeneration !== state.generation) return; return { mode: "unknown", username: currentSession()?.username || "" }; }
169// Offers the shared sudo mechanism; resolves to options for the retried call or null.
170let sudoQueue = Promise.resolve();
171function askSudo(reason, { error = "", privilege = null, current = () => true } = {}) {
172 const account = privilege?.username || currentSession()?.username || "";
173 const show = () => !current() ? null : new Promise((resolve) => {
174 const needsPassword = !privilege || privilege.mode === "sudo-password" || privilege.mode === "unknown";
175 elements.sudoReason.textContent = reason;
176 elements.sudoError.hidden = !error; elements.sudoError.textContent = error;
177 elements.sudoAccount.textContent = account;
178 elements.sudoPasswordRow.hidden = !needsPassword || privilege?.sudoCached === true;
179 elements.sudoPassword.value = "";
180 const close = () => {
181 elements.sudoDialog.removeEventListener("close", close);
182 const ok = elements.sudoDialog.returnValue === "ok";
183 const password = elements.sudoPassword.value;
184 elements.sudoPassword.value = "";
185 resolve(ok ? { sudo: true, ...(password ? { sudoPassword: password } : {}), rememberSudo: elements.sudoRemember.checked } : null);
186 };
187 elements.sudoDialog.addEventListener("close", close);
188 elements.sudoDialog.returnValue = "";
189 elements.sudoDialog.showModal();
190 if (!elements.sudoPasswordRow.hidden) elements.sudoPassword.focus();
191 });
192 const answer = sudoQueue.then(show);
193 sudoQueue = answer.then(() => {}, () => {});
194 return answer;
197// Runs action(options); on insufficient permissions offers sudo (password re-asked on rejection).
198async function withPrivilege(reason, action, initial = {}) {
199 const requestGeneration = state.generation;
200 try {
201 let options = initial;
202 const sessionId = selectedSessionId();
203 for (let attempt = 0; attempt < 5; attempt += 1) {
204 try { return { value: await sessionResult(action(options), requestGeneration), options }; }
205 catch (error) { if (requestGeneration !== state.generation) return;
206 const code = errorCode(error);
207 if (!NEEDS_SUDO.has(code) && code !== "SUDO_PASSWORD_INCORRECT") throw error;
208 // After the user approved sudo (remembered in Main memory) for this session, retry silently once.
209 if (!options.sudo && state.sudoAccepted.has(sessionId) && code !== "SUDO_PASSWORD_REQUIRED") { options = { ...initial, sudo: true }; continue; }
210 const privilege = await sessionResult(privileges(), requestGeneration);
211 if (privilege.mode === "root" && code !== "SUDO_PASSWORD_INCORRECT") throw error; // Root sees the real filesystem error.
212 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" });
213 const answer = await sessionResult(askSudo(reason, { privilege: code === "SUDO_PASSWORD_INCORRECT" || code === "SUDO_PASSWORD_REQUIRED" ? { ...privilege, mode: "sudo-password", sudoCached: false } : privilege,
214 error: code === "SUDO_PASSWORD_INCORRECT" ? t(ERROR_TEXT.SUDO_PASSWORD_INCORRECT) : options.sudo ? errorText(error) : "", current: () => requestGeneration === state.generation }), requestGeneration);
215 if (!answer) throw Object.assign(new Error(t("Operace byla zrušena.")), { code: "CANCELED" });
216 if (answer.rememberSudo) state.sudoAccepted.add(sessionId); else state.sudoAccepted.delete(sessionId);
217 options = { ...initial, ...answer };
218 }
219 }
220 throw new Error(t("Sudo autorizace se nezdařila."));
221 } catch (error) { if (requestGeneration !== state.generation) return; throw error; }
224// ---- directory view ---------------------------------------------------------------------------
226function renderBreadcrumbs() {
227 const fragment = document.createDocumentFragment(); let current = "";
228 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); };
229 append("/", "/");
230 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); }
231 elements.breadcrumbs.replaceChildren(fragment); elements.breadcrumbs.scrollLeft = elements.breadcrumbs.scrollWidth;
234function compareEntries(left, right) {
235 const leftDir = left.type === "directory" || left.targetType === "directory"; const rightDir = right.type === "directory" || right.targetType === "directory";
236 if (leftDir && !rightDir) return -1;
237 if (rightDir && !leftDir) return 1;
238 const key = state.sortKey; let result;
239 if (key === "size") result = left.size - right.size;
240 else if (key === "modifiedAt") result = Date.parse(left.modifiedAt || 0) - Date.parse(right.modifiedAt || 0);
241 else result = String(left[key] || "").localeCompare(String(right[key] || ""), i18n.locale(), { numeric: true, sensitivity: "base" });
242 return result * state.sortDirection;
245function typeLabel(item) {
246 if (item.type === "directory") return t("složka");
247 if (item.type === "symlink") return item.targetType === "directory" ? t("odkaz → složka") : item.targetType === "missing" ? t("odkaz (neplatný)") : t("odkaz → soubor");
248 return item.name.split(".").pop()?.toUpperCase() || t("soubor");
251function renderEntries() {
252 const query = elements.filterInput.value.trim().toLocaleLowerCase(i18n.locale());
253 state.visibleEntries = state.entries.filter((entry) => !query || entry.name.toLocaleLowerCase(i18n.locale()).includes(query)).sort(compareEntries);
254 const fragment = document.createDocumentFragment();
255 for (const item of state.visibleEntries) {
256 const isDir = item.type === "directory" || item.targetType === "directory";
257 const row = document.createElement("tr"); row.dataset.path = item.path; row.dataset.type = item.type; row.classList.toggle("selected", state.selected.has(item.path));
258 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);
259 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);
260 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); }
261 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);
262 }
263 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); }
264 elements.filesBody.replaceChildren(fragment); updateActions();
267// Shared by the classic view and every local/SSH panel (index.js loads before panels.js).
268function isMacOS() { return /Mac/i.test(navigator.userAgentData?.platform || navigator.platform || ""); }
269function fileSelectionModifier(event) { return isMacOS() ? event.metaKey : event.ctrlKey; }
270function changeFileSelection(selected, anchor, entries, path, options = {}) {
271 if (options.clear) return { selected: new Set(), anchor: null };
272 if (options.all) return { selected: new Set(entries.map((entry) => entry.path)), anchor };
273 if (options.context && selected.has(path)) return { selected, anchor };
274 const from = entries.findIndex((entry) => entry.path === anchor), to = entries.findIndex((entry) => entry.path === path);
275 if (options.range && from >= 0 && to >= 0) {
276 const range = entries.slice(Math.min(from, to), Math.max(from, to) + 1).map((entry) => entry.path);
277 return { selected: new Set(options.toggle ? [...selected, ...range] : range), anchor };
278 }
279 if (options.toggle) { selected = new Set(selected); if (selected.has(path)) selected.delete(path); else selected.add(path); }
280 else selected = new Set([path]);
281 return { selected, anchor: path };
284function bindFileSelection(body, select) {
285 let pressedPath = null;
286 body.tabIndex = -1;
287 body.addEventListener("mousedown", (event) => {
288 pressedPath = null;
289 const row = event.target.closest("tr[data-path]");
290 if (!row || event.button !== 0 || (isMacOS() && event.ctrlKey) || event.target.closest("input, button")) return;
291 body.focus({ preventScroll: true });
292 if (!event.shiftKey && !fileSelectionModifier(event)) return;
293 // WebKit can start native text/row dragging instead of delivering a modifier click.
294 // Commit here without replacing the row; consume the following click exactly once.
295 event.preventDefault();
296 select(row.dataset.path, { toggle: fileSelectionModifier(event), range: event.shiftKey });
297 pressedPath = row.dataset.path;
298 });
299 body.addEventListener("click", (event) => {
300 const row = event.target.closest("tr[data-path]");
301 if (!row || event.target.closest("button") || event.button > 0) return;
302 if (isMacOS() && event.ctrlKey) { event.preventDefault(); return; }
303 const handled = pressedPath === row.dataset.path && event.detail !== 0;
304 pressedPath = null;
305 if (!handled) select(row.dataset.path, { toggle: fileSelectionModifier(event) || event.target.matches("input[type=checkbox]"), range: event.shiftKey });
306 });
307 body.addEventListener("selectstart", (event) => { if (event.target.closest("tr[data-path]") && !event.target.closest("input, button")) event.preventDefault(); });
310function showFileSelection() {
311 for (const row of elements.filesBody.querySelectorAll("tr[data-path]")) {
312 const on = state.selected.has(row.dataset.path);
313 row.classList.toggle("selected", on);
314 row.querySelector("input[type=checkbox]").checked = on;
315 }
316 updateActions();
318function selectPath(remotePath, options = {}) {
319 ({ selected: state.selected, anchor: state.anchorPath } = changeFileSelection(state.selected, state.anchorPath, state.visibleEntries, remotePath, options));
320 showFileSelection();
323async function loadSessions() {
324 if (sessionsLoading) return;
325 sessionsLoading = true;
326 try {
327 const sessions = (await api.sessions.list()).filter((session) => session.state === "connected");
328 const previous = state.sessionId;
329 const next = api.sessions.reconcile(endedSessionId || previous, sessions);
330 state.sessions = sessions;
331 for (const session of sessions) state.transferSessionNames.set(session.sessionId, api.sessions.displayName(session));
332 elements.sessionSelect.replaceChildren(new Option(sessions.length ? t("Vyberte relaci…") : t("Žádná připojená relace"), ""));
333 for (const session of sessions) elements.sessionSelect.add(new Option(api.sessions.displayName(session), session.sessionId));
334 elements.sessionSelect.value = next.id;
335 if (next.reason === "gone") endedSessionId ||= previous;
336 if (next.id !== previous) { resetSession(next.id, next.reason === "gone"); if (next.id) void loadDirectory("/"); }
337 if (next.reason === "gone") setStatus(t("Relace skončila. Vyberte server."), true);
338 if (next.reason === "choose" && !globalThis.chjPanels?.active()) api.sessions.offerChoice(elements.sessionSelect, sessions, t("Vyberte relaci…"));
339 globalThis.chjPanels?.syncSessions(sessions);
340 } finally { sessionsLoading = false; }
343function resetSession(id, preserveDraft = false) {
344 state.generation += 1;
345 state.sessionId = id;
346 if (!preserveDraft && state.edit?.editId) void api.files.closeText(state.edit.editId).catch(() => {});
347 for (const dialog of document.querySelectorAll("dialog[open]")) dialog.close();
348 elements.sudoPassword.value = ""; elements.planPassword.value = "";
349 if (!preserveDraft) {
350 elements.editor.hidden = true; state.editor?.setValue("");
351 Object.assign(state, { editorPath: null, savedText: "", edit: null });
352 }
353 elements.saveEditorButton.disabled = !id; elements.downloadEditorButton.disabled = !id;
354 Object.assign(state, { currentPath: "/", entries: [], visibleEntries: [], selected: new Set(), anchorPath: null,
355 listPrivileged: false });
356 state.sudoAccepted.clear(); state.transferSelected.clear();
357 elements.pathInput.value = "/"; elements.filterInput.value = "";
358 hideContextMenu(); renderBreadcrumbs(); renderEntries(); queueTransferRender(); setBusy(false);
361async function loadDirectory(requested = state.currentPath) {
362 const requestGeneration = state.generation;
363 if (!selectedSessionId() || state.busy) return; const target = normalizePath(requested); setBusy(true, t("Načítám {path}…", { path: target })); hideContextMenu();
364 try {
365 const { value: entries, options } = await sessionResult(withPrivilege(t("Adresář {path} nelze číst pod účtem SSH. Zobrazit jeho obsah se sudo?", { path: target }),
366 (opts) => api.files.list(selectedSessionId(), target, opts)), requestGeneration);
367 state.listPrivileged = options.sudo === true;
368 state.currentPath = target; state.entries = entries; state.selected.clear(); state.anchorPath = null; elements.pathInput.value = target; renderBreadcrumbs(); renderEntries();
369 setStatus(`${t("Načteno {count} položek z {path}", { count: entries.length, path: target })}${state.listPrivileged ? " (sudo)" : ""}.`);
370 }
371 catch (error) { if (requestGeneration !== state.generation) return; if (errorCode(error) !== "CANCELED") setStatus(errorText(error), true); }
372 finally { if (requestGeneration === state.generation) { setBusy(false);} }
375async function mutate(label, reason, action) {
376 const requestGeneration = state.generation;
377 if (state.busy) return; setBusy(true, label);
378 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); }
379 catch (error) { if (requestGeneration !== state.generation) return; setStatus(errorCode(error) === "CANCELED" ? t("Operace byla zrušena.") : errorText(error), errorCode(error) !== "CANCELED"); }
380 finally { if (requestGeneration === state.generation) { setBusy(false);} }
383// ---- Monaco editor ----------------------------------------------------------------------------
385function languageFor(remotePath) {
386 const extension = remotePath.toLowerCase().split(".").pop();
387 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");
390function ensureMonaco() {
391 if (state.monaco) return Promise.resolve(state.monaco); if (state.monacoPromise) return state.monacoPromise;
392 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; });
393 return state.monacoPromise;
396function updateDirtyState() { const dirty = Boolean(state.editor && state.editorPath && state.editor.getValue() !== state.savedText); elements.editorDirty.hidden = !dirty; return dirty; }
398function renderEditorMeta() {
399 const edit = state.edit;
400 elements.editorPrivilege.hidden = !edit?.privileged;
401 elements.editorPrivilege.textContent = edit?.privileged ? "sudo" : "";
402 elements.editorTarget.hidden = !edit?.link;
403 elements.editorTarget.textContent = edit?.link ? `↗ ${t("Symbolický odkaz – upravuje se cílový soubor {path}; odkaz zůstane zachován.", { path: edit.resolvedPath })}` : "";
406async function saveEditor() {
407 const requestGeneration = state.generation;
408 try {
409 if (!state.editorPath || !state.editor || state.busy || !state.edit || state.edit.sessionId !== selectedSessionId()) return; setBusy(true, t("Ukládám soubor…"));
410 const text = state.editor.getValue();
411 const edit = state.edit;
412 const editorPath = state.editorPath;
413 const base = { editId: edit.editId, ...(elements.editorBackup.checked ? { backup: true } : {}), ...(edit.privileged || !edit.directSave ? { sudo: true } : {}) };
414 try {
415 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) => {
416 const requestGeneration = state.generation;
417 try {
418 const outcome = await sessionResult(api.files.saveText(edit.sessionId, editorPath, text, { ...base, ...opts }), requestGeneration);
419 if (!outcome.ok) throw Object.assign(new Error(`${outcome.error.code}: ${outcome.error.message}`), { code: outcome.error.code, detail: outcome.error });
420 return outcome.value;
421 } catch (error) { if (requestGeneration !== state.generation) return; throw error; }
422 }, base.sudo ? { sudo: true } : {}), requestGeneration);
423 if (options.sudo) { edit.privileged = true; renderEditorMeta(); }
424 state.savedText = text; elements.editorSize.textContent = bytes(result.size); updateDirtyState();
425 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")}` : ""}.`);
426 } catch (error) { if (requestGeneration !== state.generation) return;
427 const code = errorCode(error);
428 if (code === "CANCELED") setStatus(t("Uložení bylo zrušeno; změny zůstávají v editoru."));
429 else if (code === "FILE_CONFLICT") {
430 setStatus(errorText(error), true);
431 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); }
432 } else setStatus(`${errorText(error)}${error.detail?.recoveryId ? ` ${t("Kopie pro obnovu: {id}.", { id: error.detail.recoveryId })}` : ""}`, true);
433 } finally { if (requestGeneration === state.generation) { setBusy(false);} }
434 } catch (error) { if (requestGeneration !== state.generation) return; throw error; }
437async function openText(remotePath, { force = false } = {}) {
438 const requestGeneration = state.generation;
439 if (state.busy) return;
440 if (!force && state.editorPath && updateDirtyState() && !window.confirm(t("Otevřený soubor obsahuje neuložené změny. Zahodit je a otevřít jiný soubor?"))) return;
441 setBusy(true, t("Otevírám {path}…", { path: remotePath }));
442 try {
443 const [{ value: result, options }, monaco] = await sessionResult(Promise.all([
444 withPrivilege(t("Soubor {path} nelze číst pod účtem SSH. Otevřít jej se sudo?", { path: remotePath }), (opts) => api.files.readText(selectedSessionId(), remotePath, opts)),
445 ensureMonaco()
446 ]), requestGeneration);
447 const language = languageFor(result.resolvedPath || remotePath);
448 if (state.edit?.editId) void api.files.closeText(state.edit.editId).catch(() => {});
449 if (!state.editor) {
450 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 });
451 state.editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => void saveEditor());
452 state.editor.onDidChangeModelContent(updateDirtyState); state.editor.onDidChangeCursorPosition((event) => { elements.editorPosition.textContent = t("Řádek {line}, sloupec {column}", { line: event.position.lineNumber, column: event.position.column }); });
453 } else { state.editor.setValue(result.text); monaco.editor.setModelLanguage(state.editor.getModel(), language); }
454 state.edit = { sessionId: selectedSessionId(), editId: result.editId, resolvedPath: result.resolvedPath || result.path, link: result.link === true, directSave: result.directSave !== false, privileged: options.sudo === true };
455 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);
456 elements.saveEditorButton.disabled = false; elements.downloadEditorButton.disabled = false;
457 elements.editorBackup.checked = false; renderEditorMeta(); updateDirtyState(); elements.editor.hidden = false; state.editor.focus();
458 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")}` : ""}.`);
459 } catch (error) { if (requestGeneration !== state.generation) return; if (errorCode(error) !== "CANCELED") setStatus(errorText(error), true); }
460 finally { if (requestGeneration === state.generation) { setBusy(false);} }
463function closeEditor() {
464 if (!elements.editor.hidden && updateDirtyState() && !window.confirm(t("Soubor obsahuje neuložené změny. Opravdu zavřít editor?"))) return;
465 if (state.edit?.editId) void api.files.closeText(state.edit.editId).catch(() => {});
466 elements.editor.hidden = true; state.editorPath = null; state.savedText = ""; state.edit = null;
469// ---- transfers --------------------------------------------------------------------------------
471function showPlan(plan) {
472 return new Promise((resolve) => {
473 const upload = plan.direction === "upload";
474 elements.planTitle.textContent = plan.title || `${api.sessions.displayName(currentSession())} — ${upload ? t("Nahrání na server") : t("Stažení ze serveru")}`;
475 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}`;
476 elements.planConflicts.hidden = !plan.conflictCount;
477 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 });
478 elements.planConflictList.replaceChildren(...plan.conflicts.map((item) => { const li = document.createElement("li"); li.textContent = item; return li; }));
479 elements.planSkipped.hidden = !plan.skippedCount;
480 elements.planSkipped.textContent = t("{count} položek bude vynecháno (symbolické odkazy, speciální soubory nebo kolize se složkou).", { count: plan.skippedCount });
481 elements.planSudo.hidden = !plan.requiresSudo;
482 elements.planPasswordRow.hidden = !plan.sudoPasswordRequired;
483 elements.planPassword.value = "";
484 elements.planError.hidden = !plan.error; elements.planError.textContent = plan.error || "";
485 elements.planSkip.hidden = !plan.conflictCount; elements.planOverwrite.hidden = !plan.conflictCount; elements.planStart.hidden = Boolean(plan.conflictCount);
486 const close = () => {
487 elements.planDialog.removeEventListener("close", close);
488 const choice = elements.planDialog.returnValue;
489 const password = elements.planPassword.value; elements.planPassword.value = "";
490 resolve(["start", "skip", "overwrite"].includes(choice) ? { conflict: choice === "start" ? "fail" : choice, ...(plan.requiresSudo ? { sudo: true, rememberSudo: elements.planRemember.checked } : {}), ...(password ? { sudoPassword: password } : {}) } : null);
491 };
492 elements.planDialog.addEventListener("close", close);
493 elements.planDialog.returnValue = "";
494 elements.planDialog.showModal();
495 if (plan.requiresSudo && plan.sudoPasswordRequired) elements.planPassword.focus();
496 });
499async function runPlan(plan) {
500 const requestGeneration = state.generation;
501 try {
502 if (!plan || plan.canceled) { setStatus(t("Výběr byl zrušen.")); return; }
503 if (!plan.files && !plan.directories) { setStatus(t("Výběr neobsahuje žádné přenositelné soubory.")); return; }
504 for (let attempt = 0; attempt < 4; attempt += 1) {
505 const choice = await sessionResult(showPlan(plan), requestGeneration);
506 if (!choice) { setStatus(t("Přenos nebyl spuštěn.")); return; }
507 try {
508 const started = await sessionResult(api.files.transfers.start(plan.planId, { ...choice, sessionId: selectedSessionId() }), requestGeneration);
509 setStatus(`${t("Zařazeno {count} přenosů", { count: started.jobs })}${started.skipped ? `, ${t("přeskočeno {count}", { count: started.skipped })}` : ""}.`);
510 setTransfersCollapsed(false);
511 return;
512 } catch (error) { if (requestGeneration !== state.generation) return;
513 const code = errorCode(error);
514 if (code === "SUDO_PASSWORD_INCORRECT" || code === "SUDO_PASSWORD_REQUIRED") { plan = { ...plan, sudoPasswordRequired: true, error: errorText(error) }; continue; }
515 setStatus(errorText(error), true); return;
516 }
517 }
518 } catch (error) { if (requestGeneration !== state.generation) return; throw error; }
521async function uploadSelection() {
522 const requestGeneration = state.generation;
523 try {
524 if (state.busy || !selectedSessionId()) return;
525 if (!api.files.transfers) { await sessionResult(mutate(t("Nahrávám soubory a složky…"), "", () => api.files.upload(selectedSessionId(), state.currentPath)), requestGeneration); return; }
526 setBusy(true, t("Připravuji nahrání…"));
527 let plan;
528 try { plan = await sessionResult(api.files.transfers.planUpload(selectedSessionId(), state.currentPath), requestGeneration); }
529 catch (error) { if (requestGeneration !== state.generation) return; setStatus(errorText(error), true); }
530 finally { if (requestGeneration === state.generation) { setBusy(false);} }
531 if (plan) await sessionResult(runPlan(plan), requestGeneration);
532 } catch (error) { if (requestGeneration !== state.generation) return; throw error; }
535async function downloadSelection(paths = selectedEntries().map((item) => item.path)) {
536 const requestGeneration = state.generation;
537 try {
538 if (!paths.length || state.busy) return;
539 if (!api.files.transfers) {
540 const items = selectedEntries(); setBusy(true, t("Stahuji {count} položek…", { count: items.length }));
541 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) })); }
542 catch (error) { if (requestGeneration !== state.generation) return; setStatus(errorText(error), true); }
543 finally { if (requestGeneration === state.generation) { setBusy(false);} }
544 return;
545 }
546 let plan;
547 try {
548 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."),
549 (opts) => api.files.transfers.planDownload(selectedSessionId(), paths, opts), state.listPrivileged ? { sudo: true } : {}), requestGeneration)).value;
550 } catch (error) { if (requestGeneration !== state.generation) return; if (errorCode(error) !== "CANCELED") setStatus(errorText(error), true); return; }
551 await sessionResult(runPlan(plan), requestGeneration);
552 } catch (error) { if (requestGeneration !== state.generation) return; throw error; }
555async function archiveSelection(format) {
556 const requestGeneration = state.generation;
557 const items = selectedEntries(); if (!items.length || state.busy) return; setBusy(true, t("Vytvářím {format} archiv…", { format: format.toUpperCase() }));
558 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 })); }
559 catch (error) { if (requestGeneration !== state.generation) return; setStatus(errorText(error), true); }
560 finally { if (requestGeneration === state.generation) { setBusy(false);} }
563function setTransfersCollapsed(collapsed) {
564 elements.transfers.classList.toggle("collapsed", collapsed);
565 elements.transfersToggle.setAttribute("aria-expanded", String(!collapsed));
566 try { localStorage.setItem("chj.fm.transfersCollapsed", collapsed ? "1" : "0"); } catch {}
569function applyTransferUpdate(update) {
570 if (update.full) state.transfers = new Map();
571 for (const id of update.removed || []) { state.transfers.delete(id); state.transferSelected.delete(id); }
572 for (const job of update.jobs || []) state.transfers.set(job.id, job);
573 if (update.summary) state.transferSummary = update.summary;
574 if (update.settings) {
575 elements.concurrencySelect.value = String(update.settings.maxConcurrent);
576 elements.autoRetryCheckbox.checked = update.settings.autoRetry;
577 elements.verifyCheckbox.checked = update.settings.verify;
578 }
579 queueTransferRender();
580 if ((update.jobs || []).some((job) => job.state === "completed")) globalThis.chjPanels?.transfersFinished();
583function queueTransferRender() {
584 if (state.renderQueued) return;
585 state.renderQueued = true;
586 requestAnimationFrame(() => { state.renderQueued = false; renderTransfers(); });
589function jobStatusText(job) {
590 let text = STATE_TEXT[job.state] ? t(STATE_TEXT[job.state]) : job.state;
591 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)) })})`;
592 if (job.authRequired) text += ` – ${t("vyžaduje sudo")}`;
593 return text;
596function buildTransferNode(job) {
597 const node = document.createElement("div"); node.className = "transfer"; node.dataset.id = job.id;
598 node.innerHTML = `<input type="checkbox" aria-label="${escapeHtml(t("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>`;
599 return node;
602function 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; }
604function updateTransferNode(node, job) {
605 node.classList.toggle("selected", state.transferSelected.has(job.id));
606 node.querySelector("input").checked = state.transferSelected.has(job.id);
607 node.querySelector(".t-title strong").textContent = job.name;
608 node.querySelector(".t-direction").textContent = job.direction === "relay"
609 ? `${identityName(job.sourceIdentity)} → ${identityName(job.identity)}${job.move ? ` · ${t("přesun")}` : ""}`
610 : `${transferSessionName(job)} · ${job.direction === "upload" ? "Upload →" : "Download →"} ${job.target}${job.move ? ` · ${t("přesun")}` : ""}`;
611 node.querySelector(".t-badge").hidden = job.mode !== "privileged";
612 node.querySelector(".t-paths").textContent = `${t("Zdroj: {source} · Cíl: {target}", { source: job.source, target: job.target })}${job.resolvedTarget && job.resolvedTarget !== job.target ? ` (${t("cíl odkazu {path}", { path: job.resolvedTarget })})` : ""}`;
613 const bar = node.querySelector(".progress");
614 const value = job.state === "completed" ? 100 : job.percent;
615 bar.classList.toggle("indeterminate", value === null && RUNNING.has(job.state));
616 bar.classList.toggle("done", job.state === "completed"); bar.classList.toggle("error", job.state === "failed" || job.state === "cancelled");
617 bar.classList.toggle("paused", ["paused", "interrupted", "reconnecting"].includes(job.state));
618 bar.querySelector("span").style.width = `${value ?? 0}%`;
619 if (value === null) bar.removeAttribute("aria-valuenow"); else bar.setAttribute("aria-valuenow", String(value));
620 const stats = [];
621 stats.push(`<b>${value === null ? (job.size === 0 ? "0 B" : "—") : `${value.toLocaleString(i18n.locale())} %`}</b>`);
622 stats.push(escapeHtml(`${bytes(job.transferred)} / ${job.size === null ? t("neznámá velikost") : bytes(job.size)}`));
623 if (job.state === "transferring") { stats.push(escapeHtml(t("Rychlost: {rate}", { rate: rate(job.speed) }))); stats.push(escapeHtml(t("Zbývá: {time}", { time: duration(job.eta) }))); }
624 if (job.averageSpeed) stats.push(escapeHtml(t("Průměr: {rate}", { rate: rate(job.averageSpeed) })));
625 stats.push(`${escapeHtml(t("Stav:"))} <span class="t-state ${job.state}">${escapeHtml(jobStatusText(job))}</span>`);
626 if (job.moveResult) {
627 stats.push(escapeHtml(job.moveResult.state === "deleted" ? t("Zdroj odstraněn po ověření") : job.moveResult.state === "unknown"
628 ? t("nepotvrzeno – ověří se při obnovení") : t("Zdroj ponechán: {reason}", { reason: t(MOVE_KEPT_TEXT[job.moveResult.reason] || MOVE_KEPT_TEXT["remove-failed"]) })));
629 if (job.moveResult.temporaryPath) stats.push(escapeHtml(`${t("Obnovení původní cesty selhalo.")} ${t("Kopie pro obnovu: {id}.", { id: job.moveResult.temporaryPath })}`));
630 }
631 if (job.direction !== "download" || job.mode === "privileged") stats.push(escapeHtml(`${t("Dokončení na cíli: {state}", { state: FINALIZE_TEXT[job.finalize.state] ? t(FINALIZE_TEXT[job.finalize.state]) : job.finalize.state })}${job.finalize.state === "done" && job.finalize.metadataPreserved ? ` · ${t("metadata zachována")}` : ""}`));
632 if (job.verification) stats.push(escapeHtml(t("Ověření: {method}", { method: job.verification.method === "sha256" ? (job.verification.matched ? t("SHA-256 shoda") : "SHA-256") : t("jen velikost") })));
633 node.querySelector(".t-stats").innerHTML = stats.join("<span></span>");
634 const errorNode = node.querySelector(".t-error");
635 errorNode.hidden = !job.error || (job.state === "completed" && job.moveResult?.state !== "unknown" && !job.moveResult?.temporaryPath); errorNode.textContent = job.error ? errorText({ code: job.error.code, message: job.error.message }) : "";
636 const actions = node.querySelector(".t-actions"); const buttons = [];
637 if (job.actions.pause) buttons.push(actionButton(`⏸ ${t("Pozastavit")}`, "pause", job.id));
638 if (job.actions.resume) buttons.push(actionButton(job.state === "completed" ? `↻ ${t("Obnovit")}` : job.state === "interrupted" ? (job.authRequired ? `🔑 ${t("Pokračovat se sudo")}` : `↻ ${t("Obnovit")}`) : `▶ ${t("Pokračovat")}`, "resume", job.id));
639 if (job.actions.overwrite) buttons.push(actionButton(t("Přepsat cíl"), "overwrite", job.id));
640 if (job.actions.retry) buttons.push(actionButton(`⟲ ${t("Znovu od začátku")}`, "retry", job.id, "ghost"));
641 if (job.actions.cancel) buttons.push(actionButton(`✕ ${t("Zrušit")}`, "cancel", job.id, "danger"));
642 if (job.actions.remove) buttons.push(actionButton(t("Odebrat"), "remove", job.id, "ghost"));
643 const signature = buttons.map((button) => button.dataset.transferAction + button.textContent).join("|");
644 if (actions.dataset.signature !== signature) { actions.replaceChildren(...buttons); actions.dataset.signature = signature; }
647function escapeHtml(value) { return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]); }
649function identityName(identity) { return identity ? `${identity.username || ""}@${identity.host || ""}` : "—"; }
651function transferSessionName(job) {
652 return state.transferSessionNames.get(job.sessionId) || api.sessions.displayName({
653 label: job.sessionId || "—", username: job.identity?.username || "", host: job.identity?.host || ""
654 });
657function sessionTransfers() {
658 if (globalThis.chjPanels?.active()) return [...state.transfers.values()];
659 const session = currentSession();
660 if (!session) return [];
661 return [...state.transfers.values()].filter((job) => job.sessionId === session.sessionId || (
662 !state.sessions.some((entry) => entry.sessionId === job.sessionId) &&
663 job.identity?.profileId === session.profileId && job.identity?.host === session.host &&
664 job.identity?.port === session.port && job.identity?.username === session.username
665 ));
668function transferSummary(jobs) {
669 const counted = jobs.filter((job) => job.state !== "cancelled");
670 const count = (state) => jobs.filter((job) => job.state === state).length;
671 const sizesKnown = counted.every((job) => typeof job.size === "number");
672 const totalBytes = counted.reduce((sum, job) => sum + (job.size || 0), 0);
673 const transferredBytes = counted.reduce((sum, job) => sum + Math.min(job.transferred || 0, job.size ?? Infinity), 0);
674 const active = jobs.filter((job) => RUNNING.has(job.state));
675 const speed = active.reduce((sum, job) => sum + (job.speed || 0), 0);
676 const remaining = counted.filter((job) => !["completed", "failed", "cancelled"].includes(job.state))
677 .reduce((sum, job) => sum + Math.max(0, (job.size || 0) - (job.transferred || 0)), 0);
678 let percent = sizesKnown && totalBytes > 0 ? Math.floor(transferredBytes / totalBytes * 1000) / 10
679 : sizesKnown && counted.length && count("completed") === counted.length ? 100 : null;
680 if (percent >= 100 && count("completed") !== counted.length) percent = 99.9;
681 return { total: jobs.length, completed: count("completed"), active: active.length, waiting: count("waiting") + count("reconnecting"),
682 paused: count("paused"), interrupted: count("interrupted"), failed: count("failed"), cancelled: count("cancelled"),
683 totalBytes, transferredBytes, sizesKnown, percent, speed,
684 eta: speed > 0 && active.every((job) => job.state !== "transferring" || job.eta !== null) ? Math.ceil(remaining / speed) : null };
687function renderTransfers() {
688 const jobs = sessionTransfers();
689 const summary = transferSummary(jobs);
690 if (!jobs.length || !summary) {
691 elements.transferSummary.textContent = t("Žádné přenosy.");
692 elements.transferOverall.querySelector("span").style.width = "0%"; elements.transferOverallText.textContent = "";
693 elements.transferList.replaceChildren(Object.assign(document.createElement("div"), { className: "transfer-empty", textContent: t("Nahrané a stažené soubory se zobrazí zde i po přepnutí obrazovky nebo restartu aplikace.") }));
694 state.transferNodes.clear();
695 } else {
696 const parts = [t("{count} souborů", { count: summary.total }), t("{count} dokončeno", { count: summary.completed }), t("{count} aktivní", { count: summary.active }), t("{count} čeká", { count: summary.waiting })];
697 if (summary.paused) parts.push(t("{count} pozastaveno", { count: summary.paused }));
698 if (summary.interrupted) parts.push(t("{count} přerušeno", { count: summary.interrupted }));
699 if (summary.failed) parts.push(t("{count} s chybou", { count: summary.failed }));
700 if (summary.cancelled) parts.push(t("{count} zrušeno", { count: summary.cancelled }));
701 elements.transferSummary.textContent = parts.join(" · ");
702 const bar = elements.transferOverall;
703 bar.classList.toggle("indeterminate", summary.percent === null && summary.active > 0);
704 bar.classList.toggle("done", summary.completed === summary.total - summary.cancelled && summary.total > summary.cancelled);
705 bar.querySelector("span").style.width = `${summary.percent ?? 0}%`;
706 elements.transferOverallText.textContent = `${summary.percent === null ? "—" : `${summary.percent.toLocaleString(i18n.locale())} %`} · ${bytes(summary.transferredBytes)} / ${summary.sizesKnown ? bytes(summary.totalBytes) : t("neznámá velikost")}${summary.active ? ` · ${rate(summary.speed)} · ${t("Zbývá: {time}", { time: duration(summary.eta) })}` : ""}`;
707 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;
708 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);
709 const keep = new Set(ordered.map((job) => job.id));
710 for (const [id, node] of state.transferNodes) if (!keep.has(id)) { node.remove(); state.transferNodes.delete(id); }
711 const empty = elements.transferList.querySelector(".transfer-empty"); if (empty) empty.remove();
712 let previous = null;
713 for (const job of ordered) {
714 let node = state.transferNodes.get(job.id);
715 if (!node) { node = buildTransferNode(job); state.transferNodes.set(job.id, node); }
716 updateTransferNode(node, job);
717 const expected = previous ? previous.nextSibling : elements.transferList.firstChild;
718 if (expected !== node) elements.transferList.insertBefore(node, expected);
719 previous = node;
720 }
721 }
722 const selected = [...state.transferSelected].map((id) => state.transfers.get(id)).filter(Boolean);
723 elements.pauseSelectedButton.disabled = !selected.some((job) => job.actions.pause);
724 elements.resumeSelectedButton.disabled = !selected.some((job) => job.actions.resume);
725 elements.cancelSelectedButton.disabled = !selected.some((job) => job.actions.cancel);
726 elements.pauseAllButton.disabled = !jobs.some((job) => job.actions.pause);
727 elements.resumeAllButton.disabled = !jobs.some((job) => job.actions.resume);
728 elements.cancelAllButton.disabled = !jobs.some((job) => job.actions.cancel);
729 elements.clearFinishedButton.disabled = !jobs.some((job) => job.actions.remove);
732function transferTargetName() { return currentSession() ? api.sessions.displayName(currentSession()) : t("Přenosy"); }
733// The job's own connection while it lives; after a reconnect, the one connection to the same server, if it is unambiguous.
734function liveConnection(identity, previous) {
735 if (!identity) return undefined;
736 const same = state.sessions.filter((entry) => entry.profileId === identity.profileId && entry.host === identity.host && entry.port === identity.port && entry.username === identity.username);
737 if (same.some((entry) => entry.sessionId === previous)) return previous;
738 return same.length === 1 ? same[0].sessionId : undefined;
740function jobConnections(job) {
741 const sessionId = liveConnection(job.identity, job.sessionId), sourceSessionId = liveConnection(job.sourceIdentity, job.sourceSessionId);
742 return { ...(sessionId ? { sessionId } : {}), ...(sourceSessionId ? { sourceSessionId } : {}) };
745async function transferAction(action, ids) {
746 const requestGeneration = state.generation;
747 const visible = sessionTransfers();
748 ids = ids === "all" ? visible.map((job) => job.id) : ids.filter((id) => visible.some((job) => job.id === id));
749 const list = ids.map((id) => state.transfers.get(id));
750 if (!list.length) return;
751 const panels = globalThis.chjPanels?.active();
752 // Authorize and resume one job at a time. An answer belongs only to this job's
753 // failing endpoint and role, never to the remaining jobs in the selection.
754 const each = async (call, extra = {}) => {
755 const jobs = list.map((job) => ({ ...job, sessionId: undefined, sourceSessionId: undefined, ...jobConnections(job) }));
756 for (const job of jobs) {
757 const connections = { ...(job.sessionId ? { sessionId: job.sessionId } : {}), ...(job.sourceSessionId ? { sourceSessionId: job.sourceSessionId } : {}) };
758 const options = await sessionResult(transferAuthorization([job]), requestGeneration);
759 if (requestGeneration !== state.generation || options == null) return;
760 await sessionResult(call([job.id], { ...options, ...extra, ...connections }), requestGeneration);
761 }
762 };
763 if (!panels && ["resume", "retry", "overwrite"].includes(action) && list.some((job) => job.sessionId !== selectedSessionId()) &&
764 !window.confirm(`${[...new Set(list.map(transferSessionName))].join("\n")}\n→ ${transferTargetName()}\n\n${t("Pokračovat")}`)) return;
765 try {
766 if (action === "pause") await sessionResult(api.files.transfers.pause(ids), requestGeneration);
767 else if (action === "cancel") {
768 if (!window.confirm(`${transferTargetName()}\n\n${t("Definitivně zrušit {count} přenosů? Dočasná data vytvořená aplikací budou odstraněna, existující cílové soubory zůstanou beze změny.", { count: list.filter((job) => job.actions.cancel).length })}`)) return;
769 await sessionResult(api.files.transfers.cancel(ids), requestGeneration);
770 } else if (action === "remove") await sessionResult(api.files.transfers.remove(ids), requestGeneration);
771 else if (action === "retry") {
772 if (!window.confirm(`${transferTargetName()}\n\n${t("Přenést soubor znovu od začátku? Dosud přenesená část bude zahozena.")}`)) return;
773 await each(api.files.transfers.retry);
774 } else if (action === "overwrite") {
775 if (!window.confirm(`${transferTargetName()}\n\n${t("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;
776 await each(api.files.transfers.resume, { overwrite: true });
777 } else if (action === "resume") {
778 await each(api.files.transfers.resume);
779 }
780 } catch (error) { if (requestGeneration !== state.generation) return; setStatus(errorText(error), true); }
783// Interrupted jobs that need sudo get a password prompt before resuming.
784async function transferAuthorization(jobs) {
785 const requestGeneration = state.generation;
786 try {
787 if (!jobs.some((job) => job.authRequired)) return {};
788 const job = jobs.find((job) => job.authRequired);
789 const endpoint = job.sourceAuthRequired ? job.sourceSessionId : job.sessionId;
790 if (!endpoint) throw Object.assign(new Error(), { code: "SESSION_NOT_CONNECTED" });
791 const privilege = await sessionResult(api.files.privileges(endpoint), requestGeneration);
792 if (privilege.mode === "none") { setStatus(t(ERROR_TEXT.SUDO_NOT_PERMITTED), true); return null; }
793 const codes = new Set(jobs.map((job) => job.error?.code));
794 return askSudo(t("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)."), {
795 privilege: codes.has("SUDO_PASSWORD_REQUIRED") || codes.has("SUDO_PASSWORD_INCORRECT") ? { ...privilege, mode: "sudo-password", sudoCached: false } : privilege,
796 error: codes.has("SUDO_PASSWORD_INCORRECT") ? t(ERROR_TEXT.SUDO_PASSWORD_INCORRECT) : "", current: () => requestGeneration === state.generation
797 });
798 } catch (error) { if (requestGeneration !== state.generation) return; throw error; }
801async function initTransfers() {
802 if (!api.files?.transfers) { elements.transfers.hidden = true; return; }
803 try { setTransfersCollapsed(localStorage.getItem("chj.fm.transfersCollapsed") === "1"); } catch {}
804 api.files.transfers.onUpdate(applyTransferUpdate);
805 const current = await api.files.transfers.list();
806 applyTransferUpdate({ ...current, full: true });
807 setInterval(() => { if ([...state.transfers.values()].some((job) => job.state === "reconnecting" || job.state === "transferring")) queueTransferRender(); }, 1000);
810// ---- remaining actions ------------------------------------------------------------------------
812async function openSelection() {
813 const requestGeneration = state.generation;
814 try { const item = selectedEntries()[0]; if (!item) return; if (item.type === "directory" || item.targetType === "directory") await sessionResult(loadDirectory(item.path), requestGeneration); else await sessionResult(openText(item.path), requestGeneration);
815 } catch (error) { if (requestGeneration !== state.generation) return; throw error; }
817async function renameSelection() {
818 const requestGeneration = state.generation;
819 try { const item = selectedEntries()[0]; if (!item || selectedEntries().length !== 1) return; const name = await askName(t("Nový název:"), item.name); if (!name || name === item.name) return; await sessionResult(mutate(t("Přejmenovávám…"), t("Přejmenování {path} vyžaduje oprávnění správce.", { path: item.path }), (opts) => api.files.rename(selectedSessionId(), item.path, joinPath(parentPath(item.path), name), opts)), requestGeneration);
820 } catch (error) { if (requestGeneration !== state.generation) return; throw error; }
822async function removeSelection() {
823 const requestGeneration = state.generation;
824 try { const items = selectedEntries(); if (!items.length || !window.confirm(`${api.sessions.displayName(currentSession())}\n\n${t("Opravdu trvale smazat {count} vybraných položek včetně obsahu vybraných složek?", { count: items.length })}`)) return; await sessionResult(mutate(t("Mažu vybrané položky…"), t("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)), requestGeneration);
825 } catch (error) { if (requestGeneration !== state.generation) return; throw error; }
828function hideContextMenu() { elements.contextMenu.hidden = true; }
829function showContextMenu(event, remotePath) { event.preventDefault(); if (remotePath) selectPath(remotePath, { context: true }); 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`; }
831elements.closeButton.addEventListener("click", () => { api.close(); });
832elements.refreshSessionsButton.addEventListener("click", async () => { await loadSessions(); await loadDirectory(state.currentPath); });
833elements.sessionSelect.addEventListener("change", () => {
834 if (updateDirtyState() && !window.confirm(t("Soubor obsahuje neuložené změny. Opravdu zavřít editor?"))) { elements.sessionSelect.value = state.sessionId; return; }
835 endedSessionId = "";
836 resetSession(elements.sessionSelect.value);
837 void loadDirectory("/");
838});
839elements.pathForm.addEventListener("submit", (event) => { event.preventDefault(); void loadDirectory(elements.pathInput.value); });
840elements.rootButton.addEventListener("click", () => loadDirectory("/")); elements.upButton.addEventListener("click", () => loadDirectory(parentPath(state.currentPath))); elements.refreshButton.addEventListener("click", () => loadDirectory(state.currentPath));
841elements.filterInput.addEventListener("input", renderEntries);
842elements.selectAllCheckbox.addEventListener("change", () => { for (const item of state.visibleEntries) { if (elements.selectAllCheckbox.checked) state.selected.add(item.path); else state.selected.delete(item.path); } showFileSelection(); });
843for (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:not(.sort-label)").textContent = other === button ? (state.sortDirection > 0 ? "↑" : "↓") : ""; } renderEntries(); });
844elements.newFileButton.addEventListener("click", async () => { const name = await askName(t("Název nového souboru:"), t("novy-soubor.txt")); if (!name) return; const target = joinPath(state.currentPath, name); void mutate(t("Vytvářím soubor…"), t("Vytvoření {path} vyžaduje oprávnění správce.", { path: target }), (opts) => api.files.createFile(selectedSessionId(), target, opts)); });
845elements.mkdirButton.addEventListener("click", async () => { const name = await askName(t("Název nové složky:")); if (!name) return; const target = joinPath(state.currentPath, name); void mutate(t("Vytvářím složku…"), t("Vytvoření {path} vyžaduje oprávnění správce.", { path: target }), (opts) => api.files.mkdir(selectedSessionId(), target, opts)); });
846elements.uploadButton.addEventListener("click", () => void uploadSelection());
847elements.downloadButton.addEventListener("click", () => void downloadSelection()); elements.zipButton.addEventListener("click", () => archiveSelection("zip")); elements.tarGzButton.addEventListener("click", () => archiveSelection("tar.gz")); elements.tarButton.addEventListener("click", () => archiveSelection("tar"));
848bindFileSelection(elements.filesBody, selectPath);
849elements.filesBody.addEventListener("click", (event) => { const action = event.target.closest("button[data-row-action]"); if (!action || event.button > 0 || (isMacOS() && event.ctrlKey)) return; if (action.dataset.rowAction === "open") { selectPath(action.dataset.path); void openSelection(); } else showContextMenu(event, action.dataset.path); });
850elements.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); });
851elements.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(); });
852elements.saveEditorButton.addEventListener("click", () => void saveEditor()); elements.closeEditorButton.addEventListener("click", closeEditor); elements.downloadEditorButton.addEventListener("click", () => { if (state.editorPath) void downloadSelection([state.editorPath]); });
853elements.transfersToggle.addEventListener("click", () => setTransfersCollapsed(!elements.transfers.classList.contains("collapsed")));
854elements.transferList.addEventListener("click", (event) => {
855 const button = event.target.closest("button[data-transfer-action]");
856 if (button) { void transferAction(button.dataset.transferAction, [button.dataset.id]); return; }
857 const node = event.target.closest(".transfer"); if (!node) return;
858 if (state.transferSelected.has(node.dataset.id)) state.transferSelected.delete(node.dataset.id); else state.transferSelected.add(node.dataset.id);
859 queueTransferRender();
860});
861elements.pauseAllButton.addEventListener("click", () => void transferAction("pause", "all"));
862elements.resumeAllButton.addEventListener("click", () => void transferAction("resume", sessionTransfers().filter((job) => job.actions.resume).map((job) => job.id)));
863elements.cancelAllButton.addEventListener("click", () => void transferAction("cancel", "all"));
864elements.pauseSelectedButton.addEventListener("click", () => void transferAction("pause", [...state.transferSelected]));
865elements.resumeSelectedButton.addEventListener("click", () => void transferAction("resume", [...state.transferSelected]));
866elements.cancelSelectedButton.addEventListener("click", () => void transferAction("cancel", [...state.transferSelected]));
867elements.clearFinishedButton.addEventListener("click", () => void transferAction("remove", sessionTransfers().filter((job) => job.actions.remove).map((job) => job.id)));
868for (const element of [elements.concurrencySelect, elements.autoRetryCheckbox, elements.verifyCheckbox]) element.addEventListener("change", () => {
869 void api.files.transfers?.updateSettings({ maxConcurrent: Number(elements.concurrencySelect.value), autoRetry: elements.autoRetryCheckbox.checked, verify: elements.verifyCheckbox.checked }).catch((error) => setStatus(errorText(error), true));
870});
871window.__CHJ_HAS_UNSAVED_CHANGES__ = () => Boolean(state.editorPath && updateDirtyState());
872window.addEventListener("keydown", (event) => {
873 if (globalThis.chjPanels?.active() && elements.editor.hidden) return;
874 if (document.querySelector("dialog[open], #rsyncDialog:not([hidden])")) return;
875 if (event.key === "Escape") {
876 if (!elements.contextMenu.hidden) hideContextMenu();
877 else if (!elements.editor.hidden) closeEditor();
878 else if (!event.target.closest("input, select, textarea, [contenteditable]")) { selectPath(null, { clear: true }); event.preventDefault(); }
879 return;
880 }
881 if (!elements.editor.hidden || !elements.contextMenu.hidden || event.target.closest("input, select, textarea, [contenteditable]")) return;
882 if (event.key === "F5") { event.preventDefault(); void loadDirectory(state.currentPath); }
883 if (event.key === "F2") { event.preventDefault(); void renameSelection(); }
884 if (event.key === "Delete") { event.preventDefault(); void removeSelection(); }
885 if (event.key === "Enter") { event.preventDefault(); void openSelection(); }
886 if (fileSelectionModifier(event) && event.key.toLowerCase() === "a") { event.preventDefault(); selectPath(null, { all: true }); }
887 if (event.key === "Backspace") { event.preventDefault(); void loadDirectory(parentPath(state.currentPath)); }
888});
890(async () => {
891 try { i18n.setLanguage(await api.ui.getLanguage()); } catch {}
892 i18n.apply(document);
893 api.ui.onLanguageChanged((language) => { i18n.setLanguage(language); i18n.apply(document); renderEntries(); queueTransferRender(); });
894 const info = await api.getInfo(); elements.pluginVersion.textContent = `${info.name} v${info.version} · ${info.publisher}`;
895 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(t("Tento build modulu vyžaduje nový Core s rozšířeným files.* API. Nejdříve aktualizujte aplikaci."), true); return; }
896 await initTransfers().catch((error) => setStatus(errorText(error), true));
897 await loadSessions(); await loadDirectory("/");
898})().catch((error) => setStatus(errorText(error), true));
900setInterval(() => { if (!document.hidden) void loadSessions().catch(() => {}); }, 3000);
SHA-2564bf547c7a20fd54d452b24ae71819fdfc7bd8e1d4ddc643e32d9d6c39ce6155e

CH-J Proprietary Software License 1.14 · Ibinibigay ang source sa ilalim ng CH-J Proprietary Software License 1.14. Hindi binabago ng pagiging available nito ang mga tuntunin ng lisensya at hindi ito nagbibigay ng karagdagang karapatan.

SHA-256 ng archiveac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
Mga file