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;
|
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in new issue
Block a user