Add Phase 3 remote read-only file operations

Introduce dedicated remote file facade for /Clients paths, add agent read/download endpoints, enable remote view/properties/download/image preview in the web UI, and keep remote write operations disabled.
This commit is contained in:
kodi
2026-03-27 15:16:01 +01:00
parent 2fa4a0b291
commit 9778dc6c33
10 changed files with 1011 additions and 29 deletions
+63 -18
View File
@@ -459,6 +459,23 @@ function isOpenableSelection(item) {
return isImageSelection(item) || isVideoSelection(item);
}
function isTextPreviewSelection(item) {
if (!item || item.kind !== "file") {
return false;
}
const lower = (item.name || "").toLowerCase();
if (lower === "dockerfile" || lower === "containerfile") {
return true;
}
return [".txt", ".log", ".ini", ".cfg", ".conf", ".md", ".yml", ".yaml", ".json", ".js", ".py", ".css", ".html"].some((suffix) =>
lower.endsWith(suffix)
);
}
function isRemoteViewableSelection(item) {
return isImageSelection(item) || isTextPreviewSelection(item);
}
function isZipDownloadSelection(items) {
return items.length > 1 || (items.length === 1 && items[0].kind === "directory");
}
@@ -778,16 +795,17 @@ function openContextMenu(pane, entry, event) {
contextMenuState.anchorPath = entry.path;
const isMulti = items.length > 1;
const openableSingle = items.length === 1 && (!remoteSelection || items[0].kind === "directory") && isOpenableSelection(items[0]);
const openableSingle =
items.length === 1 && (remoteSelection ? items[0].kind === "directory" || isRemoteViewableSelection(items[0]) : isOpenableSelection(items[0]));
const editableSingle = items.length === 1 && !remoteSelection && isEditableSelection(items[0]);
const downloadableSelection = items.length > 0 && !remoteSelection;
const downloadableSelection = items.length === 1 && items[0].kind === "file";
elements.scope.textContent = isMulti ? "Multi-selection" : "Single item";
elements.target.textContent = isMulti ? `${items.length} selected items` : entry.name;
elements.openButton.classList.toggle("hidden", isMulti);
elements.openButton.disabled = !openableSingle;
elements.editButton.classList.toggle("hidden", isMulti || items.length !== 1 || items[0].kind !== "file" || remoteSelection);
elements.editButton.disabled = !editableSingle;
elements.downloadButton.classList.toggle("hidden", remoteSelection);
elements.downloadButton.classList.remove("hidden");
elements.downloadButton.disabled = !downloadableSelection;
elements.renameButton.classList.toggle("hidden", isMulti || remoteSelection);
elements.duplicateButton.classList.remove("hidden");
@@ -798,8 +816,8 @@ function openContextMenu(pane, entry, event) {
elements.moveButton.disabled = remoteSelection || items.length === 0;
elements.deleteButton.classList.remove("hidden");
elements.deleteButton.disabled = remoteSelection || items.length === 0;
elements.propertiesButton.classList.toggle("hidden", remoteSelection);
elements.propertiesButton.disabled = remoteSelection || items.length === 0;
elements.propertiesButton.classList.remove("hidden");
elements.propertiesButton.disabled = items.length === 0;
const menuWidth = 220;
const menuHeight = 120;
@@ -960,17 +978,23 @@ async function startDownloadSelected() {
setStatus(`Download started: ${task.destination}`);
return;
}
const { blob, fileName } = await downloadFileRequest(selectedPaths);
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName || selected.name;
document.body.append(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
markSingleFileDownloadRequested(anchor.download, selected.path);
setStatus(`Download requested: ${anchor.download}`);
let fileName = selected.name;
if (isRemoteBrowsePath(selected.path)) {
fileName = startDirectSingleFileDownload(selected.path, selected.name).fileName || selected.name;
} else {
const response = await downloadFileRequest(selectedPaths);
const url = URL.createObjectURL(response.blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = response.fileName || selected.name;
document.body.append(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
fileName = anchor.download || selected.name;
}
markSingleFileDownloadRequested(fileName, selected.path);
setStatus(`Download requested: ${fileName}`);
} catch (err) {
if (zipDownload) {
if (err.code === "download_cancelled") {
@@ -1279,6 +1303,18 @@ async function downloadFileRequest(paths) {
};
}
function startDirectSingleFileDownload(path, fallbackName) {
const anchor = document.createElement("a");
anchor.href = `/api/files/download?${new URLSearchParams({ path }).toString()}`;
anchor.download = fallbackName || "";
document.body.append(anchor);
anchor.click();
anchor.remove();
return {
fileName: anchor.download || fallbackName || null,
};
}
async function createArchiveDownloadTask(paths) {
return apiRequest("POST", "/api/files/download/archive-prepare", { paths });
}
@@ -2108,7 +2144,8 @@ function updateActionButtons() {
const exactlyOne = count === 1;
const allFiles = hasSelection && selectedItems.every((item) => item.kind === "file");
const remoteBrowse = isRemoteBrowsePath(activePaneState().currentPath);
document.getElementById("view-btn").disabled = remoteBrowse || !exactlyOne || !allFiles;
const remoteViewable = exactlyOne && isRemoteViewableSelection(selectedItems[0] || null);
document.getElementById("view-btn").disabled = remoteBrowse ? !remoteViewable : !exactlyOne || !allFiles;
document.getElementById("edit-btn").disabled = remoteBrowse || !exactlyOne || !allFiles || !isEditableSelection(selectedItems[0] || null);
document.getElementById("rename-btn").disabled = remoteBrowse || !exactlyOne;
document.getElementById("delete-btn").disabled = remoteBrowse || !hasSelection;
@@ -4691,6 +4728,14 @@ function openViewer() {
return;
}
const selected = selectedItems[0];
if (isRemoteBrowsePath(selected.path)) {
if (isImageSelection(selected)) {
openImageViewer();
return;
}
openTextViewer();
return;
}
if (isImageSelection(selected)) {
openImageViewer();
return;
@@ -4792,7 +4837,7 @@ function openCurrentDirectory() {
openImageViewer();
return;
}
if (isVideoSelection(item)) {
if (!isRemoteBrowsePath(item.path) && isVideoSelection(item)) {
openVideoViewer();
}
}