From cf191ca8211837bc9cefc66e3fc3cae8d1a81daf Mon Sep 17 00:00:00 2001 From: pptx704 Date: Sat, 11 Apr 2026 05:43:32 +0600 Subject: [PATCH] Harden file browser: cap preview lines, fix race conditions, download UX - Cap text preview at 5,000 lines with truncation footer and download link to prevent browser freeze on large files (300k+ DOM nodes) - Add request generation counters to discard stale API responses from rapid directory/file clicking - Guard initial $effect with hasInitiallyLoaded to prevent double-load - Add download loading state with spinner and disabled button - Delay URL.revokeObjectURL by 5s so browser can start download --- frontend/src/lib/api/files.ts | 3 +- frontend/src/lib/components/FilesTab.svelte | 57 ++++++++++++++++----- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/frontend/src/lib/api/files.ts b/frontend/src/lib/api/files.ts index c0f1852..c1d664c 100644 --- a/frontend/src/lib/api/files.ts +++ b/frontend/src/lib/api/files.ts @@ -120,5 +120,6 @@ export async function downloadFile(sandboxId: string, path: string, filename: st document.body.appendChild(a); a.click(); a.remove(); - URL.revokeObjectURL(url); + // Delay revocation so the browser has time to start the download + setTimeout(() => URL.revokeObjectURL(url), 5000); } diff --git a/frontend/src/lib/components/FilesTab.svelte b/frontend/src/lib/components/FilesTab.svelte index 38119a0..f204fed 100644 --- a/frontend/src/lib/components/FilesTab.svelte +++ b/frontend/src/lib/components/FilesTab.svelte @@ -27,6 +27,13 @@ let fileContent = $state(null); let fileLoading = $state(false); let fileError = $state(null); + let downloading = $state(false); + + // Request generation counters — discard stale responses from rapid clicks + let dirGeneration = 0; + let fileGeneration = 0; + + const MAX_PREVIEW_LINES = 5000; // Path input let pathInput = $state('~'); @@ -98,7 +105,9 @@ if (!isRunning) return; dirLoading = true; dirError = null; + const gen = ++dirGeneration; const result = await listDir(sandboxId, currentPath); + if (gen !== dirGeneration) return; // stale response if (result.ok) { entries = result.data.entries ?? []; // Resolve actual path when envd expanded ~ or a relative path @@ -130,12 +139,12 @@ } fileLoading = true; + const gen = ++fileGeneration; const result = await readFile(sandboxId, entry.path); + if (gen !== fileGeneration) return; // stale response — user clicked another file if (result.ok) { - // Check if content appears to be binary (contains null bytes or mostly non-printable) if (looksLikeBinary(result.data)) { fileContent = null; - // Will show download prompt } else { fileContent = result.data; } @@ -158,12 +167,14 @@ } async function handleDownload() { - if (!selectedFile) return; + if (!selectedFile || downloading) return; + downloading = true; try { await downloadFile(sandboxId, selectedFile.path, selectedFile.name); } catch { fileError = 'Download failed'; } + downloading = false; } function handlePathSubmit(e: SubmitEvent) { @@ -242,10 +253,11 @@ } // Load initial directory on mount, falling back to / if home can't be resolved + let hasInitiallyLoaded = false; $effect(() => { - if (isRunning) { + if (isRunning && !hasInitiallyLoaded) { + hasInitiallyLoaded = true; loadDir().then(() => { - // If ~ couldn't be resolved (empty dir or error), fall back to / if (!currentPath.startsWith('/')) { currentPath = '/'; pathInput = '/'; @@ -537,13 +549,18 @@ {formatFileSize(selectedFile.size)} @@ -614,10 +631,24 @@ {:else if fileContent !== null} - + + {@const allLines = fileContent.split('\n')} + {@const lines = allLines.length > MAX_PREVIEW_LINES ? allLines.slice(0, MAX_PREVIEW_LINES) : allLines} + {@const truncated = allLines.length > MAX_PREVIEW_LINES}
-
{#each fileContent.split('\n') as line, i}
{i + 1}{line || ' '}
{/each}
+
{#each lines as line, i}
{i + 1}{line || ' '}
{/each}
+ {#if truncated} +
+ + Showing {MAX_PREVIEW_LINES.toLocaleString()} of {allLines.length.toLocaleString()} lines + + +
+ {/if} {/if} {/if}