forked from wrenn/wrenn
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
This commit is contained in:
@ -120,5 +120,6 @@ export async function downloadFile(sandboxId: string, path: string, filename: st
|
|||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
a.remove();
|
a.remove();
|
||||||
URL.revokeObjectURL(url);
|
// Delay revocation so the browser has time to start the download
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,6 +27,13 @@
|
|||||||
let fileContent = $state<string | null>(null);
|
let fileContent = $state<string | null>(null);
|
||||||
let fileLoading = $state(false);
|
let fileLoading = $state(false);
|
||||||
let fileError = $state<string | null>(null);
|
let fileError = $state<string | null>(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
|
// Path input
|
||||||
let pathInput = $state('~');
|
let pathInput = $state('~');
|
||||||
@ -98,7 +105,9 @@
|
|||||||
if (!isRunning) return;
|
if (!isRunning) return;
|
||||||
dirLoading = true;
|
dirLoading = true;
|
||||||
dirError = null;
|
dirError = null;
|
||||||
|
const gen = ++dirGeneration;
|
||||||
const result = await listDir(sandboxId, currentPath);
|
const result = await listDir(sandboxId, currentPath);
|
||||||
|
if (gen !== dirGeneration) return; // stale response
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
entries = result.data.entries ?? [];
|
entries = result.data.entries ?? [];
|
||||||
// Resolve actual path when envd expanded ~ or a relative path
|
// Resolve actual path when envd expanded ~ or a relative path
|
||||||
@ -130,12 +139,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
fileLoading = true;
|
fileLoading = true;
|
||||||
|
const gen = ++fileGeneration;
|
||||||
const result = await readFile(sandboxId, entry.path);
|
const result = await readFile(sandboxId, entry.path);
|
||||||
|
if (gen !== fileGeneration) return; // stale response — user clicked another file
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
// Check if content appears to be binary (contains null bytes or mostly non-printable)
|
|
||||||
if (looksLikeBinary(result.data)) {
|
if (looksLikeBinary(result.data)) {
|
||||||
fileContent = null;
|
fileContent = null;
|
||||||
// Will show download prompt
|
|
||||||
} else {
|
} else {
|
||||||
fileContent = result.data;
|
fileContent = result.data;
|
||||||
}
|
}
|
||||||
@ -158,12 +167,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDownload() {
|
async function handleDownload() {
|
||||||
if (!selectedFile) return;
|
if (!selectedFile || downloading) return;
|
||||||
|
downloading = true;
|
||||||
try {
|
try {
|
||||||
await downloadFile(sandboxId, selectedFile.path, selectedFile.name);
|
await downloadFile(sandboxId, selectedFile.path, selectedFile.name);
|
||||||
} catch {
|
} catch {
|
||||||
fileError = 'Download failed';
|
fileError = 'Download failed';
|
||||||
}
|
}
|
||||||
|
downloading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePathSubmit(e: SubmitEvent) {
|
function handlePathSubmit(e: SubmitEvent) {
|
||||||
@ -242,10 +253,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load initial directory on mount, falling back to / if home can't be resolved
|
// Load initial directory on mount, falling back to / if home can't be resolved
|
||||||
|
let hasInitiallyLoaded = false;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (isRunning) {
|
if (isRunning && !hasInitiallyLoaded) {
|
||||||
|
hasInitiallyLoaded = true;
|
||||||
loadDir().then(() => {
|
loadDir().then(() => {
|
||||||
// If ~ couldn't be resolved (empty dir or error), fall back to /
|
|
||||||
if (!currentPath.startsWith('/')) {
|
if (!currentPath.startsWith('/')) {
|
||||||
currentPath = '/';
|
currentPath = '/';
|
||||||
pathInput = '/';
|
pathInput = '/';
|
||||||
@ -537,13 +549,18 @@
|
|||||||
<span class="font-mono text-badge text-[var(--color-text-muted)]">{formatFileSize(selectedFile.size)}</span>
|
<span class="font-mono text-badge text-[var(--color-text-muted)]">{formatFileSize(selectedFile.size)}</span>
|
||||||
<button
|
<button
|
||||||
onclick={handleDownload}
|
onclick={handleDownload}
|
||||||
class="flex items-center gap-1.5 rounded-[var(--radius-button)] border border-[var(--color-border)] bg-[var(--color-bg-3)] px-2.5 py-1 text-badge font-semibold uppercase tracking-[0.05em] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-bg-4)] hover:text-[var(--color-text-primary)]"
|
disabled={downloading}
|
||||||
|
class="flex items-center gap-1.5 rounded-[var(--radius-button)] border border-[var(--color-border)] bg-[var(--color-bg-3)] px-2.5 py-1 text-badge font-semibold uppercase tracking-[0.05em] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-bg-4)] hover:text-[var(--color-text-primary)] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
|
{#if downloading}
|
||||||
|
<svg class="animate-spin" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
|
||||||
|
{:else}
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
<polyline points="7 10 12 15 17 10" />
|
<polyline points="7 10 12 15 17 10" />
|
||||||
<line x1="12" y1="15" x2="12" y2="3" />
|
<line x1="12" y1="15" x2="12" y2="3" />
|
||||||
</svg>
|
</svg>
|
||||||
|
{/if}
|
||||||
Download
|
Download
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -614,10 +631,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if fileContent !== null}
|
{:else if fileContent !== null}
|
||||||
<!-- Text preview with line numbers -->
|
<!-- Text preview with line numbers (capped at MAX_PREVIEW_LINES) -->
|
||||||
|
{@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}
|
||||||
<div style="animation: fadeUp 0.15s ease both">
|
<div style="animation: fadeUp 0.15s ease both">
|
||||||
<pre class="preview-code p-0 m-0"><code class="block">{#each fileContent.split('\n') as line, i}<div class="code-line flex"><span class="line-num sticky left-0 inline-block w-[52px] shrink-0 select-none border-r border-[var(--color-border)] bg-[var(--color-bg-1)] px-3 py-0 text-right font-mono text-badge leading-[1.65rem] text-[var(--color-text-muted)] transition-colors duration-75">{i + 1}</span><span class="line-content flex-1 whitespace-pre-wrap break-all px-4 py-0 font-mono text-meta leading-[1.65rem] text-[var(--color-text-secondary)] transition-colors duration-75">{line || ' '}</span></div>{/each}</code></pre>
|
<pre class="preview-code p-0 m-0"><code class="block">{#each lines as line, i}<div class="code-line flex"><span class="line-num sticky left-0 inline-block w-[52px] shrink-0 select-none border-r border-[var(--color-border)] bg-[var(--color-bg-1)] px-3 py-0 text-right font-mono text-badge leading-[1.65rem] text-[var(--color-text-muted)] transition-colors duration-75">{i + 1}</span><span class="line-content flex-1 whitespace-pre-wrap break-all px-4 py-0 font-mono text-meta leading-[1.65rem] text-[var(--color-text-secondary)] transition-colors duration-75">{line || ' '}</span></div>{/each}</code></pre>
|
||||||
</div>
|
</div>
|
||||||
|
{#if truncated}
|
||||||
|
<div class="flex items-center justify-center gap-2 border-t border-[var(--color-border)] bg-[var(--color-bg-2)] px-4 py-3">
|
||||||
|
<span class="text-meta text-[var(--color-text-tertiary)]">
|
||||||
|
Showing {MAX_PREVIEW_LINES.toLocaleString()} of {allLines.length.toLocaleString()} lines
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onclick={handleDownload}
|
||||||
|
class="font-mono text-meta text-[var(--color-accent-mid)] transition-colors hover:text-[var(--color-accent-bright)]"
|
||||||
|
>Download full file</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user