Skip to content
Open
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ export const make = Effect.gen(function* () {
projectCloneTracking: true,
projectOrganization: true,
ignoredFileListing: true,
ignoredFileSearch: true,
triage: true,
oneTimeSchedules: true,
freshContext: true,
Expand Down
16 changes: 16 additions & 0 deletions apps/server/src/workspace/WorkspaceEntries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,22 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => {
});
}),
);

it.effect("finds ignored files that sort past the listing limit", () =>
Effect.gen(function* () {
const cwd = yield* makeTempDir({ git: true });
yield* writeTextFile(cwd, ".gitignore", "*.secret\n");
for (let index = 1; index <= 205; index += 1) {
yield* writeTextFile(cwd, `file-${String(index).padStart(3, "0")}.secret`, "s");
}

const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
expect(yield* workspaceEntries.listIgnoredFiles({ cwd, query: "FILE-205" })).toEqual({
files: [{ path: "file-205.secret", byteLength: 1 }],
truncated: false,
});
}),
);
});

describe("search", () => {
Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/workspace/WorkspaceEntries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,9 +502,13 @@ export const make = Effect.gen(function* () {
})
.pipe(Effect.orElseSucceed(() => undefined));
if (!result || result.exitCode !== 0) return { files: [], truncated: false };
// Filter before the cap so files sorting past it stay reachable by search.
const query = input.query?.toLowerCase() ?? "";
const paths = result.stdout
.split("\0")
.filter((entry) => entry.length > 0 && !entry.endsWith("/"))
.filter(
(entry) => entry.length > 0 && !entry.endsWith("/") && entry.toLowerCase().includes(query),
)
.toSorted();
const files = yield* Effect.promise(() =>
Promise.all(
Expand Down
134 changes: 93 additions & 41 deletions apps/web/src/components/AddProjectToMachineDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { useAddProjectTargets } from "../hooks/useAddProjectTargets";
import { useNewThreadHandler } from "../hooks/useHandleNewThread";
import { useLogicalProjectEnvironments } from "../hooks/useLogicalProjectEnvironments";
import {
COPIED_FILES_MAX_COUNT,
COPYABLE_FILE_MAX_BYTES,
addProjectToMachine,
isDefaultCopiedFile,
Expand All @@ -35,6 +36,7 @@ import { newProjectId } from "../lib/utils";
import { readProject, subscribeProject, useProject } from "../state/entities";
import { useEnvironments } from "../state/environments";
import { projectEnvironment } from "../state/projects";
import { useDebouncedValue } from "../state/queries";
import { useEnvironmentQuery } from "../state/query";
import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
import { Button } from "./ui/button";
Expand Down Expand Up @@ -121,22 +123,40 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe
const [pathOverride, setPathOverride] = useState<string | null>(null);
const destinationPath = pathOverride ?? defaultPath;

const canListFiles = source?.serverConfig?.environment.capabilities.ignoredFileListing === true;
const ignoredFiles = useEnvironmentQuery(
const capabilities = source?.serverConfig?.environment.capabilities;
const canListFiles = capabilities?.ignoredFileListing === true;
const canSearchFiles = canListFiles && capabilities?.ignoredFileSearch === true;
const allIgnoredFiles = useEnvironmentQuery(
project && canListFiles
? projectEnvironment.listIgnoredFiles({
environmentId: project.environmentId,
input: { cwd: project.workspaceRoot },
})
: null,
);
const [fileQuery, setFileQuery] = useState("");
const sentFileQuery = useDebouncedValue(fileQuery.trim(), 150);
const searchedFiles = useEnvironmentQuery(
project && canSearchFiles && sentFileQuery.length > 0
? projectEnvironment.listIgnoredFiles({
environmentId: project.environmentId,
input: { cwd: project.workspaceRoot, query: sentFileQuery },
})
: null,
);
const searching = canSearchFiles && sentFileQuery.length > 0;
const ignoredFiles = searching ? searchedFiles : allIgnoredFiles;
const files = ignoredFiles.data?.files ?? [];
const [selected, setSelected] = useState<ReadonlySet<string> | null>(null);
const selectedFiles = selected ?? defaultSelection(files);
// Keyed by path with the file kept, so picks made under one search survive the next.
const [selected, setSelected] = useState<ReadonlyMap<string, ProjectIgnoredFile> | null>(null);
const selectedFiles = selected ?? defaultSelection(allIgnoredFiles.data?.files ?? []);
const copyable = files.filter((file) => file.byteLength <= COPYABLE_FILE_MAX_BYTES);
const selectedBytes = files
.filter((file) => selectedFiles.has(file.path))
.reduce((total, file) => total + file.byteLength, 0);
const allCopyableSelected = copyable.every((file) => selectedFiles.has(file.path));
const atSelectionLimit = selectedFiles.size >= COPIED_FILES_MAX_COUNT;
const selectedBytes = [...selectedFiles.values()].reduce(
(total, file) => total + file.byteLength,
0,
);

const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
Expand All @@ -146,11 +166,20 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe
const unavailable =
project === null ||
target === null ||
(canListFiles && ignoredFiles.data === null && ignoredFiles.error === null);
const toggleFile = (path: string, checked: boolean) => {
const next = new Set(selectedFiles);
if (checked) next.add(path);
else next.delete(path);
(canListFiles && allIgnoredFiles.data === null && allIgnoredFiles.error === null);
const toggleFile = (file: ProjectIgnoredFile, checked: boolean) => {
const next = new Map(selectedFiles);
if (!checked) next.delete(file.path);
else if (!atSelectionLimit) next.set(file.path, file);
setSelected(next);
};

const toggleShownFiles = () => {
const next = new Map(selectedFiles);
for (const file of copyable) {
if (allCopyableSelected) next.delete(file.path);
else if (next.size < COPIED_FILES_MAX_COUNT) next.set(file.path, file);
}
setSelected(next);
};

Expand Down Expand Up @@ -198,7 +227,7 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe
title: project.title,
remoteUrl,
destinationPath: destinationPath.trim(),
files: files.filter((file) => selectedFiles.has(file.path)).map((file) => file.path),
files: [...selectedFiles.keys()],
});
close();
openClonedProject(scopeProjectRef(target.environmentId, projectId));
Expand Down Expand Up @@ -331,36 +360,44 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<Label className="flex-1">Copy ignored files</Label>
{selectedFiles.size > 0 ? (
<span className="text-muted-foreground text-xs">
{selectedFiles.size} selected
{selectedBytes > 0 ? ` · ${formatBytes(selectedBytes)}` : ""}
</span>
) : null}
{copyable.length > 0 ? (
<>
<span className="text-muted-foreground text-xs">
{selectedFiles.size} of {files.length}
{selectedBytes > 0 ? ` · ${formatBytes(selectedBytes)}` : ""}
</span>
<Button
type="button"
variant="ghost"
size="xs"
onClick={() =>
setSelected(
selectedFiles.size === copyable.length
? new Set()
: new Set(copyable.map((file) => file.path)),
)
}
>
{selectedFiles.size === copyable.length ? "None" : "All"}
</Button>
</>
<Button type="button" variant="ghost" size="xs" onClick={toggleShownFiles}>
{allCopyableSelected ? "None" : "All"}
</Button>
) : null}
</div>
{canSearchFiles && (allIgnoredFiles.data?.truncated || fileQuery.length > 0) ? (
<Input
size="sm"
type="search"
aria-label="Search ignored files"
placeholder="Search ignored files"
value={fileQuery}
maxLength={256}
onChange={(event) => setFileQuery(event.target.value)}
// Enter would otherwise submit the clone.
onKeyDown={(event) => {
if (event.key === "Enter") event.preventDefault();
}}
spellCheck={false}
autoComplete="off"
/>
) : null}
{ignoredFiles.error ? (
<p className="text-destructive-foreground text-xs">{ignoredFiles.error}</p>
) : files.length === 0 ? (
<p className="text-muted-foreground text-xs">
{ignoredFiles.isSuccess
? "No ignored files to copy."
: "Finding ignored files…"}
{!ignoredFiles.isSuccess
? "Finding ignored files…"
: searching
? "No ignored files match."
: "No ignored files to copy."}
</p>
) : (
<div className="max-h-52 overflow-y-auto rounded-md border">
Expand All @@ -369,14 +406,21 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe
key={file.path}
file={file}
checked={selectedFiles.has(file.path)}
onCheckedChange={(checked) => toggleFile(file.path, checked)}
disabled={atSelectionLimit && !selectedFiles.has(file.path)}
onCheckedChange={(checked) => toggleFile(file, checked)}
/>
))}
</div>
)}
{ignoredFiles.data?.truncated ? (
<p className="text-muted-foreground text-xs">
Showing the first {files.length} files.
{canSearchFiles ? " Search to find the rest." : ""}
</p>
) : null}
{atSelectionLimit ? (
<p className="text-muted-foreground text-xs">
Up to {COPIED_FILES_MAX_COUNT} files can be copied.
</p>
) : null}
<p className="text-muted-foreground text-xs">
Expand Down Expand Up @@ -415,29 +459,37 @@ function projectSubdirectory(repositoryRoot: string | undefined, workspaceRoot:
: "";
}

function defaultSelection(files: readonly ProjectIgnoredFile[]): ReadonlySet<string> {
return new Set(
function defaultSelection(
files: readonly ProjectIgnoredFile[],
): ReadonlyMap<string, ProjectIgnoredFile> {
return new Map(
files
.filter(
(file) => file.byteLength <= COPYABLE_FILE_MAX_BYTES && isDefaultCopiedFile(file.path),
)
.map((file) => file.path),
.map((file) => [file.path, file]),
);
}

function IgnoredFileRow({
file,
checked,
disabled,
onCheckedChange,
}: {
file: ProjectIgnoredFile;
checked: boolean;
disabled: boolean;
onCheckedChange: (checked: boolean) => void;
}) {
const tooLarge = file.byteLength > COPYABLE_FILE_MAX_BYTES;
return (
<label className="flex cursor-pointer items-center gap-2 px-2.5 py-1.5 text-sm not-last:border-b has-disabled:cursor-not-allowed has-disabled:opacity-64">
<Checkbox checked={checked} disabled={tooLarge} onCheckedChange={onCheckedChange} />
<Checkbox
checked={checked}
disabled={tooLarge || disabled}
onCheckedChange={onCheckedChange}
/>
<span className="min-w-0 flex-1 truncate font-mono text-xs">{file.path}</span>
<span className="shrink-0 text-muted-foreground text-xs tabular-nums">
{tooLarge ? "Over 1 MB" : formatBytes(file.byteLength)}
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/lib/addProjectToMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ import { sourceControlEnvironment } from "../state/sourceControl";
/** Matches the server's read cap: a larger file would arrive truncated. */
export const COPYABLE_FILE_MAX_BYTES = 1024 * 1024;

/** Matches the server's ignored-file listing limit, so search cannot raise how many get copied. */
export const COPIED_FILES_MAX_COUNT = 200;

const DEFAULT_COPIED_FILE_NAME = /^(?:\.env(?:\..+)?|\.envrc|\.dev\.vars)$/;

/** Env files are the ignored files people almost always want on a new machine. */
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
projectOrganization: Schema.optionalKey(Schema.Boolean),
/** Server answers projects.listIgnoredFiles. */
ignoredFileListing: Schema.optionalKey(Schema.Boolean),
/** projects.listIgnoredFiles filters by `query` before its listing limit. */
ignoredFileSearch: Schema.optionalKey(Schema.Boolean),
environmentIcon: Schema.optionalKey(Schema.Boolean),
desktopAppUpdate: Schema.optionalKey(Schema.Boolean),
/** The desktop app switches update tracks when a server update request names one. */
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,8 @@ export type ProjectListEntriesResult = typeof ProjectListEntriesResult.Type;
// are left out, so the list stays small enough to pick from.
export const ProjectListIgnoredFilesInput = Schema.Struct({
cwd: TrimmedNonEmptyString,
/** Case-insensitive path substring; servers without `ignoredFileSearch` ignore it. */
query: Schema.optional(TrimmedString.check(Schema.isMaxLength(256))),
});
export type ProjectListIgnoredFilesInput = typeof ProjectListIgnoredFilesInput.Type;

Expand Down
Loading