diff --git a/Overlord-Client/cmd/agent/handlers/command.go b/Overlord-Client/cmd/agent/handlers/command.go
index 418703c..ce045db 100644
--- a/Overlord-Client/cmd/agent/handlers/command.go
+++ b/Overlord-Client/cmd/agent/handlers/command.go
@@ -33,15 +33,16 @@ import (
var ErrReconnect = errors.New("reconnect requested")
var (
- activeCommands = make(map[string]context.CancelFunc)
- activeCommandsMu sync.Mutex
- voiceSessionMu sync.Mutex
- voiceSession *voiceRuntime
- desktopAudioMu sync.Mutex
- desktopAudioSession *voiceRuntime
- backstageInputOnce sync.Once
- backstageInputQueue chan backstageInputEvent
- backstageInputDropped atomic.Uint64
+ activeCommands = make(map[string]context.CancelFunc)
+ activeCommandsMu sync.Mutex
+ voiceSessionMu sync.Mutex
+ voiceSession *voiceRuntime
+ desktopAudioMu sync.Mutex
+ desktopAudioSession *voiceRuntime
+ backstageInputOnce sync.Once
+ backstageInputQueue chan backstageInputEvent
+ backstageInputDropped atomic.Uint64
+ fileHashSlots = make(chan struct{}, 2)
)
type backstageInputKind int
@@ -2697,8 +2698,17 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
payload, _ := envelope["payload"].(map[string]interface{})
path, _ := payload["path"].(string)
algorithm, _ := payload["algorithm"].(string)
+ hashCtx, cancel := context.WithCancel(ctx)
+ registerCancellableCommand(cmdID, cancel)
goSafe("file_hash", env.Cancel, func() {
- if err := HandleFileHash(ctx, env, cmdID, path, algorithm); err != nil && err != context.Canceled {
+ defer unregisterCommand(cmdID)
+ select {
+ case fileHashSlots <- struct{}{}:
+ defer func() { <-fileHashSlots }()
+ case <-hashCtx.Done():
+ return
+ }
+ if err := HandleFileHash(hashCtx, env, cmdID, path, algorithm); err != nil && err != context.Canceled {
log.Printf("file_hash error: %v", err)
}
})
diff --git a/Overlord-Client/cmd/agent/handlers/files.go b/Overlord-Client/cmd/agent/handlers/files.go
index 23ea69c..2714e0d 100644
--- a/Overlord-Client/cmd/agent/handlers/files.go
+++ b/Overlord-Client/cmd/agent/handlers/files.go
@@ -359,16 +359,30 @@ func HandleFileHash(ctx context.Context, env *agentRuntime.Env, cmdID string, pa
}
defer f.Close()
- if _, err := io.Copy(h, f); err != nil {
- return wire.WriteMsg(ctx, env.Conn, wire.FileHashResult{
- Type: "file_hash_result",
- CommandID: cmdID,
- Path: path,
- Algorithm: algorithm,
- Error: err.Error(),
- })
+ buf := make([]byte, 256*1024)
+ for {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ n, readErr := f.Read(buf)
+ if n > 0 {
+ if _, err := h.Write(buf[:n]); err != nil {
+ return err
+ }
+ }
+ if readErr == io.EOF {
+ break
+ }
+ if readErr != nil {
+ return wire.WriteMsg(ctx, env.Conn, wire.FileHashResult{
+ Type: "file_hash_result", CommandID: cmdID, Path: path,
+ Algorithm: algorithm, Error: readErr.Error(),
+ })
+ }
+ }
+ if err := ctx.Err(); err != nil {
+ return err
}
-
return wire.WriteMsg(ctx, env.Conn, wire.FileHashResult{
Type: "file_hash_result",
CommandID: cmdID,
diff --git a/Overlord-Server/public/assets/filebrowser-hex-hash.js b/Overlord-Server/public/assets/filebrowser-hex-hash.js
index 2eebd55..fa37afc 100644
--- a/Overlord-Server/public/assets/filebrowser-hex-hash.js
+++ b/Overlord-Server/public/assets/filebrowser-hex-hash.js
@@ -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");
}
diff --git a/Overlord-Server/public/assets/filebrowser-preview-modal.js b/Overlord-Server/public/assets/filebrowser-preview-modal.js
index a32ae17..c25fcbf 100644
--- a/Overlord-Server/public/assets/filebrowser-preview-modal.js
+++ b/Overlord-Server/public/assets/filebrowser-preview-modal.js
@@ -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 = '';
@@ -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 = `
File too large to preview (${escapeHtml(formatBytes(contentLength))})
`;
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 = `File too large to preview (${escapeHtml(formatBytes(arrayBuffer.byteLength))})
`;
- 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 = `${escapeHtml(err.message || "Failed to load preview")}
`;
if (previewStatusEl) previewStatusEl.textContent = "";
+ } finally {
+ if (currentPreviewAbortController === abortController) currentPreviewAbortController = null;
}
}
diff --git a/Overlord-Server/public/assets/filebrowser-utils.js b/Overlord-Server/public/assets/filebrowser-utils.js
index cf362ec..54f3a3d 100644
--- a/Overlord-Server/public/assets/filebrowser-utils.js
+++ b/Overlord-Server/public/assets/filebrowser-utils.js
@@ -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",
diff --git a/Overlord-Server/public/assets/filebrowser.js b/Overlord-Server/public/assets/filebrowser.js
index d2cb28d..edb9a26 100644
--- a/Overlord-Server/public/assets/filebrowser.js
+++ b/Overlord-Server/public/assets/filebrowser.js
@@ -9,6 +9,7 @@ import {
escapeHtml,
formatBytes,
getFileExt,
+ getHighlightLanguage,
getParentPath,
isPreviewable,
shouldShowParentDirectory,
@@ -140,7 +141,8 @@ function updateStatus(state, text) {
disconnected: '',
};
- 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) {
Zipping Directory
-