From 3365e4478e65cd031dd2f358d9da89fca6723ec6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 25 Oct 2026 07:33:33 +0000 Subject: [PATCH 1/4] test(server): ignored files past the listing limit can be found Regression for #24504: listIgnoredFiles caps sorted candidates at 200 with no way to search past them. Co-Authored-By: Claude Opus 5.5 --- .../src/workspace/WorkspaceEntries.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 730d02c577f43..e9d5c0fd2ef7f 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -305,6 +305,29 @@ 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"); + const folders = Array.from( + { length: 205 }, + (_, index) => `folder-${String(index + 1).padStart(3, "0")}`, + ); + // A tracked file keeps each folder from collapsing into one wholly ignored entry. + for (const folder of folders) { + yield* writeTextFile(cwd, `${folder}/anchor.txt`, "a"); + yield* writeTextFile(cwd, `${folder}/local.secret`, "s"); + } + yield* git(cwd, ["add", "."]); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + expect(yield* workspaceEntries.listIgnoredFiles({ cwd, query: "folder-205" })).toEqual({ + files: [{ path: "folder-205/local.secret", byteLength: 1 }], + truncated: false, + }); + }), + ); }); describe("search", () => { From 203a30bacc0dbb269fa68b66f12b9c5e7cb6d0fc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 25 Oct 2026 07:37:26 +0000 Subject: [PATCH 2/4] fix: search ignored files past the listing limit when adding to another machine The Add to another machine dialog listed only the first 200 sorted ignored files, leaving the rest unreachable. listIgnoredFiles now takes an optional query applied before the cap, advertised by the ignoredFileSearch capability, and the dialog offers a search box when the list is truncated. Selections persist across searches and stay capped at 200 files. Fixes #24504 Co-Authored-By: Claude Opus 5.5 --- .../src/environment/ServerEnvironment.ts | 1 + apps/server/src/workspace/WorkspaceEntries.ts | 6 +- .../components/AddProjectToMachineDialog.tsx | 134 ++++++++++++------ apps/web/src/lib/addProjectToMachine.ts | 3 + packages/contracts/src/environment.ts | 2 + packages/contracts/src/project.ts | 2 + 6 files changed, 106 insertions(+), 42 deletions(-) diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 2bd56e30e9dcb..0f9d7e2ac0a8f 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -257,6 +257,7 @@ export const make = Effect.gen(function* () { environmentIcon: true, projectCloneTracking: true, ignoredFileListing: true, + ignoredFileSearch: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverInstallation === null ? {} : { serverInstallation }), ...(serverSelfUpdate === "boot-service" || desktopAppUpdate diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 5f1c5f2660a68..f64dbffd2b63f 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -418,9 +418,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( diff --git a/apps/web/src/components/AddProjectToMachineDialog.tsx b/apps/web/src/components/AddProjectToMachineDialog.tsx index fc6dce938ec10..d82d5490b27d3 100644 --- a/apps/web/src/components/AddProjectToMachineDialog.tsx +++ b/apps/web/src/components/AddProjectToMachineDialog.tsx @@ -25,6 +25,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, @@ -34,6 +35,7 @@ import { newProjectId } from "../lib/utils"; import { 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"; @@ -102,8 +104,10 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe const [pathOverride, setPathOverride] = useState(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, @@ -111,13 +115,29 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe }) : 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 | 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 | 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(null); @@ -127,15 +147,24 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe const unavailable = project === null || target === null || - (canListFiles && ignoredFiles.data === null && ignoredFiles.error === null); + (canListFiles && allIgnoredFiles.data === null && allIgnoredFiles.error === null); useEffect(() => { if (project === null) close(); }, [project]); - const toggleFile = (path: string, checked: boolean) => { - const next = new Set(selectedFiles); - if (checked) next.add(path); - else next.delete(path); + 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); }; @@ -184,7 +213,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)); @@ -315,36 +344,44 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe
+ {selectedFiles.size > 0 ? ( + + {selectedFiles.size} selected + {selectedBytes > 0 ? ` · ${formatBytes(selectedBytes)}` : ""} + + ) : null} {copyable.length > 0 ? ( - <> - - {selectedFiles.size} of {files.length} - {selectedBytes > 0 ? ` · ${formatBytes(selectedBytes)}` : ""} - - - + ) : null}
+ {canSearchFiles && (allIgnoredFiles.data?.truncated || fileQuery.length > 0) ? ( + 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 ? (

{ignoredFiles.error}

) : files.length === 0 ? (

- {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."}

) : (
@@ -353,7 +390,8 @@ 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)} /> ))}
@@ -361,6 +399,12 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe {ignoredFiles.data?.truncated ? (

Showing the first {files.length} files. + {canSearchFiles ? " Search to find the rest." : ""} +

+ ) : null} + {atSelectionLimit ? ( +

+ Up to {COPIED_FILES_MAX_COUNT} files can be copied.

) : null}

@@ -399,29 +443,37 @@ function projectSubdirectory(repositoryRoot: string | undefined, workspaceRoot: : ""; } -function defaultSelection(files: readonly ProjectIgnoredFile[]): ReadonlySet { - return new Set( +function defaultSelection( + files: readonly ProjectIgnoredFile[], +): ReadonlyMap { + 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 (