Skip to content
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("ServerEnvironmen
mode === "web" ? undefined : "desktop-managed",
);
expect(descriptor.capabilities).toMatchObject({
privateFileWrites: true,
settledWorktreeCleanup: true,
storageCleanupRun: true,
projectWorktreeCleanup: true,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ export const make = Effect.gen(function* () {
projectCloneTracking: true,
projectOrganization: true,
ignoredFileListing: true,
...(hostPlatform === "win32" ? {} : { privateFileWrites: true }),
ignoredFileSearch: true,
triage: true,
oneTimeSchedules: true,
Expand Down
66 changes: 65 additions & 1 deletion apps/server/src/workspace/WorkspaceEntries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,19 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => {
entries: [{ path: "linkdir/nested.txt", kind: "file" }],
truncated: false,
});
expect(
(yield* workspaceEntries.list({ cwd, directoryPath: "", reportSkipped: true }))
.skippedPaths,
).toEqual([".git", "dangling", "git-link", "outside-link"]);
yield* writeTextFile(cwd, ".agents/README.md", "# Local skills\n");
yield* fileSystem.symlink(outside, path.join(cwd, ".agents/skills"));
expect(
yield* workspaceEntries.list({ cwd, directoryPath: ".agents", reportSkipped: true }),
).toEqual({
entries: [{ path: ".agents/README.md", kind: "file" }],
skippedPaths: [".agents/skills"],
truncated: false,
});
}),
);

Expand Down Expand Up @@ -413,15 +426,55 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => {
});

