perf(studio): speed up nitro scan and stop the cleanup panel freezing
This commit is contained in:
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;
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
if (!lastModified) return "age unknown";
|
||||
const days = Math.floor((Date.now() - lastModified) / DAY_MS);
|
||||
@@ -103,8 +110,11 @@ function CleanupGroup({
|
||||
onToggle: (fileName: string) => void;
|
||||
onToggleAll: (names: string[]) => void;
|
||||
}) {
|
||||
const [shown, setShown] = useState(RENDER_CHUNK);
|
||||
|
||||
const checked = entries.filter((e) => selected.has(e.fileName));
|
||||
const allChecked = entries.length > 0 && checked.length === entries.length;
|
||||
const visibleEntries = entries.slice(0, shown);
|
||||
const toneClass =
|
||||
tone === "error"
|
||||
? "border-[var(--admin-error-border)] bg-[var(--admin-error-subtle)]"
|
||||
@@ -148,45 +158,63 @@ function CleanupGroup({
|
||||
No {label.toLowerCase()} found
|
||||
</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) => (
|
||||
<li
|
||||
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 }}
|
||||
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)}
|
||||
<>
|
||||
<ul className="max-h-80 space-y-1 overflow-auto rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-2">
|
||||
{visibleEntries.map((entry) => (
|
||||
<li
|
||||
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 }}
|
||||
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}
|
||||
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>
|
||||
onClick={() => setShown((s) => s + RENDER_CHUNK)}
|
||||
>
|
||||
Load {Math.min(RENDER_CHUNK, entries.length - shown)} more
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -217,14 +217,21 @@ interface DirectoryRead {
|
||||
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(
|
||||
dir: string,
|
||||
regex: RegExp,
|
||||
): Promise<DirectoryRead[]> {
|
||||
const names = await fs.readdir(dir).catch(() => [] as string[]);
|
||||
const entries: DirectoryRead[] = [];
|
||||
for (const name of names) {
|
||||
if (!regex.test(name)) continue;
|
||||
await runPool(names, DIR_STAT_CONCURRENCY, async (name) => {
|
||||
if (!regex.test(name)) return;
|
||||
let size = 0;
|
||||
let mtimeMs = 0;
|
||||
try {
|
||||
@@ -240,7 +247,7 @@ async function readAssetDir(
|
||||
size,
|
||||
lastModified: mtimeMs,
|
||||
});
|
||||
}
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
|
||||
@@ -321,7 +328,7 @@ export async function scanFakeBrokenNitros(): Promise<NitroCleanupScan> {
|
||||
const fake: 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.
|
||||
if (!validStems.has(entry.base.toLowerCase())) {
|
||||
fake.push(entry);
|
||||
|
||||
Reference in new issue
Block a user