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:
panels.js Idinagdag: Hindi naitala ang petsa 34.7 KB · 535 linya
Code I-download ang file
1"use strict";
3// Panels of the File Manager: local disk or any open SSH session, one to three side by side.
4// Copies always go through the Transfer Manager; the classic one-panel view keeps the editor and archives.
5(function () {
6 const local = api.files.local;
7 const LOCAL = "local";
8 const $ = (id) => document.getElementById(id);
9 const view = { mode: 2, panels: [], active: null, clipboard: null, places: null, drag: null, refreshTimer: null };
10 try { const saved = Number(localStorage.getItem("chj.fm.panels")); if ([1, 2, 3].includes(saved)) view.mode = saved; } catch {}
12 const isLocal = (panel) => panel.source === LOCAL;
13 const sep = () => view.places?.separator || "/";
14 const session = (id) => state.sessions.find((entry) => entry.sessionId === id);
15 const panelName = (panel) => isLocal(panel) ? t("Lokální disk") : session(panel.source) ? api.sessions.displayName(session(panel.source)) : "—";
16 const isDir = (item) => item.type === "directory" || item.targetType === "directory";
17 const visible = () => view.panels.slice(0, view.mode);
19 function joinFor(panel, name) {
20 if (!isLocal(panel)) return joinPath(panel.path, name);
21 return `${panel.path.replace(/[\\/]+$/, "")}${sep()}${name}`;
22 }
23 function parentFor(panel) {
24 if (!isLocal(panel)) return parentPath(panel.path);
25 const trimmed = panel.path.replace(/[\\/]+$/, "");
26 const cut = trimmed.lastIndexOf(sep());
27 if (cut <= 0) return view.places?.roots?.[0] || sep();
28 const parent = trimmed.slice(0, cut);
29 return /^[A-Za-z]:$/.test(parent) ? `${parent}\\` : parent;
30 }
31 function panelStatus(panel, message, error = false) { panel.statusNode.textContent = message; panel.statusNode.classList.toggle("error", error); }
33 function createPanel(index) {
34 const root = document.createElement("article");
35 root.className = "fs-panel panel";
36 root.innerHTML = `<header class="fs-head"><span class="fs-dot"></span><select class="fs-source" data-i18n-aria-label="Umístění panelu"></select></header>
37 <div class="fs-nav"><button type="button" class="icon-button" data-nav="back" data-i18n-title="Zpět">‹</button><button type="button" class="icon-button" data-nav="forward" data-i18n-title="Vpřed">›</button><button type="button" class="icon-button" data-nav="up" data-i18n-title="O úroveň výše">↑</button><button type="button" class="icon-button" data-nav="refresh" data-i18n-title="Obnovit (F5)">↻</button><form class="fs-path"><input maxlength="4096" spellcheck="false" autocomplete="off" data-i18n-aria-label="Cesta"></form></div>
38 <div class="fs-table"><table class="files-table"><thead><tr><th class="check-col"></th><th data-i18n="Název"></th><th data-i18n="Velikost"></th><th data-i18n="Změněno"></th></tr></thead><tbody></tbody></table></div>
39 <footer class="fs-status"></footer>`;
40 const panel = { index, root, source: "", path: "/", back: [], forward: [], entries: [], selected: new Set(), anchor: null, generation: 0, ended: false, touched: false,
41 select: root.querySelector(".fs-source"), input: root.querySelector(".fs-path input"), body: root.querySelector("tbody"), statusNode: root.querySelector(".fs-status"), dot: root.querySelector(".fs-dot") };
42 panel.select.addEventListener("change", () => { panel.touched = true; panel.ended = false; setSource(panel, panel.select.value); });
43 root.querySelector(".fs-path").addEventListener("submit", (event) => { event.preventDefault(); void open(panel, panel.input.value.trim()); });
44 root.querySelector(".fs-nav").addEventListener("click", (event) => {
45 const action = event.target.closest("[data-nav]")?.dataset.nav;
46 if (action === "up") void open(panel, parentFor(panel));
47 else if (action === "refresh") refresh(panel);
48 else if (action === "back" && panel.back.length) { panel.forward.push(panel.path); void open(panel, panel.back.pop(), false); }
49 else if (action === "forward" && panel.forward.length) { panel.back.push(panel.path); void open(panel, panel.forward.pop(), false); }
50 });
51 root.addEventListener("pointerdown", () => setActive(panel));
52 root.querySelector(".fs-table").addEventListener("contextmenu", (event) => { if (!event.target.closest("tbody")) { event.preventDefault(); setActive(panel); selectPanelPath(panel, null, { clear: true }); showMenu(panel, event.clientX, event.clientY); } });
53 root.addEventListener("focusin", () => setActive(panel));
54 bindFileSelection(panel.body, (path, options) => selectPanelPath(panel, path, options));
55 panel.body.addEventListener("contextmenu", (event) => {
56 event.preventDefault();
57 setActive(panel);
58 const row = event.target.closest("tr[data-path]");
59 if (row) selectPanelPath(panel, row.dataset.path, { context: true });
60 if (!row) selectPanelPath(panel, null, { clear: true });
61 showMenu(panel, event.clientX, event.clientY);
62 });
63 panel.body.addEventListener("dblclick", (event) => {
64 const row = event.target.closest("tr[data-path]");
65 const item = row && panel.entries.find((entry) => entry.path === row.dataset.path);
66 if (!item) return;
67 if (isDir(item)) void open(panel, item.path);
68 else if (!isLocal(panel)) void openInEditor(panel, item.path);
69 });
70 panel.body.addEventListener("dragstart", (event) => {
71 const row = event.target.closest("tr[data-path]");
72 if (!row) return;
73 selectPanelPath(panel, row.dataset.path, { context: true });
74 view.drag = selection(panel);
75 event.dataTransfer.effectAllowed = "copyMove";
76 event.dataTransfer.setData("text/plain", [...panel.selected].join("\n"));
77 });
78 root.addEventListener("dragover", (event) => { if (view.drag && view.drag.panel !== panel && panel.source) { event.preventDefault(); root.classList.add("drop-target"); event.dataTransfer.dropEffect = event.shiftKey ? "move" : "copy"; } });
79 root.addEventListener("dragleave", (event) => { if (!root.contains(event.relatedTarget)) root.classList.remove("drop-target"); });
80 root.addEventListener("drop", (event) => {
81 root.classList.remove("drop-target");
82 if (!view.drag || view.drag.panel === panel) return;
83 event.preventDefault();
84 const drag = view.drag; view.drag = null;
85 void transfer(drag, panel, event.shiftKey);
86 });
87 root.addEventListener("dragend", () => { view.drag = null; for (const item of view.panels) item.root.classList.remove("drop-target"); });
88 return panel;
89 }
91 function selectPanelPath(panel, path, options = {}) {
92 Object.assign(panel, changeFileSelection(panel.selected, panel.anchor, sorted(panel), path, options));
93 showSelection(panel);
94 }
95 function setActive(panel) {
96 if (view.active === panel) return;
97 view.active = panel;
98 for (const item of view.panels) item.root.classList.toggle("active", item === panel);
99 }
100 function sorted(panel) {
101 return [...panel.entries].sort((a, b) => (isDir(b) - isDir(a)) || String(a.name).localeCompare(String(b.name), i18n.locale(), { numeric: true, sensitivity: "base" }));
102 }
104 function render(panel) {
105 panel.input.value = panel.source ? panel.path : "";
106 panel.dot.dataset.kind = !panel.source ? "none" : isLocal(panel) ? "local" : "remote";
107 const fragment = document.createDocumentFragment();
108 for (const item of sorted(panel)) {
109 const row = document.createElement("tr");
110 row.dataset.path = item.path; row.draggable = true; row.classList.toggle("selected", panel.selected.has(item.path));
111 const checkCell = document.createElement("td"); const check = document.createElement("input"); check.type = "checkbox"; check.checked = panel.selected.has(item.path); check.setAttribute("aria-label", item.name); checkCell.append(check);
112 const name = document.createElement("td"); name.className = "file-name"; const icon = document.createElement("span"); icon.className = "file-icon"; icon.textContent = isDir(item) ? "📁" : item.type === "symlink" ? "↗" : "📄"; name.append(icon, document.createTextNode(item.name));
113 const size = document.createElement("td"); size.className = "file-meta"; size.textContent = isDir(item) ? "—" : bytes(item.size);
114 const when = document.createElement("td"); when.className = "file-meta"; when.textContent = item.modifiedAt ? new Date(item.modifiedAt).toLocaleString(i18n.locale()) : "—";
115 row.append(checkCell, name, size, when);
116 fragment.append(row);
117 }
118 if (!panel.entries.length) {
119 const row = document.createElement("tr"); const cell = document.createElement("td"); cell.colSpan = 4; cell.className = "empty";
120 cell.textContent = panel.source ? t("Adresář je prázdný.") : t("Vyberte umístění panelu.");
121 row.append(cell); fragment.append(row);
122 }
123 panel.body.replaceChildren(fragment);
124 const chosen = panel.entries.filter((entry) => panel.selected.has(entry.path));
125 if (panel.source && !panel.statusNode.classList.contains("error")) panelStatus(panel, t("{count} položek · {selected} vybráno", { count: panel.entries.length, selected: chosen.length }));
126 updateActions();
127 }
129 function showSelection(panel) {
130 for (const row of panel.body.querySelectorAll("tr[data-path]")) {
131 const on = panel.selected.has(row.dataset.path);
132 row.classList.toggle("selected", on);
133 row.querySelector("input").checked = on;
134 }
135 if (panel.source && !panel.statusNode.classList.contains("error")) panelStatus(panel, t("{count} položek · {selected} vybráno", { count: panel.entries.length, selected: panel.selected.size }));
136 updateActions();
137 }
139 function hideMenu() { $("panelMenu").hidden = true; }
140 function showMenu(panel, x, y) {
141 const menu = $("panelMenu"), chosen = panel.entries.filter((entry) => panel.selected.has(entry.path)), one = chosen.length === 1 ? chosen[0] : null;
142 const add = (label, action, enabled = true, danger = false) => { const button = document.createElement("button"); button.type = "button"; button.textContent = label; button.disabled = !enabled; if (danger) button.className = "danger-text"; button.addEventListener("click", () => { hideMenu(); action(); }); menu.append(button); };
143 const line = () => { const separator = document.createElement("div"); separator.className = "menu-separator"; menu.append(separator); };
144 menu.replaceChildren();
145 if (one && isDir(one)) add(t("Otevřít"), () => void open(panel, one.path));
146 else if (one && !isLocal(panel)) add(t("Upravit"), () => void openInEditor(panel, one.path));
147 if (one) line();
148 if (chosen.length) {
149 add(t("Kopírovat"), () => toClipboard(false));
150 add(t("Vyjmout"), () => toClipboard(true));
151 }
152 add(t("Vložit"), paste, Boolean(view.clipboard && panel.source));
153 const others = visible().filter((other) => other !== panel && other.source && !(isLocal(other) && isLocal(panel)));
154 if (chosen.length && others.length) {
155 line();
156 for (const other of others) add(`${t("Kopírovat")} → ${panelName(other)}`, () => void transfer(selection(panel), other, false));
157 for (const other of others) add(`${t("Přesun")} → ${panelName(other)}`, () => void transfer(selection(panel), other, true));
158 }
159 // Rsync works on one folder or file, or on the whole folder of the panel.
160 const rsyncable = visible().filter((other) => other !== panel && other.source);
161 if (panel.source && rsyncable.length && (chosen.length <= 1)) {
162 line();
163 for (const other of rsyncable) {
164 add(`${t("Synchronizovat pomocí rsync")} → ${panelName(other)}`, () => rsyncJob(panel, other, "copy", one));
165 add(`${t("Porovnat s jiným panelem")} → ${panelName(other)}`, () => rsyncJob(panel, other, "compare", one));
166 add(`${t("Zálohovat pomocí rsync")} → ${panelName(other)}`, () => rsyncJob(panel, other, "versioned", one));
167 }
168 add(t("Otevřít v Rsync Manageru"), () => void api.rsync.openManager({ spec: rsyncSpec(panel, rsyncable[0], "copy", one) }).catch(() => {}));
169 }
170 line();
171 if (one) add(t("Přejmenovat"), () => void rename());
172 add(t("+ Soubor"), () => void newFile(), Boolean(panel.source));
173 add(t("+ Složka"), () => void mkdir(), Boolean(panel.source));
174 add(t("Obnovit (F5)"), () => refresh(panel), Boolean(panel.source));
175 if (chosen.length) { line(); add(t("Smazat"), () => void remove(), true, true); }
176 menu.hidden = false;
177 const box = menu.getBoundingClientRect();
178 menu.style.left = `${Math.max(4, Math.min(x, innerWidth - box.width - 4))}px`;
179 menu.style.top = `${Math.max(4, Math.min(y, innerHeight - box.height - 4))}px`;
180 menu.querySelector("button:not(:disabled)")?.focus();
181 }
183 function updateActions() {
184 const panel = view.active, chosen = panel ? panel.selected.size : 0;
185 for (const id of ["panelCopy", "panelCut", "panelDelete"]) $(id).disabled = !chosen;
186 $("panelRename").disabled = chosen !== 1;
187 $("panelMkdir").disabled = !panel?.source;
188 $("panelNewFile").disabled = !panel?.source;
189 $("panelPaste").disabled = !view.clipboard || !panel?.source;
190 $("panelClipboard").textContent = view.clipboard ? `${view.clipboard.cut ? t("Vyjmuto") : t("Zkopírováno")}: ${view.clipboard.paths.length}` : "";
191 }
193 // Same rules as withPrivilege in the one-panel view, for the session of this panel: on insufficient
194 // permissions offer sudo, ask again after a rejected password, and show the real error when sudo cannot help.
195 async function remoteAction(source, reason, action, initial = {}, current = () => true) {
196 const check = () => { if (!current()) throw Object.assign(new Error(), { panelChanged: true }); };
197 let options = initial;
198 for (let attempt = 0; attempt < 5; attempt += 1) {
199 check();
200 try { return { value: await action(options), options }; }
201 catch (error) {
202 check();
203 const code = errorCode(error);
204 if (!NEEDS_SUDO.has(code) && code !== "SUDO_PASSWORD_INCORRECT") throw error;
205 if (!options.sudo && state.sudoAccepted.has(source) && code !== "SUDO_PASSWORD_REQUIRED") { options = { ...initial, sudo: true }; continue; }
206 const privilege = await api.files.privileges(source);
207 check();
208 if (privilege.mode === "none" || (privilege.mode === "root" && code !== "SUDO_PASSWORD_INCORRECT")) throw error;
209 const rejected = code === "SUDO_PASSWORD_INCORRECT" || code === "SUDO_PASSWORD_REQUIRED";
210 const answer = await askSudo(reason, {
211 privilege: rejected ? { ...privilege, mode: "sudo-password", sudoCached: false } : privilege,
212 error: code === "SUDO_PASSWORD_INCORRECT" ? t(ERROR_TEXT.SUDO_PASSWORD_INCORRECT) : "", current });
213 check();
214 if (!answer) throw error;
215 if (answer.rememberSudo) state.sudoAccepted.add(source); else state.sudoAccepted.delete(source);
216 options = { ...initial, ...answer };
217 }
218 }
219 throw new Error(t("Sudo autorizace se nezdařila."));
220 }
221 async function listRemote(source, target, current = () => true) {
222 const { value: entries, options } = await remoteAction(source, t("Adresář {path} nelze číst pod účtem SSH. Zobrazit jeho obsah se sudo?", { path: target }),
223 (opts) => api.files.list(source, target, opts), {}, current);
224 return { entries, privileged: options.sudo === true };
225 }
226 async function mutate(item, label, reason, action) {
227 if (item.panel && panelCurrent(item)) panelStatus(item.panel, label);
228 const result = await remoteAction(item.source, reason, action, {}, () => panelCurrent(item));
229 item.options = result.options;
230 return result;
231 }
232 // Asked for by the user, so a folder read with sudo may prompt again; automatic refreshes never do.
233 function refresh(panel) { void (panel.privileged ? open(panel, panel.path, false) : reload(panel, true)); }
234 function markPrivileged(panel) {
235 if (panel.privileged) panelStatus(panel, `${panel.statusNode.textContent} (sudo)`);
236 }
238 async function open(panel, path, remember = true) {
239 if (!panel.source || !path) return;
240 const token = ++panel.generation, source = panel.source;
241 panelStatus(panel, t("Načítám {path}…", { path }));
242 try {
243 const target = isLocal(panel) ? path : normalizePath(path);
244 const { entries, privileged } = isLocal(panel) ? { entries: await local.list(target), privileged: false } : await listRemote(source, target, () => token === panel.generation && source === panel.source);
245 if (token !== panel.generation || source !== panel.source) return;
246 if (remember && target !== panel.path) { panel.back.push(panel.path); panel.forward = []; }
247 panel.path = target; panel.entries = entries; panel.selected = new Set(); panel.anchor = null; panel.privileged = privileged;
248 panel.statusNode.classList.remove("error");
249 render(panel);
250 markPrivileged(panel);
251 } catch (error) {
252 if (token !== panel.generation || source !== panel.source) return;
253 panel.input.value = panel.path;
254 panelStatus(panel, errorText(error), true);
255 }
256 }
257 // options: the authorization of an operation that just succeeded in this panel, used for this one refresh only.
258 async function reload(panel, clearError = false, options) {
259 if (!panel.source) return;
260 if (clearError) panel.statusNode.classList.remove("error");
261 const request = panel.listGeneration = (panel.listGeneration || 0) + 1;
262 const token = panel.generation, source = panel.source, path = panel.path, privileged = panel.privileged, keep = new Set(panel.selected);
263 try {
264 // A refresh never prompts: without a remembered password the current list stays and the status says why.
265 const entries = isLocal(panel) ? await local.list(path) : await api.files.list(source, path, !privileged ? {} : options?.sudo ? options : { sudo: true });
266 if (request !== panel.listGeneration || token !== panel.generation || source !== panel.source) return;
267 panel.entries = entries; panel.selected = new Set(entries.filter((entry) => keep.has(entry.path)).map((entry) => entry.path));
268 render(panel);
269 markPrivileged(panel);
270 } catch (error) {
271 if (request !== panel.listGeneration || token !== panel.generation || source !== panel.source || (privileged && errorCode(error) === "SUDO_PASSWORD_REQUIRED")) return;
272 panelStatus(panel, errorText(error), true);
273 }
274 }
276 function setSource(panel, source) {
277 panel.generation += 1;
278 panel.source = source; panel.entries = []; panel.selected = new Set(); panel.anchor = null; panel.back = []; panel.forward = []; panel.privileged = false;
279 panel.statusNode.classList.remove("error");
280 panel.path = source === LOCAL ? (view.places?.home || "/") : "/";
281 render(panel);
282 if (source) void open(panel, panel.path, false);
283 }
285 function renderSources(panel) {
286 const list = state.sessions;
287 panel.select.replaceChildren(new Option(list.length ? t("Vyberte relaci…") : t("Žádná připojená relace"), ""), new Option(t("Lokální disk"), LOCAL));
288 for (const entry of list) panel.select.add(new Option(api.sessions.displayName(entry), entry.sessionId));
289 panel.select.value = panel.source;
290 }
292 // One open session fills the next free panel. A session that ended never makes its panel jump to another
293 // server; with two sessions and one free panel the user decides.
294 function syncSessions(sessions) {
295 view.sessions = sessions;
296 if (!view.panels.length) return;
297 const ids = new Set(sessions.map((entry) => entry.sessionId));
298 for (const panel of view.panels) {
299 if (panel.source && panel.source !== LOCAL && !ids.has(panel.source)) {
300 panel.ended = true;
301 setSource(panel, "");
302 panelStatus(panel, t("Relace skončila. Vyberte server."), true);
303 }
304 }
305 // T1 before T2: the same order every time.
306 const ordered = [...sessions].sort((a, b) => (a.slot || 99) - (b.slot || 99));
307 const free = () => ordered.filter((entry) => !view.panels.some((panel) => panel.source === entry.sessionId));
308 const empty = visible().filter((panel) => !panel.source && !panel.ended && !panel.touched);
309 if (active() && free().length <= empty.length) for (const panel of empty) { const next = free()[0]; if (next) setSource(panel, next.sessionId); }
310 for (const panel of view.panels) renderSources(panel);
311 if (active() && empty[0] && !empty[0].source && free().length > empty.length) api.sessions.offerChoice(empty[0].select, free(), t("Vyberte relaci…"));
312 updateActions();
313 }
315 function rsyncEndpoint(panel, path) {
316 if (isLocal(panel)) return { kind: "local", path };
317 const session = (view.sessions || []).find((item) => item.sessionId === panel.source);
318 const profileId = session?.profileId && !String(session.profileId).startsWith("adhoc:") ? session.profileId : "";
319 return { kind: "ssh", sessionId: panel.source, profileId, path };
320 }
321 // With a chosen entry the entry itself travels; without one, the content of the folder the panel shows.
322 function rsyncSpec(from, to, mode, one) {
323 const single = Boolean(one);
324 return { mode, contentsOnly: !single, source: rsyncEndpoint(from, single ? one.path : from.path), target: rsyncEndpoint(to, to.path), options: {} };
325 }
326 function rsyncJob(from, to, mode, one) {
327 const spec = rsyncSpec(from, to, mode, one);
328 window.chjRsync.open({ ...spec, sourceLabel: `${panelName(from)}: ${spec.source.path}`, targetLabel: `${panelName(to)}: ${spec.target.path}` }, () => { void reload(to, true); void reload(from, true); });
329 }
331 function selection(panel) { return { panel, paths: [...panel.selected], directory: panel.path, source: panel.source, generation: panel.generation, privileged: panel.privileged === true }; }
332 function panelCurrent(item) { return !item.panel || (item.source === item.panel.source && item.generation === item.panel.generation); }
334 async function transfer(from, to, move) {
335 if (!to.source || !from.source) return;
336 if (from.source === LOCAL && to.source === LOCAL) { panelStatus(to, t("Mezi dvěma lokálními panely se nekopíruje."), true); return; }
337 if (from.source !== LOCAL && !session(from.source)) { panelStatus(to, t("Zdrojová relace už není připojena."), true); return; }
338 from = { ...from, paths: [...from.paths] };
339 const target = selection(to), current = () => panelCurrent(from) && panelCurrent(target);
340 if (!current()) return;
341 const fromName = from.source === LOCAL ? t("Lokální disk") : api.sessions.displayName(session(from.source));
342 try {
343 // Within one connection a move is a single atomic rename.
344 if (move && from.source === target.source) {
345 panelStatus(to, t("Přejmenovávám…"));
346 let done; for (const path of from.paths) done = await remoteAction(from.source, t("Přejmenování {path} vyžaduje oprávnění správce.", { path }),
347 (opts) => api.files.rename(from.source, path, joinPath(target.directory, path.split("/").pop()), opts), {}, current);
348 if (!current()) return;
349 for (const panel of visible()) void reload(panel, false, panel.source === from.source ? done?.options : undefined);
350 if (view.clipboard?.cut) { view.clipboard = null; updateActions(); }
351 return;
352 }
353 panelStatus(to, t("Připravuji přenos…"));
354 const action = (opts) => api.files.transfers.planCopy(
355 from.source === LOCAL ? { kind: "local", paths: from.paths } : { kind: "remote", sessionId: from.source, paths: from.paths },
356 target.source === LOCAL ? { kind: "local", directory: target.directory } : { kind: "remote", sessionId: target.source, directory: target.directory }, opts);
357 const { value: plan, options } = from.source === LOCAL ? { value: await action({}), options: {} } : await remoteAction(from.source,
358 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."),
359 action, from.privileged ? { sudo: true } : {}, current);
360 if (!current()) return;
361 to.statusNode.classList.remove("error");
362 render(to);
363 if (plan.canceled) return;
364 if (!plan.files && !plan.directories) { panelStatus(to, t("Výběr neobsahuje žádné přenositelné soubory.")); return; }
365 plan.title = `${move ? t("Přesun") : t("Kopírování")}: ${fromName} → ${panelName(to)}`;
366 await runPlanned(plan, to, move, from.directory, options, from.source, current);
367 if (view.clipboard?.cut) { view.clipboard = null; updateActions(); }
368 } catch (error) { if (current()) panelStatus(to, errorText(error), true); }
369 }
371 async function runPlanned(plan, target, move, sourceDirectory, options = {}, source = "", currentPanel = () => true) {
372 let current = plan;
373 for (let attempt = 0; attempt < 4; attempt += 1) {
374 if (!currentPanel()) return;
375 const choice = await showPlan(current);
376 if (!currentPanel() || !choice) return;
377 try {
378 const started = await api.files.transfers.start(plan.planId, { ...(plan.direction === "relay" ? {} : options), ...choice, sourceOptions: options, ...(move ? { move: true, sourceDirectory } : {}) });
379 setStatus(`${t("Zařazeno {count} přenosů", { count: started.jobs })}${started.skipped ? `, ${t("přeskočeno {count}", { count: started.skipped })}` : ""}.`);
380 setTransfersCollapsed(false);
381 // Only empty folders: there are no jobs, and so no finished transfer to refresh after.
382 if (!started.jobs) for (const panel of visible()) void reload(panel);
383 return;
384 } catch (error) {
385 if (!currentPanel()) return;
386 const code = errorCode(error);
387 if (code !== "SUDO_PASSWORD_INCORRECT" && code !== "SUDO_PASSWORD_REQUIRED") throw error;
388 if (error.sourceSudo && source) {
389 const privilege = await api.files.privileges(source);
390 if (!currentPanel()) return;
391 const answer = await askSudo(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."), {
392 privilege: { ...privilege, mode: "sudo-password", sudoCached: false },
393 error: code === "SUDO_PASSWORD_INCORRECT" ? t(ERROR_TEXT.SUDO_PASSWORD_INCORRECT) : "", current: currentPanel });
394 if (!currentPanel() || !answer) return;
395 if (answer.rememberSudo) state.sudoAccepted.add(source); else state.sudoAccepted.delete(source);
396 options = { ...options, ...answer };
397 } else current = { ...current, sudoPasswordRequired: true, error: errorText(error) };
398 }
399 }
400 }
402 async function openInEditor(panel, path) {
403 const item = selection(panel), source = item.source;
404 if (state.sessionId !== source) {
405 elements.sessionSelect.value = source;
406 elements.sessionSelect.dispatchEvent(new Event("change", { bubbles: true }));
407 if (state.sessionId !== source) return;
408 for (let wait = 0; state.busy && panelCurrent(item) && state.sessionId === source && wait < 100; wait += 1) await new Promise((done) => setTimeout(done, 50));
409 }
410 if (panelCurrent(item) && state.sessionId === source) await openText(path);
411 }
413 async function mkdir() {
414 const panel = view.active; if (!panel?.source) return;
415 const item = selection(panel), { source, directory } = item;
416 const name = await askName(t("Název nové složky:")); if (!name || !panelCurrent(item)) return;
417 const path = joinPath(directory, name);
418 try { if (source === LOCAL) await local.mkdir(directory, name); else await mutate(item, t("Vytvářím složku…"), t("Vytvoření {path} vyžaduje oprávnění správce.", { path }),
419 (opts) => api.files.mkdir(source, path, opts)); if (panelCurrent(item)) await reload(panel, true, item.options); }
420 catch (error) { if (panelCurrent(item)) panelStatus(panel, errorText(error), true); }
421 }
422 // Any name and extension. On a server the new file opens in the editor right away.
423 async function newFile() {
424 const panel = view.active; if (!panel?.source) return;
425 const item = selection(panel), { source, directory } = item;
426 const name = await askName(t("Název nového souboru:"), t("novy-soubor.txt")); if (!name || !panelCurrent(item)) return;
427 try {
428 if (source === LOCAL) { await local.createFile(directory, name); if (panelCurrent(item)) await reload(panel, true); return; }
429 const path = joinPath(directory, name);
430 await mutate(item, t("Vytvářím soubor…"), t("Vytvoření {path} vyžaduje oprávnění správce.", { path }),
431 (opts) => api.files.createFile(source, path, opts));
432 if (!panelCurrent(item)) return;
433 const refreshed = reload(panel, true, item.options), token = panel.generation;
434 await refreshed;
435 if (source === panel.source && token === panel.generation) await openInEditor(panel, path);
436 } catch (error) { if (panelCurrent(item)) panelStatus(panel, errorText(error), true); }
437 }
438 async function rename() {
439 const panel = view.active; if (panel?.selected.size !== 1) return;
440 const item = selection(panel), { source, directory } = item, path = item.paths[0], entry = panel.entries.find((entry) => entry.path === path);
441 const name = await askName(t("Nový název:"), entry?.name); if (!name || name === entry?.name || !panelCurrent(item)) return;
442 const target = joinPath(directory, name);
443 try { if (source === LOCAL) await local.rename(path, name); else await mutate(item, t("Přejmenovávám…"), t("Přejmenování {path} vyžaduje oprávnění správce.", { path }),
444 (opts) => api.files.rename(source, path, target, opts)); if (panelCurrent(item)) await reload(panel, true, item.options); }
445 catch (error) { if (panelCurrent(item)) panelStatus(panel, errorText(error), true); }
446 }
447 async function remove() {
448 const panel = view.active; if (!panel?.selected.size) return;
449 const item = selection(panel), { source, paths } = item;
450 if (!window.confirm(`${panelName(panel)}\n\n${t("Opravdu trvale smazat {count} vybraných položek včetně obsahu vybraných složek?", { count: paths.length })}`)) return;
451 let directAttempt = false;
452 try { if (source === LOCAL) await local.remove(paths); else await mutate(item, t("Mažu vybrané položky…"), t("Smazání vybraných položek vyžaduje oprávnění správce."), async (opts) => {
453 if (!opts.sudo) { directAttempt = true; return api.files.removeMany(source, paths, true, opts); }
454 if (!directAttempt) return api.files.removeMany(source, paths, true, opts);
455 // The direct batch can have removed a prefix. Only the confirmed paths may
456 // be retried, and only their absence is success; all other failures propagate.
457 // The selection was confirmed and captured, so leaving the folder must not leave it half deleted.
458 for (const path of paths) {
459 try { await api.files.removeMany(source, [path], true, opts); }
460 catch (error) { if (errorCode(error) !== "FILE_NOT_FOUND") throw error; }
461 }
462 }); if (panelCurrent(item)) await reload(panel, true, item.options); }
463 catch (error) { if (panelCurrent(item)) panelStatus(panel, errorText(error), true); }
464 }
465 function toClipboard(cut) { const panel = view.active; if (!panel?.selected.size) return; view.clipboard = { ...selection(panel), panel: null, cut }; updateActions(); }
466 function paste() { const panel = view.active; if (!panel?.source || !view.clipboard) return; void transfer(view.clipboard, panel, view.clipboard.cut); }
468 function active() { return view.mode > 1; }
469 function applyMode() {
470 document.querySelector("main").classList.toggle("panels-mode", active());
471 document.body.classList.toggle("panels-active", active());
472 $("panelsView").hidden = !active();
473 view.panels.forEach((panel, index) => { panel.root.hidden = index >= view.mode; });
474 document.querySelectorAll("#panelMode button").forEach((button) => { const on = Number(button.dataset.panels) === view.mode; button.classList.toggle("active", on); button.setAttribute("aria-checked", String(on)); });
475 $("panelGrid").style.setProperty("--panels", String(view.mode));
476 if (active()) { if (!view.active || view.panels.indexOf(view.active) >= view.mode) setActive(view.panels[0]); syncSessions(state.sessions); }
477 queueTransferRender();
478 }
480 // Refreshes open folders after finished transfers, at most every 1.5 s.
481 function transfersFinished() {
482 if (view.refreshTimer || !active()) return;
483 view.refreshTimer = setTimeout(() => { view.refreshTimer = null; for (const panel of visible()) void reload(panel); }, 1500);
484 }
486 document.addEventListener("pointerdown", (event) => { if (!event.target.closest("#panelMenu")) hideMenu(); });
487 window.addEventListener("blur", hideMenu);
488 $("panelMode").addEventListener("click", (event) => {
489 const mode = Number(event.target.closest("[data-panels]")?.dataset.panels);
490 if (!mode) return;
491 view.mode = mode;
492 try { localStorage.setItem("chj.fm.panels", String(mode)); } catch {}
493 applyMode();
494 });
495 $("panelCopy").addEventListener("click", () => toClipboard(false));
496 $("panelCut").addEventListener("click", () => toClipboard(true));
497 $("panelPaste").addEventListener("click", paste);
498 $("panelRename").addEventListener("click", () => void rename());
499 $("panelDelete").addEventListener("click", () => void remove());
500 $("panelMkdir").addEventListener("click", () => void mkdir());
501 $("panelNewFile").addEventListener("click", () => void newFile());
502 window.addEventListener("keydown", (event) => {
503 if (!active() || !elements.editor.hidden || document.querySelector("dialog[open], #rsyncDialog:not([hidden])") || event.target.closest("input, select, textarea, [contenteditable]")) return;
504 if (event.key === "Escape") {
505 if (!$("panelMenu").hidden) hideMenu();
506 else if (!elements.contextMenu.hidden) hideContextMenu();
507 else if (view.active) selectPanelPath(view.active, null, { clear: true });
508 event.preventDefault(); event.stopPropagation(); return;
509 }
510 if (!$("panelMenu").hidden || !elements.contextMenu.hidden) return;
511 const mod = fileSelectionModifier(event), key = event.key.toLowerCase();
512 if (mod && key === "c") toClipboard(false);
513 else if (mod && key === "x") toClipboard(true);
514 else if (mod && key === "v") paste();
515 else if (mod && key === "a" && view.active) { selectPanelPath(view.active, null, { all: true }); }
516 else if (event.key === "F2") void rename();
517 else if (event.key === "F5" && view.active) refresh(view.active);
518 else if (event.key === "Delete" || (event.key === "Backspace" && event.metaKey)) void remove();
519 else return;
520 event.preventDefault(); event.stopPropagation();
521 }, true);
522 // This handler can run before the one in index.js, so the language is set here as well.
523 api.ui.onLanguageChanged((language) => { i18n.setLanguage(language); for (const panel of view.panels) { renderSources(panel); render(panel); } i18n.apply(document); });
525 window.chjPanels = { active, syncSessions, transfersFinished, view };
526 void (async () => {
527 try { view.places = await local.places(); } catch {}
528 for (let index = 0; index < 3; index += 1) { const panel = createPanel(index); view.panels.push(panel); $("panelGrid").append(panel.root); }
529 view.panels[0].touched = true;
530 setSource(view.panels[0], LOCAL);
531 for (const panel of view.panels) renderSources(panel);
532 i18n.apply(document);
533 applyMode();
534 })();
535})();
SHA-256a736c6e11e08dac40c9ae9b8f3d336478a256f4b116d658440564993b5516526

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