file browser improvements and hardening.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { escapeHtml, formatBytes } from "./filebrowser-utils.js";
|
||||
import { escapeHtml, formatBytes, getHighlightLanguage } from "./filebrowser-utils.js";
|
||||
|
||||
const KNOWN_HASHES_SHA256 = {
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855": "Empty file (0 bytes)",
|
||||
@@ -39,7 +39,23 @@ export function createFileHexHashManager({
|
||||
|
||||
function resetPreviewTextHead() {
|
||||
previewTextHeadHost?.classList.add("hidden");
|
||||
if (previewTextHead) previewTextHead.textContent = "";
|
||||
if (previewTextHead) {
|
||||
previewTextHead.textContent = "";
|
||||
previewTextHead.className = "";
|
||||
delete previewTextHead.dataset.highlighted;
|
||||
}
|
||||
}
|
||||
|
||||
function renderPreviewTextHead(text, fileName) {
|
||||
if (!previewTextHead) return;
|
||||
const requestedLanguage = getHighlightLanguage(fileName);
|
||||
const language = window.hljs?.getLanguage(requestedLanguage) ? requestedLanguage : "plaintext";
|
||||
previewTextHead.className = language === "plaintext" ? "hljs" : `language-${language}`;
|
||||
previewTextHead.textContent = text;
|
||||
delete previewTextHead.dataset.highlighted;
|
||||
if (window.hljs && language !== "plaintext") {
|
||||
window.hljs.highlightElement(previewTextHead);
|
||||
}
|
||||
}
|
||||
|
||||
function resetPreviewHash() {
|
||||
@@ -157,7 +173,7 @@ export function createFileHexHashManager({
|
||||
return;
|
||||
}
|
||||
const text = decodeTextHead(data);
|
||||
if (previewTextHead) previewTextHead.textContent = text;
|
||||
renderPreviewTextHead(text, req.fileName);
|
||||
previewTextHeadHost?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
|
||||
@@ -22,8 +22,11 @@ export function createFilePreviewModal({
|
||||
|
||||
let currentPreviewBlobUrl = null;
|
||||
let currentPreviewPath = null;
|
||||
let currentPreviewAbortController = null;
|
||||
|
||||
function closePreview() {
|
||||
currentPreviewAbortController?.abort();
|
||||
currentPreviewAbortController = null;
|
||||
if (filePreviewModal) filePreviewModal.classList.remove("show");
|
||||
if (currentPreviewBlobUrl) {
|
||||
URL.revokeObjectURL(currentPreviewBlobUrl);
|
||||
@@ -48,6 +51,9 @@ export function createFilePreviewModal({
|
||||
return;
|
||||
}
|
||||
|
||||
currentPreviewAbortController?.abort();
|
||||
const abortController = new AbortController();
|
||||
currentPreviewAbortController = abortController;
|
||||
currentPreviewPath = path;
|
||||
if (previewFileNameEl) previewFileNameEl.textContent = fileName;
|
||||
if (previewContent) previewContent.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin text-slate-400 text-2xl"></i>';
|
||||
@@ -59,7 +65,8 @@ export function createFilePreviewModal({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ clientId, path }),
|
||||
signal: abortController.signal,
|
||||
body: JSON.stringify({ clientId, path, preview: true }),
|
||||
});
|
||||
|
||||
if (!requestRes.ok) {
|
||||
@@ -87,6 +94,7 @@ export function createFilePreviewModal({
|
||||
const res = await fetch(downloadUrl, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -99,22 +107,37 @@ export function createFilePreviewModal({
|
||||
|
||||
const contentLength = Number(res.headers.get("Content-Length") || 0);
|
||||
if (contentLength > PREVIEW_MAX_BYTES) {
|
||||
await res.body?.cancel("preview size limit exceeded");
|
||||
if (currentPreviewPath !== path) return;
|
||||
if (previewContent) previewContent.innerHTML = `<div class="text-slate-400 text-sm text-center p-6"><i class="fa-solid fa-file mr-2"></i>File too large to preview (${escapeHtml(formatBytes(contentLength))})</div>`;
|
||||
if (previewStatusEl) previewStatusEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
if (!res.body) throw new Error("Preview response did not include a body");
|
||||
const reader = res.body.getReader();
|
||||
const chunks = [];
|
||||
let received = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
received += value.byteLength;
|
||||
if (received > PREVIEW_MAX_BYTES) {
|
||||
await reader.cancel("preview size limit exceeded");
|
||||
throw new Error(`File too large to preview (${formatBytes(received)})`);
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
const arrayBuffer = new Uint8Array(received);
|
||||
let chunkOffset = 0;
|
||||
for (const chunk of chunks) {
|
||||
arrayBuffer.set(chunk, chunkOffset);
|
||||
chunkOffset += chunk.byteLength;
|
||||
}
|
||||
|
||||
if (currentPreviewPath !== path) return;
|
||||
|
||||
if (arrayBuffer.byteLength > PREVIEW_MAX_BYTES) {
|
||||
if (previewContent) previewContent.innerHTML = `<div class="text-slate-400 text-sm text-center p-6"><i class="fa-solid fa-file mr-2"></i>File too large to preview (${escapeHtml(formatBytes(arrayBuffer.byteLength))})</div>`;
|
||||
if (previewStatusEl) previewStatusEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPreviewBlobUrl) {
|
||||
URL.revokeObjectURL(currentPreviewBlobUrl);
|
||||
currentPreviewBlobUrl = null;
|
||||
@@ -140,9 +163,12 @@ export function createFilePreviewModal({
|
||||
|
||||
if (previewStatusEl) previewStatusEl.textContent = "";
|
||||
} catch (err) {
|
||||
if (err?.name === "AbortError" || abortController.signal.aborted) return;
|
||||
if (currentPreviewPath !== path) return;
|
||||
if (previewContent) previewContent.innerHTML = `<div class="text-red-400 text-sm text-center p-6"><i class="fa-solid fa-exclamation-triangle mr-2"></i>${escapeHtml(err.message || "Failed to load preview")}</div>`;
|
||||
if (previewStatusEl) previewStatusEl.textContent = "";
|
||||
} finally {
|
||||
if (currentPreviewAbortController === abortController) currentPreviewAbortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,40 @@ export const PREVIEW_IMAGE_EXTS = new Set(["jpg", "jpeg", "png", "gif", "webp",
|
||||
export const PREVIEW_PDF_EXTS = new Set(["pdf"]);
|
||||
export const PREVIEW_MAX_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
const HIGHLIGHT_FILENAME_MAP = {
|
||||
dockerfile: "dockerfile",
|
||||
makefile: "makefile",
|
||||
gnumakefile: "makefile",
|
||||
"nginx.conf": "nginx",
|
||||
".env": "bash",
|
||||
};
|
||||
|
||||
const HIGHLIGHT_EXTENSION_MAP = {
|
||||
js: "javascript", mjs: "javascript", cjs: "javascript", jsx: "javascript",
|
||||
ts: "typescript", mts: "typescript", cts: "typescript", tsx: "typescript",
|
||||
py: "python", pyw: "python", rb: "ruby", java: "java",
|
||||
cpp: "cpp", cxx: "cpp", cc: "cpp", hpp: "cpp", hxx: "cpp",
|
||||
c: "c", h: "c", cs: "csharp", php: "php", phtml: "php",
|
||||
go: "go", rs: "rust",
|
||||
sh: "bash", bash: "bash", zsh: "bash", ksh: "bash",
|
||||
bat: "powershell", cmd: "powershell", ps1: "powershell", psm1: "powershell", psd1: "powershell",
|
||||
json: "json", jsonc: "json", json5: "json",
|
||||
xml: "xml", svg: "xml", xsl: "xml", xslt: "xml", plist: "xml",
|
||||
html: "xml", htm: "xml", xhtml: "xml", vue: "xml", svelte: "xml",
|
||||
css: "css", scss: "scss", sass: "scss", sql: "sql",
|
||||
yaml: "yaml", yml: "yaml", md: "markdown", markdown: "markdown",
|
||||
ini: "ini", cfg: "ini", properties: "ini", toml: "ini",
|
||||
diff: "diff", patch: "diff", dockerfile: "dockerfile", makefile: "makefile", nginx: "nginx",
|
||||
};
|
||||
|
||||
export function getHighlightLanguage(name = "") {
|
||||
const fileName = name.split(/[\\/]/).pop()?.toLowerCase() || "";
|
||||
if (HIGHLIGHT_FILENAME_MAP[fileName]) return HIGHLIGHT_FILENAME_MAP[fileName];
|
||||
if (fileName.startsWith("dockerfile.")) return "dockerfile";
|
||||
if (fileName.startsWith(".env.")) return "bash";
|
||||
return HIGHLIGHT_EXTENSION_MAP[getFileExt(fileName)] || "plaintext";
|
||||
}
|
||||
|
||||
export const KNOWN_BINARY_EXTS = new Set([
|
||||
"mp4", "avi", "mkv", "mov", "wmv", "flv", "webm", "m4v",
|
||||
"mp3", "wav", "flac", "ogg", "aac", "wma", "m4a",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
escapeHtml,
|
||||
formatBytes,
|
||||
getFileExt,
|
||||
getHighlightLanguage,
|
||||
getParentPath,
|
||||
isPreviewable,
|
||||
shouldShowParentDirectory,
|
||||
@@ -140,7 +141,8 @@ function updateStatus(state, text) {
|
||||
disconnected: '<i class="fa-solid fa-circle text-slate-500"></i>',
|
||||
};
|
||||
|
||||
statusEl.innerHTML = `${icons[state] || icons.disconnected} ${text}`;
|
||||
statusEl.innerHTML = icons[state] || icons.disconnected;
|
||||
statusEl.appendChild(document.createTextNode(` ${String(text ?? "")}`));
|
||||
const stateClasses = {
|
||||
connected: "bg-emerald-500/10 text-emerald-300 border-emerald-500/30",
|
||||
error: "bg-red-500/10 text-red-300 border-red-500/30",
|
||||
@@ -662,6 +664,28 @@ const THUMBNAIL_EXTS = new Set([
|
||||
// key → { blobUrl?: string, failed?: boolean, pending?: boolean }
|
||||
const iconCache = new Map();
|
||||
const thumbCache = new Map();
|
||||
const MAX_ICON_CACHE_ENTRIES = 256;
|
||||
const MAX_THUMB_CACHE_ENTRIES = 128;
|
||||
|
||||
function trimBlobCache(cache, maxEntries) {
|
||||
while (cache.size > maxEntries) {
|
||||
const oldestKey = cache.keys().next().value;
|
||||
const entry = cache.get(oldestKey);
|
||||
if (entry?.blobUrl) URL.revokeObjectURL(entry.blobUrl);
|
||||
cache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
function clearBlobCaches() {
|
||||
for (const cache of [iconCache, thumbCache]) {
|
||||
for (const entry of cache.values()) {
|
||||
if (entry?.blobUrl) URL.revokeObjectURL(entry.blobUrl);
|
||||
}
|
||||
cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("pagehide", clearBlobCaches, { once: true });
|
||||
|
||||
// Queues + batching
|
||||
const iconQueue = []; // {key, path?, ext?}
|
||||
@@ -743,8 +767,14 @@ function flushThumbQueue() {
|
||||
function requestIconFor(entry) {
|
||||
const key = iconCacheKey(entry);
|
||||
if (!key) return null;
|
||||
if (iconCache.has(key)) return key;
|
||||
if (iconCache.has(key)) {
|
||||
const cached = iconCache.get(key);
|
||||
iconCache.delete(key);
|
||||
iconCache.set(key, cached);
|
||||
return key;
|
||||
}
|
||||
iconCache.set(key, { pending: true });
|
||||
trimBlobCache(iconCache, MAX_ICON_CACHE_ENTRIES);
|
||||
const item = { key };
|
||||
if (key.startsWith("path:")) {
|
||||
item.path = entry.path;
|
||||
@@ -761,8 +791,14 @@ function requestIconFor(entry) {
|
||||
function requestThumbFor(entry) {
|
||||
const key = thumbCacheKey(entry);
|
||||
if (!key) return null;
|
||||
if (thumbCache.has(key)) return key;
|
||||
if (thumbCache.has(key)) {
|
||||
const cached = thumbCache.get(key);
|
||||
thumbCache.delete(key);
|
||||
thumbCache.set(key, cached);
|
||||
return key;
|
||||
}
|
||||
thumbCache.set(key, { pending: true });
|
||||
trimBlobCache(thumbCache, MAX_THUMB_CACHE_ENTRIES);
|
||||
thumbQueue.push({ key, path: entry.path, size: THUMB_EDGE });
|
||||
scheduleThumbFlush();
|
||||
return key;
|
||||
@@ -776,12 +812,15 @@ function handleFileIconResult(msg) {
|
||||
const entry = iconCache.get(item.key) || {};
|
||||
entry.pending = false;
|
||||
if (item.png && item.png.length > 0) {
|
||||
if (entry.blobUrl) URL.revokeObjectURL(entry.blobUrl);
|
||||
const blob = new Blob([item.png], { type: "image/png" });
|
||||
entry.blobUrl = URL.createObjectURL(blob);
|
||||
} else {
|
||||
entry.failed = true;
|
||||
}
|
||||
iconCache.delete(item.key);
|
||||
iconCache.set(item.key, entry);
|
||||
trimBlobCache(iconCache, MAX_ICON_CACHE_ENTRIES);
|
||||
applyIconToDom(item.key, entry);
|
||||
}
|
||||
}
|
||||
@@ -796,6 +835,7 @@ function handleFileThumbResult(msg) {
|
||||
const entry = thumbCache.get(item.key) || {};
|
||||
entry.pending = false;
|
||||
if (item.jpeg && item.jpeg.length > 0) {
|
||||
if (entry.blobUrl) URL.revokeObjectURL(entry.blobUrl);
|
||||
const blob = new Blob([item.jpeg], { type: "image/jpeg" });
|
||||
entry.blobUrl = URL.createObjectURL(blob);
|
||||
entry.w = item.w || 0;
|
||||
@@ -803,7 +843,9 @@ function handleFileThumbResult(msg) {
|
||||
} else {
|
||||
entry.failed = true;
|
||||
}
|
||||
thumbCache.delete(item.key);
|
||||
thumbCache.set(item.key, entry);
|
||||
trimBlobCache(thumbCache, MAX_THUMB_CACHE_ENTRIES);
|
||||
applyThumbToDom(item.key, entry);
|
||||
}
|
||||
}
|
||||
@@ -2120,7 +2162,7 @@ function showProgressNotification(commandId, message, path) {
|
||||
<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)">
|
||||
<button class="cancel-zip-operation text-slate-400 hover:text-red-400 transition-colors" data-command-id="${escapeHtml(commandId)}">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -2128,6 +2170,11 @@ function showProgressNotification(commandId, message, path) {
|
||||
<div class="text-xs text-slate-500 truncate" title="${escapeHtml(path)}">${escapeHtml(path)}</div>
|
||||
`;
|
||||
|
||||
notification.querySelector(".cancel-zip-operation")?.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
cancelZipOperation(commandId);
|
||||
});
|
||||
|
||||
document.body.appendChild(notification);
|
||||
activeProgressNotifications.set(commandId, notification);
|
||||
}
|
||||
@@ -3375,7 +3422,8 @@ function handleFileSearchResult(msg) {
|
||||
"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 lineNumber = Number(result.line);
|
||||
const lineInfo = Number.isSafeInteger(lineNumber) && lineNumber > 0 ? ` (line ${lineNumber})` : "";
|
||||
const matchPreview = result.match
|
||||
? `<div class="text-xs text-slate-500 mt-1 font-mono">${escapeHtml(result.match.substring(0, 100))}</div>`
|
||||
: "";
|
||||
@@ -3390,12 +3438,17 @@ function handleFileSearchResult(msg) {
|
||||
<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)}')">
|
||||
<button class="search-result-download px-2 py-1 rounded hover:bg-slate-700">
|
||||
<i class="fa-solid fa-download"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
row.querySelector(".search-result-download")?.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
downloadFile(String(result.path || ""));
|
||||
});
|
||||
|
||||
row.onclick = async () => {
|
||||
const resultName = result.path.split(/[\/\\]/).pop() || "";
|
||||
if (isPreviewable(resultName)) {
|
||||
@@ -3507,48 +3560,23 @@ function closeEditor() {
|
||||
|
||||
function applySyntaxHighlighting() {
|
||||
const code = editorTextarea.value;
|
||||
const MAX_HIGHLIGHT_CHARS = 512 * 1024;
|
||||
const displayedCode = code.length > MAX_HIGHLIGHT_CHARS
|
||||
? `${code.slice(0, MAX_HIGHLIGHT_CHARS)}\n\n[Syntax preview truncated for performance]`
|
||||
: code;
|
||||
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;
|
||||
const requestedLanguage = getHighlightLanguage(fileName);
|
||||
const language = window.hljs?.getLanguage(requestedLanguage) ? requestedLanguage : "plaintext";
|
||||
codeElement.className = language === "plaintext" ? "hljs" : `language-${language}`;
|
||||
codeElement.textContent = displayedCode;
|
||||
if (code.length > MAX_HIGHLIGHT_CHARS) {
|
||||
editorStatus.textContent = `Ready — syntax preview limited to ${Math.round(MAX_HIGHLIGHT_CHARS / 1024)} KB`;
|
||||
}
|
||||
|
||||
delete codeElement.dataset.highlighted;
|
||||
|
||||
if (window.hljs) {
|
||||
if (window.hljs && language !== "plaintext") {
|
||||
hljs.highlightElement(codeElement);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -760,7 +760,7 @@
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<button id="preview-hex-btn" class="inline-flex items-center justify-center gap-2 px-2 py-1.5 rounded bg-slate-800 hover:bg-slate-700 text-xs text-slate-100" type="button">
|
||||
<i class="fa-solid fa-magnifying-glass-chart"></i> Hex peek
|
||||
<i class="fa-solid fa-table-cells"></i> Hex preview (4 KB)
|
||||
</button>
|
||||
<button id="preview-hash-btn" class="inline-flex items-center justify-center gap-2 px-2 py-1.5 rounded bg-slate-800 hover:bg-slate-700 text-xs text-slate-100" type="button">
|
||||
<i class="fa-solid fa-fingerprint"></i> SHA-256
|
||||
@@ -821,7 +821,7 @@
|
||||
<i class="fa-solid fa-star-half-stroke mr-2"></i> Unpin
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="hex_peek">
|
||||
<i class="fa-solid fa-magnifying-glass-chart mr-2"></i> Hex peek (4 KB)
|
||||
<i class="fa-solid fa-table-cells mr-2"></i> Hex preview (4 KB)
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="hash">
|
||||
<i class="fa-solid fa-fingerprint mr-2"></i> SHA-256
|
||||
@@ -837,8 +837,8 @@
|
||||
<div class="hex-viewer-content">
|
||||
<div class="px-4 py-3 bg-slate-800 border-b border-slate-700 flex items-center justify-between flex-shrink-0">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<i class="fa-solid fa-magnifying-glass-chart text-blue-400 flex-shrink-0"></i>
|
||||
<span class="font-semibold truncate" id="hex-viewer-file">--</span>
|
||||
<i class="fa-solid fa-table-cells text-blue-400 flex-shrink-0"></i>
|
||||
<div class="min-w-0"><div class="text-[10px] uppercase tracking-wider text-slate-500">Hex preview</div><div class="font-semibold truncate" id="hex-viewer-file">--</div></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0 ml-2">
|
||||
<span id="hex-viewer-info" class="text-xs text-slate-400"></span>
|
||||
|
||||
Reference in New Issue
Block a user