Skip to content
Open
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
59 changes: 59 additions & 0 deletions apps/server/src/workspace/WorkspaceEntries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,65 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => {
}),
);

it.effect("reports modified times only when asked, following links to their target", () =>
Effect.gen(function* () {
const platform = yield* HostProcess.Platform;
if (platform === "win32") return;
const cwd = yield* makeTempDir();
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
yield* writeTextFile(cwd, "file.txt");
yield* writeTextFile(cwd, "dir/nested.txt");
yield* writeTextFile(cwd, "unreadable.txt");
yield* fileSystem.symlink("file.txt", path.join(cwd, "link.txt"));
const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
const mtimes = () =>
workspaceEntries
.list({ cwd, directoryPath: "", includeModifiedTimes: true })
.pipe(
Effect.map(({ entries }) =>
Object.fromEntries(entries.map((entry) => [entry.path, entry.mtimeMs])),
),
);

const fileTime = 1_600_000_000;
const dirTime = 1_500_000_000;
yield* Effect.promise(() => NodeFSP.utimes(path.join(cwd, "file.txt"), fileTime, fileTime));
yield* Effect.promise(() => NodeFSP.utimes(path.join(cwd, "dir"), dirTime, dirTime));
const actual = yield* Effect.promise(() => NodeFSP.stat(path.join(cwd, "unreadable.txt")));
expect(yield* mtimes()).toEqual({
"file.txt": 1_600_000_000_000,
"link.txt": 1_600_000_000_000,
dir: 1_500_000_000_000,
"unreadable.txt": actual.mtimeMs,
});

const stat = NodeFSP.stat;
const statSpy = vi.spyOn(NodeFSP, "stat").mockImplementation((async (target: string) => {
if (target.endsWith("unreadable.txt")) throw new Error("EACCES");
return stat(target);
}) as typeof NodeFSP.stat);
const { entries } = yield* workspaceEntries.list({
cwd,
directoryPath: "",
includeModifiedTimes: true,
});
expect(entries.find((entry) => entry.path === "unreadable.txt")).toEqual({
path: "unreadable.txt",
kind: "file",
});
expect(entries).toHaveLength(4);

statSpy.mockClear();
for (const input of [{}, { includeModifiedTimes: false }]) {
const plain = yield* workspaceEntries.list({ cwd, directoryPath: "", ...input });
expect(plain.entries.some((entry) => "mtimeMs" in entry)).toBe(false);
expect(plain.entries).toHaveLength(4);
}
expect(statSpy).toHaveBeenCalledTimes(2);
}),
);

it.effect("hides folder links that lead back to a folder on the listed path", () =>
// "Expand all folders" loads every folder it expands, so a link that loops would
// keep nesting the same entries without end.
Expand Down
46 changes: 32 additions & 14 deletions apps/server/src/workspace/WorkspaceEntries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,11 @@ const IGNORE_CHECK_CHUNK_SIZE = 1000;
/** Children classified at once when listing a folder; each symlink costs a realpath and a stat. */
const SYMLINK_RESOLVE_BATCH_SIZE = 64;

interface ListedChild {
readonly kind: ProjectEntry["kind"];
readonly mtimeMs: number | undefined;
}

const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(function* (
input: FilesystemBrowseInput,
path: Path.Path,
Expand Down Expand Up @@ -345,9 +350,7 @@ export const make = Effect.gen(function* () {
}
return folders;
})());
const resolveSymlinkKind = async (
name: string,
): Promise<ProjectEntry["kind"] | undefined> => {
const resolveSymlink = async (name: string): Promise<ListedChild | undefined> => {
const resolved = await NodeFSP.realpath(path.join(directory, name)).catch(
() => undefined,
);
Expand All @@ -359,25 +362,37 @@ export const make = Effect.gen(function* () {
return undefined;
}
const stat = await NodeFSP.stat(resolved).catch(() => undefined);
if (stat?.isFile()) return "file";
if (stat?.isFile()) return { kind: "file", mtimeMs: stat.mtimeMs };
if (!stat?.isDirectory()) return undefined;
const folders = await loadVisitedFolders();
return folders.some((folder) => isSameOrInside(resolved, folder))
? undefined
: "directory";
: { kind: "directory", mtimeMs: stat.mtimeMs };
};
const statMtime = async (name: string) =>
(await NodeFSP.stat(path.join(directory, name)).catch(() => undefined))?.mtimeMs;
// Bound filesystem work for directories such as pnpm node_modules.
const kinds: Array<ProjectEntry["kind"] | undefined> = [];
const listed: Array<ListedChild | undefined> = [];
for (let offset = 0; offset < children.length; offset += SYMLINK_RESOLVE_BATCH_SIZE) {
kinds.push(
listed.push(
...(await Promise.all(
children
.slice(offset, offset + SYMLINK_RESOLVE_BATCH_SIZE)
.map(async (child): Promise<ProjectEntry["kind"] | undefined> => {
.map(async (child): Promise<ListedChild | undefined> => {
if (child.name === ".git") return undefined;
if (child.isDirectory()) return "directory";
if (child.isFile()) return "file";
return child.isSymbolicLink() ? resolveSymlinkKind(child.name) : undefined;
if (child.isSymbolicLink()) return resolveSymlink(child.name);
const kind = child.isDirectory()
? "directory"
: child.isFile()
? "file"
: undefined;
if (kind === undefined) return undefined;
return {
kind,
mtimeMs: input.includeModifiedTimes
? await statMtime(child.name)
: undefined,
};
}),
)),
);
Expand All @@ -394,12 +409,15 @@ export const make = Effect.gen(function* () {
return {
nestedGitRoots,
entries: children.flatMap((child, index): ProjectEntry[] => {
const kind = kinds[index];
if (kind === undefined) return [];
const item = listed[index];
if (item === undefined) return [];
return [
{
path: target.relativePath ? `${target.relativePath}/${child.name}` : child.name,
kind,
kind: item.kind,
...(input.includeModifiedTimes && item.mtimeMs !== undefined
? { mtimeMs: item.mtimeMs }
: {}),
},
];
}),
Expand Down
Loading
Loading