@@ -602,7 +747,7 @@ export function NitroCleanupPanel() {
className="w-20"
value={minAgeDays}
disabled={busy}
- onChange={(e) => setMinAgeDays(Number(e.target.value))}
+ onChange={(e) => changeMinAgeDays(Number(e.target.value))}
aria-label="Minimum age in days to display"
/>
days
@@ -613,7 +758,7 @@ export function NitroCleanupPanel() {
setOnlyOld(value === true)}
+ onCheckedChange={(value) => toggleOnlyOld(value === true)}
/>
Hide recent files
@@ -624,6 +769,12 @@ export function NitroCleanupPanel() {
{scan.total}
asset files scanned
+ {scan.cached && (
+
+ {" "}
+ (cached — directories unchanged)
+
+ )}
@@ -648,7 +799,7 @@ export function NitroCleanupPanel() {
label="Fake nitros"
description="These files match no item in items_base — leftovers from deleted or renamed furniture."
tone="error"
- entries={visibleFake}
+ entries={scan.fake}
selected={selected}
onToggle={toggle("nitro")}
onToggleAll={toggleAll("nitro")}
@@ -657,7 +808,7 @@ export function NitroCleanupPanel() {
label="Broken nitros"
description="These files belong to an item but cannot be parsed as a valid Nitro bundle."
tone="warning"
- entries={onlyOld ? [] : scan.broken}
+ entries={scan.broken}
selected={selected}
onToggle={toggle("nitro")}
onToggleAll={toggleAll("nitro")}
@@ -667,7 +818,7 @@ export function NitroCleanupPanel() {
label="Orphaned SWFs"
description=".swf files that match no item in items_base."
tone="warning"
- entries={visibleSwf}
+ entries={scan.orphanedSwf}
selected={selectedSwf}
onToggle={toggle("swf")}
onToggleAll={toggleAll("swf")}
@@ -676,7 +827,7 @@ export function NitroCleanupPanel() {
label="Orphaned icons"
description="Icon files that match no item in items_base."
tone="neutral"
- entries={visibleIcon}
+ entries={scan.orphanedIcon}
selected={selectedIcon}
onToggle={toggle("icon")}
onToggleAll={toggleAll("icon")}
diff --git a/src/lib/services/furni-import-cache.ts b/src/lib/services/furni-import-cache.ts
index 1ce59549..eb38b278 100644
--- a/src/lib/services/furni-import-cache.ts
+++ b/src/lib/services/furni-import-cache.ts
@@ -16,7 +16,11 @@ import { existsSync } from "node:fs";
import path from "node:path";
import { sql } from "drizzle-orm";
import { db, ItemsBase, queryRows } from "@/lib/db";
-import { normalizeClassname } from "@/lib/furni/classname";
+import {
+ getBaseClassname,
+ getSafeClassnameStem,
+ normalizeClassname,
+} from "@/lib/furni/classname";
import { indexLocalFurniture } from "@/lib/furni/local-presence";
import { readFurniData } from "./furni-data";
@@ -107,6 +111,58 @@ export async function getCatalogIdSet(): Promise> {
const nitroPresenceCache = new Map();
+/** Every classname stem a `.nitro`/`.swf`/icon file may legitimately use. */
+function addClassnameStem(set: Set, classname: string): void {
+ const normalized = classname.trim().toLowerCase();
+ if (!normalized) return;
+ const base = getBaseClassname(normalized).toLowerCase();
+ if (base) set.add(base);
+ if (normalized.includes("*")) {
+ const safe = getSafeClassnameStem(normalized).toLowerCase();
+ if (safe) set.add(safe);
+ }
+}
+
+let cleanupStemsSnapshot: { set: Set; timestamp: number } | null = null;
+
+/**
+ * Authoritative stems for the nitro-cleanup scan: every `item_name` plus the
+ * `public_name` fallback (mirroring repair-nitros.ts resolution). Cached the
+ * same way as the Studio listings so repeated scans (auto-clean, panel) don't
+ * re-query `items_base` on every call.
+ */
+export async function getCleanupValidStems(): Promise> {
+ const now = Date.now();
+ if (
+ cleanupStemsSnapshot &&
+ now - cleanupStemsSnapshot.timestamp < CACHE_TTL
+ ) {
+ return cleanupStemsSnapshot.set;
+ }
+ const set = new Set();
+ try {
+ const rows = await queryRows<{
+ item_name: string;
+ public_name: string;
+ }>(sql`SELECT item_name, public_name FROM items_base`);
+ for (const row of rows) {
+ addClassnameStem(set, row.item_name);
+ if (
+ row.public_name &&
+ row.public_name !== row.item_name &&
+ !row.public_name.includes(" ") &&
+ row.public_name !== "0"
+ ) {
+ addClassnameStem(set, row.public_name);
+ }
+ }
+ } catch {
+ /* empty set → every asset is treated as orphaned */
+ }
+ cleanupStemsSnapshot = { set, timestamp: now };
+ return set;
+}
+
/** `existsSync` memoized per (dir + base) — nitro files change rarely. */
export function nitroExistsCached(dir: string, base: string): boolean {
const key = `${dir}/${base}`;
@@ -210,5 +266,6 @@ export function clearFurniImportCache(): void {
furniDataSetSnapshot = null;
catalogIdsSnapshot = null;
nitroPresenceCache.clear();
+ cleanupStemsSnapshot = null;
statsSnapshot = null;
}
diff --git a/src/lib/services/nitro-cleanup.test.ts b/src/lib/services/nitro-cleanup.test.ts
index dba657b5..319884db 100644
--- a/src/lib/services/nitro-cleanup.test.ts
+++ b/src/lib/services/nitro-cleanup.test.ts
@@ -1,4 +1,5 @@
// @vitest-environment node
+import { createHash } from "node:crypto";
import { beforeEach, describe, expect, it, vi } from "vitest";
type AnyFn = (...args: any[]) => any;
@@ -130,6 +131,13 @@ function mockReaddir(byDir: Record): void {
readdirFn.mockImplementation(async (dir: string) => byDir[dir] ?? []);
}
+/** Replicates `dirNameSignature` in the service so cache fixtures match. */
+function dirSignature(names: string[]): string {
+ const hash = createHash("sha1");
+ for (const name of names) hash.update(name).update("\n");
+ return hash.digest("hex");
+}
+
describe("scanFakeBrokenNitros", () => {
it("classifies fake (no matching item) and broken (unparseable) bundles", async () => {
mockReaddir({
@@ -233,6 +241,73 @@ describe("scanFakeBrokenNitros", () => {
expect(result.fake[0].size).toBe(900);
expect(result.fake[0].lastModified).toBe(2000);
});
+
+ it("serves results from the on-disk cache when directories are unchanged", async () => {
+ mockReaddir({
+ "/assets/nitro": ["chair.nitro", "ghost.nitro"],
+ });
+ const dirs = {
+ "nitro\u0000/assets/nitro": dirSignature(["chair.nitro", "ghost.nitro"]),
+ "swf\u0000/assets/swf": dirSignature([]),
+ "icon\u0000/assets/icons": dirSignature([]),
+ };
+ const cachedResult = {
+ fake: [
+ {
+ fileName: "ghost.nitro",
+ base: "ghost",
+ size: 500,
+ dirs: ["/assets/nitro"],
+ lastModified: 0,
+ error: undefined,
+ },
+ ],
+ broken: [],
+ orphanedSwf: [],
+ orphanedIcon: [],
+ total: 4,
+ };
+ existsFn.mockImplementation((filePath: string) =>
+ filePath.includes("scan-cache.json"),
+ );
+ readFileFn.mockImplementation(async (filePath: string) => {
+ if (filePath.includes("scan-cache.json")) {
+ return JSON.stringify({ dirs, result: cachedResult });
+ }
+ return Buffer.from("nitro-data");
+ });
+
+ const result = await scanFakeBrokenNitros();
+
+ // `total: 4` could never come from a fresh scan of 2 files — the cache
+ // result came back verbatim, so classification was skipped entirely.
+ expect(result).toEqual(expect.objectContaining(cachedResult));
+ expect(result.cached).toBe(true);
+ expect(result.total).toBe(4);
+ expect(executeFn).not.toHaveBeenCalled();
+ expect(statFn).not.toHaveBeenCalled();
+ expect(readFileFn).toHaveBeenCalledTimes(1);
+ expect(writeFileFn).not.toHaveBeenCalled();
+ });
+
+ it("force bypasses the cache and re-validates the directories", async () => {
+ mockReaddir({
+ "/assets/nitro": ["chair.nitro", "ghost.nitro"],
+ });
+ // A matching-on-disk cache exists, but `force: true` ignores it.
+ existsFn.mockImplementation(() => true);
+ readFileFn.mockResolvedValue(Buffer.from("nitro-data"));
+ parseNitroBundle.mockReturnValue({});
+ statFn.mockImplementation(async () => ({ size: 512, mtimeMs: 0 }));
+
+ const result = await scanFakeBrokenNitros({ force: true });
+
+ expect(result.cached).toBeUndefined();
+ expect(result.fake.map((e) => e.fileName)).toEqual(["ghost.nitro"]);
+ expect(result.broken).toEqual([]);
+ expect(result.total).toBe(2);
+ expect(statFn).toHaveBeenCalled();
+ });
});
describe("deleteNitroCleanupFiles", () => {
diff --git a/src/lib/services/nitro-cleanup.ts b/src/lib/services/nitro-cleanup.ts
index 2efe4321..0c9aa42d 100644
--- a/src/lib/services/nitro-cleanup.ts
+++ b/src/lib/services/nitro-cleanup.ts
@@ -1,13 +1,13 @@
+import { createHash } from "node:crypto";
import { existsSync, promises as fs } from "node:fs";
import os from "node:os";
-import { sql } from "drizzle-orm";
-import { queryRows } from "@/lib/db";
-import { getBaseClassname, getSafeClassnameStem } from "@/lib/furni/classname";
+import path from "node:path";
import {
DEFAULT_NITRO_SOURCES,
listSources,
} from "@/lib/services/clone-sources";
import { getFurniAssetWriteTargets } from "@/lib/services/furni-asset-dirs";
+import { getCleanupValidStems } from "@/lib/services/furni-import-cache";
import { downloadFile } from "@/lib/services/import/core/download";
import { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
import { getRuntimePath } from "@/lib/utils/runtime-path";
@@ -57,6 +57,23 @@ export interface NitroCleanupScan {
orphanedSwf: AssetCleanupEntry[];
orphanedIcon: AssetCleanupEntry[];
total: number;
+ /** True when the result was served from the on-disk scan cache. */
+ cached?: boolean;
+}
+
+export interface CleanupProgress {
+ phase: "stems" | "readdir" | "validate" | "done";
+ scanned: number;
+}
+
+export interface CleanupScanOptions {
+ /** Bypass the scan cache and re-validate every bundle. */
+ force?: boolean;
+ /**
+ * Coarse progress callback used to stream scan status to the UI instead of
+ * leaving the tab looking frozen while a large directory is processed.
+ */
+ onProgress?: (progress: CleanupProgress) => void;
}
export interface NitroCleanupDeleteResult {
@@ -198,18 +215,6 @@ async function hasValidNitroHeader(filePath: string): Promise {
}
}
-/** Register every .nitro file stem a classname could legitimately use. */
-function addStems(set: Set, classname: string): void {
- const normalized = classname.trim().toLowerCase();
- if (!normalized) return;
- const base = getBaseClassname(normalized).toLowerCase();
- if (base) set.add(base);
- if (normalized.includes("*")) {
- const safe = getSafeClassnameStem(normalized).toLowerCase();
- if (safe) set.add(safe);
- }
-}
-
interface DirectoryRead {
name: string;
base: string;
@@ -217,6 +222,23 @@ interface DirectoryRead {
lastModified: number;
}
+/** Discriminated cache key per asset directory (kind + path). */
+function kindKey(kind: CleanupAssetKind, dir: string): string {
+ return `${kind}\0${dir}`;
+}
+
+async function readDirNames(dir: string, regex: RegExp): Promise {
+ const names = await fs.readdir(dir).catch(() => [] as string[]);
+ return names.filter((name) => regex.test(name)).sort();
+}
+
+/** Cheap fingerprint of a directory listing — a rename/add/remove changes it. */
+function dirNameSignature(names: string[]): string {
+ const hash = createHash("sha1");
+ for (const name of names) hash.update(name).update("\n");
+ return hash.digest("hex");
+}
+
/**
* How many `stat` calls run in parallel while reading an asset directory.
* Directories can hold hundreds of thousands of files; serial `stat`ing them
@@ -224,14 +246,12 @@ interface DirectoryRead {
*/
const DIR_STAT_CONCURRENCY = 32;
-async function readAssetDir(
+async function buildDirEntries(
dir: string,
- regex: RegExp,
+ names: string[],
): Promise {
- const names = await fs.readdir(dir).catch(() => [] as string[]);
const entries: DirectoryRead[] = [];
await runPool(names, DIR_STAT_CONCURRENCY, async (name) => {
- if (!regex.test(name)) return;
let size = 0;
let mtimeMs = 0;
try {
@@ -252,12 +272,14 @@ async function readAssetDir(
}
async function collectAssets(
+ kind: CleanupAssetKind,
dirs: string[],
- regex: RegExp,
+ namesByKey: Map,
): Promise