Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/reactCompiledFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,5 @@ export const REACT_COMPILED_FILES = [
"apps/web/src/components/files/AttachmentFilePreview.tsx",
"apps/web/src/components/usage/UsagePage.tsx",
"apps/web/src/components/ProviderUpdateEnvironmentRows.tsx",
"apps/web/src/components/files/useDirectoryEntries.ts",
] as const;
194 changes: 96 additions & 98 deletions apps/web/src/components/files/useDirectoryEntries.ts
Original file line number Diff line number Diff line change
@@ -1,136 +1,134 @@
import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime";
import * as Cause from "effect/Cause";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";

import { appAtomRegistry } from "~/rpc/atomRegistry";
import { projectEnvironment } from "~/state/projects";

export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) {
const [directories, setDirectories] = useState(new Map<string, readonly ProjectEntry[]>());
const [errors, setErrors] = useState(new Map<string, string>());
const [pending, setPending] = useState(0);
const [loadingDirectories, setLoadingDirectories] = useState<ReadonlySet<string>>(new Set());
// The root starts counted as loading because the mount effect requests it.
const [pending, setPending] = useState(1);
const [loadingDirectories, setLoadingDirectories] = useState<ReadonlySet<string>>(
() => new Set([""]),
);
const requests = useRef(new Map<string, Promise<void>>());
const loaded = useRef(new Set<string>());
const requested = useRef(new Set<string>());
const active = useRef(true);
const running = useRef(0);
const waiting = useRef<Array<() => void>>([]);

const load = useCallback(
function loadDirectory(directoryPath: string, refresh = false): Promise<void> {
const existing = requests.current.get(directoryPath);
if (existing)
return refresh ? existing.then(() => loadDirectory(directoryPath, true)) : existing;
if (!refresh && loaded.current.has(directoryPath)) return Promise.resolve();
loaded.current.add(directoryPath);
requested.current.add(directoryPath);
const atom = projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath } });
setPending((count) => count + 1);
if (!refresh) setLoadingDirectories((previous) => new Set(previous).add(directoryPath));
const request = (async () => {
if (running.current >= 4)
await new Promise<void>((resolve) => waiting.current.push(resolve));
else running.current++;
try {
if (!active.current) return undefined;
return await executeAtomQuery(appAtomRegistry, atom, {
// Callers count the request as pending first; the mount load is counted by the initial state.
const startRequest = (directoryPath: string, refresh: boolean): Promise<void> => {
loaded.current.add(directoryPath);
requested.current.add(directoryPath);
const atom = projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath } });
const request = (async () => {
if (running.current >= 4) await new Promise<void>((resolve) => waiting.current.push(resolve));
else running.current++;
// executeAtomQuery settles instead of throwing, so the slot is always released.
const result = active.current
? await executeAtomQuery(appAtomRegistry, atom, {
refresh: true,
reportFailure: false,
reportDefect: false,
})
: undefined;
const next = waiting.current.shift();
if (next) next();
else running.current--;
return result;
})()
.then((result) => {
if (!active.current || !result) return;
if (result._tag === "Success") {
setDirectories((previous) =>
new Map(previous).set(
directoryPath,
result.value.entries.filter(
(entry) =>
entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))) === directoryPath,
),
),
);
setErrors((previous) => {
const next = new Map(previous);
next.delete(directoryPath);
return next;
});
} finally {
const next = waiting.current.shift();
if (next) next();
else running.current--;
} else {
loaded.current.delete(directoryPath);
const cause = Cause.squash(result.cause);
setErrors((previous) =>
new Map(previous).set(
directoryPath,
cause instanceof Error ? cause.message : "Unable to load folder.",
),
);
}
})()
.then((result) => {
if (!active.current || !result) return;
if (result._tag === "Success") {
setDirectories((previous) =>
new Map(previous).set(
directoryPath,
result.value.entries.filter(
(entry) =>
entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))) === directoryPath,
),
),
);
setErrors((previous) => {
const next = new Map(previous);
next.delete(directoryPath);
return next;
});
} else {
loaded.current.delete(directoryPath);
const cause = Cause.squash(result.cause);
setErrors((previous) =>
new Map(previous).set(
directoryPath,
cause instanceof Error ? cause.message : "Unable to load folder.",
),
);
}
})
.finally(() => {
requests.current.delete(directoryPath);
if (!active.current) return;
setPending((count) => count - 1);
if (!refresh)
setLoadingDirectories((previous) => {
const next = new Set(previous);
next.delete(directoryPath);
return next;
});
});
requests.current.set(directoryPath, request);
return request;
},
[cwd, environmentId],
);
})
.finally(() => {
requests.current.delete(directoryPath);
if (!active.current) return;
setPending((count) => count - 1);
if (!refresh)
setLoadingDirectories((previous) => {
const next = new Set(previous);
next.delete(directoryPath);
return next;
});
});
requests.current.set(directoryPath, request);
return request;
};

const load = function loadDirectory(directoryPath: string, refresh = false): Promise<void> {
const existing = requests.current.get(directoryPath);
if (existing)
return refresh ? existing.then(() => loadDirectory(directoryPath, true)) : existing;
if (!refresh && loaded.current.has(directoryPath)) return Promise.resolve();
setPending((count) => count + 1);
if (!refresh) setLoadingDirectories((previous) => new Set(previous).add(directoryPath));
return startRequest(directoryPath, refresh);
};

useEffect(() => {
active.current = true;
void load("");
if (!requests.current.has("") && !loaded.current.has("")) void startRequest("", false);
return () => {
active.current = false;
};
}, [load]);
}, [startRequest]);

const entries = useMemo(() => {
const result: ProjectEntry[] = [];
const visit = (path: string) => {
for (const entry of directories.get(path) ?? []) {
result.push(entry);
if (entry.kind === "directory") visit(entry.path);
}
};
visit("");
return result;
}, [directories]);
const entries: ProjectEntry[] = [];
const visit = (path: string) => {
for (const entry of directories.get(path) ?? []) {
entries.push(entry);
if (entry.kind === "directory") visit(entry.path);
}
};
visit("");

const reachableDirectories = useMemo(
() =>
new Set([
"",
...entries.filter((entry) => entry.kind === "directory").map((entry) => entry.path),
]),
[entries],
);
const reachableDirectories = new Set([
"",
...entries.filter((entry) => entry.kind === "directory").map((entry) => entry.path),
]);

const refresh = useCallback(() => {
const refresh = () => {
const paths = [...requested.current].filter((path) => reachableDirectories.has(path));
let next = 0;
const workerCount = Math.min(4, paths.length);
const worker = async () => {
while (next < paths.length && active.current) {
const path = paths[next++];
if (path !== undefined) await load(path, true);
while (active.current) {
const path = paths.shift();
if (path === undefined) return;
await load(path, true);
}
};
for (let index = 0; index < Math.min(4, paths.length); index++) void worker();
}, [load, reachableDirectories]);
for (let index = 0; index < workerCount; index++) void worker();
};

return {
entries,
Expand Down
Loading