describe("listIgnoredFiles", () => {
it.effect("lists ignored files with sizes and skips wholly ignored folders", () =>
it.effect("keeps ignored files selectable when folders fill the listing limit", () =>
Effect.gen(function* () {
const cwd = yield* makeTempDir({ git: true });
yield* writeTextFile(cwd, ".gitignore", "node_modules/\n.env\n");
for (let index = 0; index < 200; index++) {
const packagePath = `packages/p${String(index).padStart(3, "0")}`;
yield* writeTextFile(cwd, `${packagePath}/package.json`, "{}");
yield* writeTextFile(cwd, `${packagePath}/node_modules/pkg/index.js`, "x");
}
yield* writeTextFile(cwd, "z-app/index.ts", "export {};");
yield* git(cwd, ["add", "packages", "z-app/index.ts"]);
yield* writeTextFile(cwd, "z-app/.env", "A=1");

const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
const fileOnly = yield* workspaceEntries.listIgnoredFiles({ cwd });
const withFolders = yield* workspaceEntries.listIgnoredFiles({
cwd,
includeDirectories: true,
});

expect(fileOnly.files).toEqual([{ path: "z-app/.env", byteLength: 3 }]);
expect(withFolders.files).toContainEqual(fileOnly.files[0]);
expect(withFolders.files).toHaveLength(201);
expect(withFolders.truncated).toBe(false);

for (let index = 0; index < 200; index++) {
yield* writeTextFile(cwd, `z-app/.env.${index}`, "B=2");
}
yield* writeTextFile(cwd, ".gitignore", "node_modules/\n.env*\n");
const fullFiles = yield* workspaceEntries.listIgnoredFiles({
cwd,
includeDirectories: true,
});
expect(fullFiles.files.filter((file) => !file.path.endsWith("/"))).toHaveLength(200);
expect(fullFiles.files.filter((file) => file.path.endsWith("/"))).toHaveLength(200);
expect(fullFiles.truncated).toBe(true);
}),
);

it.effect("lists ignored files with sizes and selectable folders without scanning them", () =>
Effect.gen(function* () {
const cwd = yield* makeTempDir({ git: true });
yield* writeTextFile(cwd, ".gitignore", "node_modules/\n.env*\n.agents/\n");
yield* writeTextFile(cwd, ".env", "A=1");
yield* writeTextFile(cwd, "apps/web/index.ts", "y");
yield* git(cwd, ["add", "apps/web/index.ts"]);
yield* writeTextFile(cwd, "apps/web/.env.local", "B=22");
yield* writeTextFile(cwd, "node_modules/pkg/index.js", "x");
yield* writeTextFile(cwd, ".agents/skills/example/SKILL.md", "# Example skill\n");

const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
expect(yield* workspaceEntries.listIgnoredFiles({ cwd })).toEqual({
Expand All @@ -431,6 +484,17 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => {
],
truncated: false,
});
expect(yield* workspaceEntries.listIgnoredFiles({ cwd, includeDirectories: true })).toEqual(
{
files: [
{ path: ".agents/", byteLength: 0 },
{ path: ".env", byteLength: 3 },
{ path: "apps/web/.env.local", byteLength: 4 },
{ path: "node_modules/", byteLength: 0 },
],
truncated: false,
},
);
}),
);

Expand Down
40 changes: 30 additions & 10 deletions apps/server/src/workspace/WorkspaceEntries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,6 @@ export class WorkspaceEntries extends Context.Service<
}
>()("t3/workspace/WorkspaceEntries") {}

/** Ignored files offered for copying; more than this is a build folder, not config. */
const IGNORED_FILES_LIMIT = 200;

/** Paths sent to one `git check-ignore` call when labelling a folder's children. */
Expand Down Expand Up @@ -391,7 +390,7 @@ export const make = Effect.gen(function* () {
relativePath: directoryPath,
})
.pipe(Effect.mapError(toError));
const { entries, throughSymlink, nestedGitRoots } = yield* Effect.tryPromise({
const { entries, throughSymlink, skippedPaths, nestedGitRoots } = yield* Effect.tryPromise({
try: async () => {
const root = await NodeFSP.realpath(normalizedCwd);
const directory = await NodeFSP.realpath(target.absolutePath);
Expand Down Expand Up @@ -486,6 +485,18 @@ export const make = Effect.gen(function* () {
}),
// Git refuses to classify paths beyond a symbolic link.
throughSymlink: relative !== target.relativePath.split("/").join(path.sep),
skippedPaths:
input.reportSkipped === true
? children.flatMap((child, index) =>
kinds[index] === undefined
? [
target.relativePath
? `${target.relativePath}/${child.name}`
: child.name,
]
: [],
)
: [],
};
},
catch: toError,
Expand Down Expand Up @@ -525,6 +536,7 @@ export const make = Effect.gen(function* () {
return {
entries: entries.map((entry) => ({ ...entry, ignored: true })),
truncated: false,
...(input.reportSkipped === true ? { skippedPaths } : {}),
};
}
const ignored = new Set<string>();
Expand All @@ -540,6 +552,7 @@ export const make = Effect.gen(function* () {
ignored.has(entry.path) ? { ...entry, ignored: true } : entry,
),
truncated: false,
...(input.reportSkipped === true ? { skippedPaths } : {}),
};
}
return yield* Effect.gen(function* () {
Expand All @@ -559,8 +572,7 @@ export const make = Effect.gen(function* () {
"WorkspaceEntries.listIgnoredFiles",
)(function* (input) {
const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd);
// `--directory` reports a folder holding nothing but ignored files as one
// `name/` entry, which is how `node_modules` and build output drop out.
// Keep ignored folders collapsed so opening the picker never scans their contents.
const result = yield* vcsProcess
.run({
operation: "WorkspaceEntries.listIgnoredFiles",
Expand Down Expand Up @@ -588,23 +600,31 @@ export const make = Effect.gen(function* () {
const paths = result.stdout
.split("\0")
.filter(
(entry) => entry.length > 0 && !entry.endsWith("/") && entry.toLowerCase().includes(query),
(entry) =>
entry.length > 0 &&
(input.includeDirectories === true || !entry.endsWith("/")) &&
entry.toLowerCase().includes(query),
)
.toSorted();
const selectedPaths = [
...paths.filter((entry) => !entry.endsWith("/")).slice(0, IGNORED_FILES_LIMIT),
...paths.filter((entry) => entry.endsWith("/")).slice(0, IGNORED_FILES_LIMIT),
].toSorted();
const files = yield* Effect.promise(() =>
Promise.all(
paths.slice(0, IGNORED_FILES_LIMIT).map(async (relativePath) => {
selectedPaths.map(async (relativePath) => {
// lstat: a symlink usually points outside the workspace, where reads refuse to go.
const stat = await NodeFSP.lstat(path.join(normalizedCwd, relativePath)).catch(
() => null,
);
const stat = await NodeFSP.lstat(
path.join(normalizedCwd, relativePath.replace(/\/$/, "")),
).catch(() => null);
if (stat?.isDirectory()) return { path: relativePath, byteLength: 0 };
return stat?.isFile() ? { path: relativePath, byteLength: stat.size } : null;
}),
),
);
return {
files: files.filter((file) => file !== null),
truncated: paths.length > IGNORED_FILES_LIMIT,
truncated: paths.length > selectedPaths.length,
};
});

Expand Down
110 changes: 109 additions & 1 deletion apps/server/src/workspace/WorkspaceFileSystem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,17 +223,29 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i
const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
const cwd = yield* makeTempDir;
yield* writeTextFile(cwd, "src/index.ts", "export const answer = 42;\n");
const supportsMode = (yield* HostProcess.Platform) !== "win32";
if (supportsMode) {
yield* Effect.promise(() => NodeFSP.chmod(`${cwd}/src/index.ts`, 0o755));
}

const result = yield* workspaceFileSystem.readFile({
cwd,
relativePath: "src/index.ts",
includeMode: true,
});

expect(result).toEqual({
relativePath: "src/index.ts",
contents: "export const answer = 42;\n",
byteLength: 26,
truncated: false,
...(supportsMode ? { mode: 0o755 } : {}),
});
expect(yield* workspaceFileSystem.readFile({ cwd, relativePath: "src/index.ts" })).toEqual({
relativePath: "src/index.ts",
contents: "export const answer = 42;\n",
byteLength: 26,
truncated: false,
});

const bomContents = "\uFEFFexport const answer = 42;\n";
Expand Down Expand Up @@ -619,11 +631,35 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i
false,
);

yield* workspaceFileSystem.writeFile({
const supportsMode = (yield* HostProcess.Platform) !== "win32";
const expectedMode = 0o700 & ~(yield* HostProcess.Umask);
const written = yield* workspaceFileSystem.writeFile({
cwd,
relativePath: "plans/effect-rpc.md",
contents: "# Plan\n",
mode: 0o755,
});
expect(written).toEqual({
relativePath: "plans/effect-rpc.md",
...(supportsMode ? { mode: expectedMode } : {}),
});
if (supportsMode) {
const stat = yield* Effect.promise(() => NodeFSP.stat(`${cwd}/plans/effect-rpc.md`));
expect(stat.mode & 0o777).toBe(expectedMode);
const parent = yield* Effect.promise(() => NodeFSP.stat(`${cwd}/plans`));
expect(parent.mode & 0o777).toBe(0o700 & ~(yield* HostProcess.Umask));
const secret = yield* workspaceFileSystem
.writeFile({
cwd,
relativePath: "private/.env",
contents: "SECRET=example\n",
mode: 0o644,
})
.pipe(Effect.provideService(HostProcess.Umask, 0o077));
expect(secret.mode).toBe(0o600);
const secretStat = yield* Effect.promise(() => NodeFSP.stat(`${cwd}/private/.env`));
expect(secretStat.mode & 0o777).toBe(0o600);
}

const afterWrite = yield* workspaceEntries.list({ cwd });
expect(afterWrite.entries).toEqual(
Expand All @@ -640,6 +676,25 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i
const path = yield* Path.Path;
const cwd = yield* makeTempDir;
yield* writeTextFile(cwd, "src/main.ts", "export {};\n");
const supportsMode = (yield* HostProcess.Platform) !== "win32";
if (supportsMode) {
yield* Effect.promise(() => NodeFSP.chmod(path.join(cwd, "src/main.ts"), 0o755));
const native = yield* Effect.promise(() =>
vi.importActual<typeof NodeFSP>("node:fs/promises"),
);
const open = vi.mocked(NodeFSP.open).mockImplementation(async (file, flags, mode) => {
const handle = await native.open(file, flags, mode);
if (file === path.join(cwd, "src/main.ts") && mode !== undefined) {
const write = handle.writeFile.bind(handle);
handle.writeFile = async (...args) => {
expect((await handle.stat()).mode & 0o077).toBe(0);
return write(...args);
};
}
return handle;
});
yield* Effect.addFinalizer(() => Effect.sync(() => open.mockImplementation(native.open)));
}

yield* workspaceFileSystem.writeFile({
cwd,
Expand All @@ -650,6 +705,59 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i
expect(yield* fileSystem.readFileString(path.join(cwd, "src/main.ts"))).toBe(
"export const edited = true;\n",
);
if (supportsMode) {
const stat = yield* Effect.promise(() => NodeFSP.stat(path.join(cwd, "src/main.ts")));
expect(stat.mode & 0o777).toBe(0o755);
}
yield* workspaceFileSystem.writeFile({
cwd,
relativePath: "src/main.ts",
contents: "export const edited = true;\n",
mode: 0o700,
});
if (supportsMode) {
const stat = yield* Effect.promise(() => NodeFSP.stat(path.join(cwd, "src/main.ts")));
expect(stat.mode & 0o777).toBe(0o700);

const restricted = yield* workspaceFileSystem.writeFile({
cwd,
relativePath: "src/main.ts",
contents: "export const edited = true;\n",
mode: 0o644,
});
expect(restricted.mode).toBe(0o600);
yield* Effect.promise(() => NodeFSP.chmod(path.join(cwd, "src/main.ts"), 0o700));

const native = yield* Effect.promise(() =>
vi.importActual<typeof NodeFSP>("node:fs/promises"),
);
const open = vi.mocked(NodeFSP.open).mockImplementation(async (file, flags, mode) => {
const handle = await native.open(file, flags, mode);
if (file === path.join(cwd, "src/main.ts")) handle.chmod = async () => {};
return handle;
});
yield* Effect.addFinalizer(() => Effect.sync(() => open.mockImplementation(native.open)));
const written = yield* workspaceFileSystem.writeFile({
cwd,
relativePath: "src/main.ts",
contents: "export const edited = true;\n",
mode: 0o600,
});
expect(written.mode).toBe(0o700);
yield* Effect.promise(() => NodeFSP.chmod(path.join(cwd, "src/main.ts"), 0o644));
const unsupported = yield* workspaceFileSystem
.writeFile({
cwd,
relativePath: "src/main.ts",
contents: "SECRET=example\n",
mode: 0o600,
})
.pipe(Effect.flip);
expect(unsupported).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFileSystemOperationError);
expect(yield* fileSystem.readFileString(path.join(cwd, "src/main.ts"))).toBe(
"export const edited = true;\n",
);
}

yield* writeTextFile(cwd, "report.txt", "ordinary file");
for (const relativePath of whitespaceFilePaths) {
Expand Down
Loading
Loading