perf(studio): speed up nitro scan and stop the cleanup panel freezing
CI / check (push) Successful in 4m13s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m0s

This commit is contained in:
openhands committed 2026-09-19 11:39:19 +02:00
1 parent 91e649398c
commit c916e42572
2 files changed
+77 -42

No files matched your search

@@ -63,6 +63,13 @@ type AssetKind = "nitro" | "swf" | "icon";
const DAY_MS = 24 * 60 * 60 * 1000; const DAY_MS = 24 * 60 * 60 * 1000;
/**
* How many rows a group renders at once. Directories can contain tens of
* thousands of leftover files; mounting every row as DOM nodes freezes the
* tab, so rows are added in chunks via a "load more" button.
*/
const RENDER_CHUNK = 500;
function ageLabel(lastModified: number): string { function ageLabel(lastModified: number): string {
if (!lastModified) return "age unknown"; if (!lastModified) return "age unknown";
const days = Math.floor((Date.now() - lastModified) / DAY_MS); const days = Math.floor((Date.now() - lastModified) / DAY_MS);
@@ -103,8 +110,11 @@ function CleanupGroup({
onToggle: (fileName: string) => void; onToggle: (fileName: string) => void;
onToggleAll: (names: string[]) => void; onToggleAll: (names: string[]) => void;
}) { }) {
const [shown, setShown] = useState(RENDER_CHUNK);
const checked = entries.filter((e) => selected.has(e.fileName)); const checked = entries.filter((e) => selected.has(e.fileName));
const allChecked = entries.length > 0 && checked.length === entries.length; const allChecked = entries.length > 0 && checked.length === entries.length;
const visibleEntries = entries.slice(0, shown);
const toneClass = const toneClass =
tone === "error" tone === "error"
? "border-[var(--admin-error-border)] bg-[var(--admin-error-subtle)]" ? "border-[var(--admin-error-border)] bg-[var(--admin-error-subtle)]"
@@ -148,45 +158,63 @@ function CleanupGroup({
No {label.toLowerCase()} found No {label.toLowerCase()} found
</p> </p>
) : ( ) : (
<ul className="max-h-80 space-y-1 overflow-auto rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-2"> <>
{entries.map((entry) => ( <ul className="max-h-80 space-y-1 overflow-auto rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-2">
<li {visibleEntries.map((entry) => (
key={entry.fileName} <li
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-[var(--admin-accent)]/5" key={entry.fileName}
> className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-[var(--admin-accent)]/5"
<Checkbox >
style={{ display: "inline-flex", width: 16, height: 16 }} <Checkbox
className="border-[var(--admin-border)] bg-[var(--admin-surface)] data-checked:border-[var(--admin-accent)] data-checked:bg-[var(--admin-accent)] data-checked:text-[var(--admin-accent-foreground)]" style={{ display: "inline-flex", width: 16, height: 16 }}
checked={selected.has(entry.fileName)} className="border-[var(--admin-border)] bg-[var(--admin-surface)] data-checked:border-[var(--admin-accent)] data-checked:bg-[var(--admin-accent)] data-checked:text-[var(--admin-accent-foreground)]"
checked={selected.has(entry.fileName)}
disabled={disabled}
onCheckedChange={() => onToggle(entry.fileName)}
aria-label={`Select ${entry.fileName}`}
/>
<code className="min-w-0 flex-1 truncate font-mono text-xs">
{entry.fileName}
</code>
<span className="shrink-0 text-xs text-[var(--admin-text-muted)]">
{formatBytes(entry.size)}
</span>
<span className="shrink-0 text-xs text-[var(--admin-text-muted)]">
{ageLabel(entry.lastModified)}
</span>
{entry.error && (
<span
title={entry.error}
className="hidden max-w-[320px] truncate text-xs text-[var(--admin-error)] lg:block"
>
{entry.error}
</span>
)}
{entry.dirs.length > 1 && (
<span className="shrink-0 rounded bg-[var(--admin-accent)]/10 px-1.5 py-0.5 text-[10px] font-medium text-[var(--admin-text-muted)]">
{entry.dirs.length} copies
</span>
)}
</li>
))}
</ul>
{entries.length > shown && (
<div className="flex items-center justify-between px-2 text-xs text-[var(--admin-text-muted)]">
<span>
Showing {shown} of {entries.length}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled} disabled={disabled}
onCheckedChange={() => onToggle(entry.fileName)} onClick={() => setShown((s) => s + RENDER_CHUNK)}
aria-label={`Select ${entry.fileName}`} >
/> Load {Math.min(RENDER_CHUNK, entries.length - shown)} more
<code className="min-w-0 flex-1 truncate font-mono text-xs"> </Button>
{entry.fileName} </div>
</code> )}
<span className="shrink-0 text-xs text-[var(--admin-text-muted)]"> </>
{formatBytes(entry.size)}
</span>
<span className="shrink-0 text-xs text-[var(--admin-text-muted)]">
{ageLabel(entry.lastModified)}
</span>
{entry.error && (
<span
title={entry.error}
className="hidden max-w-[320px] truncate text-xs text-[var(--admin-error)] lg:block"
>
{entry.error}
</span>
)}
{entry.dirs.length > 1 && (
<span className="shrink-0 rounded bg-[var(--admin-accent)]/10 px-1.5 py-0.5 text-[10px] font-medium text-[var(--admin-text-muted)]">
{entry.dirs.length} copies
</span>
)}
</li>
))}
</ul>
)} )}
</section> </section>
); );
+11 -4
View File
@@ -217,14 +217,21 @@ interface DirectoryRead {
lastModified: number; lastModified: number;
} }
/**
* How many `stat` calls run in parallel while reading an asset directory.
* Directories can hold hundreds of thousands of files; serial `stat`ing them
* is the single biggest cost of the scan, so the work is spread over a pool.
*/
const DIR_STAT_CONCURRENCY = 32;
async function readAssetDir( async function readAssetDir(
dir: string, dir: string,
regex: RegExp, regex: RegExp,
): Promise<DirectoryRead[]> { ): Promise<DirectoryRead[]> {
const names = await fs.readdir(dir).catch(() => [] as string[]); const names = await fs.readdir(dir).catch(() => [] as string[]);
const entries: DirectoryRead[] = []; const entries: DirectoryRead[] = [];
for (const name of names) { await runPool(names, DIR_STAT_CONCURRENCY, async (name) => {
if (!regex.test(name)) continue; if (!regex.test(name)) return;
let size = 0; let size = 0;
let mtimeMs = 0; let mtimeMs = 0;
try { try {
@@ -240,7 +247,7 @@ async function readAssetDir(
size, size,
lastModified: mtimeMs, lastModified: mtimeMs,
}); });
} });
return entries; return entries;
} }
@@ -321,7 +328,7 @@ export async function scanFakeBrokenNitros(): Promise<NitroCleanupScan> {
const fake: NitroCleanupEntry[] = []; const fake: NitroCleanupEntry[] = [];
const broken: NitroCleanupEntry[] = []; const broken: NitroCleanupEntry[] = [];
await runPool(entries, 8, async (entry) => { await runPool(entries, 16, async (entry) => {
// A .nitro that no DB item maps to is a leftover / fake bundle. // A .nitro that no DB item maps to is a leftover / fake bundle.
if (!validStems.has(entry.base.toLowerCase())) { if (!validStems.has(entry.base.toLowerCase())) {
fake.push(entry); fake.push(entry);