Skip to content
Closed
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/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,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 @@ -576,9 +576,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
136 changes: 95 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,46 @@ 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>
{copyable.length > 0 || selectedFiles.size > 0 ? (
<span className="text-muted-foreground text-xs">
{allIgnoredFiles.data?.truncated
? `${selectedFiles.size} selected`
: `${selectedFiles.size} of ${files.length}`}
{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 +408,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 +461,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