From f648af8325d9dbf241707c25404dea24134053fa Mon Sep 17 00:00:00 2001 From: Maximilian Mauroner <39530837+MaximilianMauroner@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:32:18 +0200 Subject: [PATCH 01/15] test(projects): reproduce ignored env files hidden by the list limit --- .../src/workspace/WorkspaceEntries.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 150ac2c69131..3606ac32d62d 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -289,6 +289,27 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }); describe("listIgnoredFiles", () => { + it.effect("keeps environment files visible beyond 200 ignored files", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ git: true }); + yield* writeTextFile(cwd, ".gitignore", "*.log\n.env*\n"); + yield* writeTextFile(cwd, "data/keep.txt", "tracked"); + yield* writeTextFile(cwd, "services/api/index.ts", "tracked"); + yield* git(cwd, ["add", "data/keep.txt", "services/api/index.ts"]); + yield* Effect.forEach( + Array.from({ length: 205 }, (_, i) => i), + (i) => writeTextFile(cwd, `data/generated-${String(i).padStart(3, "0")}.log`, "x"), + ); + yield* writeTextFile(cwd, "services/api/.env.local", "A=1"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.listIgnoredFiles({ cwd }); + expect(result.files).toContainEqual({ path: "services/api/.env.local", byteLength: 3 }); + expect(result.files).toHaveLength(200); + expect(result.truncated).toBe(true); + }), + ); + it.effect("lists ignored files with sizes and skips wholly ignored folders", () => Effect.gen(function* () { const cwd = yield* makeTempDir({ git: true }); From da505329179031efbea8d0b8d441f0a9e139ea62 Mon Sep 17 00:00:00 2001 From: Maximilian Mauroner <39530837+MaximilianMauroner@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:47:57 +0200 Subject: [PATCH 02/15] fix(web): find ignored files beyond the clone picker limit --- .../src/environment/ServerEnvironment.test.ts | 2 + .../src/environment/ServerEnvironment.ts | 1 + .../src/workspace/WorkspaceEntries.test.ts | 51 +++++ apps/server/src/workspace/WorkspaceEntries.ts | 42 +++-- .../components/AddProjectToMachineDialog.tsx | 176 ++++++++++++------ apps/web/src/lib/addProjectToMachine.test.ts | 58 +++++- apps/web/src/lib/addProjectToMachine.ts | 38 ++-- packages/contracts/src/environment.ts | 1 + packages/contracts/src/project.ts | 10 + 9 files changed, 290 insertions(+), 89 deletions(-) diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 7cebccb2a1f7..dda5eec6157b 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -113,6 +113,8 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { mode === "web" ? undefined : "desktop-managed", ); expect(descriptor.capabilities).toMatchObject({ + ignoredFileListing: true, + ignoredFileSearch: true, settledWorktreeCleanup: true, storageCleanupRun: true, projectWorktreeCleanup: true, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index fbb5970560bc..ae4e779de6a3 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -259,6 +259,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.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 3606ac32d62d..831371f386ef 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -310,6 +310,57 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); + it.effect("searches all ignored paths before limiting results", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ git: true }); + yield* writeTextFile(cwd, ".gitignore", "*.log\nnode_modules/\n"); + yield* writeTextFile(cwd, "data/keep.txt", "tracked"); + yield* git(cwd, ["add", "data/keep.txt"]); + yield* Effect.forEach( + Array.from({ length: 205 }, (_, i) => i), + (i) => writeTextFile(cwd, `data/generated-${String(i).padStart(3, "0")}.log`, "x"), + ); + yield* writeTextFile(cwd, "data/z-target.log", "found"); + yield* writeTextFile(cwd, "node_modules/z-target.log", "excluded"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + expect(yield* workspaceEntries.listIgnoredFiles({ cwd, query: "z-target" })).toEqual({ + files: [{ path: "data/z-target.log", byteLength: 5 }], + truncated: false, + }); + expect(yield* workspaceEntries.listIgnoredFiles({ cwd, query: "node_modules" })).toEqual({ + files: [], + truncated: false, + }); + }), + ); + + it.effect( + "matches full paths without case sensitivity and treats empty queries as unfiltered", + () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ git: true }); + yield* writeTextFile(cwd, ".gitignore", ".env*\n*.log\n"); + yield* writeTextFile(cwd, "services/API/index.ts", "tracked"); + yield* git(cwd, ["add", "services/API/index.ts"]); + yield* writeTextFile(cwd, "services/API/.env.local", "A=1"); + yield* writeTextFile(cwd, "debug.log", "log"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const unfiltered = yield* workspaceEntries.listIgnoredFiles({ cwd }); + expect(yield* workspaceEntries.listIgnoredFiles({ cwd, query: "" })).toEqual(unfiltered); + expect(yield* workspaceEntries.listIgnoredFiles({ cwd, query: " " })).toEqual( + unfiltered, + ); + expect( + yield* workspaceEntries.listIgnoredFiles({ cwd, query: " SERVICES/api/.ENV " }), + ).toEqual({ + files: [{ path: "services/API/.env.local", byteLength: 3 }], + truncated: false, + }); + }), + ); + it.effect("lists ignored files with sizes and skips wholly ignored folders", () => Effect.gen(function* () { const cwd = yield* makeTempDir({ git: true }); diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index a0d35202c211..7fb1af9b3b50 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -8,18 +8,20 @@ import * as Path from "effect/Path"; import * as RcMap from "effect/RcMap"; import * as Schema from "effect/Schema"; -import type { - FilesystemBrowseInput, - FilesystemBrowseResult, - ProjectEntry, - ProjectListEntriesInput, - ProjectListEntriesResult, - ProjectListIgnoredFilesInput, - ProjectListIgnoredFilesResult, - ProjectSearchContentsInput, - ProjectSearchContentsResult, - ProjectSearchEntriesInput, - ProjectSearchEntriesResult, +import { + PROJECT_IGNORED_FILES_LIMIT, + isDefaultCopiedFile, + type FilesystemBrowseInput, + type FilesystemBrowseResult, + type ProjectEntry, + type ProjectListEntriesInput, + type ProjectListEntriesResult, + type ProjectListIgnoredFilesInput, + type ProjectListIgnoredFilesResult, + type ProjectSearchContentsInput, + type ProjectSearchContentsResult, + type ProjectSearchEntriesInput, + type ProjectSearchEntriesResult, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; @@ -111,9 +113,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. */ const IGNORE_CHECK_CHUNK_SIZE = 1000; @@ -424,13 +423,20 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.orElseSucceed(() => undefined)); if (!result || result.exitCode !== 0) return { files: [], truncated: false }; + const query = input.query?.trim().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 prioritizedPaths = [ + ...paths.filter(isDefaultCopiedFile), + ...paths.filter((entry) => !isDefaultCopiedFile(entry)), + ]; const files = yield* Effect.promise(() => Promise.all( - paths.slice(0, IGNORED_FILES_LIMIT).map(async (relativePath) => { + prioritizedPaths.slice(0, PROJECT_IGNORED_FILES_LIMIT).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, @@ -441,7 +447,7 @@ export const make = Effect.gen(function* () { ); return { files: files.filter((file) => file !== null), - truncated: paths.length > IGNORED_FILES_LIMIT, + truncated: paths.length > PROJECT_IGNORED_FILES_LIMIT, }; }); diff --git a/apps/web/src/components/AddProjectToMachineDialog.tsx b/apps/web/src/components/AddProjectToMachineDialog.tsx index 7d1748f9d0c4..d99d31f5c473 100644 --- a/apps/web/src/components/AddProjectToMachineDialog.tsx +++ b/apps/web/src/components/AddProjectToMachineDialog.tsx @@ -6,6 +6,8 @@ import { getCloneDirectoryName, } from "@t3tools/client-runtime/operations/projects"; import { + PROJECT_IGNORED_FILES_LIMIT, + isDefaultCopiedFile, type EnvironmentId, type ProjectIgnoredFile, type ScopedProjectRef, @@ -25,13 +27,14 @@ import { addProjectToMachine, cloneUrlForProtocol, cloneUrlProtocol, - isDefaultCopiedFile, + updateIgnoredFileSelection, } from "../lib/addProjectToMachine"; import { formatBytes } from "../lib/formatBytes"; 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"; @@ -98,7 +101,11 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe const destinationPath = pathOverride ?? defaultPath; const canListFiles = source?.serverConfig?.environment.capabilities.ignoredFileListing === true; - const ignoredFiles = useEnvironmentQuery( + const canSearchFiles = source?.serverConfig?.environment.capabilities.ignoredFileSearch === true; + const [query, setQuery] = useState(""); + const normalizedQuery = query.trim(); + const debouncedQuery = useDebouncedValue(normalizedQuery, 200); + const defaultIgnoredFiles = useEnvironmentQuery( project && canListFiles ? projectEnvironment.listIgnoredFiles({ environmentId: project.environmentId, @@ -106,34 +113,40 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe }) : null, ); - const files = ignoredFiles.data?.files ?? []; - const [selected, setSelected] = useState | null>(null); - const selectedFiles = selected ?? defaultSelection(files); + const filteredIgnoredFiles = useEnvironmentQuery( + project && canSearchFiles && debouncedQuery + ? projectEnvironment.listIgnoredFiles({ + environmentId: project.environmentId, + input: { cwd: project.workspaceRoot, query: debouncedQuery }, + }) + : null, + ); + const ignoredFiles = + canSearchFiles && normalizedQuery && debouncedQuery + ? filteredIgnoredFiles + : defaultIgnoredFiles; + const loadingDefaults = defaultIgnoredFiles.data === null && defaultIgnoredFiles.error === null; + const loadingFiles = ignoredFiles.data === null && ignoredFiles.error === null; + const files = ignoredFiles.error ? [] : (ignoredFiles.data?.files ?? []); + const [selected, setSelected] = useState | null>(null); + const selectedFiles = selected ?? defaultSelection(defaultIgnoredFiles.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 selectedBytes = Array.from(selectedFiles.values()).reduce( + (total, file) => total + file.byteLength, + 0, + ); + const atSelectionLimit = selectedFiles.size >= PROJECT_IGNORED_FILES_LIMIT; const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); // The project or every target can vanish while the dialog is open. Submitting // before the file list loads would silently skip the default .env copies. - const unavailable = - project === null || - target === null || - (canListFiles && ignoredFiles.data === null && ignoredFiles.error === null); + const unavailable = project === null || target === null || (canListFiles && loadingDefaults); 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); - setSelected(next); - }; - // Lands the user on the new machine right away; its composer shows the clone // and holds sends until it finishes. A draft open on the source project moves // over with whatever was typed in it. @@ -179,7 +192,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: Array.from(selectedFiles.keys()), }); close(); openClonedProject(scopeProjectRef(target.environmentId, projectId)); @@ -310,36 +323,68 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe
- {copyable.length > 0 ? ( - <> - - {selectedFiles.size} of {files.length} - {selectedBytes > 0 ? ` · ${formatBytes(selectedBytes)}` : ""} - - - - ) : null} + + {selectedFiles.size} selected + {selectedBytes > 0 ? ` · ${formatBytes(selectedBytes)}` : ""} +
- {ignoredFiles.error ? ( + {canSearchFiles ? ( + <> + + setQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") event.preventDefault(); + }} + maxLength={256} + spellCheck={false} + autoComplete="off" + /> + + ) : ( +

+ Update T3 Code on {source?.label ?? "the source machine"} to search ignored + files. +

+ )} +
+ {files.length} shown + + +
+ {loadingFiles ? ( +

Finding ignored files…

+ ) : ignoredFiles.error ? (

{ignoredFiles.error}

) : files.length === 0 ? (

- {ignoredFiles.isSuccess - ? "No ignored files to copy." - : "Finding ignored files…"} + {debouncedQuery + ? "No ignored files match your search." + : "No ignored files to copy."}

) : (
@@ -348,14 +393,25 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe key={file.path} file={file} checked={selectedFiles.has(file.path)} - onCheckedChange={(checked) => toggleFile(file.path, checked)} + atSelectionLimit={atSelectionLimit} + onCheckedChange={(checked) => + setSelected(updateIgnoredFileSelection(selectedFiles, [file], checked)) + } /> ))}
)} - {ignoredFiles.data?.truncated ? ( + {!loadingFiles && !ignoredFiles.error && ignoredFiles.data?.truncated ? (

- Showing the first {files.length} files. + {canSearchFiles + ? "More files match. Refine your search." + : `Showing the first ${files.length} files.`} +

+ ) : null} + {atSelectionLimit ? ( +

+ You can copy up to {PROJECT_IGNORED_FILES_LIMIT} files. Clear a selection to + choose another file.

) : null}

@@ -394,29 +450,35 @@ function projectSubdirectory(repositoryRoot: string | undefined, workspaceRoot: : ""; } -function defaultSelection(files: readonly ProjectIgnoredFile[]): ReadonlySet { - return new Set( - files - .filter( - (file) => file.byteLength <= COPYABLE_FILE_MAX_BYTES && isDefaultCopiedFile(file.path), - ) - .map((file) => file.path), +function defaultSelection( + files: readonly ProjectIgnoredFile[], +): ReadonlyMap { + return updateIgnoredFileSelection( + new Map(), + files.filter((file) => isDefaultCopiedFile(file.path)), + true, ); } function IgnoredFileRow({ file, checked, + atSelectionLimit, onCheckedChange, }: { file: ProjectIgnoredFile; checked: boolean; + atSelectionLimit: boolean; onCheckedChange: (checked: boolean) => void; }) { const tooLarge = file.byteLength > COPYABLE_FILE_MAX_BYTES; return (

)}
- {files.length} shown + + {files.length} listed +
{loadingFiles ? ( @@ -386,26 +388,30 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe ? "No ignored files match your search." : "No ignored files to copy."}

- ) : ( -
- {files.map((file) => ( - - setSelected(updateIgnoredFileSelection(selectedFiles, [file], checked)) - } - /> - ))} -
- )} - {!loadingFiles && !ignoredFiles.error && ignoredFiles.data?.truncated ? ( + ) : null} + 0} + selected={selectedFiles} + atSelectionLimit={atSelectionLimit} + onCheckedChange={(file, checked) => + setSelected(updateIgnoredFileSelection(selectedFiles, [file], checked)) + } + /> + {!normalizedQuery && defaultIgnoredFiles.data?.truncated ? (

- {canSearchFiles - ? "More files match. Refine your search." - : `Showing the first ${files.length} files.`} + Only the first {PROJECT_IGNORED_FILES_LIMIT} ignored files are listed. + {canSearchFiles ? " Search to find more files." : ""} +

+ ) : null} + {normalizedQuery && + !loadingFiles && + !ignoredFiles.error && + ignoredFiles.data?.truncated ? ( +

+ Only the first {PROJECT_IGNORED_FILES_LIMIT} matches are listed. Refine your + search to find more.

) : null} {atSelectionLimit ? ( @@ -459,30 +465,3 @@ function defaultSelection( true, ); } - -function IgnoredFileRow({ - file, - checked, - atSelectionLimit, - onCheckedChange, -}: { - file: ProjectIgnoredFile; - checked: boolean; - atSelectionLimit: boolean; - onCheckedChange: (checked: boolean) => void; -}) { - const tooLarge = file.byteLength > COPYABLE_FILE_MAX_BYTES; - return ( - - ); -} diff --git a/apps/web/src/components/IgnoredFileTree.tsx b/apps/web/src/components/IgnoredFileTree.tsx new file mode 100644 index 000000000000..101185930fa1 --- /dev/null +++ b/apps/web/src/components/IgnoredFileTree.tsx @@ -0,0 +1,157 @@ +import type { ProjectIgnoredFile } from "@t3tools/contracts"; +import { ChevronDownIcon, ChevronRightIcon, FolderIcon } from "lucide-react"; +import { useState, type Dispatch, type SetStateAction } from "react"; + +import { COPYABLE_FILE_MAX_BYTES } from "../lib/addProjectToMachine"; +import { formatBytes } from "../lib/formatBytes"; +import { + buildIgnoredFileTree, + countSelectedIgnoredDescendants, + getDefaultIgnoredFileExpansion, + type IgnoredFileTreeNode, +} from "../lib/ignoredFileTree"; +import { Checkbox } from "./ui/checkbox"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +export function IgnoredFileTree({ + files, + defaultFiles, + searching, + selected, + atSelectionLimit, + onCheckedChange, +}: { + files: readonly ProjectIgnoredFile[]; + defaultFiles: readonly ProjectIgnoredFile[] | null; + searching: boolean; + selected: ReadonlyMap; + atSelectionLimit: boolean; + onCheckedChange: (file: ProjectIgnoredFile, checked: boolean) => void; +}) { + const tree = buildIgnoredFileTree(files); + const [expanded, setExpanded] = useState | null>(null); + if (expanded === null && defaultFiles !== null) { + setExpanded(getDefaultIgnoredFileExpansion(buildIgnoredFileTree(defaultFiles))); + } + + const fileRow = (file: ProjectIgnoredFile, name: string) => ( + onCheckedChange(file, checked)} + /> + ); + + if (files.length === 0) return null; + return ( +
+
    + {searching ? ( + files.map((file) =>
  • {fileRow(file, file.path)}
  • ) + ) : ( + + )} +
+
+ ); +} + +function IgnoredFileTreeNodes({ + nodes, + expanded, + setExpanded, + selected, + fileRow, +}: { + nodes: readonly IgnoredFileTreeNode[]; + expanded: ReadonlySet | null; + setExpanded: Dispatch | null>>; + selected: ReadonlyMap; + fileRow: (file: ProjectIgnoredFile, name: string) => React.ReactNode; +}) { + return nodes.map((node) => { + if (node.kind === "file") return
  • {fileRow(node.file, node.name)}
  • ; + const open = expanded?.has(node.path) ?? false; + const selectedCount = countSelectedIgnoredDescendants(node.path, selected.keys()); + const ChevronIcon = open ? ChevronDownIcon : ChevronRightIcon; + return ( +
  • + + {open ? ( +
      + +
    + ) : null} +
  • + ); + }); +} + +function IgnoredFileRow({ + file, + name, + checked, + atSelectionLimit, + onCheckedChange, +}: { + file: ProjectIgnoredFile; + name: string; + checked: boolean; + atSelectionLimit: boolean; + onCheckedChange: (checked: boolean) => void; +}) { + const tooLarge = file.byteLength > COPYABLE_FILE_MAX_BYTES; + return ( + + ); +} diff --git a/apps/web/src/lib/ignoredFileTree.test.ts b/apps/web/src/lib/ignoredFileTree.test.ts new file mode 100644 index 000000000000..6c640ab365d4 --- /dev/null +++ b/apps/web/src/lib/ignoredFileTree.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { COPYABLE_FILE_MAX_BYTES } from "./addProjectToMachine"; +import { + buildIgnoredFileTree, + countSelectedIgnoredDescendants, + getDefaultIgnoredFileExpansion, + type IgnoredFileTreeNode, +} from "./ignoredFileTree"; + +const file = (path: string, byteLength = 10) => ({ path, byteLength }); + +function listedPaths(nodes: readonly IgnoredFileTreeNode[]): string[] { + return nodes.flatMap((node) => + node.kind === "file" ? [node.file.path] : listedPaths(node.children), + ); +} + +describe("ignored file tree", () => { + it("groups crowded directories and keeps files and folders in code-unit name order", () => { + const files = [ + file("z-folder/file"), + file("README.md"), + file(".env"), + file(".agents/z.md"), + file(".agents/A.md"), + ]; + const tree = buildIgnoredFileTree(files); + expect(tree.map((node) => [node.name, node.kind])).toEqual([ + [".agents", "directory"], + [".env", "file"], + ["README.md", "file"], + ["z-folder", "directory"], + ]); + expect(tree[0]).toMatchObject({ + listedCount: 2, + children: [{ name: "A.md" }, { name: "z.md" }], + }); + expect(listedPaths(tree)).toEqual(files.map((entry) => entry.path).toSorted()); + }); + + it("preserves exact Git paths and literal backslashes", () => { + const files = [file("literal\\folder/.env.local"), file(".agents/a\\b.md"), file("root\\file")]; + const tree = buildIgnoredFileTree(files); + expect(listedPaths(tree).toSorted()).toEqual(files.map((entry) => entry.path).toSorted()); + expect(tree.map((node) => node.name)).toEqual([".agents", "literal\\folder", "root\\file"]); + }); + + it("compacts directory chains to the deepest path but never into a file", () => { + const tree = buildIgnoredFileTree([ + file("services/api/config/.env"), + file("services/api/config/secret"), + file("single/only.txt"), + ]); + expect(tree).toMatchObject([ + { + kind: "directory", + name: "services/api/config", + path: "services/api/config", + listedCount: 2, + children: [{ name: ".env" }, { name: "secret" }], + }, + { + kind: "directory", + name: "single", + path: "single", + listedCount: 1, + children: [{ kind: "file", name: "only.txt" }], + }, + ]); + expect(Array.from(getDefaultIgnoredFileExpansion(tree))).toEqual(["services/api/config"]); + }); + + it("opens default environment ancestors and leaves crowded agent files collapsed", () => { + const tree = buildIgnoredFileTree([ + file(".agents/skills/one.md"), + file(".agents/skills/two.md"), + file("apps/api/.env.local"), + file("apps/web/.dev.vars"), + file("apps/web/debug.log"), + file("private/.env", COPYABLE_FILE_MAX_BYTES + 1), + file(".env"), + ]); + expect(Array.from(getDefaultIgnoredFileExpansion(tree)).toSorted()).toEqual([ + "apps", + "apps/api", + "apps/web", + ]); + }); + + it("counts selected descendants even when they are outside the current bounded listing", () => { + const selected = new Set([ + ".agents/a.md", + ".agents/unlisted/b.md", + ".agents-other/c.md", + ".env", + "apps/api/.env", + ]); + expect(countSelectedIgnoredDescendants(".agents", selected)).toBe(2); + expect(countSelectedIgnoredDescendants("apps/api", selected)).toBe(1); + expect(countSelectedIgnoredDescendants("apps/web", selected)).toBe(0); + expect(Array.from(selected)).toEqual([ + ".agents/a.md", + ".agents/unlisted/b.md", + ".agents-other/c.md", + ".env", + "apps/api/.env", + ]); + }); + + it("keeps original file metadata for selection and handles an empty list", () => { + const original = file("config/.env", 512); + const tree = buildIgnoredFileTree([original]); + const directory = tree[0]; + expect(directory?.kind).toBe("directory"); + if (directory?.kind !== "directory") throw new Error("Missing config directory"); + const leaf = directory.children[0]; + expect(leaf?.kind).toBe("file"); + if (leaf?.kind !== "file") throw new Error("Missing .env file"); + expect(leaf.file).toBe(original); + expect(buildIgnoredFileTree([])).toEqual([]); + expect(getDefaultIgnoredFileExpansion([]).size).toBe(0); + }); +}); diff --git a/apps/web/src/lib/ignoredFileTree.ts b/apps/web/src/lib/ignoredFileTree.ts new file mode 100644 index 000000000000..0f7af1da73ce --- /dev/null +++ b/apps/web/src/lib/ignoredFileTree.ts @@ -0,0 +1,105 @@ +import { isDefaultCopiedFile, type ProjectIgnoredFile } from "@t3tools/contracts"; + +import { COPYABLE_FILE_MAX_BYTES } from "./addProjectToMachine"; + +export type IgnoredFileTreeNode = + | { + readonly kind: "file"; + readonly name: string; + readonly path: string; + readonly file: ProjectIgnoredFile; + } + | { + readonly kind: "directory"; + readonly name: string; + readonly path: string; + readonly children: readonly IgnoredFileTreeNode[]; + readonly listedCount: number; + readonly defaultExpanded: boolean; + }; + +type Directory = { + name: string; + path: string; + children: Map; +}; + +export function buildIgnoredFileTree( + files: readonly ProjectIgnoredFile[], +): readonly IgnoredFileTreeNode[] { + const root: Directory = { name: "", path: "", children: new Map() }; + for (const file of files) { + const parts = file.path.split("/"); + const name = parts.pop()!; + let directory = root; + for (const part of parts) { + let child = directory.children.get(part); + if (!child) { + child = { + name: part, + path: directory.path ? `${directory.path}/${part}` : part, + children: new Map(), + }; + directory.children.set(part, child); + } + if ("children" in child) directory = child; + } + directory.children.set(name, file); + } + + function children(directory: Directory): IgnoredFileTreeNode[] { + return Array.from(directory.children.entries()) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([name, entry]): IgnoredFileTreeNode => { + if (!("children" in entry)) return { kind: "file", name, path: entry.path, file: entry }; + const nodes = children(entry); + const onlyChild = nodes[0]; + if (nodes.length === 1 && onlyChild?.kind === "directory") { + return { ...onlyChild, name: `${name}/${onlyChild.name}` }; + } + return { + kind: "directory", + name, + path: entry.path, + children: nodes, + listedCount: nodes.reduce( + (count, node) => count + (node.kind === "file" ? 1 : node.listedCount), + 0, + ), + defaultExpanded: nodes.some((node) => + node.kind === "directory" + ? node.defaultExpanded + : isDefaultCopiedFile(node.path) && node.file.byteLength <= COPYABLE_FILE_MAX_BYTES, + ), + }; + }); + } + return children(root); +} + +export function getDefaultIgnoredFileExpansion( + nodes: readonly IgnoredFileTreeNode[], +): ReadonlySet { + const expanded = new Set(); + function visit(nodes: readonly IgnoredFileTreeNode[]) { + for (const node of nodes) { + if (node.kind === "directory" && node.defaultExpanded) { + expanded.add(node.path); + visit(node.children); + } + } + } + visit(nodes); + return expanded; +} + +export function countSelectedIgnoredDescendants( + directoryPath: string, + selectedPaths: Iterable, +): number { + let count = 0; + for (const path of selectedPaths) { + if (path.startsWith(`${directoryPath}/`)) count++; + } + return count; +} From 03ae79ad8142e2507235072e9dfa9dde4b478f27 Mon Sep 17 00:00:00 2001 From: Maximilian Mauroner <39530837+MaximilianMauroner@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:48:55 +0200 Subject: [PATCH 04/15] fix(web): align ignored search view with displayed results --- .../src/components/AddProjectToMachineDialog.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/AddProjectToMachineDialog.tsx b/apps/web/src/components/AddProjectToMachineDialog.tsx index 6d07fdb6102b..933a26b57051 100644 --- a/apps/web/src/components/AddProjectToMachineDialog.tsx +++ b/apps/web/src/components/AddProjectToMachineDialog.tsx @@ -121,10 +121,8 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe }) : null, ); - const ignoredFiles = - canSearchFiles && normalizedQuery && debouncedQuery - ? filteredIgnoredFiles - : defaultIgnoredFiles; + const searchActive = canSearchFiles && normalizedQuery.length > 0 && debouncedQuery.length > 0; + const ignoredFiles = searchActive ? filteredIgnoredFiles : defaultIgnoredFiles; const loadingDefaults = defaultIgnoredFiles.data === null && defaultIgnoredFiles.error === null; const loadingFiles = ignoredFiles.data === null && ignoredFiles.error === null; const files = ignoredFiles.error ? [] : (ignoredFiles.data?.files ?? []); @@ -384,7 +382,7 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe

    {ignoredFiles.error}

    ) : files.length === 0 ? (

    - {debouncedQuery + {searchActive ? "No ignored files match your search." : "No ignored files to copy."}

    @@ -392,20 +390,20 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe 0} + searching={searchActive} selected={selectedFiles} atSelectionLimit={atSelectionLimit} onCheckedChange={(file, checked) => setSelected(updateIgnoredFileSelection(selectedFiles, [file], checked)) } /> - {!normalizedQuery && defaultIgnoredFiles.data?.truncated ? ( + {!searchActive && defaultIgnoredFiles.data?.truncated ? (

    Only the first {PROJECT_IGNORED_FILES_LIMIT} ignored files are listed. {canSearchFiles ? " Search to find more files." : ""}

    ) : null} - {normalizedQuery && + {searchActive && !loadingFiles && !ignoredFiles.error && ignoredFiles.data?.truncated ? ( From a5dc75d54c379b277403c3c8f20eace06b1e5c69 Mon Sep 17 00:00:00 2001 From: Maximilian Mauroner <39530837+MaximilianMauroner@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:19:53 +0200 Subject: [PATCH 05/15] fix(projects): page ignored files in the clone dialog --- .../src/environment/ServerEnvironment.test.ts | 1 + .../src/environment/ServerEnvironment.ts | 1 + .../src/workspace/WorkspaceEntries.test.ts | 187 ++++++++++++++++++ apps/server/src/workspace/WorkspaceEntries.ts | 42 +++- apps/server/src/ws.ts | 2 + .../components/AddProjectToMachineDialog.tsx | 134 ++++++++++--- .../src/state/projectCommands.ts | 1 + packages/contracts/src/environment.ts | 1 + packages/contracts/src/project.ts | 4 + 9 files changed, 339 insertions(+), 34 deletions(-) diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index dda5eec6157b..0fd23f11c0fa 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -115,6 +115,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(descriptor.capabilities).toMatchObject({ ignoredFileListing: true, ignoredFileSearch: true, + ignoredFilePaging: true, settledWorktreeCleanup: true, storageCleanupRun: true, projectWorktreeCleanup: true, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index ae4e779de6a3..3480949df7ce 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -260,6 +260,7 @@ export const make = Effect.gen(function* () { projectCloneTracking: true, ignoredFileListing: true, ignoredFileSearch: true, + ignoredFilePaging: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverInstallation === null ? {} : { serverInstallation }), ...(serverSelfUpdate === "boot-service" || desktopAppUpdate diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 831371f386ef..d1518973863f 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -6,13 +6,20 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import { ChildProcessSpawner } from "effect/process"; import { vi } from "vite-plus/test"; import * as ServerConfig from "../config.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { + type ProjectListIgnoredFilesResult, + VcsProcessSpawnError, + VcsProcessTimeoutError, +} from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as WorkspaceEntries from "./WorkspaceEntries.ts"; import * as WorkspacePaths from "./WorkspacePaths.ts"; +import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; vi.mock("node:fs/promises", async (importOriginal) => { const actual = await importOriginal(); @@ -310,6 +317,186 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); + it.effect("browses every captured ignored path in bounded pages without duplicates", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ git: true }); + const fileSystem = yield* FileSystem.FileSystem; + yield* writeTextFile(cwd, ".gitignore", "*.log\n.env\nnode_modules/\n"); + yield* writeTextFile(cwd, ".agents/keep.txt", "tracked"); + yield* git(cwd, ["add", ".agents/keep.txt"]); + const logs = Array.from( + { length: 605 }, + (_, i) => `.agents/run-${String(i).padStart(4, "0")}.log`, + ); + yield* Effect.forEach(logs, (name) => writeTextFile(cwd, name, "x")); + yield* writeTextFile(cwd, ".env", "A=1"); + yield* writeTextFile(cwd, "node_modules/excluded.log", "excluded"); + + const entries = yield* WorkspaceEntries.WorkspaceEntries; + const seen: string[] = []; + let after: string | undefined; + let pageCount = 0; + do { + const page: ProjectListIgnoredFilesResult = yield* entries.listIgnoredFiles({ + cwd, + ...(after ? { after } : {}), + }); + expect(page.files.length).toBeLessThanOrEqual(200); + expect(page.truncated).toBe(page.nextCursor !== undefined); + expect(page.outputTruncated).not.toBe(true); + if (pageCount === 0) { + expect(page.files[0]).toEqual({ path: ".env", byteLength: 3 }); + // A keyset cursor remains valid when its file is removed between requests. + expect(page.nextCursor).toBe(".agents/run-0198.log"); + yield* fileSystem.remove(`${cwd}/.agents/run-0198.log`); + } + seen.push(...page.files.map((file) => file.path)); + after = page.nextCursor; + pageCount++; + expect(pageCount).toBeLessThanOrEqual(4); + } while (after !== undefined); + expect(pageCount).toBe(4); + expect(new Set(seen).size).toBe(seen.length); + expect(seen.toSorted()).toEqual([".env", ...logs].toSorted()); + + const matches: string[] = []; + after = undefined; + let filteredPages = 0; + do { + const page: ProjectListIgnoredFilesResult = yield* entries.listIgnoredFiles({ + cwd, + query: " .AGENTS/RUN-0 ", + ...(after ? { after } : {}), + }); + expect(page.files.length).toBeLessThanOrEqual(200); + matches.push(...page.files.map((file) => file.path)); + after = page.nextCursor; + filteredPages++; + expect(filteredPages).toBeLessThanOrEqual(4); + } while (after !== undefined); + expect(filteredPages).toBe(4); + expect(new Set(matches).size).toBe(matches.length); + expect(matches).toEqual(logs.filter((name) => name !== ".agents/run-0198.log")); + }), + ); + + it.effect("advances past an empty candidate page and a removed or symlink cursor", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir(); + const fileSystem = yield* FileSystem.FileSystem; + const missing = Array.from({ length: 199 }, (_, i) => `a-${String(i).padStart(3, "0")}`); + yield* writeTextFile(cwd, "z-file", "last"); + yield* fileSystem.symlink(`${cwd}/z-file`, `${cwd}/a-link`); + const entries = yield* WorkspaceEntries.make.pipe( + Effect.provideService( + VcsProcess.VcsProcess, + VcsProcess.VcsProcess.of({ + run: () => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: [...missing, "a-link", "z-file"].join("\0") + "\0", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + }), + ), + Effect.provide(WorkspaceSearchIndex.WorkspaceSearchIndexMap.layer), + ); + const first = yield* entries.listIgnoredFiles({ cwd }); + expect(first).toEqual({ files: [], truncated: true, nextCursor: "a-link" }); + yield* fileSystem.remove(`${cwd}/a-link`); + expect(yield* entries.listIgnoredFiles({ cwd, after: first.nextCursor })).toEqual({ + files: [{ path: "z-file", byteLength: 4 }], + truncated: false, + }); + }), + ); + + it.effect("drops only the incomplete trailing record when Git output is truncated", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir(); + yield* writeTextFile(cwd, ".env", "A=1"); + yield* writeTextFile(cwd, "partial", "must not be listed"); + for (const stdout of [".env\0partial", ".env\0"]) { + const entries = yield* WorkspaceEntries.make.pipe( + Effect.provideService( + VcsProcess.VcsProcess, + VcsProcess.VcsProcess.of({ + run: () => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: true, + stderrTruncated: false, + }), + }), + ), + Effect.provide(WorkspaceSearchIndex.WorkspaceSearchIndexMap.layer), + ); + expect(yield* entries.listIgnoredFiles({ cwd })).toEqual({ + files: [{ path: ".env", byteLength: 3 }], + truncated: false, + outputTruncated: true, + }); + } + }), + ); + + it.effect("keeps a successful Git exit when stdout capture reaches its limit", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ git: true }); + yield* writeTextFile(cwd, ".gitignore", "*.log\n"); + yield* writeTextFile(cwd, "some-long-file-name.log", "x"); + const vcs = yield* VcsProcess.VcsProcess; + const result = yield* vcs.run({ + operation: "WorkspaceEntries.test.truncatedGitOutput", + command: "git", + args: ["ls-files", "--others", "--ignored", "--exclude-standard", "-z"], + cwd, + maxOutputBytes: 8, + }); + expect(result.exitCode).toBe(0); + expect(result.stdoutTruncated).toBe(true); + }), + ); + + it.effect("preserves Git timeout and spawn failures as typed listing errors", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir(); + const context = { cwd, command: "git", operation: "WorkspaceEntries.listIgnoredFiles" }; + for (const cause of [ + new VcsProcessSpawnError({ ...context, cause: new Error("git unavailable") }), + new VcsProcessTimeoutError({ ...context, timeoutMs: 10_000 }), + ]) { + const entries = yield* WorkspaceEntries.make.pipe( + Effect.provideService( + VcsProcess.VcsProcess, + VcsProcess.VcsProcess.of({ + run: () => Effect.fail(cause), + }), + ), + Effect.provide(WorkspaceSearchIndex.WorkspaceSearchIndexMap.layer), + ); + const error = yield* entries.listIgnoredFiles({ cwd }).pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "WorkspaceEntriesListIgnoredFilesError", cause }); + } + }), + ); + + it.effect("returns a typed failure when Git exits unsuccessfully", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir(); + const entries = yield* WorkspaceEntries.WorkspaceEntries; + const error = yield* entries.listIgnoredFiles({ cwd }).pipe(Effect.flip); + expect(error._tag).toBe("WorkspaceEntriesListIgnoredFilesError"); + if (error._tag === "WorkspaceEntriesListIgnoredFilesError") { + expect(error.cause).toMatchObject({ _tag: "VcsProcessExitError" }); + } + }), + ); + it.effect("searches all ignored paths before limiting results", () => Effect.gen(function* () { const cwd = yield* makeTempDir({ git: true }); diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 7fb1af9b3b50..f1a2fdb49975 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -72,6 +72,18 @@ export class WorkspaceEntriesReadDirectoryError extends Schema.TaggedError()( + "WorkspaceEntriesListIgnoredFilesError", + { + cwd: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to list ignored files."; + } +} + export const WorkspaceEntriesBrowseError = Schema.Union([ WorkspaceEntriesWindowsPathUnsupportedError, WorkspaceEntriesCurrentProjectRequiredError, @@ -80,6 +92,7 @@ export const WorkspaceEntriesBrowseError = Schema.Union([ export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Type; export const WorkspaceEntriesError = Schema.Union([ + WorkspaceEntriesListIgnoredFilesError, WorkspaceEntriesReadDirectoryError, WorkspacePaths.WorkspaceRootNotExistsError, WorkspacePaths.WorkspaceRootCreateFailedError, @@ -417,26 +430,39 @@ export const make = Effect.gen(function* () { "-z", ], cwd: normalizedCwd, - allowNonZeroExit: true, timeoutMs: 10_000, maxOutputBytes: 4 * 1024 * 1024, }) - .pipe(Effect.orElseSucceed(() => undefined)); - if (!result || result.exitCode !== 0) return { files: [], truncated: false }; + .pipe( + Effect.mapError( + (cause) => new WorkspaceEntriesListIgnoredFilesError({ cwd: normalizedCwd, cause }), + ), + ); + const stdout = result.stdoutTruncated + ? result.stdout.slice(0, result.stdout.lastIndexOf("\0") + 1) + : result.stdout; const query = input.query?.trim().toLowerCase() ?? ""; - const paths = result.stdout + const paths = stdout .split("\0") .filter( (entry) => entry.length > 0 && !entry.endsWith("/") && entry.toLowerCase().includes(query), ) .toSorted(); + const after = input.after; + const afterIsDefault = after !== undefined && isDefaultCopiedFile(after); const prioritizedPaths = [ ...paths.filter(isDefaultCopiedFile), ...paths.filter((entry) => !isDefaultCopiedFile(entry)), - ]; + ].filter((entry) => { + if (after === undefined) return true; + const isDefault = isDefaultCopiedFile(entry); + return isDefault === afterIsDefault ? entry > after : afterIsDefault; + }); + const candidates = prioritizedPaths.slice(0, PROJECT_IGNORED_FILES_LIMIT); + const truncated = prioritizedPaths.length > PROJECT_IGNORED_FILES_LIMIT; const files = yield* Effect.promise(() => Promise.all( - prioritizedPaths.slice(0, PROJECT_IGNORED_FILES_LIMIT).map(async (relativePath) => { + candidates.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, @@ -447,7 +473,9 @@ export const make = Effect.gen(function* () { ); return { files: files.filter((file) => file !== null), - truncated: paths.length > PROJECT_IGNORED_FILES_LIMIT, + truncated, + ...(truncated ? { nextCursor: candidates.at(-1) } : {}), + ...(result.stdoutTruncated ? { outputTruncated: true } : {}), }; }); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 763365ceeddc..0aa19309ff81 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -456,6 +456,8 @@ function projectEntriesFailureContext(error: WorkspaceEntries.WorkspaceEntriesEr failure: "workspace_root_not_directory", normalizedCwd: error.normalizedWorkspaceRoot, }; + case "WorkspaceEntriesListIgnoredFilesError": + return { failure: "ignored_files_list_failed", normalizedCwd: error.cwd }; case "WorkspaceEntriesReadDirectoryError": return { failure: "directory_list_failed", diff --git a/apps/web/src/components/AddProjectToMachineDialog.tsx b/apps/web/src/components/AddProjectToMachineDialog.tsx index 933a26b57051..0b1b1add2e56 100644 --- a/apps/web/src/components/AddProjectToMachineDialog.tsx +++ b/apps/web/src/components/AddProjectToMachineDialog.tsx @@ -102,7 +102,12 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe const canListFiles = source?.serverConfig?.environment.capabilities.ignoredFileListing === true; const canSearchFiles = source?.serverConfig?.environment.capabilities.ignoredFileSearch === true; + const canPageFiles = source?.serverConfig?.environment.capabilities.ignoredFilePaging === true; const [query, setQuery] = useState(""); + const [pages, setPages] = useState<{ query: string; cursors: readonly string[] }>({ + query: "", + cursors: [], + }); const normalizedQuery = query.trim(); const debouncedQuery = useDebouncedValue(normalizedQuery, 200); const defaultIgnoredFiles = useEnvironmentQuery( @@ -113,19 +118,37 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe }) : null, ); - const filteredIgnoredFiles = useEnvironmentQuery( - project && canSearchFiles && debouncedQuery + const listQuery = canSearchFiles && normalizedQuery ? debouncedQuery : ""; + if (pages.query !== listQuery) { + setPages({ query: listQuery, cursors: [] }); + } + const cursors = canPageFiles && pages.query === listQuery ? pages.cursors : []; + const after = cursors.at(-1); + const ignoredFiles = useEnvironmentQuery( + project && canListFiles ? projectEnvironment.listIgnoredFiles({ environmentId: project.environmentId, - input: { cwd: project.workspaceRoot, query: debouncedQuery }, + input: { + cwd: project.workspaceRoot, + ...(listQuery ? { query: listQuery } : {}), + ...(after ? { after } : {}), + }, }) : null, ); - const searchActive = canSearchFiles && normalizedQuery.length > 0 && debouncedQuery.length > 0; - const ignoredFiles = searchActive ? filteredIgnoredFiles : defaultIgnoredFiles; + const searchActive = listQuery.length > 0; + const debouncing = canSearchFiles && normalizedQuery !== debouncedQuery; const loadingDefaults = defaultIgnoredFiles.data === null && defaultIgnoredFiles.error === null; - const loadingFiles = ignoredFiles.data === null && ignoredFiles.error === null; - const files = ignoredFiles.error ? [] : (ignoredFiles.data?.files ?? []); + const loadingFiles = + ignoredFiles.isPending || (ignoredFiles.data === null && ignoredFiles.error === null); + const listingError = + ignoredFiles.error ?? + (canPageFiles && ignoredFiles.data?.truncated && !ignoredFiles.data.nextCursor + ? "The source machine did not return the next page. Retry or return to the first page." + : null); + const files = loadingFiles || listingError ? [] : (ignoredFiles.data?.files ?? []); + const pagingDisabled = loadingFiles || debouncing; + const nextCursor = ignoredFiles.data?.nextCursor; const [selected, setSelected] = useState | null>(null); const selectedFiles = selected ?? defaultSelection(defaultIgnoredFiles.data?.files ?? []); const copyable = files.filter((file) => file.byteLength <= COPYABLE_FILE_MAX_BYTES); @@ -349,7 +372,7 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe )}
    - {files.length} listed + {files.length} listed on this page +
    ) : files.length === 0 ? (

    - {searchActive - ? "No ignored files match your search." - : "No ignored files to copy."} + {cursors.length > 0 || nextCursor + ? "No copyable files on this page." + : searchActive + ? "No ignored files match your search." + : "No ignored files to copy."}

    ) : null} - {!searchActive && defaultIgnoredFiles.data?.truncated ? ( -

    - Only the first {PROJECT_IGNORED_FILES_LIMIT} ignored files are listed. - {canSearchFiles ? " Search to find more files." : ""} -

    - ) : null} - {searchActive && - !loadingFiles && - !ignoredFiles.error && - ignoredFiles.data?.truncated ? ( -

    - Only the first {PROJECT_IGNORED_FILES_LIMIT} matches are listed. Refine your - search to find more. + {canPageFiles ? ( +

    + + Page {cursors.length + 1} + + + + +
    + ) : ( + <> + {ignoredFiles.data?.truncated ? ( +

    + Only the first {PROJECT_IGNORED_FILES_LIMIT}{" "} + {searchActive ? "matches" : "ignored files"} are listed. +

    + ) : null} +

    + Update T3 Code on {source?.label ?? "the source machine"} to browse more + ignored files. +

    + + )} + {ignoredFiles.data?.outputTruncated ? ( +

    + Git output reached the listing limit. Some ignored files are not shown and + cannot be reached by paging or search.

    ) : null} {atSelectionLimit ? ( diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index 645e3ed63083..1943cd77bf01 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -139,6 +139,7 @@ export function createProjectEnvironmentAtoms( label: "environment-data:projects:list-ignored-files", tag: WS_METHODS.projectsListIgnoredFiles, staleTimeMs: 15_000, + idleTtlMs: 0, }), readFile: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:projects:read-file", diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 6829b583417d..211c102296a2 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -144,6 +144,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server answers projects.listIgnoredFiles. */ ignoredFileListing: Schema.optionalKey(Schema.Boolean), ignoredFileSearch: Schema.optionalKey(Schema.Boolean), + ignoredFilePaging: Schema.optionalKey(Schema.Boolean), environmentIcon: Schema.optionalKey(Schema.Boolean), desktopAppUpdate: Schema.optionalKey(Schema.Boolean), /** Server answers orchestration.subscribeCommandOutput. Absent on older servers, diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 084c41351f0c..b3dcefbb54ec 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -311,6 +311,7 @@ export type ProjectListEntriesResult = typeof ProjectListEntriesResult.Type; export const ProjectListIgnoredFilesInput = Schema.Struct({ cwd: TrimmedNonEmptyString, query: Schema.optional(TrimmedString.check(Schema.isMaxLength(256))), + after: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(4096))), }); export type ProjectListIgnoredFilesInput = typeof ProjectListIgnoredFilesInput.Type; @@ -330,6 +331,8 @@ export function isDefaultCopiedFile(path: string): boolean { export const ProjectListIgnoredFilesResult = Schema.Struct({ files: Schema.Array(ProjectIgnoredFile), truncated: Schema.Boolean, + nextCursor: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(4096))), + outputTruncated: Schema.optional(Schema.Boolean), }); export type ProjectListIgnoredFilesResult = typeof ProjectListIgnoredFilesResult.Type; @@ -342,6 +345,7 @@ export const ProjectEntriesFailure = Schema.Literals([ "search_index_scan_timed_out", "search_index_search_failed", "directory_list_failed", + "ignored_files_list_failed", ]); export type ProjectEntriesFailure = typeof ProjectEntriesFailure.Type; From 2e63fe18d1042c52c169f44effd95a0e1f0b2f66 Mon Sep 17 00:00:00 2001 From: Maximilian Mauroner <39530837+MaximilianMauroner@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:26:52 +0200 Subject: [PATCH 06/15] fix(contracts): preserve exact ignored-file cursors --- .../src/workspace/WorkspaceEntries.test.ts | 1 - packages/contracts/src/project.test.ts | 25 +++++++++++++++++++ packages/contracts/src/project.ts | 8 +++--- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index d1518973863f..705f847889b4 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -346,7 +346,6 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { expect(page.outputTruncated).not.toBe(true); if (pageCount === 0) { expect(page.files[0]).toEqual({ path: ".env", byteLength: 3 }); - // A keyset cursor remains valid when its file is removed between requests. expect(page.nextCursor).toBe(".agents/run-0198.log"); yield* fileSystem.remove(`${cwd}/.agents/run-0198.log`); } diff --git a/packages/contracts/src/project.test.ts b/packages/contracts/src/project.test.ts index 168886ebe441..37d5c86ad3c2 100644 --- a/packages/contracts/src/project.test.ts +++ b/packages/contracts/src/project.test.ts @@ -6,6 +6,8 @@ import { describe, expect, it } from "vite-plus/test"; import { OrchestrationProjectShell } from "./orchestrationProject.ts"; import { + ProjectListIgnoredFilesInput, + ProjectListIgnoredFilesResult, ProjectFaviconPath, ReceivedProjectIcon, StoredProjectIcon, @@ -177,3 +179,26 @@ effectIt.effect("an icon kind from a newer server shows the default icon", () => assert.strictEqual(broken._tag, "Failure"); }), ); + +describe("ignored-file paging", () => { + it.each([" notes.txt", "notes.txt ", " "])( + "preserves the exact path %j through RPC schemas", + (path) => { + const result = Schema.decodeUnknownSync(ProjectListIgnoredFilesResult)( + Schema.encodeSync(ProjectListIgnoredFilesResult)({ + files: [{ path, byteLength: 1 }], + truncated: true, + nextCursor: path, + }), + ); + const input = Schema.decodeUnknownSync(ProjectListIgnoredFilesInput)( + Schema.encodeSync(ProjectListIgnoredFilesInput)({ + cwd: "/workspace", + after: result.nextCursor, + }), + ); + expect(result.files[0]?.path).toBe(path); + expect(input.after).toBe(path); + }, + ); +}); diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index b3dcefbb54ec..596865acbcf8 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -306,17 +306,19 @@ export const ProjectListEntriesResult = Schema.Struct({ }); export type ProjectListEntriesResult = typeof ProjectListEntriesResult.Type; +const ProjectIgnoredFilePath = Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(4096)); + // Untracked files git ignores. Wholly ignored folders such as `node_modules` // are left out, so the list stays small enough to pick from. export const ProjectListIgnoredFilesInput = Schema.Struct({ cwd: TrimmedNonEmptyString, query: Schema.optional(TrimmedString.check(Schema.isMaxLength(256))), - after: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(4096))), + after: Schema.optional(ProjectIgnoredFilePath), }); export type ProjectListIgnoredFilesInput = typeof ProjectListIgnoredFilesInput.Type; export const ProjectIgnoredFile = Schema.Struct({ - path: TrimmedNonEmptyString, + path: ProjectIgnoredFilePath, byteLength: NonNegativeInt, }); export type ProjectIgnoredFile = typeof ProjectIgnoredFile.Type; @@ -331,7 +333,7 @@ export function isDefaultCopiedFile(path: string): boolean { export const ProjectListIgnoredFilesResult = Schema.Struct({ files: Schema.Array(ProjectIgnoredFile), truncated: Schema.Boolean, - nextCursor: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(4096))), + nextCursor: Schema.optional(ProjectIgnoredFilePath), outputTruncated: Schema.optional(Schema.Boolean), }); export type ProjectListIgnoredFilesResult = typeof ProjectListIgnoredFilesResult.Type; From 4857b150280f17b090833efeee55154b32ecb857 Mon Sep 17 00:00:00 2001 From: Maximilian Mauroner <39530837+MaximilianMauroner@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:30:14 +0200 Subject: [PATCH 07/15] test(contracts): reuse ignored-file schema codecs --- packages/contracts/src/project.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/contracts/src/project.test.ts b/packages/contracts/src/project.test.ts index 37d5c86ad3c2..2cf64a54ef78 100644 --- a/packages/contracts/src/project.test.ts +++ b/packages/contracts/src/project.test.ts @@ -20,6 +20,10 @@ import { ProjectWriteFileError, } from "./project.ts"; +const decodeIgnoredFilesResult = Schema.decodeUnknownSync(ProjectListIgnoredFilesResult); +const encodeIgnoredFilesResult = Schema.encodeSync(ProjectListIgnoredFilesResult); +const decodeIgnoredFilesInput = Schema.decodeUnknownSync(ProjectListIgnoredFilesInput); +const encodeIgnoredFilesInput = Schema.encodeSync(ProjectListIgnoredFilesInput); const decodeProjectCreatePayload = Schema.decodeUnknownSync(ProjectCreatePayload); const decodeProjectUpdatePayload = Schema.decodeUnknownSync(ProjectUpdatePayload); const decodeProjectMutation = Schema.decodeUnknownSync(ProjectMutation); @@ -184,15 +188,15 @@ describe("ignored-file paging", () => { it.each([" notes.txt", "notes.txt ", " "])( "preserves the exact path %j through RPC schemas", (path) => { - const result = Schema.decodeUnknownSync(ProjectListIgnoredFilesResult)( - Schema.encodeSync(ProjectListIgnoredFilesResult)({ + const result = decodeIgnoredFilesResult( + encodeIgnoredFilesResult({ files: [{ path, byteLength: 1 }], truncated: true, nextCursor: path, }), ); - const input = Schema.decodeUnknownSync(ProjectListIgnoredFilesInput)( - Schema.encodeSync(ProjectListIgnoredFilesInput)({ + const input = decodeIgnoredFilesInput( + encodeIgnoredFilesInput({ cwd: "/workspace", after: result.nextCursor, }), From 02a099533d1964c0617be00bb9aeb84dc4c28f92 Mon Sep 17 00:00:00 2001 From: Maximilian Mauroner <39530837+MaximilianMauroner@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:41:37 +0200 Subject: [PATCH 08/15] test(web): cover ignored-picker loading state --- ...AddProjectToMachineDialog.browser.test.tsx | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 apps/web/src/components/AddProjectToMachineDialog.browser.test.tsx diff --git a/apps/web/src/components/AddProjectToMachineDialog.browser.test.tsx b/apps/web/src/components/AddProjectToMachineDialog.browser.test.tsx new file mode 100644 index 000000000000..ab125864b37f --- /dev/null +++ b/apps/web/src/components/AddProjectToMachineDialog.browser.test.tsx @@ -0,0 +1,185 @@ +import { RegistryContext } from "@effect/atom-react"; +import { + EnvironmentId, + ProjectId, + type ProjectListIgnoredFilesInput, + type ProjectListIgnoredFilesResult, +} from "@t3tools/contracts"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import "~/index.css"; +import { act, render } from "../../test/render"; +import type { AddProjectToMachineInput } from "../lib/addProjectToMachine"; + +type Listing = AsyncResult.AsyncResult; +const mocks = vi.hoisted(() => ({ + listIgnoredFiles: + vi.fn<(target: { input: ProjectListIgnoredFilesInput }) => Atom.Writable>(), + addProject: vi.fn<(input: AddProjectToMachineInput) => Promise>(), + newThread: vi.fn(), +})); + +vi.mock("../state/entities", () => ({ useProject: () => project })); +vi.mock("../state/environments", () => ({ + useEnvironments: () => ({ environments: [source, target] }), +})); +vi.mock("../hooks/useAddProjectTargets", () => ({ + useAddProjectTargets: () => [{ ...target, machine: "server" }], +})); +vi.mock("../hooks/useLogicalProjectEnvironments", () => ({ + useLogicalProjectEnvironments: () => [], +})); +vi.mock("../hooks/useHandleNewThread", () => ({ useNewThreadHandler: () => mocks.newThread })); +vi.mock("../state/queries", () => ({ useDebouncedValue: (value: A) => value })); +vi.mock("../state/projects", () => ({ + projectEnvironment: { listIgnoredFiles: mocks.listIgnoredFiles }, +})); +vi.mock(import("../lib/addProjectToMachine"), async (original) => ({ + ...(await original()), + addProjectToMachine: mocks.addProject, +})); + +import { + AddProjectToMachineDialogHost, + openAddProjectToMachine, +} from "./AddProjectToMachineDialog"; + +const source = { + environmentId: EnvironmentId.make("source"), + label: "Source", + serverConfig: { + environment: { + platform: { machine: "server" }, + capabilities: { ignoredFileListing: true, ignoredFileSearch: true, ignoredFilePaging: true }, + }, + }, +}; +const target = { + environmentId: EnvironmentId.make("target"), + label: "Target", + serverConfig: { settings: { addProjectBaseDirectory: "/clones" } }, +}; +const project = { + id: ProjectId.make("project"), + environmentId: source.environmentId, + title: "Project", + workspaceRoot: "/project", +}; +const defaultFiles = [ + { path: ".env", byteLength: 12 }, + { path: "notes.txt", byteLength: 4 }, +]; + +function setup(initial: Listing = AsyncResult.initial(true)) { + const registry = AtomRegistry.make(); + const defaults = Atom.make(initial); + const search = Atom.make( + AsyncResult.success({ files: [defaultFiles[1]!], truncated: false }), + ); + mocks.listIgnoredFiles + .mockReset() + .mockImplementation(({ input }) => (input.query ? search : defaults)); + mocks.addProject.mockReset().mockResolvedValue(undefined); + mocks.newThread.mockReset(); + openAddProjectToMachine({ + projectRef: { environmentId: project.environmentId, projectId: project.id }, + targetEnvironmentId: target.environmentId, + }); + return { + defaults, + registry, + addProject: mocks.addProject, + dialog: () => ( + + + + ), + [Symbol.dispose]: () => registry.dispose(), + }; +} + +const button = (name: string) => page.getByRole("button", { name, exact: true }); +const checkbox = (name: string) => page.getByRole("checkbox", { name, exact: true }); + +describe("ignored-file picker query state", () => { + it("waits for defaults before search edits and preserves an intentional clear", async () => { + using state = setup(); + await using _screen = await render(state.dialog()); + const search = page.getByRole("textbox", { name: "Search ignored files" }); + await act(() => search.element().focus()); + await userEvent.keyboard("notes"); + + await expect.element(search).toHaveValue(""); + await expect.element(checkbox("notes.txt")).not.toBeInTheDocument(); + await expect.element(button("Clone to Target")).toBeDisabled(); + + await act(() => + state.registry.set( + state.defaults, + AsyncResult.success({ files: defaultFiles, truncated: false }), + ), + ); + await expect.element(checkbox(".env")).toBeChecked(); + await expect.element(button("Clone to Target")).toBeEnabled(); + + await userEvent.fill(search, "notes"); + await userEvent.click(checkbox("notes.txt")); + await userEvent.fill(search, ""); + await expect.element(checkbox(".env")).toBeChecked(); + await expect.element(checkbox("notes.txt")).toBeChecked(); + + await userEvent.click(button("Clear listed")); + await act(() => + state.registry.set( + state.defaults, + AsyncResult.success({ files: [...defaultFiles], truncated: false }), + ), + ); + await userEvent.fill(search, "notes"); + await expect.element(checkbox("notes.txt")).not.toBeChecked(); + await userEvent.fill(search, ""); + await expect.element(checkbox(".env")).not.toBeChecked(); + await userEvent.click(button("Clone to Target")); + expect(state.addProject).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ files: [] }), + ); + }); + + it("keeps cached rows selectable while a refresh blocks page navigation", async () => { + const listing = { + files: Array.from({ length: 200 }, (_, index) => ({ + path: `file-${index}.txt`, + byteLength: 4, + })), + truncated: true, + nextCursor: "file-199.txt", + }; + using state = setup(AsyncResult.success(listing)); + await using _screen = await render(state.dialog()); + await expect.element(checkbox("file-0.txt")).toBeVisible(); + await expect.element(button("Next")).toBeEnabled(); + + await act(() => + state.registry.set(state.defaults, AsyncResult.success(listing, { waiting: true })), + ); + await expect.element(page.getByText("200 listed on this page", { exact: true })).toBeVisible(); + await expect.element(checkbox("file-0.txt")).toBeVisible(); + await expect.element(checkbox("file-199.txt")).toBeInTheDocument(); + await expect.element(button("Next")).toBeDisabled(); + await expect + .element(page.getByText("Finding ignored files…", { exact: true })) + .not.toBeInTheDocument(); + await userEvent.click(checkbox("file-0.txt")); + await expect.element(checkbox("file-0.txt")).toBeChecked(); + + await act(() => state.registry.set(state.defaults, AsyncResult.success(listing))); + await expect.element(button("Next")).toBeEnabled(); + await expect.element(checkbox("file-0.txt")).toBeChecked(); + await userEvent.click(button("Clone to Target")); + expect(state.addProject).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ files: ["file-0.txt"] }), + ); + }); +}); From 74cbc8d41174830a10320bc968aeb4418bec1e4d Mon Sep 17 00:00:00 2001 From: Maximilian Mauroner <39530837+MaximilianMauroner@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:43:16 +0200 Subject: [PATCH 09/15] fix(web): preserve ignored-picker defaults and cached rows --- apps/web/src/components/AddProjectToMachineDialog.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/AddProjectToMachineDialog.tsx b/apps/web/src/components/AddProjectToMachineDialog.tsx index 0b1b1add2e56..7dd0c1fe3908 100644 --- a/apps/web/src/components/AddProjectToMachineDialog.tsx +++ b/apps/web/src/components/AddProjectToMachineDialog.tsx @@ -139,15 +139,14 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe const searchActive = listQuery.length > 0; const debouncing = canSearchFiles && normalizedQuery !== debouncedQuery; const loadingDefaults = defaultIgnoredFiles.data === null && defaultIgnoredFiles.error === null; - const loadingFiles = - ignoredFiles.isPending || (ignoredFiles.data === null && ignoredFiles.error === null); + const loadingFiles = ignoredFiles.data === null && ignoredFiles.error === null; const listingError = ignoredFiles.error ?? (canPageFiles && ignoredFiles.data?.truncated && !ignoredFiles.data.nextCursor ? "The source machine did not return the next page. Retry or return to the first page." : null); const files = loadingFiles || listingError ? [] : (ignoredFiles.data?.files ?? []); - const pagingDisabled = loadingFiles || debouncing; + const pagingDisabled = loadingFiles || ignoredFiles.isPending || debouncing; const nextCursor = ignoredFiles.data?.nextCursor; const [selected, setSelected] = useState | null>(null); const selectedFiles = selected ?? defaultSelection(defaultIgnoredFiles.data?.files ?? []); @@ -354,6 +353,7 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe setQuery(event.target.value)} onKeyDown={(event) => { From 082c73cbee8adc130bf9f5c3e081ba2689e2341d Mon Sep 17 00:00:00 2001 From: Maximilian Mauroner <39530837+MaximilianMauroner@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:55:44 +0200 Subject: [PATCH 10/15] test(web): match ignored-file checkbox names --- .../src/components/AddProjectToMachineDialog.browser.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/AddProjectToMachineDialog.browser.test.tsx b/apps/web/src/components/AddProjectToMachineDialog.browser.test.tsx index ab125864b37f..cc77bd3a99f3 100644 --- a/apps/web/src/components/AddProjectToMachineDialog.browser.test.tsx +++ b/apps/web/src/components/AddProjectToMachineDialog.browser.test.tsx @@ -101,7 +101,8 @@ function setup(initial: Listing = AsyncResult.initial(true)) { } const button = (name: string) => page.getByRole("button", { name, exact: true }); -const checkbox = (name: string) => page.getByRole("checkbox", { name, exact: true }); +const checkbox = (name: string) => + page.getByRole("checkbox", { name: new RegExp(name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) }); describe("ignored-file picker query state", () => { it("waits for defaults before search edits and preserves an intentional clear", async () => { From 114f28b5819dbb023b160181e54ade428283016b Mon Sep 17 00:00:00 2001 From: Maximilian Mauroner <39530837+MaximilianMauroner@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:36:59 +0200 Subject: [PATCH 11/15] refactor: combine ignored-file browsing capabilities --- .../src/environment/ServerEnvironment.test.ts | 3 +- .../src/environment/ServerEnvironment.ts | 3 +- ...AddProjectToMachineDialog.browser.test.tsx | 7 ++-- .../components/AddProjectToMachineDialog.tsx | 39 +++++++------------ packages/contracts/src/environment.ts | 3 +- 5 files changed, 21 insertions(+), 34 deletions(-) diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 0fd23f11c0fa..77a677401599 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -114,8 +114,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { ); expect(descriptor.capabilities).toMatchObject({ ignoredFileListing: true, - ignoredFileSearch: true, - ignoredFilePaging: true, + ignoredFileBrowsing: true, settledWorktreeCleanup: true, storageCleanupRun: true, projectWorktreeCleanup: true, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 3480949df7ce..0b953098f4fd 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -259,8 +259,7 @@ export const make = Effect.gen(function* () { environmentIcon: true, projectCloneTracking: true, ignoredFileListing: true, - ignoredFileSearch: true, - ignoredFilePaging: true, + ignoredFileBrowsing: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverInstallation === null ? {} : { serverInstallation }), ...(serverSelfUpdate === "boot-service" || desktopAppUpdate diff --git a/apps/web/src/components/AddProjectToMachineDialog.browser.test.tsx b/apps/web/src/components/AddProjectToMachineDialog.browser.test.tsx index cc77bd3a99f3..c2fc54a380fb 100644 --- a/apps/web/src/components/AddProjectToMachineDialog.browser.test.tsx +++ b/apps/web/src/components/AddProjectToMachineDialog.browser.test.tsx @@ -52,7 +52,7 @@ const source = { serverConfig: { environment: { platform: { machine: "server" }, - capabilities: { ignoredFileListing: true, ignoredFileSearch: true, ignoredFilePaging: true }, + capabilities: { ignoredFileListing: true, ignoredFileBrowsing: true }, }, }, }; @@ -90,7 +90,6 @@ function setup(initial: Listing = AsyncResult.initial(true)) { return { defaults, registry, - addProject: mocks.addProject, dialog: () => ( @@ -143,7 +142,7 @@ describe("ignored-file picker query state", () => { await userEvent.fill(search, ""); await expect.element(checkbox(".env")).not.toBeChecked(); await userEvent.click(button("Clone to Target")); - expect(state.addProject).toHaveBeenCalledExactlyOnceWith( + expect(mocks.addProject).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ files: [] }), ); }); @@ -179,7 +178,7 @@ describe("ignored-file picker query state", () => { await expect.element(button("Next")).toBeEnabled(); await expect.element(checkbox("file-0.txt")).toBeChecked(); await userEvent.click(button("Clone to Target")); - expect(state.addProject).toHaveBeenCalledExactlyOnceWith( + expect(mocks.addProject).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ files: ["file-0.txt"] }), ); }); diff --git a/apps/web/src/components/AddProjectToMachineDialog.tsx b/apps/web/src/components/AddProjectToMachineDialog.tsx index 7dd0c1fe3908..7138853d33d3 100644 --- a/apps/web/src/components/AddProjectToMachineDialog.tsx +++ b/apps/web/src/components/AddProjectToMachineDialog.tsx @@ -101,8 +101,8 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe const destinationPath = pathOverride ?? defaultPath; const canListFiles = source?.serverConfig?.environment.capabilities.ignoredFileListing === true; - const canSearchFiles = source?.serverConfig?.environment.capabilities.ignoredFileSearch === true; - const canPageFiles = source?.serverConfig?.environment.capabilities.ignoredFilePaging === true; + const canBrowseFiles = + source?.serverConfig?.environment.capabilities.ignoredFileBrowsing === true; const [query, setQuery] = useState(""); const [pages, setPages] = useState<{ query: string; cursors: readonly string[] }>({ query: "", @@ -118,11 +118,11 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe }) : null, ); - const listQuery = canSearchFiles && normalizedQuery ? debouncedQuery : ""; + const listQuery = canBrowseFiles && normalizedQuery ? debouncedQuery : ""; if (pages.query !== listQuery) { setPages({ query: listQuery, cursors: [] }); } - const cursors = canPageFiles && pages.query === listQuery ? pages.cursors : []; + const cursors = canBrowseFiles && pages.query === listQuery ? pages.cursors : []; const after = cursors.at(-1); const ignoredFiles = useEnvironmentQuery( project && canListFiles @@ -137,12 +137,12 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe : null, ); const searchActive = listQuery.length > 0; - const debouncing = canSearchFiles && normalizedQuery !== debouncedQuery; + const debouncing = canBrowseFiles && normalizedQuery !== debouncedQuery; const loadingDefaults = defaultIgnoredFiles.data === null && defaultIgnoredFiles.error === null; const loadingFiles = ignoredFiles.data === null && ignoredFiles.error === null; const listingError = ignoredFiles.error ?? - (canPageFiles && ignoredFiles.data?.truncated && !ignoredFiles.data.nextCursor + (canBrowseFiles && ignoredFiles.data?.truncated && !ignoredFiles.data.nextCursor ? "The source machine did not return the next page. Retry or return to the first page." : null); const files = loadingFiles || listingError ? [] : (ignoredFiles.data?.files ?? []); @@ -348,7 +348,7 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe {selectedBytes > 0 ? ` · ${formatBytes(selectedBytes)}` : ""}
    - {canSearchFiles ? ( + {canBrowseFiles ? ( <> ) : (

    - Update T3 Code on {source?.label ?? "the source machine"} to search ignored - files. + Update T3 Code on {source?.label ?? "the source machine"} to search and browse + more ignored files.

    )}
    @@ -435,7 +435,7 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe setSelected(updateIgnoredFileSelection(selectedFiles, [file], checked)) } /> - {canPageFiles ? ( + {canBrowseFiles ? (
    Page {cursors.length + 1} @@ -472,20 +472,11 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe Next
    - ) : ( - <> - {ignoredFiles.data?.truncated ? ( -

    - Only the first {PROJECT_IGNORED_FILES_LIMIT}{" "} - {searchActive ? "matches" : "ignored files"} are listed. -

    - ) : null} -

    - Update T3 Code on {source?.label ?? "the source machine"} to browse more - ignored files. -

    - - )} + ) : ignoredFiles.data?.truncated ? ( +

    + Only the first {PROJECT_IGNORED_FILES_LIMIT} ignored files are listed. +

    + ) : null} {ignoredFiles.data?.outputTruncated ? (

    Git output reached the listing limit. Some ignored files are not shown and diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 211c102296a2..01e2e3175377 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -143,8 +143,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ projectCloneTracking: Schema.optionalKey(Schema.Boolean), /** Server answers projects.listIgnoredFiles. */ ignoredFileListing: Schema.optionalKey(Schema.Boolean), - ignoredFileSearch: Schema.optionalKey(Schema.Boolean), - ignoredFilePaging: Schema.optionalKey(Schema.Boolean), + ignoredFileBrowsing: Schema.optionalKey(Schema.Boolean), environmentIcon: Schema.optionalKey(Schema.Boolean), desktopAppUpdate: Schema.optionalKey(Schema.Boolean), /** Server answers orchestration.subscribeCommandOutput. Absent on older servers, From 1587ab9d81d208c2f0ce1e912ba8abf7d859c73b Mon Sep 17 00:00:00 2001 From: Maximilian Mauroner <39530837+MaximilianMauroner@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:37:01 +0200 Subject: [PATCH 12/15] refactor(web): simplify ignored-file tree rendering --- apps/web/src/components/IgnoredFileTree.tsx | 108 +++++++------------- apps/web/src/lib/ignoredFileTree.test.ts | 17 +-- apps/web/src/lib/ignoredFileTree.ts | 4 +- 3 files changed, 42 insertions(+), 87 deletions(-) diff --git a/apps/web/src/components/IgnoredFileTree.tsx b/apps/web/src/components/IgnoredFileTree.tsx index 101185930fa1..3684de202938 100644 --- a/apps/web/src/components/IgnoredFileTree.tsx +++ b/apps/web/src/components/IgnoredFileTree.tsx @@ -1,6 +1,6 @@ import type { ProjectIgnoredFile } from "@t3tools/contracts"; import { ChevronDownIcon, ChevronRightIcon, FolderIcon } from "lucide-react"; -import { useState, type Dispatch, type SetStateAction } from "react"; +import { useState } from "react"; import { COPYABLE_FILE_MAX_BYTES } from "../lib/addProjectToMachine"; import { formatBytes } from "../lib/formatBytes"; @@ -44,83 +44,53 @@ export function IgnoredFileTree({ /> ); + function renderNodes(nodes: readonly IgnoredFileTreeNode[]): React.ReactNode { + return nodes.map((node) => { + if (node.kind === "file") return

  • {fileRow(node.file, node.name)}
  • ; + const open = expanded?.has(node.path) ?? false; + const selectedCount = countSelectedIgnoredDescendants(node.path, selected.keys()); + const ChevronIcon = open ? ChevronDownIcon : ChevronRightIcon; + return ( +
  • + + {open ?
      {renderNodes(node.children)}
    : null} +
  • + ); + }); + } + if (files.length === 0) return null; return (
      - {searching ? ( - files.map((file) =>
    • {fileRow(file, file.path)}
    • ) - ) : ( - - )} + {searching + ? files.map((file) =>
    • {fileRow(file, file.path)}
    • ) + : renderNodes(tree)}
    ); } -function IgnoredFileTreeNodes({ - nodes, - expanded, - setExpanded, - selected, - fileRow, -}: { - nodes: readonly IgnoredFileTreeNode[]; - expanded: ReadonlySet | null; - setExpanded: Dispatch | null>>; - selected: ReadonlyMap; - fileRow: (file: ProjectIgnoredFile, name: string) => React.ReactNode; -}) { - return nodes.map((node) => { - if (node.kind === "file") return
  • {fileRow(node.file, node.name)}
  • ; - const open = expanded?.has(node.path) ?? false; - const selectedCount = countSelectedIgnoredDescendants(node.path, selected.keys()); - const ChevronIcon = open ? ChevronDownIcon : ChevronRightIcon; - return ( -
  • - - {open ? ( -
      - -
    - ) : null} -
  • - ); - }); -} - function IgnoredFileRow({ file, name, diff --git a/apps/web/src/lib/ignoredFileTree.test.ts b/apps/web/src/lib/ignoredFileTree.test.ts index 6c640ab365d4..0badfc375607 100644 --- a/apps/web/src/lib/ignoredFileTree.test.ts +++ b/apps/web/src/lib/ignoredFileTree.test.ts @@ -99,25 +99,12 @@ describe("ignored file tree", () => { expect(countSelectedIgnoredDescendants(".agents", selected)).toBe(2); expect(countSelectedIgnoredDescendants("apps/api", selected)).toBe(1); expect(countSelectedIgnoredDescendants("apps/web", selected)).toBe(0); - expect(Array.from(selected)).toEqual([ - ".agents/a.md", - ".agents/unlisted/b.md", - ".agents-other/c.md", - ".env", - "apps/api/.env", - ]); }); - it("keeps original file metadata for selection and handles an empty list", () => { + it("keeps file metadata for selection and handles an empty list", () => { const original = file("config/.env", 512); const tree = buildIgnoredFileTree([original]); - const directory = tree[0]; - expect(directory?.kind).toBe("directory"); - if (directory?.kind !== "directory") throw new Error("Missing config directory"); - const leaf = directory.children[0]; - expect(leaf?.kind).toBe("file"); - if (leaf?.kind !== "file") throw new Error("Missing .env file"); - expect(leaf.file).toBe(original); + expect(tree).toMatchObject([{ children: [{ file: original }] }]); expect(buildIgnoredFileTree([])).toEqual([]); expect(getDefaultIgnoredFileExpansion([]).size).toBe(0); }); diff --git a/apps/web/src/lib/ignoredFileTree.ts b/apps/web/src/lib/ignoredFileTree.ts index 0f7af1da73ce..6a3021727604 100644 --- a/apps/web/src/lib/ignoredFileTree.ts +++ b/apps/web/src/lib/ignoredFileTree.ts @@ -19,7 +19,6 @@ export type IgnoredFileTreeNode = }; type Directory = { - name: string; path: string; children: Map; }; @@ -27,7 +26,7 @@ type Directory = { export function buildIgnoredFileTree( files: readonly ProjectIgnoredFile[], ): readonly IgnoredFileTreeNode[] { - const root: Directory = { name: "", path: "", children: new Map() }; + const root: Directory = { path: "", children: new Map() }; for (const file of files) { const parts = file.path.split("/"); const name = parts.pop()!; @@ -36,7 +35,6 @@ export function buildIgnoredFileTree( let child = directory.children.get(part); if (!child) { child = { - name: part, path: directory.path ? `${directory.path}/${part}` : part, children: new Map(), }; From 1eda7333091371514e2d837138bd8277668dd14f Mon Sep 17 00:00:00 2001 From: Maximilian Mauroner <39530837+MaximilianMauroner@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:37:02 +0200 Subject: [PATCH 13/15] test: consolidate ignored-file coverage --- .../src/workspace/WorkspaceEntries.test.ts | 83 +++---------------- apps/web/src/lib/addProjectToMachine.test.ts | 13 +-- packages/contracts/src/project.test.ts | 12 +++ 3 files changed, 25 insertions(+), 83 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 705f847889b4..4db326a66322 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -296,40 +296,20 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }); describe("listIgnoredFiles", () => { - it.effect("keeps environment files visible beyond 200 ignored files", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ git: true }); - yield* writeTextFile(cwd, ".gitignore", "*.log\n.env*\n"); - yield* writeTextFile(cwd, "data/keep.txt", "tracked"); - yield* writeTextFile(cwd, "services/api/index.ts", "tracked"); - yield* git(cwd, ["add", "data/keep.txt", "services/api/index.ts"]); - yield* Effect.forEach( - Array.from({ length: 205 }, (_, i) => i), - (i) => writeTextFile(cwd, `data/generated-${String(i).padStart(3, "0")}.log`, "x"), - ); - yield* writeTextFile(cwd, "services/api/.env.local", "A=1"); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const result = yield* workspaceEntries.listIgnoredFiles({ cwd }); - expect(result.files).toContainEqual({ path: "services/api/.env.local", byteLength: 3 }); - expect(result.files).toHaveLength(200); - expect(result.truncated).toBe(true); - }), - ); - it.effect("browses every captured ignored path in bounded pages without duplicates", () => Effect.gen(function* () { const cwd = yield* makeTempDir({ git: true }); const fileSystem = yield* FileSystem.FileSystem; - yield* writeTextFile(cwd, ".gitignore", "*.log\n.env\nnode_modules/\n"); + yield* writeTextFile(cwd, ".gitignore", "*.log\n.env*\nnode_modules/\n"); yield* writeTextFile(cwd, ".agents/keep.txt", "tracked"); - yield* git(cwd, ["add", ".agents/keep.txt"]); + yield* writeTextFile(cwd, "services/api/index.ts", "tracked"); + yield* git(cwd, ["add", ".agents/keep.txt", "services/api/index.ts"]); const logs = Array.from( { length: 605 }, (_, i) => `.agents/run-${String(i).padStart(4, "0")}.log`, ); yield* Effect.forEach(logs, (name) => writeTextFile(cwd, name, "x")); - yield* writeTextFile(cwd, ".env", "A=1"); + yield* writeTextFile(cwd, "services/api/.env.local", "A=1"); yield* writeTextFile(cwd, "node_modules/excluded.log", "excluded"); const entries = yield* WorkspaceEntries.WorkspaceEntries; @@ -345,7 +325,7 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { expect(page.truncated).toBe(page.nextCursor !== undefined); expect(page.outputTruncated).not.toBe(true); if (pageCount === 0) { - expect(page.files[0]).toEqual({ path: ".env", byteLength: 3 }); + expect(page.files[0]).toEqual({ path: "services/api/.env.local", byteLength: 3 }); expect(page.nextCursor).toBe(".agents/run-0198.log"); yield* fileSystem.remove(`${cwd}/.agents/run-0198.log`); } @@ -356,7 +336,7 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { } while (after !== undefined); expect(pageCount).toBe(4); expect(new Set(seen).size).toBe(seen.length); - expect(seen.toSorted()).toEqual([".env", ...logs].toSorted()); + expect(seen.toSorted()).toEqual(["services/api/.env.local", ...logs].toSorted()); const matches: string[] = []; after = undefined; @@ -443,24 +423,6 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); - it.effect("keeps a successful Git exit when stdout capture reaches its limit", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ git: true }); - yield* writeTextFile(cwd, ".gitignore", "*.log\n"); - yield* writeTextFile(cwd, "some-long-file-name.log", "x"); - const vcs = yield* VcsProcess.VcsProcess; - const result = yield* vcs.run({ - operation: "WorkspaceEntries.test.truncatedGitOutput", - command: "git", - args: ["ls-files", "--others", "--ignored", "--exclude-standard", "-z"], - cwd, - maxOutputBytes: 8, - }); - expect(result.exitCode).toBe(0); - expect(result.stdoutTruncated).toBe(true); - }), - ); - it.effect("preserves Git timeout and spawn failures as typed listing errors", () => Effect.gen(function* () { const cwd = yield* makeTempDir(); @@ -510,10 +472,15 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { yield* writeTextFile(cwd, "node_modules/z-target.log", "excluded"); const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - expect(yield* workspaceEntries.listIgnoredFiles({ cwd, query: "z-target" })).toEqual({ + expect( + yield* workspaceEntries.listIgnoredFiles({ cwd, query: " DaTa/Z-TaRgEt.LoG " }), + ).toEqual({ files: [{ path: "data/z-target.log", byteLength: 5 }], truncated: false, }); + const unfiltered = yield* workspaceEntries.listIgnoredFiles({ cwd }); + expect(yield* workspaceEntries.listIgnoredFiles({ cwd, query: "" })).toEqual(unfiltered); + expect(yield* workspaceEntries.listIgnoredFiles({ cwd, query: " " })).toEqual(unfiltered); expect(yield* workspaceEntries.listIgnoredFiles({ cwd, query: "node_modules" })).toEqual({ files: [], truncated: false, @@ -521,32 +488,6 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); - it.effect( - "matches full paths without case sensitivity and treats empty queries as unfiltered", - () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ git: true }); - yield* writeTextFile(cwd, ".gitignore", ".env*\n*.log\n"); - yield* writeTextFile(cwd, "services/API/index.ts", "tracked"); - yield* git(cwd, ["add", "services/API/index.ts"]); - yield* writeTextFile(cwd, "services/API/.env.local", "A=1"); - yield* writeTextFile(cwd, "debug.log", "log"); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const unfiltered = yield* workspaceEntries.listIgnoredFiles({ cwd }); - expect(yield* workspaceEntries.listIgnoredFiles({ cwd, query: "" })).toEqual(unfiltered); - expect(yield* workspaceEntries.listIgnoredFiles({ cwd, query: " " })).toEqual( - unfiltered, - ); - expect( - yield* workspaceEntries.listIgnoredFiles({ cwd, query: " SERVICES/api/.ENV " }), - ).toEqual({ - files: [{ path: "services/API/.env.local", byteLength: 3 }], - truncated: false, - }); - }), - ); - it.effect("lists ignored files with sizes and skips wholly ignored folders", () => Effect.gen(function* () { const cwd = yield* makeTempDir({ git: true }); diff --git a/apps/web/src/lib/addProjectToMachine.test.ts b/apps/web/src/lib/addProjectToMachine.test.ts index caa298701a37..a0a7623414a3 100644 --- a/apps/web/src/lib/addProjectToMachine.test.ts +++ b/apps/web/src/lib/addProjectToMachine.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { PROJECT_IGNORED_FILES_LIMIT, isDefaultCopiedFile } from "@t3tools/contracts"; +import { PROJECT_IGNORED_FILES_LIMIT } from "@t3tools/contracts"; import { COPYABLE_FILE_MAX_BYTES, @@ -79,14 +79,3 @@ describe("ignored file selection", () => { ).toEqual([atCap]); }); }); - -describe("isDefaultCopiedFile", () => { - it("matches environment config by file name at any depth", () => { - for (const path of [".env", "services/api/.env.local", ".envrc", "apps/web/.dev.vars"]) { - expect(isDefaultCopiedFile(path)).toBe(true); - } - for (const path of ["env", "debug.log", ".env/secret.txt", ".envrc.local", ".dev.vars.local"]) { - expect(isDefaultCopiedFile(path)).toBe(false); - } - }); -}); diff --git a/packages/contracts/src/project.test.ts b/packages/contracts/src/project.test.ts index 2cf64a54ef78..0aa7a5530d6b 100644 --- a/packages/contracts/src/project.test.ts +++ b/packages/contracts/src/project.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "vite-plus/test"; import { OrchestrationProjectShell } from "./orchestrationProject.ts"; import { + isDefaultCopiedFile, ProjectListIgnoredFilesInput, ProjectListIgnoredFilesResult, ProjectFaviconPath, @@ -206,3 +207,14 @@ describe("ignored-file paging", () => { }, ); }); + +describe("isDefaultCopiedFile", () => { + it("matches environment config by file name at any depth", () => { + for (const path of [".env", "services/api/.env.local", ".envrc", "apps/web/.dev.vars"]) { + expect(isDefaultCopiedFile(path)).toBe(true); + } + for (const path of ["env", "debug.log", ".env/secret.txt", ".envrc.local", ".dev.vars.local"]) { + expect(isDefaultCopiedFile(path)).toBe(false); + } + }); +}); From 4a5bc2ba22dba771c224e8b593a712c1ceb6d8ce Mon Sep 17 00:00:00 2001 From: Maximilian Mauroner <39530837+MaximilianMauroner@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:43:41 +0200 Subject: [PATCH 14/15] test(server): retain case-insensitive path coverage --- apps/server/src/workspace/WorkspaceEntries.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 4db326a66322..49388cd911ad 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -468,17 +468,18 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { Array.from({ length: 205 }, (_, i) => i), (i) => writeTextFile(cwd, `data/generated-${String(i).padStart(3, "0")}.log`, "x"), ); - yield* writeTextFile(cwd, "data/z-target.log", "found"); + yield* writeTextFile(cwd, "data/z-Target.log", "found"); yield* writeTextFile(cwd, "node_modules/z-target.log", "excluded"); const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; expect( yield* workspaceEntries.listIgnoredFiles({ cwd, query: " DaTa/Z-TaRgEt.LoG " }), ).toEqual({ - files: [{ path: "data/z-target.log", byteLength: 5 }], + files: [{ path: "data/z-Target.log", byteLength: 5 }], truncated: false, }); const unfiltered = yield* workspaceEntries.listIgnoredFiles({ cwd }); + expect(unfiltered.files.map((file) => file.path)).not.toContain("data/z-Target.log"); expect(yield* workspaceEntries.listIgnoredFiles({ cwd, query: "" })).toEqual(unfiltered); expect(yield* workspaceEntries.listIgnoredFiles({ cwd, query: " " })).toEqual(unfiltered); expect(yield* workspaceEntries.listIgnoredFiles({ cwd, query: "node_modules" })).toEqual({ From 044c063c84df2350ca77c4b3cc7526fb287f86da Mon Sep 17 00:00:00 2001 From: Maximilian Mauroner <39530837+MaximilianMauroner@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:28:50 +0200 Subject: [PATCH 15/15] fix(server): preserve ignored-file error compatibility --- apps/server/src/ws.test.ts | 91 ++++++++++++++++++++++++++++++- apps/server/src/ws.ts | 1 - packages/contracts/src/project.ts | 1 - 3 files changed, 90 insertions(+), 3 deletions(-) diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index a9a028ec94bd..729fbfeb3a1a 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -2,11 +2,13 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { AuthSessionId, + AuthFilesystemReadScope, EnvironmentId, ProjectId, ThreadId, WS_METHODS, WsSubscribeServerConfigRpc, + WsRpcGroup, type OrchestrationV2StoredEvent, type OrchestrationProjectShell, type ServerConfig, @@ -19,7 +21,9 @@ import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import { Headers } from "effect/http"; -import { RpcGroup, RpcMessage, RpcServer } from "effect/rpc"; +import { RpcGroup, RpcMessage, RpcServer, RpcTest } from "effect/rpc"; + +import * as RpcAuthorization from "./auth/RpcAuthorization.ts"; import * as SqlClient from "effect/sql/SqlClient"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; @@ -370,6 +374,91 @@ const layerConfigSources = Layer.mergeAll( Layer.provideMerge(NodeServices.layer), ); +it.effect( + "maps ignored-file failures to established project errors and permits the next request", + () => + Effect.gen(function* () { + const group = WsRpcGroup.omit( + ...[...WsRpcGroup.requests.keys()].filter( + ( + tag, + ): tag is Exclude< + RpcGroup.Rpcs["_tag"], + typeof WS_METHODS.projectsListIgnoredFiles + > => tag !== WS_METHODS.projectsListIgnoredFiles, + ), + ); + const client = yield* RpcTest.makeClient(group).pipe( + Effect.provide( + Layer.mergeAll( + layerWsRpc( + { + sessionId: AuthSessionId.make("ignored-file-test"), + subject: "ignored-file-test", + method: "browser-session-cookie", + scopes: [AuthFilesystemReadScope], + }, + {}, + {}, + yield* PreviewAutomationBroker.PreviewAutomationBroker, + yield* ServerBrowser.ServerBrowser, + ).pipe( + Layer.provide( + Layer.mergeAll( + layerUnusedConfigRpcServices, + Layer.mock(ExternalLauncher.ExternalLauncher)({}), + Layer.mock(ProviderRegistry.ProviderRegistry)({}), + Layer.mock(UsageLimitSources.UsageLimitSources)({}), + Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({}), + Layer.mock(DirectEndpoints.DirectEndpoints)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: process.cwd(), + }), + Layer.mock(ServerEnvironment.ServerEnvironment)({}), + Layer.mock(EnvironmentAuth.EnvironmentAuth)({}), + Layer.mock(WorkspaceEntries.WorkspaceEntries)({ + listIgnoredFiles: ({ cwd }) => + cwd === "/broken" + ? Effect.fail( + new WorkspaceEntries.WorkspaceEntriesListIgnoredFilesError({ + cwd, + cause: new Error("git exited with status 1"), + }), + ) + : Effect.succeed({ files: [], truncated: false }), + }), + ), + ), + ), + RpcAuthorization.layer([AuthFilesystemReadScope]), + ), + ), + ); + const error = yield* client[WS_METHODS.projectsListIgnoredFiles]({ cwd: "/broken" }).pipe( + Effect.flip, + ); + assert.equal(error._tag, "ProjectListEntriesError"); + if (error._tag !== "ProjectListEntriesError") return yield* Effect.die(error); + assert.equal(error.failure, "directory_list_failed"); + assert.equal(error.normalizedCwd, "/broken"); + assert.equal(error.detail, "Failed to list ignored files."); + assert.equal(error.message, "Failed to list workspace entries in '/broken'."); + assert.deepEqual(yield* client[WS_METHODS.projectsListIgnoredFiles]({ cwd: "/healthy" }), { + files: [], + truncated: false, + }); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.fresh(layerConfigSources), + Layer.mock(PreviewAutomationBroker.PreviewAutomationBroker)({}), + Layer.mock(ServerBrowser.ServerBrowser)({}), + ), + ), + Effect.scoped, + ), +); + it.effect( "preserves settings during discovery and keybindings during snapshot ACK at the config RPC boundary", () => diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 92c8b6094079..94b11d57fb69 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -458,7 +458,6 @@ function projectEntriesFailureContext(error: WorkspaceEntries.WorkspaceEntriesEr normalizedCwd: error.normalizedWorkspaceRoot, }; case "WorkspaceEntriesListIgnoredFilesError": - return { failure: "ignored_files_list_failed", normalizedCwd: error.cwd }; case "WorkspaceEntriesReadDirectoryError": return { failure: "directory_list_failed", diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 596865acbcf8..afaad42c93d4 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -347,7 +347,6 @@ export const ProjectEntriesFailure = Schema.Literals([ "search_index_scan_timed_out", "search_index_search_failed", "directory_list_failed", - "ignored_files_list_failed", ]); export type ProjectEntriesFailure = typeof ProjectEntriesFailure.Type;