2026-03-05 00:04:26 -06:00
|
|
|
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
|
|
|
|
|
|
|
|
|
const clientId = window.location.pathname.split("/")[1];
|
|
|
|
|
let ws = null;
|
|
|
|
|
let currentPath = "";
|
|
|
|
|
let pathHistory = [];
|
|
|
|
|
let selectedFiles = new Set();
|
|
|
|
|
let fileDownloads = new Map();
|
|
|
|
|
let fileUploads = new Map();
|
|
|
|
|
let fileUploadsById = new Map();
|
|
|
|
|
let activeTransfers = new Map();
|
|
|
|
|
let currentEditingFile = null;
|
|
|
|
|
let lastSuccessfulResponse = 0;
|
|
|
|
|
let pendingToast = null;
|
|
|
|
|
const recentToasts = new Map();
|
|
|
|
|
const pendingCommandResults = new Map();
|
2026-03-08 00:53:22 -06:00
|
|
|
const pendingCommandWaiters = new Map();
|
2026-03-06 11:28:35 -06:00
|
|
|
const VIRTUALIZATION_THRESHOLD = 400;
|
|
|
|
|
const VIRTUAL_ROW_HEIGHT = 58;
|
|
|
|
|
const VIRTUAL_OVERSCAN = 8;
|
|
|
|
|
|
|
|
|
|
let directoryEntries = [];
|
|
|
|
|
let filteredDirectoryEntries = [];
|
|
|
|
|
let virtualScrollHandler = null;
|
|
|
|
|
let virtualResizeHandler = null;
|
|
|
|
|
let virtualRenderRaf = null;
|
|
|
|
|
let isVirtualizedList = false;
|
2026-03-05 00:04:26 -06:00
|
|
|
|
|
|
|
|
const statusEl = document.getElementById("status-indicator");
|
|
|
|
|
const breadcrumbEl = document.getElementById("breadcrumb");
|
|
|
|
|
const fileListEl = document.getElementById("file-list");
|
|
|
|
|
const refreshBtn = document.getElementById("refresh-btn");
|
|
|
|
|
const uploadBtn = document.getElementById("upload-btn");
|
|
|
|
|
const mkdirBtn = document.getElementById("mkdir-btn");
|
|
|
|
|
const searchBtn = document.getElementById("search-btn");
|
|
|
|
|
const fileInput = document.getElementById("file-input");
|
|
|
|
|
const contextMenu = document.getElementById("context-menu");
|
|
|
|
|
const clientIdHeader = document.getElementById("client-id-header");
|
|
|
|
|
const backBtn = document.getElementById("back-btn");
|
|
|
|
|
const homeBtn = document.getElementById("home-btn");
|
|
|
|
|
const pathInput = document.getElementById("path-input");
|
|
|
|
|
const pathGoBtn = document.getElementById("path-go-btn");
|
|
|
|
|
const transferPanel = document.getElementById("transfer-panel");
|
|
|
|
|
const transferList = document.getElementById("transfer-list");
|
2026-03-06 11:28:35 -06:00
|
|
|
const fileListPanel = document.getElementById("file-list-panel");
|
|
|
|
|
const sortFieldEl = document.getElementById("sort-field");
|
|
|
|
|
const sortOrderBtn = document.getElementById("sort-order-btn");
|
|
|
|
|
const filterTypeEl = document.getElementById("filter-type");
|
|
|
|
|
const fileCountSummaryEl = document.getElementById("file-count-summary");
|
2026-03-05 00:04:26 -06:00
|
|
|
|
|
|
|
|
const searchBar = document.getElementById("search-bar");
|
|
|
|
|
const searchInput = document.getElementById("search-input");
|
|
|
|
|
const searchContentCheckbox = document.getElementById(
|
|
|
|
|
"search-content-checkbox",
|
|
|
|
|
);
|
|
|
|
|
const searchExecuteBtn = document.getElementById("search-execute-btn");
|
|
|
|
|
const searchCloseBtn = document.getElementById("search-close-btn");
|
|
|
|
|
const bulkActionsBar = document.getElementById("bulk-actions-bar");
|
|
|
|
|
const selectedCountEl = document.getElementById("selected-count");
|
|
|
|
|
const bulkDownloadBtn = document.getElementById("bulk-download-btn");
|
|
|
|
|
const bulkDeleteBtn = document.getElementById("bulk-delete-btn");
|
|
|
|
|
const bulkMoveBtn = document.getElementById("bulk-move-btn");
|
|
|
|
|
const bulkCopyBtn = document.getElementById("bulk-copy-btn");
|
|
|
|
|
const clearSelectionBtn = document.getElementById("clear-selection-btn");
|
|
|
|
|
const fileEditorModal = document.getElementById("file-editor-modal");
|
|
|
|
|
const editorTextarea = document.getElementById("editor-textarea");
|
|
|
|
|
const editorFileName = document.getElementById("editor-file-name");
|
|
|
|
|
const editorStatus = document.getElementById("editor-status");
|
|
|
|
|
const editorSaveBtn = document.getElementById("editor-save-btn");
|
|
|
|
|
const editorCancelBtn = document.getElementById("editor-cancel-btn");
|
|
|
|
|
const editorCloseBtn = document.getElementById("editor-close-btn");
|
|
|
|
|
|
|
|
|
|
if (clientIdHeader) {
|
|
|
|
|
clientIdHeader.textContent = `${clientId} - File Browser`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 11:28:35 -06:00
|
|
|
let sortField = localStorage.getItem("filebrowser.sortField") || "name";
|
|
|
|
|
let sortOrder = localStorage.getItem("filebrowser.sortOrder") || "asc";
|
|
|
|
|
let filterType = localStorage.getItem("filebrowser.filterType") || "all";
|
|
|
|
|
let dragDepth = 0;
|
|
|
|
|
|
2026-03-05 00:04:26 -06:00
|
|
|
function connect() {
|
|
|
|
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
|
|
|
const wsUrl = `${protocol}//${window.location.host}/api/clients/${clientId}/files/ws`;
|
|
|
|
|
|
|
|
|
|
const socket = new WebSocket(wsUrl);
|
|
|
|
|
socket.binaryType = "arraybuffer";
|
|
|
|
|
ws = socket;
|
|
|
|
|
|
|
|
|
|
socket.onopen = () => {
|
|
|
|
|
console.log("File browser connected");
|
|
|
|
|
updateStatus("connected", "Connected");
|
|
|
|
|
enableControls(true);
|
|
|
|
|
listFiles(currentPath || ".", socket);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
socket.onmessage = (event) => {
|
|
|
|
|
const msg = decodeMsgpack(event.data);
|
|
|
|
|
if (!msg) {
|
|
|
|
|
console.error("Failed to decode message");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
handleMessage(msg);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
socket.onerror = (err) => {
|
|
|
|
|
console.error("WebSocket error:", err);
|
|
|
|
|
updateStatus("error", "Connection Error");
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
socket.onclose = () => {
|
|
|
|
|
console.log("File browser disconnected");
|
|
|
|
|
updateStatus("disconnected", "Disconnected");
|
|
|
|
|
enableControls(false);
|
|
|
|
|
if (ws === socket) {
|
|
|
|
|
setTimeout(() => connect(), 3000);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateStatus(state, text) {
|
|
|
|
|
const icons = {
|
|
|
|
|
connecting: '<i class="fa-solid fa-circle-notch fa-spin"></i>',
|
|
|
|
|
connected: '<i class="fa-solid fa-circle text-green-400"></i>',
|
|
|
|
|
error: '<i class="fa-solid fa-circle-exclamation text-red-400"></i>',
|
|
|
|
|
disconnected: '<i class="fa-solid fa-circle text-slate-500"></i>',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
statusEl.innerHTML = `${icons[state] || icons.disconnected} ${text}`;
|
|
|
|
|
statusEl.className =
|
|
|
|
|
state === "connected"
|
|
|
|
|
? "inline-flex items-center gap-2 px-3 py-2 rounded-full bg-green-900/40 text-green-100 border border-green-700/60"
|
|
|
|
|
: "inline-flex items-center gap-2 px-3 py-2 rounded-full bg-slate-800 text-slate-300";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function enableControls(enabled) {
|
|
|
|
|
refreshBtn.disabled = !enabled;
|
|
|
|
|
uploadBtn.disabled = !enabled;
|
|
|
|
|
mkdirBtn.disabled = !enabled;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function send(msg, socket = ws) {
|
|
|
|
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
|
|
|
|
console.log(
|
|
|
|
|
"[DEBUG] Sending message:",
|
|
|
|
|
msg.type,
|
|
|
|
|
msg.commandType || "",
|
|
|
|
|
"to server",
|
|
|
|
|
);
|
|
|
|
|
socket.send(encodeMsgpack(msg));
|
|
|
|
|
} else {
|
|
|
|
|
console.error(
|
|
|
|
|
"[DEBUG] Cannot send - WebSocket not open. State:",
|
|
|
|
|
socket?.readyState,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function notifyToast(message, type = "info", duration = 4000) {
|
|
|
|
|
if (typeof window.showToast !== "function") return;
|
|
|
|
|
const key = `${type}:${message}`;
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
const last = recentToasts.get(key) || 0;
|
|
|
|
|
if (now - last < 1000) return;
|
|
|
|
|
recentToasts.set(key, now);
|
|
|
|
|
|
|
|
|
|
if (document.visibilityState === "hidden") {
|
|
|
|
|
if (!pendingToast) {
|
|
|
|
|
pendingToast = { message, type, duration, count: 1 };
|
|
|
|
|
} else {
|
|
|
|
|
pendingToast.message = message;
|
|
|
|
|
pendingToast.type = type;
|
|
|
|
|
pendingToast.duration = duration;
|
|
|
|
|
pendingToast.count += 1;
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
window.showToast(message, type, duration);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function trackCommandResult(commandId, options = {}) {
|
|
|
|
|
if (!commandId) return;
|
|
|
|
|
const {
|
|
|
|
|
refreshOnSuccess = false,
|
|
|
|
|
successMessage = null,
|
|
|
|
|
errorPrefix = "Operation failed",
|
|
|
|
|
} = options;
|
|
|
|
|
pendingCommandResults.set(commandId, {
|
|
|
|
|
refreshOnSuccess,
|
|
|
|
|
successMessage,
|
|
|
|
|
errorPrefix,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 00:53:22 -06:00
|
|
|
function waitForCommandResult(commandId, timeoutMs = 10 * 60 * 1000) {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
if (!commandId) {
|
|
|
|
|
reject(new Error("missing command id"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const existing = pendingCommandWaiters.get(commandId);
|
|
|
|
|
if (existing) {
|
|
|
|
|
clearTimeout(existing.timeoutId);
|
|
|
|
|
existing.reject(new Error("superseded command waiter"));
|
|
|
|
|
}
|
|
|
|
|
const timeoutId = setTimeout(() => {
|
|
|
|
|
pendingCommandWaiters.delete(commandId);
|
|
|
|
|
reject(new Error("command timed out"));
|
|
|
|
|
}, timeoutMs);
|
|
|
|
|
pendingCommandWaiters.set(commandId, { resolve, reject, timeoutId });
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 00:04:26 -06:00
|
|
|
document.addEventListener("visibilitychange", () => {
|
|
|
|
|
if (document.visibilityState !== "visible") return;
|
|
|
|
|
if (!pendingToast || typeof window.showToast !== "function") return;
|
|
|
|
|
const { message, type, duration, count } = pendingToast;
|
|
|
|
|
pendingToast = null;
|
|
|
|
|
const summary = count > 1 ? `${message} (+${count - 1} more)` : message;
|
|
|
|
|
window.showToast(summary, type, duration);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function handleMessage(msg) {
|
|
|
|
|
console.log("[DEBUG] Received message:", msg.type, msg);
|
|
|
|
|
|
|
|
|
|
switch (msg.type) {
|
|
|
|
|
case "ready":
|
|
|
|
|
console.log("Session ready:", msg.sessionId);
|
|
|
|
|
break;
|
|
|
|
|
case "status":
|
|
|
|
|
console.log("[DEBUG] Status message:", msg);
|
|
|
|
|
if (msg.status === "offline") {
|
|
|
|
|
const recentlyActive = Date.now() - lastSuccessfulResponse < 10_000;
|
|
|
|
|
if (!recentlyActive) {
|
|
|
|
|
updateStatus("error", "Client Offline");
|
|
|
|
|
enableControls(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
case "file_list_result":
|
|
|
|
|
handleFileList(msg);
|
|
|
|
|
break;
|
|
|
|
|
case "file_download":
|
|
|
|
|
handleFileDownload(msg);
|
|
|
|
|
break;
|
|
|
|
|
case "file_upload_result":
|
|
|
|
|
handleFileUploadResult(msg);
|
|
|
|
|
break;
|
|
|
|
|
case "file_read_result":
|
|
|
|
|
console.log("[DEBUG] Routing to handleFileReadResult");
|
|
|
|
|
handleFileReadResult(msg);
|
|
|
|
|
break;
|
|
|
|
|
case "file_search_result":
|
|
|
|
|
handleFileSearchResult(msg);
|
|
|
|
|
break;
|
|
|
|
|
case "command_result":
|
|
|
|
|
console.log("[DEBUG] Command result:", msg);
|
|
|
|
|
handleCommandResult(msg);
|
|
|
|
|
break;
|
|
|
|
|
case "command_progress":
|
|
|
|
|
console.log("[DEBUG] Command progress:", msg);
|
|
|
|
|
handleCommandProgress(msg);
|
|
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
console.log("[DEBUG] Unknown message type:", msg.type, msg);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function listFiles(path, socket = ws) {
|
|
|
|
|
if (currentPath && currentPath !== path) {
|
|
|
|
|
pathHistory.push(currentPath);
|
|
|
|
|
}
|
|
|
|
|
currentPath = path;
|
|
|
|
|
send({ type: "file_list", path }, socket);
|
|
|
|
|
updateBreadcrumb(path);
|
|
|
|
|
updatePathInput(path);
|
|
|
|
|
updateBackButton();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updatePathInput(path) {
|
|
|
|
|
pathInput.value = path || ".";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateBackButton() {
|
|
|
|
|
backBtn.disabled = pathHistory.length === 0;
|
|
|
|
|
backBtn.classList.toggle("opacity-50", pathHistory.length === 0);
|
|
|
|
|
backBtn.classList.toggle("cursor-not-allowed", pathHistory.length === 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function goBack() {
|
|
|
|
|
if (pathHistory.length > 0) {
|
|
|
|
|
const previousPath = pathHistory.pop();
|
|
|
|
|
currentPath = previousPath;
|
|
|
|
|
send({ type: "file_list", path: previousPath });
|
|
|
|
|
updateBreadcrumb(previousPath);
|
|
|
|
|
updatePathInput(previousPath);
|
|
|
|
|
updateBackButton();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function goHome() {
|
|
|
|
|
pathHistory = [];
|
|
|
|
|
listFiles(".");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateBreadcrumb(path) {
|
|
|
|
|
const parts = path.split(/[\/\\]/).filter((p) => p && p !== ".");
|
|
|
|
|
breadcrumbEl.innerHTML = "";
|
|
|
|
|
|
|
|
|
|
const root = document.createElement("span");
|
|
|
|
|
root.className = "breadcrumb-item hover:text-blue-400 transition-colors";
|
|
|
|
|
root.innerHTML =
|
|
|
|
|
'<i class="fa-solid fa-hard-drive"></i> <span class="text-xs">Drives</span>';
|
|
|
|
|
root.onclick = () => listFiles(".");
|
|
|
|
|
breadcrumbEl.appendChild(root);
|
|
|
|
|
|
|
|
|
|
if (!path || path === ".") {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let accumulated = "";
|
|
|
|
|
parts.forEach((part, idx) => {
|
|
|
|
|
accumulated += (accumulated ? "/" : "") + part;
|
|
|
|
|
const pathSegment = accumulated;
|
|
|
|
|
|
|
|
|
|
const separator = document.createElement("span");
|
|
|
|
|
separator.className = "text-slate-600 mx-1";
|
|
|
|
|
separator.innerHTML = '<i class="fa-solid fa-chevron-right text-xs"></i>';
|
|
|
|
|
breadcrumbEl.appendChild(separator);
|
|
|
|
|
|
|
|
|
|
const crumb = document.createElement("span");
|
|
|
|
|
crumb.className = "breadcrumb-item hover:text-blue-400 transition-colors";
|
|
|
|
|
crumb.textContent = part;
|
|
|
|
|
crumb.onclick = () => listFiles(pathSegment);
|
|
|
|
|
breadcrumbEl.appendChild(crumb);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 11:28:35 -06:00
|
|
|
function getFileExt(name = "") {
|
|
|
|
|
const idx = name.lastIndexOf(".");
|
|
|
|
|
if (idx < 0 || idx === name.length - 1) return "";
|
|
|
|
|
return name.slice(idx + 1).toLowerCase();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function entryMatchesFilter(entry, mode) {
|
|
|
|
|
if (mode === "all") return true;
|
|
|
|
|
if (mode === "dirs") return !!entry.isDir;
|
|
|
|
|
if (mode === "files") return !entry.isDir;
|
|
|
|
|
if (entry.isDir) return false;
|
|
|
|
|
|
|
|
|
|
const ext = getFileExt(entry.name);
|
|
|
|
|
const imageExt = new Set(["jpg", "jpeg", "png", "gif", "webp", "bmp", "svg", "ico"]);
|
|
|
|
|
const docExt = new Set(["txt", "md", "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "csv", "json", "xml", "yaml", "yml"]);
|
|
|
|
|
const archiveExt = new Set(["zip", "rar", "7z", "tar", "gz", "bz2", "xz"]);
|
|
|
|
|
const execExt = new Set(["exe", "msi", "bat", "cmd", "ps1", "sh", "appimage", "bin", "com"]);
|
|
|
|
|
|
|
|
|
|
if (mode === "images") return imageExt.has(ext);
|
|
|
|
|
if (mode === "docs") return docExt.has(ext);
|
|
|
|
|
if (mode === "archives") return archiveExt.has(ext);
|
|
|
|
|
if (mode === "executables") return execExt.has(ext);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sortEntries(entries, field, order) {
|
|
|
|
|
const dirRank = (entry) => (entry.isDir ? 0 : 1);
|
|
|
|
|
const factor = order === "desc" ? -1 : 1;
|
|
|
|
|
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
|
|
|
|
|
|
|
|
|
return [...entries].sort((a, b) => {
|
|
|
|
|
const dirDiff = dirRank(a) - dirRank(b);
|
|
|
|
|
if (dirDiff !== 0) return dirDiff;
|
|
|
|
|
|
|
|
|
|
let valueA = a.name || "";
|
|
|
|
|
let valueB = b.name || "";
|
|
|
|
|
|
|
|
|
|
if (field === "size") {
|
|
|
|
|
valueA = Number(a.size || 0);
|
|
|
|
|
valueB = Number(b.size || 0);
|
|
|
|
|
} else if (field === "modified") {
|
|
|
|
|
valueA = Number(a.modTime || 0);
|
|
|
|
|
valueB = Number(b.modTime || 0);
|
|
|
|
|
} else if (field === "type") {
|
|
|
|
|
valueA = a.isDir ? "" : getFileExt(a.name);
|
|
|
|
|
valueB = b.isDir ? "" : getFileExt(b.name);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (typeof valueA === "number" && typeof valueB === "number") {
|
|
|
|
|
if (valueA === valueB) {
|
|
|
|
|
return factor * collator.compare(a.name || "", b.name || "");
|
|
|
|
|
}
|
|
|
|
|
return factor * (valueA - valueB);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const diff = collator.compare(String(valueA), String(valueB));
|
|
|
|
|
if (diff !== 0) return factor * diff;
|
|
|
|
|
return factor * collator.compare(a.name || "", b.name || "");
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applySortAndFilterEntries(entries) {
|
|
|
|
|
const filtered = entries.filter((entry) => entryMatchesFilter(entry, filterType));
|
|
|
|
|
return sortEntries(filtered, sortField, sortOrder);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateSortOrderButton() {
|
|
|
|
|
if (!sortOrderBtn) return;
|
|
|
|
|
sortOrderBtn.textContent = sortOrder === "asc" ? "Asc" : "Desc";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateDirectorySummaryAndPaging(totalCount, shownCount) {
|
|
|
|
|
if (fileCountSummaryEl) {
|
|
|
|
|
fileCountSummaryEl.textContent = `${totalCount} items`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clearVirtualizedListMode() {
|
|
|
|
|
if (virtualRenderRaf) {
|
|
|
|
|
cancelAnimationFrame(virtualRenderRaf);
|
|
|
|
|
virtualRenderRaf = null;
|
|
|
|
|
}
|
|
|
|
|
if (virtualScrollHandler) {
|
|
|
|
|
window.removeEventListener("scroll", virtualScrollHandler);
|
|
|
|
|
virtualScrollHandler = null;
|
|
|
|
|
}
|
|
|
|
|
if (virtualResizeHandler) {
|
|
|
|
|
window.removeEventListener("resize", virtualResizeHandler);
|
|
|
|
|
virtualResizeHandler = null;
|
|
|
|
|
}
|
|
|
|
|
isVirtualizedList = false;
|
|
|
|
|
fileListEl.classList.add("divide-y", "divide-slate-800");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderDirectoryStandard(entries, canGoUp, parentPath, disableAnimations) {
|
|
|
|
|
clearVirtualizedListMode();
|
|
|
|
|
|
|
|
|
|
if (!disableAnimations) {
|
|
|
|
|
fileListEl.style.opacity = "0";
|
|
|
|
|
fileListEl.style.transform = "translateX(20px)";
|
|
|
|
|
} else {
|
|
|
|
|
fileListEl.style.transition = "none";
|
|
|
|
|
fileListEl.style.opacity = "1";
|
|
|
|
|
fileListEl.style.transform = "translateX(0)";
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
|
|
|
|
|
2026-03-06 11:28:35 -06:00
|
|
|
const renderList = () => {
|
2026-03-05 00:04:26 -06:00
|
|
|
fileListEl.innerHTML = "";
|
|
|
|
|
|
|
|
|
|
if (canGoUp) {
|
2026-03-06 11:28:35 -06:00
|
|
|
fileListEl.appendChild(createParentRow(parentPath));
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (entries.length === 0 && !canGoUp) {
|
|
|
|
|
fileListEl.innerHTML =
|
|
|
|
|
'<div class="px-4 py-6 text-center text-slate-400"><i class="fa-solid fa-folder-open mr-2"></i>Empty directory</div>';
|
|
|
|
|
fileListEl.style.opacity = "1";
|
|
|
|
|
fileListEl.style.transform = "translateX(0)";
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
entries.forEach((entry, index) => {
|
|
|
|
|
const row = createFileRow(entry);
|
2026-03-06 11:28:35 -06:00
|
|
|
if (!disableAnimations) {
|
|
|
|
|
row.style.animationDelay = `${index * 0.02}s`;
|
|
|
|
|
row.classList.add("card-animate");
|
|
|
|
|
}
|
2026-03-05 00:04:26 -06:00
|
|
|
fileListEl.appendChild(row);
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-06 11:28:35 -06:00
|
|
|
if (!disableAnimations) {
|
|
|
|
|
fileListEl.style.transition =
|
|
|
|
|
"opacity 0.3s ease-out, transform 0.3s ease-out";
|
|
|
|
|
fileListEl.style.opacity = "1";
|
|
|
|
|
fileListEl.style.transform = "translateX(0)";
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (disableAnimations) {
|
|
|
|
|
renderList();
|
|
|
|
|
} else {
|
|
|
|
|
setTimeout(renderList, 150);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderDirectoryVirtualized(entries, canGoUp, parentPath) {
|
|
|
|
|
clearVirtualizedListMode();
|
|
|
|
|
isVirtualizedList = true;
|
|
|
|
|
fileListEl.style.transition = "none";
|
|
|
|
|
fileListEl.style.opacity = "1";
|
|
|
|
|
fileListEl.style.transform = "translateX(0)";
|
|
|
|
|
fileListEl.classList.remove("divide-y", "divide-slate-800");
|
|
|
|
|
fileListEl.innerHTML = "";
|
|
|
|
|
|
|
|
|
|
if (canGoUp) {
|
|
|
|
|
fileListEl.appendChild(createParentRow(parentPath));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const host = document.createElement("div");
|
|
|
|
|
const topSpacer = document.createElement("div");
|
|
|
|
|
const rowsContainer = document.createElement("div");
|
|
|
|
|
const bottomSpacer = document.createElement("div");
|
|
|
|
|
|
|
|
|
|
host.className = "virtualized-list-host";
|
|
|
|
|
host.appendChild(topSpacer);
|
|
|
|
|
host.appendChild(rowsContainer);
|
|
|
|
|
host.appendChild(bottomSpacer);
|
|
|
|
|
fileListEl.appendChild(host);
|
|
|
|
|
|
|
|
|
|
const renderWindow = () => {
|
|
|
|
|
const hostTop = host.getBoundingClientRect().top + window.scrollY;
|
|
|
|
|
const viewportTop = window.scrollY;
|
|
|
|
|
const viewportBottom = viewportTop + window.innerHeight;
|
|
|
|
|
const relativeTop = Math.max(0, viewportTop - hostTop);
|
|
|
|
|
const relativeBottom = Math.max(0, viewportBottom - hostTop);
|
|
|
|
|
|
|
|
|
|
let start = Math.floor(relativeTop / VIRTUAL_ROW_HEIGHT) - VIRTUAL_OVERSCAN;
|
|
|
|
|
let end = Math.ceil(relativeBottom / VIRTUAL_ROW_HEIGHT) + VIRTUAL_OVERSCAN;
|
|
|
|
|
|
|
|
|
|
start = Math.max(0, start);
|
|
|
|
|
end = Math.min(entries.length, Math.max(start + 1, end));
|
|
|
|
|
|
|
|
|
|
topSpacer.style.height = `${start * VIRTUAL_ROW_HEIGHT}px`;
|
|
|
|
|
bottomSpacer.style.height = `${Math.max(0, (entries.length - end) * VIRTUAL_ROW_HEIGHT)}px`;
|
|
|
|
|
|
|
|
|
|
rowsContainer.innerHTML = "";
|
|
|
|
|
for (let i = start; i < end; i += 1) {
|
|
|
|
|
rowsContainer.appendChild(createFileRow(entries[i]));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const scheduleRender = () => {
|
|
|
|
|
if (virtualRenderRaf) return;
|
|
|
|
|
virtualRenderRaf = requestAnimationFrame(() => {
|
|
|
|
|
virtualRenderRaf = null;
|
|
|
|
|
renderWindow();
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
virtualScrollHandler = () => scheduleRender();
|
|
|
|
|
virtualResizeHandler = () => scheduleRender();
|
|
|
|
|
window.addEventListener("scroll", virtualScrollHandler, { passive: true });
|
|
|
|
|
window.addEventListener("resize", virtualResizeHandler);
|
|
|
|
|
|
|
|
|
|
renderWindow();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderCurrentDirectory() {
|
|
|
|
|
filteredDirectoryEntries = applySortAndFilterEntries(directoryEntries);
|
|
|
|
|
const visibleEntries = filteredDirectoryEntries;
|
|
|
|
|
updateDirectorySummaryAndPaging(visibleEntries.length, visibleEntries.length);
|
|
|
|
|
|
|
|
|
|
const canGoUp = shouldShowParentDirectory(currentPath);
|
|
|
|
|
const parentPath = canGoUp ? getParentPath(currentPath) : ".";
|
|
|
|
|
|
|
|
|
|
if (visibleEntries.length > VIRTUALIZATION_THRESHOLD) {
|
|
|
|
|
renderDirectoryVirtualized(visibleEntries, canGoUp, parentPath);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const disableAnimations = visibleEntries.length > 50;
|
|
|
|
|
renderDirectoryStandard(visibleEntries, canGoUp, parentPath, disableAnimations);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleFileList(msg) {
|
|
|
|
|
if (msg.error) {
|
|
|
|
|
clearVirtualizedListMode();
|
|
|
|
|
fileListEl.innerHTML = `<div class="px-4 py-6 text-center text-red-400"><i class="fa-solid fa-exclamation-triangle mr-2"></i>${escapeHtml(msg.error)}</div>`;
|
|
|
|
|
updateDirectorySummaryAndPaging(0, 0);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lastSuccessfulResponse = Date.now();
|
|
|
|
|
updateStatus("connected", "Connected");
|
|
|
|
|
enableControls(true);
|
|
|
|
|
|
|
|
|
|
currentPath = msg.path;
|
|
|
|
|
directoryEntries = Array.isArray(msg.entries) ? msg.entries : [];
|
|
|
|
|
|
|
|
|
|
selectedFiles.clear();
|
|
|
|
|
updateSelectionUI();
|
|
|
|
|
renderCurrentDirectory();
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function shouldShowParentDirectory(path) {
|
|
|
|
|
if (!path || path === ".") {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getParentPath(path) {
|
|
|
|
|
const normalized = path.replace(/\\/g, "/");
|
|
|
|
|
const parts = normalized.split("/").filter((p) => p);
|
|
|
|
|
|
|
|
|
|
if (parts.length === 1 && parts[0].match(/^[A-Za-z]:$/)) {
|
|
|
|
|
return ".";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (parts.length <= 1) {
|
|
|
|
|
return ".";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
parts.pop();
|
|
|
|
|
let parentPath = parts.join("/");
|
|
|
|
|
|
|
|
|
|
if (parentPath.match(/^[A-Za-z]:?$/)) {
|
|
|
|
|
return parentPath.replace(/^([A-Za-z]):?$/, "$1:\\");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return parentPath || ".";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createParentRow(parentPath) {
|
|
|
|
|
const row = document.createElement("div");
|
|
|
|
|
row.className =
|
|
|
|
|
"file-item grid grid-cols-12 gap-3 px-4 py-3 border border-transparent cursor-pointer transition-colors hover:bg-slate-800/50";
|
|
|
|
|
row.dataset.path = parentPath;
|
|
|
|
|
row.dataset.isDir = "true";
|
|
|
|
|
|
|
|
|
|
row.innerHTML = `
|
|
|
|
|
<div class="col-span-6 flex items-center gap-2">
|
|
|
|
|
<i class="fa-solid fa-folder-arrow-up text-blue-400"></i>
|
|
|
|
|
<span class="font-semibold text-blue-300">..</span>
|
|
|
|
|
<span class="text-xs text-slate-500">(parent directory)</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-span-2 text-sm text-slate-400">-</div>
|
|
|
|
|
<div class="col-span-3 text-sm text-slate-400">-</div>
|
|
|
|
|
<div class="col-span-1"></div>
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
row.ondblclick = () => listFiles(parentPath);
|
|
|
|
|
row.onclick = () => listFiles(parentPath);
|
|
|
|
|
|
|
|
|
|
return row;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createFileRow(entry) {
|
|
|
|
|
const row = document.createElement("div");
|
|
|
|
|
row.className =
|
|
|
|
|
"file-item grid grid-cols-12 gap-3 px-4 py-3 border border-transparent cursor-pointer transition-colors";
|
|
|
|
|
row.dataset.path = entry.path;
|
|
|
|
|
row.dataset.isDir = entry.isDir;
|
|
|
|
|
|
|
|
|
|
const icon = entry.isDir
|
|
|
|
|
? '<i class="fa-solid fa-folder text-yellow-400"></i>'
|
|
|
|
|
: '<i class="fa-solid fa-file text-slate-400"></i>';
|
|
|
|
|
|
|
|
|
|
const size = entry.isDir ? "-" : formatBytes(entry.size);
|
|
|
|
|
const modTime = new Date(entry.modTime * 1000).toLocaleString();
|
|
|
|
|
|
|
|
|
|
row.innerHTML = `
|
|
|
|
|
<input type="checkbox" class="file-checkbox" data-path="${escapeHtml(entry.path)}">
|
|
|
|
|
<div class="col-span-6 flex items-center gap-2 truncate pl-3">
|
|
|
|
|
${icon}
|
|
|
|
|
<span class="truncate">${escapeHtml(entry.name)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-span-2 text-sm text-slate-400 file-size-col">${size}</div>
|
|
|
|
|
<div class="col-span-3 text-sm text-slate-400 file-modified-col">${modTime}</div>
|
|
|
|
|
<div class="col-span-1 flex items-center justify-end gap-1 action-buttons">
|
|
|
|
|
${!entry.isDir ? '<button class="action-btn px-2 py-1 rounded hover:bg-slate-700" data-action="download" title="Download"><i class="fa-solid fa-download"></i></button>' : ""}
|
|
|
|
|
${entry.isDir ? '<button class="action-btn px-2 py-1 rounded hover:bg-slate-700" data-action="zip" title="Zip & Download"><i class="fa-solid fa-file-zipper"></i></button>' : ""}
|
|
|
|
|
<button class="action-btn px-2 py-1 rounded hover:bg-slate-700 text-red-400" data-action="delete" title="Delete"><i class="fa-solid fa-trash"></i></button>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const nameDiv = row.querySelector(".col-span-6");
|
|
|
|
|
const mobileMetaDiv = document.createElement("div");
|
|
|
|
|
mobileMetaDiv.className = "file-meta";
|
|
|
|
|
mobileMetaDiv.innerHTML = `<span>${size}</span><span>${modTime}</span>`;
|
|
|
|
|
nameDiv.appendChild(mobileMetaDiv);
|
|
|
|
|
|
|
|
|
|
row.onclick = (e) => {
|
|
|
|
|
if (e.target.closest(".file-checkbox") || e.target.closest(".action-btn")) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (entry.isDir) {
|
|
|
|
|
listFiles(entry.path);
|
|
|
|
|
} else {
|
|
|
|
|
openFileInEditor(entry.path);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const checkbox = row.querySelector(".file-checkbox");
|
|
|
|
|
checkbox.onclick = (e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
checkbox.onchange = (e) => {
|
|
|
|
|
if (e.target.checked) {
|
|
|
|
|
selectedFiles.add(entry.path);
|
|
|
|
|
row.classList.add("selected");
|
|
|
|
|
} else {
|
|
|
|
|
selectedFiles.delete(entry.path);
|
|
|
|
|
row.classList.remove("selected");
|
|
|
|
|
}
|
|
|
|
|
updateSelectionUI();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
row.querySelectorAll(".action-btn").forEach((btn) => {
|
|
|
|
|
btn.onclick = (e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
const action = btn.dataset.action;
|
|
|
|
|
handleFileAction(action, entry);
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
row.oncontextmenu = (e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
showContextMenu(e.clientX, e.clientY, entry);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return row;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toggleSelection(row, path) {
|
|
|
|
|
const checkbox = row.querySelector(".file-checkbox");
|
|
|
|
|
if (selectedFiles.has(path)) {
|
|
|
|
|
selectedFiles.delete(path);
|
|
|
|
|
row.classList.remove("selected");
|
|
|
|
|
if (checkbox) checkbox.checked = false;
|
|
|
|
|
} else {
|
|
|
|
|
selectedFiles.add(path);
|
|
|
|
|
row.classList.add("selected");
|
|
|
|
|
if (checkbox) checkbox.checked = true;
|
|
|
|
|
}
|
|
|
|
|
updateSelectionUI();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleFileAction(action, entry) {
|
|
|
|
|
switch (action) {
|
|
|
|
|
case "edit":
|
|
|
|
|
openFileInEditor(entry.path);
|
|
|
|
|
break;
|
|
|
|
|
case "download":
|
|
|
|
|
downloadFile(entry.path);
|
|
|
|
|
break;
|
|
|
|
|
case "zip":
|
|
|
|
|
zipAndDownload(entry.path);
|
|
|
|
|
break;
|
|
|
|
|
case "copy":
|
|
|
|
|
const copyDest = prompt("Copy to:", entry.path + "_copy");
|
|
|
|
|
if (copyDest) {
|
|
|
|
|
const commandId = `copy-${Date.now()}`;
|
|
|
|
|
send({
|
|
|
|
|
type: "command",
|
|
|
|
|
commandType: "file_copy",
|
|
|
|
|
id: commandId,
|
|
|
|
|
payload: { source: entry.path, dest: copyDest },
|
|
|
|
|
});
|
|
|
|
|
trackCommandResult(commandId, {
|
|
|
|
|
refreshOnSuccess: true,
|
|
|
|
|
successMessage: "Copy completed",
|
|
|
|
|
errorPrefix: "Copy failed",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
case "move":
|
|
|
|
|
const moveDest = prompt("Move to:", entry.path);
|
|
|
|
|
if (moveDest) {
|
|
|
|
|
const commandId = `move-${Date.now()}`;
|
|
|
|
|
send({
|
|
|
|
|
type: "command",
|
|
|
|
|
commandType: "file_move",
|
|
|
|
|
id: commandId,
|
|
|
|
|
payload: { source: entry.path, dest: moveDest },
|
|
|
|
|
});
|
|
|
|
|
trackCommandResult(commandId, {
|
|
|
|
|
refreshOnSuccess: true,
|
|
|
|
|
successMessage: "Move completed",
|
|
|
|
|
errorPrefix: "Move failed",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
case "chmod":
|
|
|
|
|
const mode = prompt(
|
|
|
|
|
"Enter permissions (octal, e.g., 0755):",
|
|
|
|
|
entry.mode || "0644",
|
|
|
|
|
);
|
|
|
|
|
if (mode) {
|
|
|
|
|
const commandId = `chmod-${Date.now()}`;
|
|
|
|
|
send({
|
|
|
|
|
type: "command",
|
|
|
|
|
commandType: "file_chmod",
|
|
|
|
|
id: commandId,
|
|
|
|
|
payload: { path: entry.path, mode },
|
|
|
|
|
});
|
|
|
|
|
trackCommandResult(commandId, {
|
|
|
|
|
refreshOnSuccess: true,
|
|
|
|
|
successMessage: "Permissions updated",
|
|
|
|
|
errorPrefix: "Permissions update failed",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
case "delete":
|
|
|
|
|
deleteFile(entry.path);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function downloadFile(path) {
|
|
|
|
|
console.log("Requesting download:", path);
|
|
|
|
|
const transferId = `download-${Date.now()}-${Math.random()}`;
|
|
|
|
|
const fileName = path.split(/[\/\\]/).pop();
|
|
|
|
|
const abortController = new AbortController();
|
|
|
|
|
|
|
|
|
|
const transfer = {
|
|
|
|
|
id: transferId,
|
|
|
|
|
type: "download",
|
|
|
|
|
path,
|
|
|
|
|
fileName,
|
|
|
|
|
progress: 0,
|
|
|
|
|
total: 0,
|
|
|
|
|
received: 0,
|
|
|
|
|
receivedBytes: 0,
|
|
|
|
|
receivedOffsets: new Map(),
|
|
|
|
|
receivedChunks: new Set(),
|
|
|
|
|
chunkSize: 0,
|
|
|
|
|
expectedChunks: 0,
|
|
|
|
|
buffer: null,
|
|
|
|
|
chunks: [],
|
|
|
|
|
cancelled: false,
|
|
|
|
|
abortController,
|
|
|
|
|
source: "http",
|
|
|
|
|
expectedCommandId: null,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
fileDownloads.set(path, transfer);
|
|
|
|
|
activeTransfers.set(transferId, transfer);
|
|
|
|
|
addTransferToUI(transfer);
|
|
|
|
|
|
|
|
|
|
updateStatus("connected", `Downloading ${fileName}...`);
|
|
|
|
|
|
|
|
|
|
(async () => {
|
|
|
|
|
try {
|
|
|
|
|
console.debug("[filebrowser] download request", { path, clientId });
|
|
|
|
|
const requestRes = await fetch("/api/file/download/request", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
credentials: "include",
|
|
|
|
|
signal: abortController.signal,
|
|
|
|
|
body: JSON.stringify({ clientId, path }),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
console.debug("[filebrowser] download request response", {
|
|
|
|
|
ok: requestRes.ok,
|
|
|
|
|
status: requestRes.status,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!requestRes.ok) {
|
|
|
|
|
const text = await requestRes.text();
|
|
|
|
|
notifyToast(text || "Download failed", "error", 5000);
|
|
|
|
|
removeTransfer(transferId);
|
|
|
|
|
fileDownloads.delete(path);
|
|
|
|
|
updateStatus("connected", "Connected");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const requestData = await requestRes.json();
|
2026-03-08 00:53:22 -06:00
|
|
|
const downloadUrl = typeof requestData?.downloadUrl === "string"
|
|
|
|
|
? requestData.downloadUrl
|
|
|
|
|
: (requestData?.downloadId
|
|
|
|
|
? `/api/file/download/${encodeURIComponent(requestData.downloadId)}`
|
|
|
|
|
: "");
|
|
|
|
|
|
|
|
|
|
if (!downloadUrl) {
|
2026-03-05 00:04:26 -06:00
|
|
|
notifyToast("Download failed", "error", 5000);
|
|
|
|
|
removeTransfer(transferId);
|
|
|
|
|
fileDownloads.delete(path);
|
|
|
|
|
updateStatus("connected", "Connected");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.debug("[filebrowser] download request accepted", {
|
2026-03-08 00:53:22 -06:00
|
|
|
downloadUrl,
|
2026-03-05 00:04:26 -06:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
console.debug("[filebrowser] download stream start", {
|
2026-03-08 00:53:22 -06:00
|
|
|
downloadUrl,
|
2026-03-05 00:04:26 -06:00
|
|
|
});
|
|
|
|
|
|
2026-03-08 00:53:22 -06:00
|
|
|
const res = await fetch(downloadUrl, {
|
|
|
|
|
method: "GET",
|
2026-03-05 00:04:26 -06:00
|
|
|
credentials: "include",
|
|
|
|
|
signal: abortController.signal,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
console.debug("[filebrowser] download response", {
|
|
|
|
|
ok: res.ok,
|
|
|
|
|
status: res.status,
|
|
|
|
|
contentType: res.headers.get("Content-Type"),
|
|
|
|
|
contentLength: res.headers.get("Content-Length"),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
const text = await res.text();
|
|
|
|
|
notifyToast(text || "Download failed", "error", 5000);
|
|
|
|
|
removeTransfer(transferId);
|
|
|
|
|
fileDownloads.delete(path);
|
|
|
|
|
updateStatus("connected", "Connected");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const total = Number(res.headers.get("Content-Length") || 0);
|
|
|
|
|
if (Number.isFinite(total) && total > 0) {
|
|
|
|
|
transfer.total = total;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const chunks = [];
|
|
|
|
|
let received = 0;
|
|
|
|
|
let lastLoggedBytes = 0;
|
|
|
|
|
if (res.body) {
|
|
|
|
|
const reader = res.body.getReader();
|
|
|
|
|
while (true) {
|
|
|
|
|
const { done, value } = await reader.read();
|
|
|
|
|
if (done) break;
|
|
|
|
|
if (value) {
|
|
|
|
|
chunks.push(value);
|
|
|
|
|
received += value.length;
|
|
|
|
|
transfer.received = received;
|
|
|
|
|
if (received - lastLoggedBytes >= 5 * 1024 * 1024) {
|
|
|
|
|
lastLoggedBytes = received;
|
|
|
|
|
console.debug("[filebrowser] download stream", {
|
|
|
|
|
path,
|
|
|
|
|
received,
|
|
|
|
|
total: transfer.total,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if (transfer.total > 0) {
|
|
|
|
|
transfer.progress = Math.round((received / transfer.total) * 100);
|
|
|
|
|
updateTransferProgress(transferId, transfer.progress, received, transfer.total);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (transfer.cancelled) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
console.warn("[filebrowser] download response missing body", { path });
|
|
|
|
|
const blob = await res.blob();
|
|
|
|
|
chunks.push(new Uint8Array(await blob.arrayBuffer()));
|
|
|
|
|
received = chunks[0]?.length || 0;
|
|
|
|
|
transfer.received = received;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (transfer.cancelled) {
|
|
|
|
|
removeTransfer(transferId);
|
|
|
|
|
fileDownloads.delete(path);
|
|
|
|
|
updateStatus("connected", "Connected");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (transfer.total === 0 && received > 0) {
|
|
|
|
|
transfer.progress = 100;
|
|
|
|
|
updateTransferProgress(transferId, transfer.progress, received, transfer.total);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const blob = new Blob(chunks);
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
const a = document.createElement("a");
|
|
|
|
|
a.href = url;
|
|
|
|
|
a.download = fileName;
|
|
|
|
|
document.body.appendChild(a);
|
|
|
|
|
a.click();
|
|
|
|
|
document.body.removeChild(a);
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
|
|
|
|
|
console.log("Download complete:", path, `${received} bytes`);
|
|
|
|
|
console.debug("[filebrowser] download complete", {
|
|
|
|
|
path,
|
|
|
|
|
received,
|
|
|
|
|
total: transfer.total,
|
|
|
|
|
chunks: chunks.length,
|
|
|
|
|
});
|
|
|
|
|
removeTransfer(transferId);
|
|
|
|
|
fileDownloads.delete(path);
|
|
|
|
|
updateStatus("connected", "Connected");
|
|
|
|
|
} catch (err) {
|
|
|
|
|
if (transfer.cancelled) return;
|
|
|
|
|
console.error("Download error:", err);
|
|
|
|
|
notifyToast(`Download failed: ${err.message || err}`, "error", 5000);
|
|
|
|
|
removeTransfer(transferId);
|
|
|
|
|
fileDownloads.delete(path);
|
|
|
|
|
updateStatus("connected", "Connected");
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleFileDownload(msg) {
|
|
|
|
|
const toNumber = (value) => {
|
|
|
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
|
|
|
if (typeof value === "bigint") {
|
|
|
|
|
const asNumber = Number(value);
|
|
|
|
|
if (Number.isSafeInteger(asNumber)) return asNumber;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
console.debug("[filebrowser] file_download", {
|
|
|
|
|
path: msg.path,
|
|
|
|
|
hasData: !!msg.data,
|
|
|
|
|
dataLen: msg.data?.length ?? null,
|
|
|
|
|
totalType: typeof msg.total,
|
|
|
|
|
total: msg.total,
|
|
|
|
|
chunkIndex: msg.chunkIndex,
|
|
|
|
|
chunksTotal: msg.chunksTotal,
|
|
|
|
|
offset: msg.offset,
|
|
|
|
|
error: msg.error || null,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (msg.error) {
|
|
|
|
|
alert(`Download failed: ${msg.error}`);
|
|
|
|
|
const download = fileDownloads.get(msg.path);
|
|
|
|
|
if (download) {
|
|
|
|
|
removeTransfer(download.id);
|
|
|
|
|
fileDownloads.delete(msg.path);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let download = fileDownloads.get(msg.path);
|
|
|
|
|
if (!download) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (download.source === "http") {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (download.expectedCommandId) {
|
|
|
|
|
if (!msg.commandId || msg.commandId !== download.expectedCommandId) {
|
|
|
|
|
console.debug("[filebrowser] ignoring unsolicited download", {
|
|
|
|
|
path: msg.path,
|
|
|
|
|
commandId: msg.commandId || null,
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (download.cancelled) {
|
|
|
|
|
fileDownloads.delete(msg.path);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const total = toNumber(msg.total);
|
|
|
|
|
if (total && total > 0) {
|
|
|
|
|
if (!download.total) {
|
|
|
|
|
download.total = total;
|
|
|
|
|
console.debug("[filebrowser] download total set", {
|
|
|
|
|
path: msg.path,
|
|
|
|
|
total: download.total,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if (download.total > 0 && !download.buffer) {
|
|
|
|
|
download.buffer = new Uint8Array(download.total);
|
|
|
|
|
download.receivedBytes = 0;
|
|
|
|
|
download.receivedOffsets = new Map();
|
|
|
|
|
download.receivedChunks = new Set();
|
|
|
|
|
download.chunkSize = 0;
|
|
|
|
|
download.expectedChunks = 0;
|
|
|
|
|
download.chunks = [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const chunkIndex = toNumber(msg.chunkIndex);
|
|
|
|
|
const chunksTotal = toNumber(msg.chunksTotal);
|
|
|
|
|
if (chunksTotal && !download.expectedChunks) {
|
|
|
|
|
download.expectedChunks = chunksTotal;
|
|
|
|
|
console.debug("[filebrowser] expected chunks set", {
|
|
|
|
|
path: msg.path,
|
|
|
|
|
expectedChunks: download.expectedChunks,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (msg.data && msg.data.length > 0) {
|
|
|
|
|
let data = msg.data;
|
|
|
|
|
if (data instanceof ArrayBuffer) {
|
|
|
|
|
data = new Uint8Array(data);
|
|
|
|
|
} else if (typeof data === "string") {
|
|
|
|
|
data = Uint8Array.from(atob(data), (c) => c.charCodeAt(0));
|
|
|
|
|
}
|
|
|
|
|
if (data instanceof Uint8Array) {
|
|
|
|
|
const chunkOffset = toNumber(msg.offset);
|
|
|
|
|
if (download.total > 0 && !download.chunkSize && data.length > 0) {
|
|
|
|
|
download.chunkSize = data.length;
|
|
|
|
|
if (!download.expectedChunks) {
|
|
|
|
|
download.expectedChunks = Math.ceil(download.total / download.chunkSize);
|
|
|
|
|
console.debug("[filebrowser] inferred expected chunks", {
|
|
|
|
|
path: msg.path,
|
|
|
|
|
expectedChunks: download.expectedChunks,
|
|
|
|
|
chunkSize: download.chunkSize,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (download.total > 0 && download.buffer && chunkOffset !== null) {
|
|
|
|
|
const end = chunkOffset + data.length;
|
|
|
|
|
if (chunkOffset >= 0 && end <= download.total) {
|
|
|
|
|
const seen = chunkIndex !== null
|
|
|
|
|
? download.receivedChunks.has(chunkIndex)
|
|
|
|
|
: download.receivedOffsets.has(chunkOffset);
|
|
|
|
|
if (!seen) {
|
|
|
|
|
download.buffer.set(data, chunkOffset);
|
|
|
|
|
if (chunkIndex !== null) {
|
|
|
|
|
download.receivedChunks.add(chunkIndex);
|
|
|
|
|
} else {
|
|
|
|
|
download.receivedOffsets.set(chunkOffset, data.length);
|
|
|
|
|
}
|
|
|
|
|
download.receivedBytes += data.length;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
download.received = Math.min(download.receivedBytes, download.total);
|
|
|
|
|
} else {
|
|
|
|
|
download.chunks.push(data);
|
|
|
|
|
download.received += data.length;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (download.total > 0) {
|
|
|
|
|
download.progress = Math.round((download.received / download.total) * 100);
|
|
|
|
|
updateTransferProgress(
|
|
|
|
|
download.id,
|
|
|
|
|
download.progress,
|
|
|
|
|
download.received,
|
|
|
|
|
download.total,
|
|
|
|
|
);
|
|
|
|
|
console.debug("[filebrowser] download progress", {
|
|
|
|
|
path: msg.path,
|
|
|
|
|
progress: download.progress,
|
|
|
|
|
received: download.received,
|
|
|
|
|
total: download.total,
|
|
|
|
|
});
|
|
|
|
|
} else if (download.expectedChunks > 0) {
|
|
|
|
|
const chunkProgress = Math.round(
|
|
|
|
|
(download.receivedChunks.size / download.expectedChunks) * 100,
|
|
|
|
|
);
|
|
|
|
|
download.progress = Math.min(100, Math.max(0, chunkProgress));
|
|
|
|
|
updateTransferProgress(
|
|
|
|
|
download.id,
|
|
|
|
|
download.progress,
|
|
|
|
|
download.received,
|
|
|
|
|
download.total || 0,
|
|
|
|
|
);
|
|
|
|
|
console.debug("[filebrowser] download chunk progress", {
|
|
|
|
|
path: msg.path,
|
|
|
|
|
progress: download.progress,
|
|
|
|
|
receivedChunks: download.receivedChunks.size,
|
|
|
|
|
expectedChunks: download.expectedChunks,
|
|
|
|
|
received: download.received,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const receivedChunkCount =
|
|
|
|
|
download.receivedChunks.size + download.receivedOffsets.size;
|
|
|
|
|
const hasAllChunks =
|
|
|
|
|
download.expectedChunks > 0
|
|
|
|
|
? receivedChunkCount >= download.expectedChunks
|
|
|
|
|
: download.received >= download.total;
|
|
|
|
|
|
|
|
|
|
if ((download.total > 0 ? download.received >= download.total : hasAllChunks) && hasAllChunks) {
|
|
|
|
|
console.debug("[filebrowser] download complete", {
|
|
|
|
|
path: msg.path,
|
|
|
|
|
received: download.received,
|
|
|
|
|
total: download.total,
|
|
|
|
|
expectedChunks: download.expectedChunks,
|
|
|
|
|
receivedChunks: download.receivedChunks.size,
|
|
|
|
|
});
|
|
|
|
|
let fullData = null;
|
|
|
|
|
if (download.buffer) {
|
|
|
|
|
fullData = download.buffer;
|
|
|
|
|
} else {
|
|
|
|
|
fullData = new Uint8Array(download.received);
|
|
|
|
|
let offset = 0;
|
|
|
|
|
download.chunks.forEach((chunk) => {
|
|
|
|
|
fullData.set(chunk, offset);
|
|
|
|
|
offset += chunk.length;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const blob = new Blob([fullData]);
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
const a = document.createElement("a");
|
|
|
|
|
a.href = url;
|
|
|
|
|
a.download = download.fileName;
|
|
|
|
|
document.body.appendChild(a);
|
|
|
|
|
a.click();
|
|
|
|
|
document.body.removeChild(a);
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
|
|
|
|
|
console.log("Download complete:", msg.path, `${download.received} bytes`);
|
|
|
|
|
removeTransfer(download.id);
|
|
|
|
|
fileDownloads.delete(msg.path);
|
|
|
|
|
updateStatus("connected", "Connected");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function zipAndDownload(path) {
|
|
|
|
|
console.log("Requesting zip:", path);
|
|
|
|
|
|
|
|
|
|
const zipPath = path + ".zip";
|
|
|
|
|
const transferId = `download-zip-${Date.now()}-${Math.random()}`;
|
|
|
|
|
const fileName = zipPath.split(/[\/\\]/).pop();
|
|
|
|
|
const transfer = {
|
|
|
|
|
id: transferId,
|
|
|
|
|
type: "download",
|
|
|
|
|
path: zipPath,
|
|
|
|
|
fileName,
|
|
|
|
|
progress: 0,
|
|
|
|
|
total: 0,
|
|
|
|
|
received: 0,
|
|
|
|
|
receivedBytes: 0,
|
|
|
|
|
receivedOffsets: new Map(),
|
|
|
|
|
receivedChunks: new Set(),
|
|
|
|
|
chunkSize: 0,
|
|
|
|
|
expectedChunks: 0,
|
|
|
|
|
buffer: null,
|
|
|
|
|
chunks: [],
|
|
|
|
|
cancelled: false,
|
|
|
|
|
source: "ws",
|
|
|
|
|
expectedCommandId: null,
|
|
|
|
|
};
|
|
|
|
|
fileDownloads.set(zipPath, transfer);
|
|
|
|
|
|
|
|
|
|
const commandId = "zip_" + Date.now();
|
|
|
|
|
transfer.expectedCommandId = commandId;
|
|
|
|
|
send({ type: "file_zip", path, commandId });
|
|
|
|
|
trackCommandResult(commandId, {
|
|
|
|
|
refreshOnSuccess: true,
|
|
|
|
|
successMessage: "Zip completed",
|
|
|
|
|
errorPrefix: "Zip failed",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
showProgressNotification(commandId, "Starting zip operation...", path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let activeProgressNotifications = new Map();
|
|
|
|
|
|
|
|
|
|
function showProgressNotification(commandId, message, path) {
|
|
|
|
|
hideProgressNotification(commandId);
|
|
|
|
|
|
|
|
|
|
const notification = document.createElement("div");
|
|
|
|
|
notification.id = `progress-${commandId}`;
|
|
|
|
|
notification.className =
|
|
|
|
|
"fixed bottom-4 right-4 bg-slate-800 border border-slate-700 rounded-lg shadow-lg p-4 min-w-[320px] z-50";
|
|
|
|
|
notification.innerHTML = `
|
|
|
|
|
<div class="flex items-start justify-between gap-3 mb-2">
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
<i class="fa-solid fa-file-zipper text-blue-400"></i>
|
|
|
|
|
<span class="font-semibold text-slate-200">Zipping Directory</span>
|
|
|
|
|
</div>
|
|
|
|
|
<button class="text-slate-400 hover:text-red-400 transition-colors" data-command-id="${escapeHtml(commandId)}" onclick="cancelZipOperation(this.dataset.commandId)">
|
|
|
|
|
<i class="fa-solid fa-xmark"></i>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="text-sm text-slate-400 mb-2" id="progress-message-${escapeHtml(commandId)}">${escapeHtml(message)}</div>
|
|
|
|
|
<div class="text-xs text-slate-500 truncate" title="${escapeHtml(path)}">${escapeHtml(path)}</div>
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
document.body.appendChild(notification);
|
|
|
|
|
activeProgressNotifications.set(commandId, notification);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateProgressNotification(commandId, message) {
|
|
|
|
|
const messageEl = document.getElementById(`progress-message-${commandId}`);
|
|
|
|
|
if (messageEl) {
|
|
|
|
|
messageEl.textContent = message;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hideProgressNotification(commandId) {
|
|
|
|
|
const notification = activeProgressNotifications.get(commandId);
|
|
|
|
|
if (notification) {
|
|
|
|
|
notification.remove();
|
|
|
|
|
activeProgressNotifications.delete(commandId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cancelZipOperation(commandId) {
|
|
|
|
|
if (confirm("Cancel this zip operation?")) {
|
|
|
|
|
send({ type: "command_abort", commandId });
|
|
|
|
|
hideProgressNotification(commandId);
|
|
|
|
|
updateStatus("connected", "Zip operation cancelled");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleCommandProgress(msg) {
|
|
|
|
|
if (msg.commandId) {
|
|
|
|
|
updateProgressNotification(msg.commandId, msg.message || "Processing...");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function deleteFile(path) {
|
|
|
|
|
if (!confirm(`Are you sure you want to delete ${path}?`)) return;
|
|
|
|
|
console.log("Deleting:", path);
|
|
|
|
|
const commandId = `delete-${Date.now()}`;
|
|
|
|
|
send({ type: "file_delete", path, commandId });
|
|
|
|
|
trackCommandResult(commandId, {
|
|
|
|
|
refreshOnSuccess: true,
|
|
|
|
|
successMessage: "Delete completed",
|
|
|
|
|
errorPrefix: "Delete failed",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleFileUploadResult(msg) {
|
|
|
|
|
const toNumber = (value) => {
|
|
|
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
|
|
|
if (typeof value === "bigint") {
|
|
|
|
|
const asNumber = Number(value);
|
|
|
|
|
if (Number.isSafeInteger(asNumber)) return asNumber;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const transfer = msg.transferId
|
|
|
|
|
? fileUploadsById.get(msg.transferId)
|
|
|
|
|
: (msg.path ? fileUploads.get(msg.path) : null);
|
|
|
|
|
|
|
|
|
|
if (!msg.ok) {
|
|
|
|
|
notifyToast(`Upload failed: ${msg.error}`, "error", 5000);
|
|
|
|
|
updateStatus("connected", "Connected");
|
|
|
|
|
if (transfer) {
|
|
|
|
|
removeTransfer(transfer.id);
|
|
|
|
|
fileUploads.delete(transfer.path);
|
|
|
|
|
fileUploadsById.delete(transfer.transferId);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!transfer) {
|
|
|
|
|
console.log("Upload ack received (no active transfer):", msg.path || msg.transferId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const offset = toNumber(msg.offset);
|
2026-03-08 00:53:22 -06:00
|
|
|
if (offset !== null) {
|
|
|
|
|
const firstAckForOffset = !transfer.ackedOffsets.has(offset);
|
|
|
|
|
if (firstAckForOffset) {
|
|
|
|
|
transfer.ackedOffsets.add(offset);
|
|
|
|
|
transfer.receivedChunks += 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (Number.isFinite(msg.received)) {
|
|
|
|
|
transfer.receivedBytes = Math.min(Number(msg.received), transfer.total);
|
|
|
|
|
transfer.sent = transfer.receivedBytes;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (transfer.total > 0) {
|
|
|
|
|
transfer.progress = Math.round((transfer.sent / transfer.total) * 100);
|
|
|
|
|
updateTransferProgress(transfer.id, transfer.progress, transfer.sent, transfer.total);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 00:04:26 -06:00
|
|
|
const pendingOffset = offset !== null
|
|
|
|
|
? offset
|
|
|
|
|
: (transfer.pendingAcks.has(0) ? 0 : null);
|
|
|
|
|
if (pendingOffset !== null) {
|
|
|
|
|
const pending = transfer.pendingAcks.get(pendingOffset);
|
|
|
|
|
if (pending) {
|
|
|
|
|
clearTimeout(pending.timeoutId);
|
|
|
|
|
transfer.pendingAcks.delete(pendingOffset);
|
|
|
|
|
pending.resolve(msg);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (transfer.completed && transfer.receivedChunks >= transfer.expectedChunks) {
|
|
|
|
|
finishUpload(transfer);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleCommandResult(msg) {
|
|
|
|
|
if (msg.commandId && activeProgressNotifications.has(msg.commandId)) {
|
|
|
|
|
setTimeout(() => hideProgressNotification(msg.commandId), 2000);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (currentEditingFile && editorStatus.textContent === "Saving...") {
|
|
|
|
|
if (msg.ok) {
|
|
|
|
|
editorStatus.textContent = "Saved successfully!";
|
|
|
|
|
notifyToast("File saved successfully", "success", 5000);
|
|
|
|
|
setTimeout(closeEditor, 1000);
|
|
|
|
|
} else {
|
|
|
|
|
editorStatus.textContent = `Error: ${msg.message || "Save failed"}`;
|
|
|
|
|
notifyToast(
|
|
|
|
|
`Save failed: ${msg.message || "Unknown error"}`,
|
|
|
|
|
"error",
|
|
|
|
|
5000,
|
|
|
|
|
);
|
|
|
|
|
editorSaveBtn.disabled = false;
|
|
|
|
|
}
|
|
|
|
|
if (msg.commandId) pendingCommandResults.delete(msg.commandId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const tracked = msg.commandId
|
|
|
|
|
? pendingCommandResults.get(msg.commandId)
|
|
|
|
|
: null;
|
2026-03-08 00:53:22 -06:00
|
|
|
|
|
|
|
|
const waiter = msg.commandId
|
|
|
|
|
? pendingCommandWaiters.get(msg.commandId)
|
|
|
|
|
: null;
|
|
|
|
|
if (waiter) {
|
|
|
|
|
clearTimeout(waiter.timeoutId);
|
|
|
|
|
pendingCommandWaiters.delete(msg.commandId);
|
|
|
|
|
if (msg.ok) {
|
|
|
|
|
waiter.resolve(msg);
|
|
|
|
|
} else {
|
|
|
|
|
waiter.reject(new Error(msg.message || "operation failed"));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 00:04:26 -06:00
|
|
|
if (!tracked) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (msg.commandId) pendingCommandResults.delete(msg.commandId);
|
|
|
|
|
|
|
|
|
|
if (!msg.ok) {
|
|
|
|
|
const errorText = msg.message
|
|
|
|
|
? `${tracked.errorPrefix}: ${msg.message}`
|
|
|
|
|
: tracked.errorPrefix;
|
|
|
|
|
notifyToast(
|
|
|
|
|
errorText,
|
|
|
|
|
"error",
|
|
|
|
|
5000,
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
notifyToast(
|
|
|
|
|
tracked.successMessage || "Operation completed successfully",
|
|
|
|
|
"success",
|
|
|
|
|
5000,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (tracked.refreshOnSuccess) {
|
|
|
|
|
listFiles(currentPath);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function showContextMenu(x, y, entry) {
|
|
|
|
|
contextMenu.style.left = `${x}px`;
|
|
|
|
|
contextMenu.style.top = `${y}px`;
|
|
|
|
|
contextMenu.classList.add("show");
|
|
|
|
|
contextMenu.dataset.path = entry.path;
|
|
|
|
|
contextMenu.dataset.isDir = entry.isDir;
|
|
|
|
|
|
|
|
|
|
const editItem = contextMenu.querySelector('[data-action="edit"]');
|
|
|
|
|
const zipItem = contextMenu.querySelector('[data-action="zip"]');
|
|
|
|
|
const chmodItem = contextMenu.querySelector('[data-action="chmod"]');
|
|
|
|
|
|
|
|
|
|
if (editItem) editItem.style.display = entry.isDir ? "none" : "block";
|
|
|
|
|
if (zipItem) zipItem.style.display = entry.isDir ? "block" : "none";
|
|
|
|
|
if (chmodItem) chmodItem.style.display = entry.mode ? "block" : "none";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hideContextMenu() {
|
|
|
|
|
contextMenu.classList.remove("show");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatBytes(bytes) {
|
|
|
|
|
if (bytes === 0 || bytes === 0n) return "0 B";
|
|
|
|
|
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
|
|
|
|
if (typeof bytes === "bigint") {
|
|
|
|
|
const k = 1024n;
|
|
|
|
|
let i = 0;
|
|
|
|
|
let value = bytes;
|
|
|
|
|
while (value >= k && i < sizes.length - 1) {
|
|
|
|
|
value /= k;
|
|
|
|
|
i += 1;
|
|
|
|
|
}
|
|
|
|
|
return `${value.toString()} ${sizes[i]}`;
|
|
|
|
|
}
|
|
|
|
|
const k = 1024;
|
|
|
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
|
|
|
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function escapeHtml(text) {
|
|
|
|
|
const div = document.createElement("div");
|
|
|
|
|
div.textContent = text;
|
|
|
|
|
return div.innerHTML;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
refreshBtn.onclick = () => listFiles(currentPath);
|
|
|
|
|
|
|
|
|
|
uploadBtn.onclick = () => fileInput.click();
|
|
|
|
|
|
|
|
|
|
fileInput.onchange = async (e) => {
|
|
|
|
|
const files = Array.from(e.target.files);
|
|
|
|
|
if (files.length === 0) return;
|
|
|
|
|
|
|
|
|
|
for (const file of files) {
|
|
|
|
|
await uploadFile(file);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fileInput.value = "";
|
|
|
|
|
listFiles(currentPath);
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-06 11:28:35 -06:00
|
|
|
async function uploadMultipleFiles(files) {
|
|
|
|
|
for (const file of files) {
|
|
|
|
|
await uploadFile(file);
|
|
|
|
|
}
|
|
|
|
|
listFiles(currentPath);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hasFileDrag(event) {
|
|
|
|
|
const types = Array.from(event.dataTransfer?.types || []);
|
|
|
|
|
return types.includes("Files");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setDropTargetActive(active) {
|
|
|
|
|
if (!fileListPanel) return;
|
|
|
|
|
fileListPanel.classList.toggle("ring-2", active);
|
|
|
|
|
fileListPanel.classList.toggle("ring-blue-500", active);
|
|
|
|
|
fileListPanel.classList.toggle("ring-offset-2", active);
|
|
|
|
|
fileListPanel.classList.toggle("ring-offset-slate-950", active);
|
|
|
|
|
fileListPanel.classList.toggle("bg-blue-500/5", active);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setupDragAndDropUpload() {
|
|
|
|
|
if (!fileListPanel) return;
|
|
|
|
|
|
|
|
|
|
fileListPanel.addEventListener("dragenter", (e) => {
|
|
|
|
|
if (!hasFileDrag(e)) return;
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
dragDepth += 1;
|
|
|
|
|
setDropTargetActive(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
fileListPanel.addEventListener("dragover", (e) => {
|
|
|
|
|
if (!hasFileDrag(e)) return;
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.dataTransfer.dropEffect = "copy";
|
|
|
|
|
setDropTargetActive(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
fileListPanel.addEventListener("dragleave", (e) => {
|
|
|
|
|
if (!hasFileDrag(e)) return;
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
dragDepth = Math.max(0, dragDepth - 1);
|
|
|
|
|
if (dragDepth === 0) {
|
|
|
|
|
setDropTargetActive(false);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
fileListPanel.addEventListener("drop", async (e) => {
|
|
|
|
|
if (!hasFileDrag(e)) return;
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
dragDepth = 0;
|
|
|
|
|
setDropTargetActive(false);
|
|
|
|
|
|
|
|
|
|
const files = Array.from(e.dataTransfer?.files || []);
|
|
|
|
|
if (files.length === 0) return;
|
|
|
|
|
|
|
|
|
|
notifyToast(`Uploading ${files.length} file(s)...`, "info", 3000);
|
|
|
|
|
await uploadMultipleFiles(files);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (sortFieldEl) {
|
|
|
|
|
sortFieldEl.value = sortField;
|
|
|
|
|
sortFieldEl.addEventListener("change", () => {
|
|
|
|
|
sortField = sortFieldEl.value;
|
|
|
|
|
localStorage.setItem("filebrowser.sortField", sortField);
|
|
|
|
|
renderCurrentDirectory();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (filterTypeEl) {
|
|
|
|
|
filterTypeEl.value = filterType;
|
|
|
|
|
filterTypeEl.addEventListener("change", () => {
|
|
|
|
|
filterType = filterTypeEl.value;
|
|
|
|
|
localStorage.setItem("filebrowser.filterType", filterType);
|
|
|
|
|
renderCurrentDirectory();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (sortOrderBtn) {
|
|
|
|
|
updateSortOrderButton();
|
|
|
|
|
sortOrderBtn.addEventListener("click", () => {
|
|
|
|
|
sortOrder = sortOrder === "asc" ? "desc" : "asc";
|
|
|
|
|
localStorage.setItem("filebrowser.sortOrder", sortOrder);
|
|
|
|
|
updateSortOrderButton();
|
|
|
|
|
renderCurrentDirectory();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 00:04:26 -06:00
|
|
|
function finishUpload(transfer) {
|
|
|
|
|
console.log("Upload complete:", transfer.path);
|
|
|
|
|
updateStatus("connected", "Connected");
|
|
|
|
|
notifyToast("File uploaded successfully", "success", 5000);
|
|
|
|
|
removeTransfer(transfer.id);
|
|
|
|
|
fileUploads.delete(transfer.path);
|
|
|
|
|
fileUploadsById.delete(transfer.transferId);
|
|
|
|
|
listFiles(currentPath);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 00:53:22 -06:00
|
|
|
async function uploadFileViaHttpPull(file, path, transfer) {
|
|
|
|
|
console.debug("[filebrowser] upload request start", {
|
|
|
|
|
clientId,
|
|
|
|
|
path,
|
|
|
|
|
fileName: file.name,
|
|
|
|
|
size: file.size,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const requestRes = await fetch("/api/file/upload/request", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
credentials: "include",
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
clientId,
|
|
|
|
|
path,
|
|
|
|
|
fileName: file.name,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!requestRes.ok) {
|
|
|
|
|
const text = await requestRes.text();
|
|
|
|
|
throw new Error(text || "upload request failed");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const requestData = await requestRes.json();
|
|
|
|
|
const uploadUrl = typeof requestData?.uploadUrl === "string"
|
|
|
|
|
? requestData.uploadUrl
|
|
|
|
|
: (requestData?.uploadId
|
|
|
|
|
? `/api/file/upload/${encodeURIComponent(requestData.uploadId)}`
|
|
|
|
|
: "");
|
|
|
|
|
if (!uploadUrl) {
|
|
|
|
|
throw new Error("upload request failed");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.debug("[filebrowser] upload stage url", { uploadUrl });
|
|
|
|
|
|
2026-03-08 03:07:23 -05:00
|
|
|
const uploadRequestOptions = {
|
|
|
|
|
headers: { "Content-Type": "application/octet-stream" },
|
|
|
|
|
credentials: "include",
|
|
|
|
|
body: file,
|
|
|
|
|
signal: transfer.abortController?.signal,
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-08 00:53:22 -06:00
|
|
|
let uploadRes;
|
|
|
|
|
try {
|
|
|
|
|
uploadRes = await fetch(uploadUrl, {
|
2026-03-08 03:07:23 -05:00
|
|
|
method: "POST",
|
|
|
|
|
...uploadRequestOptions,
|
2026-03-08 00:53:22 -06:00
|
|
|
});
|
|
|
|
|
} catch (err) {
|
2026-03-08 03:07:23 -05:00
|
|
|
console.warn("[filebrowser] upload stage POST failed, retrying as PUT", err);
|
2026-03-08 00:53:22 -06:00
|
|
|
uploadRes = await fetch(uploadUrl, {
|
2026-03-08 03:07:23 -05:00
|
|
|
method: "PUT",
|
|
|
|
|
...uploadRequestOptions,
|
2026-03-08 00:53:22 -06:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!uploadRes.ok) {
|
|
|
|
|
const text = await uploadRes.text();
|
|
|
|
|
console.debug("[filebrowser] upload stage failed", {
|
|
|
|
|
status: uploadRes.status,
|
|
|
|
|
body: text,
|
|
|
|
|
});
|
|
|
|
|
throw new Error(text || "upload staging failed");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
transfer.receivedBytes = Math.round(file.size * 0.5);
|
|
|
|
|
transfer.sent = transfer.receivedBytes;
|
|
|
|
|
transfer.progress = 50;
|
|
|
|
|
updateTransferProgress(transfer.id, transfer.progress, transfer.sent, transfer.total);
|
|
|
|
|
|
|
|
|
|
const uploadData = await uploadRes.json();
|
|
|
|
|
if (!uploadData?.pullUrl) {
|
|
|
|
|
throw new Error("upload staging failed");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.debug("[filebrowser] upload staged", {
|
|
|
|
|
pullUrl: uploadData.pullUrl,
|
|
|
|
|
size: uploadData.size,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const commandId = `upload-http-${Date.now()}-${Math.random()}`;
|
|
|
|
|
const waitResult = waitForCommandResult(commandId, 12 * 60 * 1000);
|
|
|
|
|
send({
|
|
|
|
|
type: "command",
|
|
|
|
|
commandType: "file_upload_http",
|
|
|
|
|
id: commandId,
|
|
|
|
|
payload: {
|
|
|
|
|
path,
|
|
|
|
|
url: uploadData.pullUrl,
|
|
|
|
|
total: file.size,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await waitResult;
|
|
|
|
|
|
|
|
|
|
console.debug("[filebrowser] upload command completed", {
|
|
|
|
|
path,
|
|
|
|
|
size: file.size,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
transfer.receivedBytes = file.size;
|
|
|
|
|
transfer.sent = file.size;
|
|
|
|
|
transfer.progress = 100;
|
|
|
|
|
transfer.receivedChunks = transfer.expectedChunks;
|
|
|
|
|
updateTransferProgress(transfer.id, transfer.progress, transfer.sent, transfer.total);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 00:04:26 -06:00
|
|
|
async function uploadFile(file) {
|
|
|
|
|
const path = currentPath ? `${currentPath}/${file.name}` : file.name;
|
|
|
|
|
const transferId = `upload-${Date.now()}-${Math.random()}`;
|
|
|
|
|
|
|
|
|
|
console.log("Uploading:", path);
|
|
|
|
|
|
|
|
|
|
const transfer = {
|
|
|
|
|
id: transferId,
|
|
|
|
|
type: "upload",
|
|
|
|
|
path,
|
|
|
|
|
fileName: file.name,
|
|
|
|
|
progress: 0,
|
|
|
|
|
total: file.size,
|
|
|
|
|
sent: 0,
|
|
|
|
|
cancelled: false,
|
|
|
|
|
expectedChunks: 0,
|
|
|
|
|
receivedChunks: 0,
|
|
|
|
|
receivedBytes: 0,
|
|
|
|
|
pendingAcks: new Map(),
|
|
|
|
|
ackedOffsets: new Set(),
|
|
|
|
|
transferId,
|
|
|
|
|
completed: false,
|
2026-03-08 03:07:23 -05:00
|
|
|
abortController: new AbortController(),
|
2026-03-05 00:04:26 -06:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
fileUploads.set(path, transfer);
|
|
|
|
|
fileUploadsById.set(transferId, transfer);
|
|
|
|
|
activeTransfers.set(transferId, transfer);
|
|
|
|
|
addTransferToUI(transfer);
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-08 00:53:22 -06:00
|
|
|
await uploadFileViaHttpPull(file, path, transfer);
|
|
|
|
|
finishUpload(transfer);
|
2026-03-05 00:04:26 -06:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error("Upload error:", err);
|
|
|
|
|
removeTransfer(transferId);
|
|
|
|
|
fileUploads.delete(path);
|
|
|
|
|
fileUploadsById.delete(transferId);
|
|
|
|
|
alert(`Upload failed: ${err.message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mkdirBtn.onclick = () => {
|
|
|
|
|
const name = prompt("Enter folder name:");
|
|
|
|
|
if (!name) return;
|
|
|
|
|
const path = currentPath ? `${currentPath}/${name}` : name;
|
|
|
|
|
console.log("Creating directory:", path);
|
|
|
|
|
const commandId = `mkdir-${Date.now()}`;
|
|
|
|
|
send({ type: "file_mkdir", path, commandId });
|
|
|
|
|
trackCommandResult(commandId, {
|
|
|
|
|
refreshOnSuccess: true,
|
|
|
|
|
successMessage: "Folder created",
|
|
|
|
|
errorPrefix: "Create folder failed",
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
backBtn.onclick = () => goBack();
|
|
|
|
|
|
|
|
|
|
homeBtn.onclick = () => goHome();
|
|
|
|
|
|
|
|
|
|
pathGoBtn.onclick = () => {
|
|
|
|
|
const path = pathInput.value.trim();
|
|
|
|
|
if (path) {
|
|
|
|
|
pathHistory = [];
|
|
|
|
|
listFiles(path);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
pathInput.onkeydown = (e) => {
|
|
|
|
|
if (e.key === "Enter") {
|
|
|
|
|
const path = pathInput.value.trim();
|
|
|
|
|
if (path) {
|
|
|
|
|
pathHistory = [];
|
|
|
|
|
listFiles(path);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
document.addEventListener("click", (e) => {
|
|
|
|
|
if (!e.target.closest("#context-menu")) {
|
|
|
|
|
hideContextMenu();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-06 11:28:35 -06:00
|
|
|
setupDragAndDropUpload();
|
2026-03-05 00:04:26 -06:00
|
|
|
updateStatus("connecting", "Connecting...");
|
|
|
|
|
updateBackButton();
|
|
|
|
|
connect();
|
|
|
|
|
|
|
|
|
|
function addTransferToUI(transfer) {
|
|
|
|
|
const transferItem = document.createElement("div");
|
|
|
|
|
transferItem.id = `transfer-${transfer.id}`;
|
|
|
|
|
transferItem.className =
|
|
|
|
|
"transfer-item bg-slate-800/50 border border-slate-700 rounded-lg p-3";
|
|
|
|
|
|
|
|
|
|
const icon = transfer.type === "upload" ? "fa-upload" : "fa-download";
|
|
|
|
|
const color = transfer.type === "upload" ? "text-blue-400" : "text-green-400";
|
|
|
|
|
|
|
|
|
|
transferItem.innerHTML = `
|
|
|
|
|
<div class="flex items-center justify-between mb-2">
|
|
|
|
|
<div class="flex items-center gap-2 flex-1 min-w-0">
|
|
|
|
|
<i class="fa-solid ${icon} ${color}"></i>
|
|
|
|
|
<span class="text-sm truncate transfer-name"></span>
|
|
|
|
|
</div>
|
|
|
|
|
<button class="cancel-btn text-red-400 hover:text-red-300 px-2" type="button">
|
|
|
|
|
<i class="fa-solid fa-xmark"></i>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="progress-bar-container w-full bg-slate-700 rounded-full h-2 mb-1">
|
|
|
|
|
<div class="progress-bar bg-blue-500 h-2 rounded-full transition-all duration-300" style="width: ${transfer.progress}%"></div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex justify-between text-xs text-slate-400">
|
|
|
|
|
<span class="progress-text">${transfer.progress}%</span>
|
|
|
|
|
<span class="size-text">${formatBytes(transfer.sent || transfer.received || 0)} / ${formatBytes(transfer.total)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const nameEl = transferItem.querySelector(".transfer-name");
|
|
|
|
|
if (nameEl) {
|
|
|
|
|
nameEl.textContent = transfer.fileName;
|
|
|
|
|
}
|
|
|
|
|
const cancelBtn = transferItem.querySelector(".cancel-btn");
|
|
|
|
|
if (cancelBtn) {
|
|
|
|
|
cancelBtn.addEventListener("click", () => cancelTransfer(transfer.id));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
transferList.appendChild(transferItem);
|
|
|
|
|
transferPanel.classList.remove("hidden");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateTransferProgress(transferId, progress, current, total) {
|
|
|
|
|
const transferItem = document.getElementById(`transfer-${transferId}`);
|
|
|
|
|
if (!transferItem) return;
|
|
|
|
|
|
|
|
|
|
const progressBar = transferItem.querySelector(".progress-bar");
|
|
|
|
|
const progressText = transferItem.querySelector(".progress-text");
|
|
|
|
|
const sizeText = transferItem.querySelector(".size-text");
|
|
|
|
|
|
|
|
|
|
if (progressBar) progressBar.style.width = `${progress}%`;
|
|
|
|
|
if (progressText) progressText.textContent = `${progress}%`;
|
|
|
|
|
if (sizeText)
|
|
|
|
|
sizeText.textContent = `${formatBytes(current)} / ${formatBytes(total)}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function removeTransfer(transferId) {
|
|
|
|
|
const transferItem = document.getElementById(`transfer-${transferId}`);
|
|
|
|
|
if (transferItem) {
|
|
|
|
|
transferItem.remove();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
activeTransfers.delete(transferId);
|
|
|
|
|
|
|
|
|
|
if (transferList.children.length === 0) {
|
|
|
|
|
transferPanel.classList.add("hidden");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
window.cancelTransfer = function (transferId) {
|
|
|
|
|
const transfer = activeTransfers.get(transferId);
|
|
|
|
|
if (transfer) {
|
|
|
|
|
transfer.cancelled = true;
|
|
|
|
|
if (transfer.abortController) {
|
|
|
|
|
transfer.abortController.abort();
|
|
|
|
|
}
|
|
|
|
|
if (transfer.pendingAcks) {
|
|
|
|
|
transfer.pendingAcks.forEach((pending) => {
|
|
|
|
|
clearTimeout(pending.timeoutId);
|
|
|
|
|
pending.reject(new Error("Upload cancelled"));
|
|
|
|
|
});
|
|
|
|
|
transfer.pendingAcks.clear();
|
|
|
|
|
}
|
|
|
|
|
removeTransfer(transferId);
|
|
|
|
|
|
|
|
|
|
if (transfer.type === "upload") {
|
|
|
|
|
fileUploads.delete(transfer.path);
|
|
|
|
|
fileUploadsById.delete(transfer.transferId);
|
|
|
|
|
} else {
|
|
|
|
|
fileDownloads.delete(transfer.path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log("Transfer cancelled:", transferId);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function updateSelectionUI() {
|
|
|
|
|
const count = selectedFiles.size;
|
|
|
|
|
selectedCountEl.textContent = count;
|
|
|
|
|
|
|
|
|
|
if (count > 0) {
|
|
|
|
|
bulkActionsBar.classList.remove("hidden");
|
|
|
|
|
} else {
|
|
|
|
|
bulkActionsBar.classList.add("hidden");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
document.querySelectorAll(".file-item").forEach((row) => {
|
|
|
|
|
const path = row.dataset.path;
|
|
|
|
|
if (selectedFiles.has(path)) {
|
|
|
|
|
row.classList.add("selected");
|
|
|
|
|
} else {
|
|
|
|
|
row.classList.remove("selected");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clearSelection() {
|
|
|
|
|
selectedFiles.clear();
|
|
|
|
|
updateSelectionUI();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
searchBtn.addEventListener("click", () => {
|
|
|
|
|
searchBar.classList.toggle("hidden");
|
|
|
|
|
if (!searchBar.classList.contains("hidden")) {
|
|
|
|
|
searchInput.focus();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
searchCloseBtn.addEventListener("click", () => {
|
|
|
|
|
searchBar.classList.add("hidden");
|
|
|
|
|
searchInput.value = "";
|
|
|
|
|
listFiles(currentPath);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
searchExecuteBtn.addEventListener("click", () => {
|
|
|
|
|
const query = searchInput.value.trim();
|
|
|
|
|
if (!query) return;
|
|
|
|
|
|
|
|
|
|
const searchContent = searchContentCheckbox.checked;
|
|
|
|
|
performSearch(query, searchContent);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
searchInput.addEventListener("keypress", (e) => {
|
|
|
|
|
if (e.key === "Enter") {
|
|
|
|
|
searchExecuteBtn.click();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function performSearch(pattern, searchContent) {
|
2026-03-06 11:28:35 -06:00
|
|
|
clearVirtualizedListMode();
|
|
|
|
|
updateDirectorySummaryAndPaging(0, 0);
|
2026-03-05 00:04:26 -06:00
|
|
|
const searchId = `search-${Date.now()}`;
|
|
|
|
|
const cmdId = `search-cmd-${Date.now()}`;
|
|
|
|
|
const msg = {
|
|
|
|
|
type: "command",
|
|
|
|
|
commandType: "file_search",
|
|
|
|
|
id: cmdId,
|
|
|
|
|
payload: {
|
|
|
|
|
searchId,
|
|
|
|
|
path: currentPath || ".",
|
|
|
|
|
pattern,
|
|
|
|
|
searchContent,
|
|
|
|
|
maxResults: 500,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
send(msg);
|
|
|
|
|
|
|
|
|
|
fileListEl.innerHTML =
|
|
|
|
|
'<div class="px-4 py-6 text-center text-blue-400"><i class="fa-solid fa-circle-notch fa-spin mr-2"></i>Searching...</div>';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleFileSearchResult(msg) {
|
2026-03-06 11:28:35 -06:00
|
|
|
clearVirtualizedListMode();
|
|
|
|
|
updateDirectorySummaryAndPaging(0, 0);
|
2026-03-05 00:04:26 -06:00
|
|
|
if (msg.error) {
|
|
|
|
|
fileListEl.innerHTML = `<div class="px-4 py-6 text-center text-red-400"><i class="fa-solid fa-exclamation-triangle mr-2"></i>${escapeHtml(msg.error)}</div>`;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const results = msg.results || [];
|
|
|
|
|
|
|
|
|
|
if (results.length === 0) {
|
|
|
|
|
fileListEl.innerHTML =
|
|
|
|
|
'<div class="px-4 py-6 text-center text-slate-400"><i class="fa-solid fa-search mr-2"></i>No results found</div>';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fileListEl.innerHTML = "";
|
|
|
|
|
|
|
|
|
|
results.forEach((result) => {
|
|
|
|
|
const row = document.createElement("div");
|
|
|
|
|
row.className =
|
|
|
|
|
"file-item px-4 py-3 border border-slate-700 rounded cursor-pointer hover:bg-slate-800/50 mb-2";
|
|
|
|
|
|
|
|
|
|
const fileName = result.path.split(/[\/\\]/).pop();
|
|
|
|
|
const lineInfo = result.line ? ` (line ${result.line})` : "";
|
|
|
|
|
const matchPreview = result.match
|
|
|
|
|
? `<div class="text-xs text-slate-500 mt-1 font-mono">${escapeHtml(result.match.substring(0, 100))}</div>`
|
|
|
|
|
: "";
|
|
|
|
|
|
|
|
|
|
row.innerHTML = `
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
<i class="fa-solid fa-file text-slate-400"></i>
|
|
|
|
|
<div class="flex-1">
|
|
|
|
|
<div class="font-medium">${escapeHtml(fileName)}<span class="text-slate-500">${lineInfo}</span></div>
|
|
|
|
|
<div class="text-xs text-slate-400">${escapeHtml(result.path)}</div>
|
|
|
|
|
${matchPreview}
|
|
|
|
|
</div>
|
|
|
|
|
<button class="px-2 py-1 rounded hover:bg-slate-700" onclick="event.stopPropagation(); downloadFile('${escapeHtml(result.path)}')">
|
|
|
|
|
<i class="fa-solid fa-download"></i>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
row.onclick = () => {
|
|
|
|
|
openFileInEditor(result.path);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
fileListEl.appendChild(row);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openFileInEditor(path) {
|
|
|
|
|
const cmdId = `file-read-${Date.now()}`;
|
|
|
|
|
const msg = {
|
|
|
|
|
type: "command",
|
|
|
|
|
commandType: "file_read",
|
|
|
|
|
id: cmdId,
|
|
|
|
|
payload: {
|
|
|
|
|
path,
|
|
|
|
|
maxSize: 10 * 1024 * 1024,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
console.log("[DEBUG] Opening file in editor:", path);
|
|
|
|
|
console.log(
|
|
|
|
|
"[DEBUG] Sending file_read command:",
|
|
|
|
|
JSON.stringify(msg, null, 2),
|
|
|
|
|
);
|
|
|
|
|
console.log(
|
|
|
|
|
"[DEBUG] WebSocket state:",
|
|
|
|
|
ws?.readyState,
|
|
|
|
|
"OPEN=",
|
|
|
|
|
WebSocket.OPEN,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
send(msg);
|
|
|
|
|
currentEditingFile = path;
|
|
|
|
|
editorFileName.textContent = path.split(/[/\\\\]/).pop();
|
|
|
|
|
editorStatus.textContent = "Loading...";
|
|
|
|
|
fileEditorModal.classList.add("show");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleFileReadResult(msg) {
|
|
|
|
|
console.log("[DEBUG] handleFileReadResult called:", {
|
|
|
|
|
path: msg.path,
|
|
|
|
|
hasError: !!msg.error,
|
|
|
|
|
isBinary: msg.isBinary,
|
|
|
|
|
contentLength: msg.content?.length,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (msg.error) {
|
|
|
|
|
alert(`Error reading file: ${escapeHtml(msg.error)}`);
|
|
|
|
|
closeEditor();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (msg.isBinary) {
|
|
|
|
|
alert("Cannot edit binary file");
|
|
|
|
|
closeEditor();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log("[DEBUG] Setting editor content, length:", msg.content?.length);
|
|
|
|
|
|
|
|
|
|
editorTextarea.value = msg.content || "";
|
|
|
|
|
editorStatus.textContent = "Ready";
|
|
|
|
|
|
|
|
|
|
applySyntaxHighlighting();
|
|
|
|
|
editorTextarea.classList.add("hidden");
|
|
|
|
|
editorPreview.classList.remove("hidden");
|
|
|
|
|
editorPreviewTab.classList.add("bg-blue-600");
|
|
|
|
|
editorPreviewTab.classList.remove("bg-slate-700", "hover:bg-slate-600");
|
|
|
|
|
editorEditTab.classList.remove("bg-blue-600");
|
|
|
|
|
editorEditTab.classList.add("bg-slate-700", "hover:bg-slate-600");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function saveFileFromEditor() {
|
|
|
|
|
if (!currentEditingFile) return;
|
|
|
|
|
|
|
|
|
|
const content = editorTextarea.value;
|
|
|
|
|
const cmdId = `file-write-${Date.now()}`;
|
|
|
|
|
const msg = {
|
|
|
|
|
type: "command",
|
|
|
|
|
commandType: "file_write",
|
|
|
|
|
id: cmdId,
|
|
|
|
|
payload: {
|
|
|
|
|
path: currentEditingFile,
|
|
|
|
|
content,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
send(msg);
|
|
|
|
|
editorStatus.textContent = "Saving...";
|
|
|
|
|
editorSaveBtn.disabled = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function closeEditor() {
|
|
|
|
|
fileEditorModal.classList.remove("show");
|
|
|
|
|
editorTextarea.value = "";
|
|
|
|
|
currentEditingFile = null;
|
|
|
|
|
editorStatus.textContent = "Ready";
|
|
|
|
|
editorSaveBtn.disabled = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applySyntaxHighlighting() {
|
|
|
|
|
const code = editorTextarea.value;
|
|
|
|
|
const codeElement = document.getElementById("editor-code");
|
|
|
|
|
const fileName = currentEditingFile?.split(/[/\\\\]/).pop() || "";
|
|
|
|
|
|
|
|
|
|
const ext = fileName.split(".").pop()?.toLowerCase();
|
|
|
|
|
const languageMap = {
|
|
|
|
|
js: "javascript",
|
|
|
|
|
jsx: "javascript",
|
|
|
|
|
ts: "typescript",
|
|
|
|
|
tsx: "typescript",
|
|
|
|
|
py: "python",
|
|
|
|
|
rb: "ruby",
|
|
|
|
|
java: "java",
|
|
|
|
|
cpp: "cpp",
|
|
|
|
|
c: "c",
|
|
|
|
|
cs: "csharp",
|
|
|
|
|
php: "php",
|
|
|
|
|
go: "go",
|
|
|
|
|
rs: "rust",
|
|
|
|
|
sh: "bash",
|
|
|
|
|
bash: "bash",
|
|
|
|
|
bat: "powershell",
|
|
|
|
|
cmd: "powershell",
|
|
|
|
|
ps1: "powershell",
|
|
|
|
|
json: "json",
|
|
|
|
|
xml: "xml",
|
|
|
|
|
html: "html",
|
|
|
|
|
css: "css",
|
|
|
|
|
scss: "scss",
|
|
|
|
|
sql: "sql",
|
|
|
|
|
yaml: "yaml",
|
|
|
|
|
yml: "yaml",
|
|
|
|
|
md: "markdown",
|
|
|
|
|
txt: "plaintext",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const language = languageMap[ext] || "plaintext";
|
|
|
|
|
codeElement.className = `language-${language}`;
|
|
|
|
|
codeElement.textContent = code;
|
|
|
|
|
|
|
|
|
|
delete codeElement.dataset.highlighted;
|
|
|
|
|
|
|
|
|
|
if (window.hljs) {
|
|
|
|
|
hljs.highlightElement(codeElement);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const editorEditTab = document.getElementById("editor-edit-tab");
|
|
|
|
|
const editorPreviewTab = document.getElementById("editor-preview-tab");
|
|
|
|
|
const editorPreview = document.getElementById("editor-preview");
|
|
|
|
|
|
|
|
|
|
editorEditTab.addEventListener("click", () => {
|
|
|
|
|
editorTextarea.classList.remove("hidden");
|
|
|
|
|
editorPreview.classList.add("hidden");
|
|
|
|
|
editorEditTab.classList.add("bg-blue-600");
|
|
|
|
|
editorEditTab.classList.remove("bg-slate-700", "hover:bg-slate-600");
|
|
|
|
|
editorPreviewTab.classList.remove("bg-blue-600");
|
|
|
|
|
editorPreviewTab.classList.add("bg-slate-700", "hover:bg-slate-600");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
editorPreviewTab.addEventListener("click", () => {
|
|
|
|
|
applySyntaxHighlighting();
|
|
|
|
|
editorTextarea.classList.add("hidden");
|
|
|
|
|
editorPreview.classList.remove("hidden");
|
|
|
|
|
editorPreviewTab.classList.add("bg-blue-600");
|
|
|
|
|
editorPreviewTab.classList.remove("bg-slate-700", "hover:bg-slate-600");
|
|
|
|
|
editorEditTab.classList.remove("bg-blue-600");
|
|
|
|
|
editorEditTab.classList.add("bg-slate-700", "hover:bg-slate-600");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
editorSaveBtn.addEventListener("click", saveFileFromEditor);
|
|
|
|
|
editorCancelBtn.addEventListener("click", closeEditor);
|
|
|
|
|
editorCloseBtn.addEventListener("click", closeEditor);
|
|
|
|
|
|
|
|
|
|
const editorRunBtn = document.getElementById("editor-run-btn");
|
|
|
|
|
editorRunBtn.addEventListener("click", () => {
|
|
|
|
|
if (!currentEditingFile) return;
|
|
|
|
|
|
|
|
|
|
const ext = currentEditingFile.split(".").pop()?.toLowerCase();
|
|
|
|
|
let command = "";
|
|
|
|
|
|
|
|
|
|
const isWindows = currentPath.includes(":\\");
|
|
|
|
|
|
|
|
|
|
if (isWindows) {
|
|
|
|
|
switch (ext) {
|
|
|
|
|
case "bat":
|
|
|
|
|
case "cmd":
|
|
|
|
|
command = currentEditingFile;
|
|
|
|
|
break;
|
|
|
|
|
case "ps1":
|
|
|
|
|
command = `powershell.exe -ExecutionPolicy Bypass -File "${currentEditingFile}"`;
|
|
|
|
|
break;
|
|
|
|
|
case "exe":
|
|
|
|
|
case "com":
|
|
|
|
|
command = `"${currentEditingFile}"`;
|
|
|
|
|
break;
|
|
|
|
|
case "py":
|
|
|
|
|
command = `python "${currentEditingFile}"`;
|
|
|
|
|
break;
|
|
|
|
|
case "js":
|
|
|
|
|
command = `node "${currentEditingFile}"`;
|
|
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
command = `"${currentEditingFile}"`;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
switch (ext) {
|
|
|
|
|
case "sh":
|
|
|
|
|
case "bash":
|
|
|
|
|
command = `bash "${currentEditingFile}"`;
|
|
|
|
|
break;
|
|
|
|
|
case "py":
|
|
|
|
|
command = `python3 "${currentEditingFile}"`;
|
|
|
|
|
break;
|
|
|
|
|
case "rb":
|
|
|
|
|
command = `ruby "${currentEditingFile}"`;
|
|
|
|
|
break;
|
|
|
|
|
case "js":
|
|
|
|
|
command = `node "${currentEditingFile}"`;
|
|
|
|
|
break;
|
|
|
|
|
case "pl":
|
|
|
|
|
command = `perl "${currentEditingFile}"`;
|
|
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
command = `"${currentEditingFile}"`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
window.open(
|
|
|
|
|
`/${clientId}/console?cmd=${encodeURIComponent(command)}`,
|
|
|
|
|
"_blank",
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
bulkDownloadBtn.addEventListener("click", () => {
|
|
|
|
|
selectedFiles.forEach((path) => downloadFile(path));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
bulkDeleteBtn.addEventListener("click", () => {
|
|
|
|
|
if (!confirm(`Delete ${selectedFiles.size} selected items?`)) return;
|
|
|
|
|
|
|
|
|
|
selectedFiles.forEach((path) => {
|
|
|
|
|
send({ type: "file_delete", path });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
clearSelection();
|
|
|
|
|
setTimeout(() => listFiles(currentPath), 500);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
bulkMoveBtn.addEventListener("click", () => {
|
|
|
|
|
const dest = prompt("Enter destination path:");
|
|
|
|
|
if (!dest) return;
|
|
|
|
|
|
|
|
|
|
selectedFiles.forEach((path) => {
|
|
|
|
|
const fileName = path.split(/[\/\\]/).pop();
|
|
|
|
|
const destPath = `${dest}/${fileName}`;
|
|
|
|
|
send({ type: "file_move", source: path, dest: destPath });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
clearSelection();
|
|
|
|
|
setTimeout(() => listFiles(currentPath), 500);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
bulkCopyBtn.addEventListener("click", () => {
|
|
|
|
|
const dest = prompt("Enter destination path:");
|
|
|
|
|
if (!dest) return;
|
|
|
|
|
|
|
|
|
|
selectedFiles.forEach((path) => {
|
|
|
|
|
const fileName = path.split(/[\/\\]/).pop();
|
|
|
|
|
const destPath = `${dest}/${fileName}`;
|
|
|
|
|
send({ type: "file_copy", source: path, dest: destPath });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
clearSelection();
|
|
|
|
|
setTimeout(() => listFiles(currentPath), 500);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
clearSelectionBtn.addEventListener("click", clearSelection);
|
|
|
|
|
|
|
|
|
|
contextMenu.querySelectorAll(".context-menu-item").forEach((item) => {
|
|
|
|
|
item.addEventListener("click", () => {
|
|
|
|
|
const action = item.dataset.action;
|
|
|
|
|
const path = contextMenu.dataset.path;
|
|
|
|
|
const isDir = contextMenu.dataset.isDir === "true";
|
|
|
|
|
const entry = { path, isDir };
|
|
|
|
|
|
|
|
|
|
contextMenu.classList.remove("show");
|
|
|
|
|
handleFileAction(action, entry);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|