diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 7cebccb2a1f7..77a677401599 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, + 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 4678aff95b73..31d3c0bb3a79 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -258,6 +258,7 @@ export const make = Effect.gen(function* () { environmentIcon: true, projectCloneTracking: true, ignoredFileListing: true, + ignoredFileBrowsing: 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 730d02c577f4..fe25237fd5b1 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(); @@ -285,6 +292,199 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }); describe("listIgnoredFiles", () => { + 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* 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, "services/api/.env.local", "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: "services/api/.env.local", byteLength: 3 }); + 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(["services/api/.env.local", ...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("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 }); + 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: " DaTa/Z-TaRgEt.LoG " }), + ).toEqual({ + 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({ + files: [], + 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 5f1c5f2660a6..e1c6437ce0eb 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"; @@ -70,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, @@ -78,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, @@ -111,9 +126,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; @@ -412,19 +424,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 }; - const paths = result.stdout + .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 = stdout .split("\0") - .filter((entry) => entry.length > 0 && !entry.endsWith("/")) + .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( - paths.slice(0, 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, @@ -435,7 +467,9 @@ export const make = Effect.gen(function* () { ); return { files: files.filter((file) => file !== null), - truncated: paths.length > IGNORED_FILES_LIMIT, + truncated, + ...(truncated ? { nextCursor: candidates.at(-1) } : {}), + ...(result.stdoutTruncated ? { outputTruncated: true } : {}), }; }); diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index f0d2e104ca86..3b49b538bfb4 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { AuthSessionId, + AuthFilesystemReadScope, EnvironmentId, ProjectId, ThreadId, @@ -10,6 +11,7 @@ import { WS_METHODS, WsServerRefreshProvidersRpc, WsSubscribeServerConfigRpc, + WsRpcGroup, type OrchestrationV2StoredEvent, type OrchestrationV2ThreadShell, type OrchestrationProjectShell, @@ -26,7 +28,9 @@ import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; 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"; @@ -475,6 +479,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 e5696e6f5cd1..8b712ef83f8d 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -468,6 +468,7 @@ function projectEntriesFailureContext(error: WorkspaceEntries.WorkspaceEntriesEr failure: "workspace_root_not_directory", normalizedCwd: error.normalizedWorkspaceRoot, }; + case "WorkspaceEntriesListIgnoredFilesError": case "WorkspaceEntriesReadDirectoryError": return { failure: "directory_list_failed", 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..c2fc54a380fb --- /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, ignoredFileBrowsing: 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, + dialog: () => ( + + + + ), + [Symbol.dispose]: () => registry.dispose(), + }; +} + +const button = (name: string) => page.getByRole("button", { 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 () => { + 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(mocks.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(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 fc6dce938ec1..f669acdd5699 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, @@ -27,17 +29,18 @@ import { useLogicalProjectEnvironments } from "../hooks/useLogicalProjectEnviron import { COPYABLE_FILE_MAX_BYTES, addProjectToMachine, - 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 { IgnoredFileTree } from "./IgnoredFileTree"; import { Button } from "./ui/button"; -import { Checkbox } from "./ui/checkbox"; import { Dialog, DialogDescription, @@ -103,7 +106,16 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe const destinationPath = pathOverride ?? defaultPath; const canListFiles = source?.serverConfig?.environment.capabilities.ignoredFileListing === true; - const ignoredFiles = useEnvironmentQuery( + const canBrowseFiles = + source?.serverConfig?.environment.capabilities.ignoredFileBrowsing === 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( project && canListFiles ? projectEnvironment.listIgnoredFiles({ environmentId: project.environmentId, @@ -111,34 +123,55 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe }) : null, ); - const files = ignoredFiles.data?.files ?? []; - const [selected, setSelected] = useState | null>(null); - const selectedFiles = selected ?? defaultSelection(files); + const listQuery = canBrowseFiles && normalizedQuery ? debouncedQuery : ""; + if (pages.query !== listQuery) { + setPages({ query: listQuery, cursors: [] }); + } + const cursors = canBrowseFiles && pages.query === listQuery ? pages.cursors : []; + const after = cursors.at(-1); + const ignoredFiles = useEnvironmentQuery( + project && canListFiles + ? projectEnvironment.listIgnoredFiles({ + environmentId: project.environmentId, + input: { + cwd: project.workspaceRoot, + ...(listQuery ? { query: listQuery } : {}), + ...(after ? { after } : {}), + }, + }) + : null, + ); + const searchActive = listQuery.length > 0; + 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 ?? + (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 ?? []); + const pagingDisabled = loadingFiles || ignoredFiles.isPending || 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); - 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. @@ -184,7 +217,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)); @@ -315,52 +348,150 @@ 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 ? ( -

{ignoredFiles.error}

+ {canBrowseFiles ? ( + <> + + 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 and browse + more ignored files. +

+ )} +
+ + {files.length} listed on this page + + + +
+ {loadingFiles ? ( +

Finding ignored files…

+ ) : listingError ? ( +
+

+ {listingError} +

+ +
) : files.length === 0 ? (

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

- ) : ( -
- {files.map((file) => ( - toggleFile(file.path, checked)} - /> - ))} + ) : null} + + setSelected(updateIgnoredFileSelection(selectedFiles, [file], checked)) + } + /> + {canBrowseFiles ? ( +
+ + Page {cursors.length + 1} + + + +
- )} - {ignoredFiles.data?.truncated ? ( + ) : ignoredFiles.data?.truncated ? (

- Showing the first {files.length} files. + 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 + cannot be reached by paging or search. +

+ ) : null} + {atSelectionLimit ? ( +

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

) : null}

@@ -399,33 +530,12 @@ 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 IgnoredFileRow({ - file, - checked, - onCheckedChange, -}: { - file: ProjectIgnoredFile; - checked: boolean; - onCheckedChange: (checked: boolean) => void; -}) { - const tooLarge = file.byteLength > COPYABLE_FILE_MAX_BYTES; - return ( - +function defaultSelection( + files: readonly ProjectIgnoredFile[], +): ReadonlyMap { + return updateIgnoredFileSelection( + new Map(), + files.filter((file) => isDefaultCopiedFile(file.path)), + true, ); } diff --git a/apps/web/src/components/IgnoredFileTree.tsx b/apps/web/src/components/IgnoredFileTree.tsx new file mode 100644 index 000000000000..3684de202938 --- /dev/null +++ b/apps/web/src/components/IgnoredFileTree.tsx @@ -0,0 +1,127 @@ +import type { ProjectIgnoredFile } from "@t3tools/contracts"; +import { ChevronDownIcon, ChevronRightIcon, FolderIcon } from "lucide-react"; +import { useState } 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)} + /> + ); + + 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)}
    • ) + : renderNodes(tree)} +
    +
    + ); +} + +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/addProjectToMachine.test.ts b/apps/web/src/lib/addProjectToMachine.test.ts new file mode 100644 index 000000000000..d05db42083ef --- /dev/null +++ b/apps/web/src/lib/addProjectToMachine.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { PROJECT_IGNORED_FILES_LIMIT } from "@t3tools/contracts"; + +import { COPYABLE_FILE_MAX_BYTES, updateIgnoredFileSelection } from "./addProjectToMachine"; + +describe("ignored file selection", () => { + it("keeps hidden files and their sizes when selecting and clearing shown files", () => { + const hidden = { path: "services/api/.env.local", byteLength: 42 }; + const shown = { path: "debug.log", byteLength: 8 }; + const selected = new Map([[hidden.path, hidden]]); + const withShown = updateIgnoredFileSelection(selected, [shown], true); + expect(Array.from(withShown.values())).toEqual([hidden, shown]); + expect(Array.from(updateIgnoredFileSelection(withShown, [shown], false).values())).toEqual([ + hidden, + ]); + expect(Array.from(selected.keys())).toEqual([hidden.path]); + }); + + it("caps selection across searches and permits deselection at the cap", () => { + const files = Array.from({ length: PROJECT_IGNORED_FILES_LIMIT + 1 }, (_, i) => ({ + path: `file-${i}.log`, + byteLength: 1, + })); + const full = updateIgnoredFileSelection(new Map(), files, true); + expect(full.size).toBe(PROJECT_IGNORED_FILES_LIMIT); + const extra = { path: "other/.env", byteLength: 3 }; + expect(updateIgnoredFileSelection(full, [extra], true).has(extra.path)).toBe(false); + const cleared = updateIgnoredFileSelection(full, [files[0]!], false); + const replaced = updateIgnoredFileSelection(cleared, [extra], true); + expect(replaced.size).toBe(PROJECT_IGNORED_FILES_LIMIT); + expect(replaced.has(files[0]!.path)).toBe(false); + expect(replaced.get(extra.path)).toEqual(extra); + }); + + it("does not select files above the read cap", () => { + const atCap = { path: ".env", byteLength: COPYABLE_FILE_MAX_BYTES }; + const tooLarge = { path: "large.log", byteLength: COPYABLE_FILE_MAX_BYTES + 1 }; + expect( + Array.from(updateIgnoredFileSelection(new Map(), [atCap, tooLarge], true).values()), + ).toEqual([atCap]); + }); +}); diff --git a/apps/web/src/lib/addProjectToMachine.ts b/apps/web/src/lib/addProjectToMachine.ts index bccfc9d84910..442d8d2c3fbf 100644 --- a/apps/web/src/lib/addProjectToMachine.ts +++ b/apps/web/src/lib/addProjectToMachine.ts @@ -4,11 +4,13 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { scopedProjectKey } from "@t3tools/client-runtime/environment"; -import type { - EnvironmentId, - ProjectCloneSnapshot, - ProjectId, - ScopedProjectRef, +import { + PROJECT_IGNORED_FILES_LIMIT, + type EnvironmentId, + type ProjectCloneSnapshot, + type ProjectId, + type ProjectIgnoredFile, + type ScopedProjectRef, } from "@t3tools/contracts"; import { create } from "zustand"; @@ -20,12 +22,22 @@ import { sourceControlEnvironment } from "../state/sourceControl"; /** Matches the server's read cap: a larger file would arrive truncated. */ export const COPYABLE_FILE_MAX_BYTES = 1024 * 1024; -const DEFAULT_COPIED_FILE_NAME = /^(?:\.env(?:\..+)?|\.envrc|\.dev\.vars)$/; - -/** Env files are the ignored files people almost always want on a new machine. */ -export function isDefaultCopiedFile(path: string): boolean { - const name = path.split("/").at(-1) ?? path; - return DEFAULT_COPIED_FILE_NAME.test(name); +export function updateIgnoredFileSelection( + selected: ReadonlyMap, + files: readonly ProjectIgnoredFile[], + checked: boolean, +): ReadonlyMap { + const next = new Map(selected); + for (const file of files) { + if (!checked) next.delete(file.path); + else if ( + file.byteLength <= COPYABLE_FILE_MAX_BYTES && + (next.has(file.path) || next.size < PROJECT_IGNORED_FILES_LIMIT) + ) { + next.set(file.path, file); + } + } + return next; } export interface AddProjectToMachineInput { diff --git a/apps/web/src/lib/ignoredFileTree.test.ts b/apps/web/src/lib/ignoredFileTree.test.ts new file mode 100644 index 000000000000..0badfc375607 --- /dev/null +++ b/apps/web/src/lib/ignoredFileTree.test.ts @@ -0,0 +1,111 @@ +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); + }); + + it("keeps file metadata for selection and handles an empty list", () => { + const original = file("config/.env", 512); + const tree = buildIgnoredFileTree([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 new file mode 100644 index 000000000000..6a3021727604 --- /dev/null +++ b/apps/web/src/lib/ignoredFileTree.ts @@ -0,0 +1,103 @@ +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 = { + path: string; + children: Map; +}; + +export function buildIgnoredFileTree( + files: readonly ProjectIgnoredFile[], +): readonly IgnoredFileTreeNode[] { + const root: Directory = { 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 = { + 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; +} 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 7b47d88202c7..9b6fcafa81b7 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -128,6 +128,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ projectCloneTracking: Schema.optionalKey(Schema.Boolean), /** Server answers projects.listIgnoredFiles. */ ignoredFileListing: Schema.optionalKey(Schema.Boolean), + ignoredFileBrowsing: Schema.optionalKey(Schema.Boolean), environmentIcon: Schema.optionalKey(Schema.Boolean), desktopAppUpdate: Schema.optionalKey(Schema.Boolean), /** Subscribe to command output only when advertised; older servers never send it. */ diff --git a/packages/contracts/src/project.test.ts b/packages/contracts/src/project.test.ts index 168886ebe441..0aa7a5530d6b 100644 --- a/packages/contracts/src/project.test.ts +++ b/packages/contracts/src/project.test.ts @@ -6,6 +6,9 @@ import { describe, expect, it } from "vite-plus/test"; import { OrchestrationProjectShell } from "./orchestrationProject.ts"; import { + isDefaultCopiedFile, + ProjectListIgnoredFilesInput, + ProjectListIgnoredFilesResult, ProjectFaviconPath, ReceivedProjectIcon, StoredProjectIcon, @@ -18,6 +21,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); @@ -177,3 +184,37 @@ 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 = decodeIgnoredFilesResult( + encodeIgnoredFilesResult({ + files: [{ path, byteLength: 1 }], + truncated: true, + nextCursor: path, + }), + ); + const input = decodeIgnoredFilesInput( + encodeIgnoredFilesInput({ + cwd: "/workspace", + after: result.nextCursor, + }), + ); + expect(result.files[0]?.path).toBe(path); + expect(input.after).toBe(path); + }, + ); +}); + +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.ts b/packages/contracts/src/project.ts index 2647519df10a..946a17ef50bc 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -20,6 +20,8 @@ const PROJECT_SEARCH_CONTENTS_MAX_LIMIT = 500; const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512; const PROJECT_READ_FILE_PATH_MAX_LENGTH = 512; +export const PROJECT_IGNORED_FILES_LIMIT = 200; + export const ProjectScriptIcon = Schema.Literals([ "play", "test", @@ -303,22 +305,35 @@ 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(ProjectIgnoredFilePath), }); export type ProjectListIgnoredFilesInput = typeof ProjectListIgnoredFilesInput.Type; export const ProjectIgnoredFile = Schema.Struct({ - path: TrimmedNonEmptyString, + path: ProjectIgnoredFilePath, byteLength: NonNegativeInt, }); export type ProjectIgnoredFile = typeof ProjectIgnoredFile.Type; +const DEFAULT_COPIED_FILE_NAME = /^(?:\.env(?:\..+)?|\.envrc|\.dev\.vars)$/; + +export function isDefaultCopiedFile(path: string): boolean { + const name = path.split("/").at(-1) ?? path; + return DEFAULT_COPIED_FILE_NAME.test(name); +} + export const ProjectListIgnoredFilesResult = Schema.Struct({ files: Schema.Array(ProjectIgnoredFile), truncated: Schema.Boolean, + nextCursor: Schema.optional(ProjectIgnoredFilePath), + outputTruncated: Schema.optional(Schema.Boolean), }); export type ProjectListIgnoredFilesResult = typeof ProjectListIgnoredFilesResult.Type;