| author | |
| committer | |
| log | d12da198ad5aed74505326e9d2ffa66d00dc9157 |
| tree | d16a77b936c0c5216e2c1f19ab97cdf68a29aee5 |
| parent | d0a7d18cdbbb4ebbabf4bebe884e9835feb675a6 |
| signature | Signed by PGP key B5690EEEBB952194 |
20 files changed, 1120 insertions(+), 1134 deletions(-)
apps/server/integration/OrchestrationEngineHarness.integration.ts+2-2| ... | ... | @@ -72,7 +72,7 @@ import { |
| 72 | 72 | type TestProviderAdapterHarness, |
| 73 | 73 | } from "./TestProviderAdapter.integration.ts"; |
| 74 | 74 | import { deriveServerPaths, ServerConfig } from "../src/config.ts"; |
| 75 | import { WorkspaceEntriesLive } from "../src/workspace/Layers/WorkspaceEntries.ts"; | |
| 75 | import * as WorkspaceEntries from "../src/workspace/WorkspaceEntries.ts"; | |
| 76 | 76 | import { WorkspacePathsLive } from "../src/workspace/Layers/WorkspacePaths.ts"; |
| 77 | 77 | import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; |
| 78 | 78 | import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts"; |
| ... | ... | @@ -348,7 +348,7 @@ export const makeOrchestrationIntegrationHarness = ( |
| 348 | 348 | }), |
| 349 | 349 | ), |
| 350 | 350 | Layer.provideMerge( |
| 351 | WorkspaceEntriesLive.pipe( | |
| 351 | WorkspaceEntries.layer.pipe( | |
| 352 | 352 | Layer.provide(WorkspacePathsLive), |
| 353 | 353 | Layer.provideMerge(VcsDriverRegistry.layer), |
| 354 | 354 | Layer.provide(NodeServices.layer), |
apps/server/package.json+1| ... | ... | @@ -27,6 +27,7 @@ |
| 27 | 27 | "@effect/platform-node": "catalog:", |
| 28 | 28 | "@effect/platform-node-shared": "catalog:", |
| 29 | 29 | "@effect/sql-sqlite-bun": "catalog:", |
| 30 | "@ff-labs/fff-node": "^0.9.4", | |
| 30 | 31 | "@opencode-ai/sdk": "^1.3.15", |
| 31 | 32 | "@pierre/diffs": "catalog:", |
| 32 | 33 | "effect": "catalog:", |
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+2-2| ... | ... | @@ -56,7 +56,7 @@ import { |
| 56 | 56 | } from "../../provider/Services/ProviderService.ts"; |
| 57 | 57 | import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts"; |
| 58 | 58 | import { ServerConfig } from "../../config.ts"; |
| 59 | import { WorkspaceEntriesLive } from "../../workspace/Layers/WorkspaceEntries.ts"; | |
| 59 | import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; | |
| 60 | 60 | import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts"; |
| 61 | 61 | |
| 62 | 62 | const asProjectId = (value: string): ProjectId => ProjectId.make(value); |
| ... | ... | @@ -330,7 +330,7 @@ describe("CheckpointReactor", () => { |
| 330 | 330 | Layer.provideMerge(vcsStatusBroadcasterLayer), |
| 331 | 331 | Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer))), |
| 332 | 332 | Layer.provideMerge( |
| 333 | WorkspaceEntriesLive.pipe( | |
| 333 | WorkspaceEntries.layer.pipe( | |
| 334 | 334 | Layer.provide(WorkspacePathsLive), |
| 335 | 335 | Layer.provideMerge(VcsDriverRegistry.layer), |
| 336 | 336 | ), |
apps/server/src/orchestration/Layers/CheckpointReactor.ts+6-6| ... | ... | @@ -34,7 +34,7 @@ import type { CheckpointStoreError } from "../../checkpointing/Errors.ts"; |
| 34 | 34 | import type { OrchestrationDispatchError } from "../Errors.ts"; |
| 35 | 35 | import { isGitRepository } from "../../git/Utils.ts"; |
| 36 | 36 | import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; |
| 37 | import { WorkspaceEntries } from "../../workspace/Services/WorkspaceEntries.ts"; | |
| 37 | import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; | |
| 38 | 38 | |
| 39 | 39 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); |
| 40 | 40 | |
| ... | ... | @@ -83,7 +83,7 @@ const make = Effect.gen(function* () { |
| 83 | 83 | const providerService = yield* ProviderService; |
| 84 | 84 | const checkpointStore = yield* CheckpointStore; |
| 85 | 85 | const receiptBus = yield* RuntimeReceiptBus; |
| 86 | const workspaceEntries = yield* WorkspaceEntries; | |
| 86 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; | |
| 87 | 87 | const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; |
| 88 | 88 | |
| 89 | 89 | const appendRevertFailureActivity = (input: { |
| ... | ... | @@ -252,9 +252,9 @@ const make = Effect.gen(function* () { |
| 252 | 252 | checkpointRef: targetCheckpointRef, |
| 253 | 253 | }); |
| 254 | 254 | |
| 255 | // Invalidate the workspace entry cache so the @-mention file picker | |
| 255 | // Refresh the workspace entry index so the @-mention file picker | |
| 256 | 256 | // reflects files created or deleted during this turn. |
| 257 | yield* workspaceEntries.invalidate(input.cwd); | |
| 257 | yield* workspaceEntries.refresh(input.cwd); | |
| 258 | 258 | |
| 259 | 259 | const files = yield* checkpointStore |
| 260 | 260 | .diffCheckpoints({ |
| ... | ... | @@ -690,9 +690,9 @@ const make = Effect.gen(function* () { |
| 690 | 690 | return; |
| 691 | 691 | } |
| 692 | 692 | |
| 693 | // Invalidate the workspace entry cache so the @-mention file picker | |
| 693 | // Refresh the workspace entry index so the @-mention file picker | |
| 694 | 694 | // reflects the reverted filesystem state. |
| 695 | yield* workspaceEntries.invalidate(sessionRuntime.value.cwd); | |
| 695 | yield* workspaceEntries.refresh(sessionRuntime.value.cwd); | |
| 696 | 696 | |
| 697 | 697 | const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount); |
| 698 | 698 | if (rolledBackTurns > 0) { |
apps/server/src/server.test.ts+6-5| ... | ... | @@ -51,6 +51,7 @@ import * as Option from "effect/Option"; |
| 51 | 51 | import * as Path from "effect/Path"; |
| 52 | 52 | import * as PubSub from "effect/PubSub"; |
| 53 | 53 | import * as Stream from "effect/Stream"; |
| 54 | import * as TestClock from "effect/testing/TestClock"; | |
| 54 | 55 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 55 | 56 | import { |
| 56 | 57 | FetchHttpClient, |
| ... | ... | @@ -118,7 +119,7 @@ import { |
| 118 | 119 | ServerEnvironment, |
| 119 | 120 | type ServerEnvironmentShape, |
| 120 | 121 | } from "./environment/Services/ServerEnvironment.ts"; |
| 121 | import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; | |
| 122 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; | |
| 122 | 123 | import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; |
| 123 | 124 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; |
| 124 | 125 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; |
| ... | ... | @@ -504,7 +505,7 @@ const buildAppUnderTest = (options?: { |
| 504 | 505 | const gitManagerLayer = Layer.mock(GitManager)({ |
| 505 | 506 | ...options?.layers?.gitManager, |
| 506 | 507 | }); |
| 507 | const workspaceEntriesLayer = WorkspaceEntriesLive.pipe( | |
| 508 | const workspaceEntriesLayer = WorkspaceEntries.layer.pipe( | |
| 508 | 509 | Layer.provide(WorkspacePathsLive), |
| 509 | 510 | Layer.provideMerge(vcsDriverRegistryLayer), |
| 510 | 511 | ); |
| ... | ... | @@ -4354,7 +4355,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 4354 | 4355 | assert.isAtLeast(response.entries.length, 1); |
| 4355 | 4356 | assert.isTrue(response.entries.some((entry) => entry.path === "needle-file.ts")); |
| 4356 | 4357 | assert.equal(response.truncated, false); |
| 4357 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 4358 | }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), | |
| 4358 | 4359 | ); |
| 4359 | 4360 | |
| 4360 | 4361 | it.effect("routes websocket rpc projects.listEntries and projects.readFile", () => |
| ... | ... | @@ -4390,7 +4391,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 4390 | 4391 | byteLength: 26, |
| 4391 | 4392 | truncated: false, |
| 4392 | 4393 | }); |
| 4393 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 4394 | }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), | |
| 4394 | 4395 | ); |
| 4395 | 4396 | |
| 4396 | 4397 | it.effect("routes websocket rpc projects.searchEntries excludes gitignored files", () => |
| ... | ... | @@ -4447,7 +4448,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 4447 | 4448 | |
| 4448 | 4449 | assert.equal(response.entries.length, 0); |
| 4449 | 4450 | assert.equal(response.truncated, false); |
| 4450 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 4451 | }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), | |
| 4451 | 4452 | ); |
| 4452 | 4453 | |
| 4453 | 4454 | it.effect("routes websocket rpc projects.searchEntries errors", () => |
apps/server/src/server.ts+2-5| ... | ... | @@ -54,7 +54,7 @@ import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; |
| 54 | 54 | import { ServerSettingsLive } from "./serverSettings.ts"; |
| 55 | 55 | import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; |
| 56 | 56 | import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts"; |
| 57 | import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; | |
| 57 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; | |
| 58 | 58 | import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; |
| 59 | 59 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; |
| 60 | 60 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; |
| ... | ... | @@ -247,10 +247,7 @@ const PreviewLayerLive = Layer.empty.pipe( |
| 247 | 247 | Layer.provideMerge(PortScannerLayerLive), |
| 248 | 248 | ); |
| 249 | 249 | |
| 250 | const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( | |
| 251 | Layer.provide(WorkspacePathsLive), | |
| 252 | Layer.provideMerge(VcsDriverRegistryLayerLive), | |
| 253 | ); | |
| 250 | const WorkspaceEntriesLayerLive = WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive)); | |
| 254 | 251 | |
| 255 | 252 | const WorkspaceFileSystemLayerLive = WorkspaceFileSystemLive.pipe( |
| 256 | 253 | Layer.provide(WorkspacePathsLive), |
apps/server/src/workspace/Layers/WorkspaceEntries.test.ts deleted-444| ... | ... | @@ -1,444 +0,0 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | |
| 2 | import fsPromises from "node:fs/promises"; | |
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 4 | import { it, afterEach, describe, expect, vi } from "@effect/vitest"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as Fiber from "effect/Fiber"; | |
| 7 | import * as FileSystem from "effect/FileSystem"; | |
| 8 | import * as Layer from "effect/Layer"; | |
| 9 | import * as Path from "effect/Path"; | |
| 10 | import * as PlatformError from "effect/PlatformError"; | |
| 11 | ||
| 12 | import { ServerConfig } from "../../config.ts"; | |
| 13 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 14 | import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; | |
| 15 | import * as VcsProcess from "../../vcs/VcsProcess.ts"; | |
| 16 | import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; | |
| 17 | import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts"; | |
| 18 | import { WorkspacePathsLive } from "./WorkspacePaths.ts"; | |
| 19 | ||
| 20 | const TestLayer = Layer.empty.pipe( | |
| 21 | Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), | |
| 22 | Layer.provideMerge(WorkspacePathsLive), | |
| 23 | Layer.provideMerge(VcsProcess.layer), | |
| 24 | Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))), | |
| 25 | Layer.provide( | |
| 26 | ServerConfig.layerTest(process.cwd(), { | |
| 27 | prefix: "t3-workspace-entries-test-", | |
| 28 | }), | |
| 29 | ), | |
| 30 | Layer.provideMerge(NodeServices.layer), | |
| 31 | ); | |
| 32 | ||
| 33 | const makeTempDir = Effect.fn(function* (opts?: { prefix?: string; git?: boolean }) { | |
| 34 | const fileSystem = yield* FileSystem.FileSystem; | |
| 35 | const dir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 36 | prefix: opts?.prefix ?? "t3code-workspace-entries-", | |
| 37 | }); | |
| 38 | if (opts?.git) { | |
| 39 | yield* git(dir, ["init"]); | |
| 40 | } | |
| 41 | return dir; | |
| 42 | }); | |
| 43 | ||
| 44 | function writeTextFile( | |
| 45 | cwd: string, | |
| 46 | relativePath: string, | |
| 47 | contents = "", | |
| 48 | ): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> { | |
| 49 | return Effect.gen(function* () { | |
| 50 | const fileSystem = yield* FileSystem.FileSystem; | |
| 51 | const path = yield* Path.Path; | |
| 52 | const absolutePath = path.join(cwd, relativePath); | |
| 53 | yield* fileSystem.makeDirectory(path.dirname(absolutePath), { recursive: true }); | |
| 54 | yield* fileSystem.writeFileString(absolutePath, contents); | |
| 55 | }); | |
| 56 | } | |
| 57 | ||
| 58 | const git = (cwd: string, args: ReadonlyArray<string>, env?: NodeJS.ProcessEnv) => | |
| 59 | Effect.gen(function* () { | |
| 60 | const process = yield* VcsProcess.VcsProcess; | |
| 61 | const result = yield* process.run({ | |
| 62 | operation: "WorkspaceEntries.test.git", | |
| 63 | command: "git", | |
| 64 | cwd, | |
| 65 | args, | |
| 66 | ...(env ? { env } : {}), | |
| 67 | timeoutMs: 10_000, | |
| 68 | }); | |
| 69 | return result.stdout.trim(); | |
| 70 | }); | |
| 71 | ||
| 72 | const searchWorkspaceEntries = (input: { cwd: string; query: string; limit: number }) => | |
| 73 | Effect.gen(function* () { | |
| 74 | const workspaceEntries = yield* WorkspaceEntries; | |
| 75 | return yield* workspaceEntries.search(input); | |
| 76 | }); | |
| 77 | ||
| 78 | const appendSeparator = (input: string) => | |
| 79 | Effect.map(HostProcessPlatform, (platform) => | |
| 80 | input.endsWith("/") || input.endsWith("\\") | |
| 81 | ? input | |
| 82 | : `${input}${platform === "win32" ? "\\" : "/"}`, | |
| 83 | ); | |
| 84 | ||
| 85 | it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { | |
| 86 | afterEach(() => { | |
| 87 | vi.restoreAllMocks(); | |
| 88 | }); | |
| 89 | ||
| 90 | describe("list", () => { | |
| 91 | it.effect("returns the complete cached workspace index", () => | |
| 92 | Effect.gen(function* () { | |
| 93 | const cwd = yield* makeTempDir(); | |
| 94 | yield* writeTextFile(cwd, "src/components/Composer.tsx"); | |
| 95 | yield* writeTextFile(cwd, "README.md"); | |
| 96 | yield* writeTextFile(cwd, "node_modules/pkg/index.js"); | |
| 97 | ||
| 98 | const workspaceEntries = yield* WorkspaceEntries; | |
| 99 | const result = yield* workspaceEntries.list({ cwd }); | |
| 100 | ||
| 101 | expect(result.entries).toEqual( | |
| 102 | expect.arrayContaining([ | |
| 103 | { path: "src", kind: "directory" }, | |
| 104 | { path: "src/components", kind: "directory", parentPath: "src" }, | |
| 105 | { | |
| 106 | path: "src/components/Composer.tsx", | |
| 107 | kind: "file", | |
| 108 | parentPath: "src/components", | |
| 109 | }, | |
| 110 | { path: "README.md", kind: "file" }, | |
| 111 | ]), | |
| 112 | ); | |
| 113 | expect(result.entries.some((entry) => entry.path.startsWith("node_modules"))).toBe(false); | |
| 114 | expect(result.truncated).toBe(false); | |
| 115 | }), | |
| 116 | ); | |
| 117 | }); | |
| 118 | ||
| 119 | describe("search", () => { | |
| 120 | it.effect("returns files and directories relative to cwd", () => | |
| 121 | Effect.gen(function* () { | |
| 122 | const cwd = yield* makeTempDir(); | |
| 123 | yield* writeTextFile(cwd, "src/components/Composer.tsx"); | |
| 124 | yield* writeTextFile(cwd, "src/index.ts"); | |
| 125 | yield* writeTextFile(cwd, "README.md"); | |
| 126 | yield* writeTextFile(cwd, ".git/HEAD"); | |
| 127 | yield* writeTextFile(cwd, "node_modules/pkg/index.js"); | |
| 128 | ||
| 129 | const result = yield* searchWorkspaceEntries({ cwd, query: "", limit: 100 }); | |
| 130 | const paths = result.entries.map((entry) => entry.path); | |
| 131 | ||
| 132 | expect(paths).toContain("src"); | |
| 133 | expect(paths).toContain("src/components"); | |
| 134 | expect(paths).toContain("src/components/Composer.tsx"); | |
| 135 | expect(paths).toContain("README.md"); | |
| 136 | expect(paths.some((entryPath) => entryPath.startsWith(".git"))).toBe(false); | |
| 137 | expect(paths.some((entryPath) => entryPath.startsWith("node_modules"))).toBe(false); | |
| 138 | expect(result.truncated).toBe(false); | |
| 139 | }), | |
| 140 | ); | |
| 141 | ||
| 142 | it.effect("filters and ranks entries by query", () => | |
| 143 | Effect.gen(function* () { | |
| 144 | const cwd = yield* makeTempDir({ prefix: "t3code-workspace-query-" }); | |
| 145 | yield* writeTextFile(cwd, "src/components/Composer.tsx"); | |
| 146 | yield* writeTextFile(cwd, "src/components/composePrompt.ts"); | |
| 147 | yield* writeTextFile(cwd, "docs/composition.md"); | |
| 148 | ||
| 149 | const result = yield* searchWorkspaceEntries({ cwd, query: "compo", limit: 5 }); | |
| 150 | ||
| 151 | expect(result.entries.length).toBeGreaterThan(0); | |
| 152 | expect(result.entries.some((entry) => entry.path === "src/components")).toBe(true); | |
| 153 | expect(result.entries.every((entry) => entry.path.toLowerCase().includes("compo"))).toBe( | |
| 154 | true, | |
| 155 | ); | |
| 156 | }), | |
| 157 | ); | |
| 158 | ||
| 159 | it.effect("supports fuzzy subsequence queries for composer path search", () => | |
| 160 | Effect.gen(function* () { | |
| 161 | const cwd = yield* makeTempDir({ prefix: "t3code-workspace-fuzzy-query-" }); | |
| 162 | yield* writeTextFile(cwd, "src/components/Composer.tsx"); | |
| 163 | yield* writeTextFile(cwd, "src/components/composePrompt.ts"); | |
| 164 | yield* writeTextFile(cwd, "docs/composition.md"); | |
| 165 | ||
| 166 | const result = yield* searchWorkspaceEntries({ cwd, query: "cmp", limit: 10 }); | |
| 167 | const paths = result.entries.map((entry) => entry.path); | |
| 168 | ||
| 169 | expect(result.entries.length).toBeGreaterThan(0); | |
| 170 | expect(paths).toContain("src/components"); | |
| 171 | expect(paths).toContain("src/components/Composer.tsx"); | |
| 172 | }), | |
| 173 | ); | |
| 174 | ||
| 175 | it.effect("prioritizes exact basename matches ahead of broader path matches", () => | |
| 176 | Effect.gen(function* () { | |
| 177 | const cwd = yield* makeTempDir({ prefix: "t3code-workspace-exact-ranking-" }); | |
| 178 | yield* writeTextFile(cwd, "src/components/Composer.tsx"); | |
| 179 | yield* writeTextFile(cwd, "docs/composer.tsx-notes.md"); | |
| 180 | ||
| 181 | const result = yield* searchWorkspaceEntries({ cwd, query: "Composer.tsx", limit: 5 }); | |
| 182 | ||
| 183 | expect(result.entries[0]?.path).toBe("src/components/Composer.tsx"); | |
| 184 | }), | |
| 185 | ); | |
| 186 | ||
| 187 | it.effect("tracks truncation without sorting every fuzzy match", () => | |
| 188 | Effect.gen(function* () { | |
| 189 | const cwd = yield* makeTempDir({ prefix: "t3code-workspace-fuzzy-limit-" }); | |
| 190 | yield* writeTextFile(cwd, "src/components/Composer.tsx"); | |
| 191 | yield* writeTextFile(cwd, "src/components/composePrompt.ts"); | |
| 192 | yield* writeTextFile(cwd, "docs/composition.md"); | |
| 193 | ||
| 194 | const result = yield* searchWorkspaceEntries({ cwd, query: "cmp", limit: 1 }); | |
| 195 | ||
| 196 | expect(result.entries).toHaveLength(1); | |
| 197 | expect(result.truncated).toBe(true); | |
| 198 | }), | |
| 199 | ); | |
| 200 | ||
| 201 | it.effect("excludes gitignored paths for git repositories", () => | |
| 202 | Effect.gen(function* () { | |
| 203 | const cwd = yield* makeTempDir({ prefix: "t3code-workspace-gitignore-", git: true }); | |
| 204 | yield* writeTextFile(cwd, ".gitignore", ".convex/\nconvex/\nignored.txt\n"); | |
| 205 | yield* writeTextFile(cwd, "src/keep.ts", "export {};"); | |
| 206 | yield* writeTextFile(cwd, "ignored.txt", "ignore me"); | |
| 207 | yield* writeTextFile(cwd, ".convex/local-storage/data.json", "{}"); | |
| 208 | yield* writeTextFile(cwd, "convex/UOoS-l/convex_local_storage/modules/data.json", "{}"); | |
| 209 | ||
| 210 | const result = yield* searchWorkspaceEntries({ cwd, query: "", limit: 100 }); | |
| 211 | const paths = result.entries.map((entry) => entry.path); | |
| 212 | ||
| 213 | expect(paths).toContain("src"); | |
| 214 | expect(paths).toContain("src/keep.ts"); | |
| 215 | expect(paths).not.toContain("ignored.txt"); | |
| 216 | expect(paths.some((entryPath) => entryPath.startsWith(".convex/"))).toBe(false); | |
| 217 | expect(paths.some((entryPath) => entryPath.startsWith("convex/"))).toBe(false); | |
| 218 | }), | |
| 219 | ); | |
| 220 | ||
| 221 | it.effect("excludes tracked paths that match ignore rules", () => | |
| 222 | Effect.gen(function* () { | |
| 223 | const cwd = yield* makeTempDir({ | |
| 224 | prefix: "t3code-workspace-tracked-gitignore-", | |
| 225 | git: true, | |
| 226 | }); | |
| 227 | yield* writeTextFile(cwd, ".convex/local-storage/data.json", "{}"); | |
| 228 | yield* writeTextFile(cwd, "src/keep.ts", "export {};"); | |
| 229 | yield* git(cwd, ["add", ".convex/local-storage/data.json", "src/keep.ts"]); | |
| 230 | yield* writeTextFile(cwd, ".gitignore", ".convex/\n"); | |
| 231 | ||
| 232 | const result = yield* searchWorkspaceEntries({ cwd, query: "", limit: 100 }); | |
| 233 | const paths = result.entries.map((entry) => entry.path); | |
| 234 | ||
| 235 | expect(paths).toContain("src"); | |
| 236 | expect(paths).toContain("src/keep.ts"); | |
| 237 | expect(paths.some((entryPath) => entryPath.startsWith(".convex/"))).toBe(false); | |
| 238 | }), | |
| 239 | ); | |
| 240 | ||
| 241 | it.effect("excludes .convex in non-git workspaces", () => | |
| 242 | Effect.gen(function* () { | |
| 243 | const cwd = yield* makeTempDir({ prefix: "t3code-workspace-non-git-convex-" }); | |
| 244 | yield* writeTextFile(cwd, ".convex/local-storage/data.json", "{}"); | |
| 245 | yield* writeTextFile(cwd, "src/keep.ts", "export {};"); | |
| 246 | ||
| 247 | const result = yield* searchWorkspaceEntries({ cwd, query: "", limit: 100 }); | |
| 248 | const paths = result.entries.map((entry) => entry.path); | |
| 249 | ||
| 250 | expect(paths).toContain("src"); | |
| 251 | expect(paths).toContain("src/keep.ts"); | |
| 252 | expect(paths.some((entryPath) => entryPath.startsWith(".convex/"))).toBe(false); | |
| 253 | }), | |
| 254 | ); | |
| 255 | ||
| 256 | it.effect("deduplicates concurrent index builds for the same cwd", () => | |
| 257 | Effect.gen(function* () { | |
| 258 | const cwd = yield* makeTempDir({ prefix: "t3code-workspace-concurrent-build-" }); | |
| 259 | yield* writeTextFile(cwd, "src/components/Composer.tsx"); | |
| 260 | ||
| 261 | let rootReadCount = 0; | |
| 262 | let releaseRootRead: (() => void) | undefined; | |
| 263 | const rootReadGate = new Promise<void>((resolve) => { | |
| 264 | releaseRootRead = resolve; | |
| 265 | }); | |
| 266 | const originalReaddir = fsPromises.readdir.bind(fsPromises); | |
| 267 | vi.spyOn(fsPromises, "readdir").mockImplementation((async ( | |
| 268 | ...args: Parameters<typeof fsPromises.readdir> | |
| 269 | ) => { | |
| 270 | if (args[0] === cwd) { | |
| 271 | rootReadCount += 1; | |
| 272 | await rootReadGate; | |
| 273 | } | |
| 274 | return originalReaddir(...args); | |
| 275 | }) as typeof fsPromises.readdir); | |
| 276 | ||
| 277 | const searches = yield* Effect.all( | |
| 278 | [ | |
| 279 | searchWorkspaceEntries({ cwd, query: "", limit: 100 }), | |
| 280 | searchWorkspaceEntries({ cwd, query: "comp", limit: 100 }), | |
| 281 | searchWorkspaceEntries({ cwd, query: "src", limit: 100 }), | |
| 282 | ], | |
| 283 | { concurrency: "unbounded" }, | |
| 284 | ).pipe(Effect.forkScoped); | |
| 285 | for (let attempt = 0; attempt < 50; attempt += 1) { | |
| 286 | if (rootReadCount > 0) { | |
| 287 | break; | |
| 288 | } | |
| 289 | yield* Effect.yieldNow; | |
| 290 | } | |
| 291 | releaseRootRead?.(); | |
| 292 | yield* Fiber.join(searches); | |
| 293 | ||
| 294 | expect(rootReadCount).toBe(1); | |
| 295 | }), | |
| 296 | ); | |
| 297 | ||
| 298 | it.effect("limits concurrent directory reads while walking the filesystem", () => | |
| 299 | Effect.gen(function* () { | |
| 300 | const cwd = yield* makeTempDir({ prefix: "t3code-workspace-read-concurrency-" }); | |
| 301 | yield* Effect.forEach( | |
| 302 | Array.from({ length: 80 }, (_, index) => index), | |
| 303 | (index) => writeTextFile(cwd, `group-${index}/entry-${index}.ts`, "export {};"), | |
| 304 | { discard: true }, | |
| 305 | ); | |
| 306 | ||
| 307 | let activeReads = 0; | |
| 308 | let peakReads = 0; | |
| 309 | let releaseReads: (() => void) | undefined; | |
| 310 | const readsGate = new Promise<void>((resolve) => { | |
| 311 | releaseReads = resolve; | |
| 312 | }); | |
| 313 | const originalReaddir = fsPromises.readdir.bind(fsPromises); | |
| 314 | vi.spyOn(fsPromises, "readdir").mockImplementation((async ( | |
| 315 | ...args: Parameters<typeof fsPromises.readdir> | |
| 316 | ) => { | |
| 317 | const target = args[0]; | |
| 318 | if (typeof target === "string" && target.startsWith(cwd)) { | |
| 319 | activeReads += 1; | |
| 320 | peakReads = Math.max(peakReads, activeReads); | |
| 321 | await readsGate; | |
| 322 | try { | |
| 323 | return await originalReaddir(...args); | |
| 324 | } finally { | |
| 325 | activeReads -= 1; | |
| 326 | } | |
| 327 | } | |
| 328 | return originalReaddir(...args); | |
| 329 | }) as typeof fsPromises.readdir); | |
| 330 | ||
| 331 | const search = yield* searchWorkspaceEntries({ cwd, query: "", limit: 200 }).pipe( | |
| 332 | Effect.forkScoped, | |
| 333 | ); | |
| 334 | for (let attempt = 0; attempt < 50; attempt += 1) { | |
| 335 | if (activeReads > 0) { | |
| 336 | break; | |
| 337 | } | |
| 338 | yield* Effect.yieldNow; | |
| 339 | } | |
| 340 | releaseReads?.(); | |
| 341 | yield* Fiber.join(search); | |
| 342 | ||
| 343 | expect(peakReads).toBeLessThanOrEqual(32); | |
| 344 | }), | |
| 345 | ); | |
| 346 | }); | |
| 347 | ||
| 348 | describe("browse", () => { | |
| 349 | it.effect("returns matching directories and excludes files", () => | |
| 350 | Effect.gen(function* () { | |
| 351 | const workspaceEntries = yield* WorkspaceEntries; | |
| 352 | const path = yield* Path.Path; | |
| 353 | const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-prefix-" }); | |
| 354 | yield* writeTextFile(cwd, "alphabet.txt", "ignore me"); | |
| 355 | yield* writeTextFile(cwd, "alpha/index.ts", "export {};\n"); | |
| 356 | yield* writeTextFile(cwd, "alpine/index.ts", "export {};\n"); | |
| 357 | ||
| 358 | const result = yield* workspaceEntries.browse({ | |
| 359 | partialPath: path.join(cwd, "alp"), | |
| 360 | }); | |
| 361 | ||
| 362 | expect(result).toEqual({ | |
| 363 | parentPath: cwd, | |
| 364 | entries: [ | |
| 365 | { name: "alpha", fullPath: path.join(cwd, "alpha") }, | |
| 366 | { name: "alpine", fullPath: path.join(cwd, "alpine") }, | |
| 367 | ], | |
| 368 | }); | |
| 369 | }), | |
| 370 | ); | |
| 371 | ||
| 372 | it.effect("shows dot directories in directory mode and hidden-prefix mode", () => | |
| 373 | Effect.gen(function* () { | |
| 374 | const workspaceEntries = yield* WorkspaceEntries; | |
| 375 | const path = yield* Path.Path; | |
| 376 | const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-hidden-" }); | |
| 377 | yield* writeTextFile(cwd, ".config/settings.json", "{}"); | |
| 378 | yield* writeTextFile(cwd, "config/settings.json", "{}"); | |
| 379 | const cwdWithSeparator = yield* appendSeparator(cwd); | |
| 380 | ||
| 381 | const directoryResult = yield* workspaceEntries.browse({ | |
| 382 | partialPath: cwdWithSeparator, | |
| 383 | }); | |
| 384 | const hiddenPrefixResult = yield* workspaceEntries.browse({ | |
| 385 | partialPath: `${cwdWithSeparator}.c`, | |
| 386 | }); | |
| 387 | ||
| 388 | expect(directoryResult.entries.map((entry) => entry.name)).toEqual([".config", "config"]); | |
| 389 | expect(hiddenPrefixResult).toEqual({ | |
| 390 | parentPath: cwd, | |
| 391 | entries: [{ name: ".config", fullPath: path.join(cwd, ".config") }], | |
| 392 | }); | |
| 393 | }), | |
| 394 | ); | |
| 395 | ||
| 396 | it.effect("supports relative paths when cwd is provided", () => | |
| 397 | Effect.gen(function* () { | |
| 398 | const workspaceEntries = yield* WorkspaceEntries; | |
| 399 | const path = yield* Path.Path; | |
| 400 | const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-relative-" }); | |
| 401 | yield* writeTextFile(cwd, "packages/pkg.json", "{}"); | |
| 402 | ||
| 403 | const result = yield* workspaceEntries.browse({ | |
| 404 | cwd, | |
| 405 | partialPath: "./pack", | |
| 406 | }); | |
| 407 | ||
| 408 | expect(result).toEqual({ | |
| 409 | parentPath: cwd, | |
| 410 | entries: [{ name: "packages", fullPath: path.join(cwd, "packages") }], | |
| 411 | }); | |
| 412 | }), | |
| 413 | ); | |
| 414 | ||
| 415 | it.effect("rejects relative paths without cwd", () => | |
| 416 | Effect.gen(function* () { | |
| 417 | const workspaceEntries = yield* WorkspaceEntries; | |
| 418 | ||
| 419 | const error = yield* workspaceEntries | |
| 420 | .browse({ | |
| 421 | partialPath: "./src", | |
| 422 | }) | |
| 423 | .pipe(Effect.flip); | |
| 424 | ||
| 425 | expect(error.detail).toBe("Relative filesystem browse paths require a current project."); | |
| 426 | }), | |
| 427 | ); | |
| 428 | ||
| 429 | it.effect("returns an empty listing when the OS denies directory access", () => | |
| 430 | Effect.gen(function* () { | |
| 431 | const workspaceEntries = yield* WorkspaceEntries; | |
| 432 | const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-eacces-" }); | |
| 433 | ||
| 434 | const denied = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }); | |
| 435 | vi.spyOn(fsPromises, "readdir").mockRejectedValueOnce(denied); | |
| 436 | ||
| 437 | const result = yield* workspaceEntries.browse({ | |
| 438 | partialPath: yield* appendSeparator(cwd), | |
| 439 | }); | |
| 440 | expect(result).toEqual({ parentPath: cwd, entries: [] }); | |
| 441 | }), | |
| 442 | ); | |
| 443 | }); | |
| 444 | }); |
apps/server/src/workspace/Layers/WorkspaceEntries.ts deleted-555| ... | ... | @@ -1,555 +0,0 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | |
| 2 | import * as NodeOS from "node:os"; | |
| 3 | import fsPromises from "node:fs/promises"; | |
| 4 | import type { Dirent } from "node:fs"; | |
| 5 | ||
| 6 | import * as Cache from "effect/Cache"; | |
| 7 | import * as DateTime from "effect/DateTime"; | |
| 8 | import * as Duration from "effect/Duration"; | |
| 9 | import * as Effect from "effect/Effect"; | |
| 10 | import * as Exit from "effect/Exit"; | |
| 11 | import * as Layer from "effect/Layer"; | |
| 12 | import * as Path from "effect/Path"; | |
| 13 | ||
| 14 | import { type FilesystemBrowseInput, type ProjectEntry } from "@t3tools/contracts"; | |
| 15 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 16 | import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; | |
| 17 | import { | |
| 18 | insertRankedSearchResult, | |
| 19 | normalizeSearchQuery, | |
| 20 | scoreQueryMatch, | |
| 21 | type RankedSearchResult, | |
| 22 | } from "@t3tools/shared/searchRanking"; | |
| 23 | ||
| 24 | import { VcsDriverRegistry } from "../../vcs/VcsDriverRegistry.ts"; | |
| 25 | import { | |
| 26 | WorkspaceEntries, | |
| 27 | WorkspaceEntriesBrowseError, | |
| 28 | WorkspaceEntriesError, | |
| 29 | type WorkspaceEntriesShape, | |
| 30 | } from "../Services/WorkspaceEntries.ts"; | |
| 31 | import { WorkspacePaths } from "../Services/WorkspacePaths.ts"; | |
| 32 | ||
| 33 | const WORKSPACE_CACHE_TTL_MS = 15_000; | |
| 34 | const WORKSPACE_CACHE_MAX_KEYS = 4; | |
| 35 | const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; | |
| 36 | const WORKSPACE_SCAN_READDIR_CONCURRENCY = 32; | |
| 37 | const IGNORED_DIRECTORY_NAMES = new Set([ | |
| 38 | ".git", | |
| 39 | ".convex", | |
| 40 | "node_modules", | |
| 41 | ".next", | |
| 42 | ".turbo", | |
| 43 | ".vite-plus", | |
| 44 | "dist", | |
| 45 | "build", | |
| 46 | "out", | |
| 47 | ".cache", | |
| 48 | ]); | |
| 49 | ||
| 50 | interface WorkspaceIndex { | |
| 51 | scannedAt: number; | |
| 52 | entries: SearchableWorkspaceEntry[]; | |
| 53 | truncated: boolean; | |
| 54 | } | |
| 55 | ||
| 56 | interface SearchableWorkspaceEntry extends ProjectEntry { | |
| 57 | normalizedPath: string; | |
| 58 | normalizedName: string; | |
| 59 | } | |
| 60 | ||
| 61 | type RankedWorkspaceEntry = RankedSearchResult<SearchableWorkspaceEntry>; | |
| 62 | ||
| 63 | function toPosixPath(input: string): string { | |
| 64 | return input.replaceAll("\\", "/"); | |
| 65 | } | |
| 66 | ||
| 67 | function expandHomePath(input: string, path: Path.Path): string { | |
| 68 | if (input === "~") { | |
| 69 | return NodeOS.homedir(); | |
| 70 | } | |
| 71 | if (input.startsWith("~/") || input.startsWith("~\\")) { | |
| 72 | return path.join(NodeOS.homedir(), input.slice(2)); | |
| 73 | } | |
| 74 | return input; | |
| 75 | } | |
| 76 | ||
| 77 | function parentPathOf(input: string): string | undefined { | |
| 78 | const separatorIndex = input.lastIndexOf("/"); | |
| 79 | if (separatorIndex === -1) { | |
| 80 | return undefined; | |
| 81 | } | |
| 82 | return input.slice(0, separatorIndex); | |
| 83 | } | |
| 84 | ||
| 85 | function basenameOf(input: string): string { | |
| 86 | const separatorIndex = input.lastIndexOf("/"); | |
| 87 | if (separatorIndex === -1) { | |
| 88 | return input; | |
| 89 | } | |
| 90 | return input.slice(separatorIndex + 1); | |
| 91 | } | |
| 92 | ||
| 93 | function toSearchableWorkspaceEntry(entry: ProjectEntry): SearchableWorkspaceEntry { | |
| 94 | const normalizedPath = entry.path.toLowerCase(); | |
| 95 | return { | |
| 96 | ...entry, | |
| 97 | normalizedPath, | |
| 98 | normalizedName: basenameOf(normalizedPath), | |
| 99 | }; | |
| 100 | } | |
| 101 | ||
| 102 | function scoreEntry(entry: SearchableWorkspaceEntry, query: string): number | null { | |
| 103 | if (!query) { | |
| 104 | return entry.kind === "directory" ? 0 : 1; | |
| 105 | } | |
| 106 | ||
| 107 | const { normalizedPath, normalizedName } = entry; | |
| 108 | ||
| 109 | const scores = [ | |
| 110 | scoreQueryMatch({ | |
| 111 | value: normalizedName, | |
| 112 | query, | |
| 113 | exactBase: 0, | |
| 114 | prefixBase: 2, | |
| 115 | includesBase: 5, | |
| 116 | fuzzyBase: 100, | |
| 117 | }), | |
| 118 | scoreQueryMatch({ | |
| 119 | value: normalizedPath, | |
| 120 | query, | |
| 121 | exactBase: 1, | |
| 122 | prefixBase: 3, | |
| 123 | boundaryBase: 4, | |
| 124 | includesBase: 6, | |
| 125 | fuzzyBase: 200, | |
| 126 | boundaryMarkers: ["/"], | |
| 127 | }), | |
| 128 | ].filter((score): score is number => score !== null); | |
| 129 | ||
| 130 | if (scores.length === 0) { | |
| 131 | return null; | |
| 132 | } | |
| 133 | ||
| 134 | return Math.min(...scores); | |
| 135 | } | |
| 136 | ||
| 137 | function isPathInIgnoredDirectory(relativePath: string): boolean { | |
| 138 | const firstSegment = relativePath.split("/")[0]; | |
| 139 | if (!firstSegment) return false; | |
| 140 | return IGNORED_DIRECTORY_NAMES.has(firstSegment); | |
| 141 | } | |
| 142 | ||
| 143 | function directoryAncestorsOf(relativePath: string): string[] { | |
| 144 | const segments = relativePath.split("/").filter((segment) => segment.length > 0); | |
| 145 | if (segments.length <= 1) return []; | |
| 146 | ||
| 147 | const directories: string[] = []; | |
| 148 | for (let index = 1; index < segments.length; index += 1) { | |
| 149 | directories.push(segments.slice(0, index).join("/")); | |
| 150 | } | |
| 151 | return directories; | |
| 152 | } | |
| 153 | ||
| 154 | const resolveBrowseTarget = ( | |
| 155 | input: FilesystemBrowseInput, | |
| 156 | pathService: Path.Path, | |
| 157 | ): Effect.Effect<string, WorkspaceEntriesBrowseError> => | |
| 158 | Effect.gen(function* () { | |
| 159 | const platform = yield* HostProcessPlatform; | |
| 160 | if (platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) { | |
| 161 | return yield* new WorkspaceEntriesBrowseError({ | |
| 162 | cwd: input.cwd, | |
| 163 | partialPath: input.partialPath, | |
| 164 | operation: "workspaceEntries.resolveBrowseTarget", | |
| 165 | detail: "Windows-style paths are only supported on Windows.", | |
| 166 | }); | |
| 167 | } | |
| 168 | ||
| 169 | if (!isExplicitRelativePath(input.partialPath)) { | |
| 170 | return pathService.resolve(expandHomePath(input.partialPath, pathService)); | |
| 171 | } | |
| 172 | ||
| 173 | if (!input.cwd) { | |
| 174 | return yield* new WorkspaceEntriesBrowseError({ | |
| 175 | cwd: input.cwd, | |
| 176 | partialPath: input.partialPath, | |
| 177 | operation: "workspaceEntries.resolveBrowseTarget", | |
| 178 | detail: "Relative filesystem browse paths require a current project.", | |
| 179 | }); | |
| 180 | } | |
| 181 | ||
| 182 | return pathService.resolve(expandHomePath(input.cwd, pathService), input.partialPath); | |
| 183 | }); | |
| 184 | ||
| 185 | export const makeWorkspaceEntries = Effect.gen(function* () { | |
| 186 | const path = yield* Path.Path; | |
| 187 | const vcsRegistry = yield* VcsDriverRegistry; | |
| 188 | const workspacePaths = yield* WorkspacePaths; | |
| 189 | ||
| 190 | const isInsideVcsWorkTree = (cwd: string): Effect.Effect<boolean> => | |
| 191 | vcsRegistry.detect({ cwd }).pipe( | |
| 192 | Effect.map((handle) => handle !== null), | |
| 193 | Effect.orElseSucceed(() => false), | |
| 194 | ); | |
| 195 | ||
| 196 | const filterVcsIgnoredPaths = ( | |
| 197 | cwd: string, | |
| 198 | relativePaths: string[], | |
| 199 | ): Effect.Effect<string[], never> => | |
| 200 | vcsRegistry.detect({ cwd }).pipe( | |
| 201 | Effect.flatMap((handle) => | |
| 202 | handle | |
| 203 | ? handle.driver.filterIgnoredPaths(cwd, relativePaths).pipe( | |
| 204 | Effect.map((paths) => [...paths]), | |
| 205 | Effect.orElseSucceed(() => relativePaths), | |
| 206 | ) | |
| 207 | : Effect.succeed(relativePaths), | |
| 208 | ), | |
| 209 | Effect.orElseSucceed(() => relativePaths), | |
| 210 | ); | |
| 211 | ||
| 212 | const buildWorkspaceIndexFromVcs = Effect.fn("WorkspaceEntries.buildWorkspaceIndexFromVcs")( | |
| 213 | function* (cwd: string) { | |
| 214 | const vcs = yield* vcsRegistry.detect({ cwd }).pipe(Effect.orElseSucceed(() => null)); | |
| 215 | if (!vcs) { | |
| 216 | return null; | |
| 217 | } | |
| 218 | ||
| 219 | const listedFiles = yield* vcs.driver | |
| 220 | .listWorkspaceFiles(cwd) | |
| 221 | .pipe(Effect.orElseSucceed(() => null)); | |
| 222 | ||
| 223 | if (!listedFiles) { | |
| 224 | return null; | |
| 225 | } | |
| 226 | ||
| 227 | const listedPaths: Array<string> = []; | |
| 228 | for (const rawEntry of listedFiles.paths) { | |
| 229 | const entry = toPosixPath(rawEntry); | |
| 230 | if (entry.length > 0 && !isPathInIgnoredDirectory(entry)) { | |
| 231 | listedPaths.push(entry); | |
| 232 | } | |
| 233 | } | |
| 234 | const filePaths = yield* vcs.driver.filterIgnoredPaths(cwd, listedPaths).pipe( | |
| 235 | Effect.map((paths) => [...paths]), | |
| 236 | Effect.catch(() => filterVcsIgnoredPaths(cwd, listedPaths)), | |
| 237 | ); | |
| 238 | ||
| 239 | const directorySet = new Set<string>(); | |
| 240 | for (const filePath of filePaths) { | |
| 241 | for (const directoryPath of directoryAncestorsOf(filePath)) { | |
| 242 | if (!isPathInIgnoredDirectory(directoryPath)) { | |
| 243 | directorySet.add(directoryPath); | |
| 244 | } | |
| 245 | } | |
| 246 | } | |
| 247 | ||
| 248 | const directoryEntries = [...directorySet] | |
| 249 | .toSorted((left, right) => left.localeCompare(right)) | |
| 250 | .map( | |
| 251 | (directoryPath): ProjectEntry => ({ | |
| 252 | path: directoryPath, | |
| 253 | kind: "directory", | |
| 254 | parentPath: parentPathOf(directoryPath), | |
| 255 | }), | |
| 256 | ) | |
| 257 | .map(toSearchableWorkspaceEntry); | |
| 258 | const fileEntries = [...new Set(filePaths)] | |
| 259 | .toSorted((left, right) => left.localeCompare(right)) | |
| 260 | .map( | |
| 261 | (filePath): ProjectEntry => ({ | |
| 262 | path: filePath, | |
| 263 | kind: "file", | |
| 264 | parentPath: parentPathOf(filePath), | |
| 265 | }), | |
| 266 | ) | |
| 267 | .map(toSearchableWorkspaceEntry); | |
| 268 | ||
| 269 | const now = yield* DateTime.now; | |
| 270 | const entries = [...directoryEntries, ...fileEntries]; | |
| 271 | return { | |
| 272 | scannedAt: now.epochMilliseconds, | |
| 273 | entries: entries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES), | |
| 274 | truncated: listedFiles.truncated || entries.length > WORKSPACE_INDEX_MAX_ENTRIES, | |
| 275 | }; | |
| 276 | }, | |
| 277 | ); | |
| 278 | ||
| 279 | const readDirectoryEntries = Effect.fn("WorkspaceEntries.readDirectoryEntries")(function* ( | |
| 280 | cwd: string, | |
| 281 | relativeDir: string, | |
| 282 | ): Effect.fn.Return< | |
| 283 | { readonly relativeDir: string; readonly dirents: Dirent[] | null }, | |
| 284 | WorkspaceEntriesError | |
| 285 | > { | |
| 286 | return yield* Effect.tryPromise({ | |
| 287 | try: async () => { | |
| 288 | const absoluteDir = relativeDir ? path.join(cwd, relativeDir) : cwd; | |
| 289 | const dirents = await fsPromises.readdir(absoluteDir, { withFileTypes: true }); | |
| 290 | return { relativeDir, dirents }; | |
| 291 | }, | |
| 292 | catch: (cause) => | |
| 293 | new WorkspaceEntriesError({ | |
| 294 | cwd, | |
| 295 | operation: "workspaceEntries.readDirectoryEntries", | |
| 296 | detail: cause instanceof Error ? cause.message : String(cause), | |
| 297 | cause, | |
| 298 | }), | |
| 299 | }).pipe( | |
| 300 | Effect.catchIf( | |
| 301 | () => relativeDir.length > 0, | |
| 302 | () => Effect.succeed({ relativeDir, dirents: null }), | |
| 303 | ), | |
| 304 | ); | |
| 305 | }); | |
| 306 | ||
| 307 | const buildWorkspaceIndexFromFilesystem = Effect.fn( | |
| 308 | "WorkspaceEntries.buildWorkspaceIndexFromFilesystem", | |
| 309 | )(function* (cwd: string): Effect.fn.Return<WorkspaceIndex, WorkspaceEntriesError> { | |
| 310 | const shouldFilterWithGitIgnore = yield* isInsideVcsWorkTree(cwd); | |
| 311 | ||
| 312 | let pendingDirectories: string[] = [""]; | |
| 313 | const entries: SearchableWorkspaceEntry[] = []; | |
| 314 | let truncated = false; | |
| 315 | ||
| 316 | while (pendingDirectories.length > 0 && !truncated) { | |
| 317 | const currentDirectories = pendingDirectories; | |
| 318 | pendingDirectories = []; | |
| 319 | ||
| 320 | const directoryEntries = yield* Effect.forEach( | |
| 321 | currentDirectories, | |
| 322 | (relativeDir) => readDirectoryEntries(cwd, relativeDir), | |
| 323 | { concurrency: WORKSPACE_SCAN_READDIR_CONCURRENCY }, | |
| 324 | ); | |
| 325 | ||
| 326 | const candidateEntriesByDirectory = directoryEntries.map((directoryEntry) => { | |
| 327 | const { relativeDir, dirents } = directoryEntry; | |
| 328 | if (!dirents) return [] as Array<{ dirent: Dirent; relativePath: string }>; | |
| 329 | ||
| 330 | dirents.sort((left, right) => left.name.localeCompare(right.name)); | |
| 331 | const candidates: Array<{ dirent: Dirent; relativePath: string }> = []; | |
| 332 | for (const dirent of dirents) { | |
| 333 | if (!dirent.name || dirent.name === "." || dirent.name === "..") { | |
| 334 | continue; | |
| 335 | } | |
| 336 | if (dirent.isDirectory() && IGNORED_DIRECTORY_NAMES.has(dirent.name)) { | |
| 337 | continue; | |
| 338 | } | |
| 339 | if (!dirent.isDirectory() && !dirent.isFile()) { | |
| 340 | continue; | |
| 341 | } | |
| 342 | ||
| 343 | const relativePath = toPosixPath( | |
| 344 | relativeDir ? path.join(relativeDir, dirent.name) : dirent.name, | |
| 345 | ); | |
| 346 | if (isPathInIgnoredDirectory(relativePath)) { | |
| 347 | continue; | |
| 348 | } | |
| 349 | candidates.push({ dirent, relativePath }); | |
| 350 | } | |
| 351 | return candidates; | |
| 352 | }); | |
| 353 | ||
| 354 | const candidatePaths = candidateEntriesByDirectory.flatMap((candidateEntries) => | |
| 355 | candidateEntries.map((entry) => entry.relativePath), | |
| 356 | ); | |
| 357 | const allowedPathSet = shouldFilterWithGitIgnore | |
| 358 | ? new Set(yield* filterVcsIgnoredPaths(cwd, candidatePaths)) | |
| 359 | : null; | |
| 360 | ||
| 361 | for (const candidateEntries of candidateEntriesByDirectory) { | |
| 362 | for (const candidate of candidateEntries) { | |
| 363 | if (allowedPathSet && !allowedPathSet.has(candidate.relativePath)) { | |
| 364 | continue; | |
| 365 | } | |
| 366 | ||
| 367 | const entry = toSearchableWorkspaceEntry({ | |
| 368 | path: candidate.relativePath, | |
| 369 | kind: candidate.dirent.isDirectory() ? "directory" : "file", | |
| 370 | parentPath: parentPathOf(candidate.relativePath), | |
| 371 | }); | |
| 372 | entries.push(entry); | |
| 373 | ||
| 374 | if (candidate.dirent.isDirectory()) { | |
| 375 | pendingDirectories.push(candidate.relativePath); | |
| 376 | } | |
| 377 | ||
| 378 | if (entries.length >= WORKSPACE_INDEX_MAX_ENTRIES) { | |
| 379 | truncated = true; | |
| 380 | break; | |
| 381 | } | |
| 382 | } | |
| 383 | ||
| 384 | if (truncated) { | |
| 385 | break; | |
| 386 | } | |
| 387 | } | |
| 388 | } | |
| 389 | ||
| 390 | const now = yield* DateTime.now; | |
| 391 | return { | |
| 392 | scannedAt: now.epochMilliseconds, | |
| 393 | entries, | |
| 394 | truncated, | |
| 395 | }; | |
| 396 | }); | |
| 397 | ||
| 398 | const buildWorkspaceIndex = Effect.fn("WorkspaceEntries.buildWorkspaceIndex")(function* ( | |
| 399 | cwd: string, | |
| 400 | ): Effect.fn.Return<WorkspaceIndex, WorkspaceEntriesError> { | |
| 401 | const vcsIndexed = yield* buildWorkspaceIndexFromVcs(cwd); | |
| 402 | if (vcsIndexed) { | |
| 403 | return vcsIndexed; | |
| 404 | } | |
| 405 | return yield* buildWorkspaceIndexFromFilesystem(cwd); | |
| 406 | }); | |
| 407 | ||
| 408 | const workspaceIndexCache = yield* Cache.makeWith<string, WorkspaceIndex, WorkspaceEntriesError>( | |
| 409 | buildWorkspaceIndex, | |
| 410 | { | |
| 411 | capacity: WORKSPACE_CACHE_MAX_KEYS, | |
| 412 | timeToLive: (exit) => | |
| 413 | Exit.isSuccess(exit) ? Duration.millis(WORKSPACE_CACHE_TTL_MS) : Duration.zero, | |
| 414 | }, | |
| 415 | ); | |
| 416 | ||
| 417 | const normalizeWorkspaceRoot = Effect.fn("WorkspaceEntries.normalizeWorkspaceRoot")(function* ( | |
| 418 | cwd: string, | |
| 419 | ): Effect.fn.Return<string, WorkspaceEntriesError> { | |
| 420 | return yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( | |
| 421 | Effect.mapError( | |
| 422 | (cause) => | |
| 423 | new WorkspaceEntriesError({ | |
| 424 | cwd, | |
| 425 | operation: "workspaceEntries.normalizeWorkspaceRoot", | |
| 426 | detail: cause.message, | |
| 427 | cause, | |
| 428 | }), | |
| 429 | ), | |
| 430 | ); | |
| 431 | }); | |
| 432 | ||
| 433 | const invalidate: WorkspaceEntriesShape["invalidate"] = Effect.fn("WorkspaceEntries.invalidate")( | |
| 434 | function* (cwd) { | |
| 435 | const normalizedCwd = yield* normalizeWorkspaceRoot(cwd).pipe( | |
| 436 | Effect.orElseSucceed(() => cwd), | |
| 437 | ); | |
| 438 | yield* Cache.invalidate(workspaceIndexCache, cwd); | |
| 439 | if (normalizedCwd !== cwd) { | |
| 440 | yield* Cache.invalidate(workspaceIndexCache, normalizedCwd); | |
| 441 | } | |
| 442 | }, | |
| 443 | ); | |
| 444 | ||
| 445 | const browse: WorkspaceEntriesShape["browse"] = Effect.fn("WorkspaceEntries.browse")( | |
| 446 | function* (input) { | |
| 447 | const resolvedInputPath = yield* resolveBrowseTarget(input, path); | |
| 448 | const endsWithSeparator = /[\\/]$/.test(input.partialPath) || input.partialPath === "~"; | |
| 449 | const parentPath = endsWithSeparator ? resolvedInputPath : path.dirname(resolvedInputPath); | |
| 450 | const prefix = endsWithSeparator ? "" : path.basename(resolvedInputPath); | |
| 451 | ||
| 452 | const dirents = yield* Effect.tryPromise({ | |
| 453 | try: () => fsPromises.readdir(parentPath, { withFileTypes: true }), | |
| 454 | catch: (cause) => | |
| 455 | new WorkspaceEntriesBrowseError({ | |
| 456 | cwd: input.cwd, | |
| 457 | partialPath: input.partialPath, | |
| 458 | operation: "workspaceEntries.browse.readDirectory", | |
| 459 | detail: `Unable to browse '${parentPath}': ${cause instanceof Error ? cause.message : String(cause)}`, | |
| 460 | cause, | |
| 461 | }), | |
| 462 | }).pipe( | |
| 463 | // The user can deny macOS TCC prompts for the target dir (Documents, | |
| 464 | // Downloads, Music, etc.); surface an empty listing instead of an | |
| 465 | // error so the caller doesn't retry-loop the prompt. | |
| 466 | Effect.catchIf( | |
| 467 | (error) => { | |
| 468 | const code = (error.cause as NodeJS.ErrnoException | undefined)?.code; | |
| 469 | return code === "EACCES" || code === "EPERM"; | |
| 470 | }, | |
| 471 | () => Effect.succeed<Dirent[]>([]), | |
| 472 | ), | |
| 473 | ); | |
| 474 | ||
| 475 | const showHidden = endsWithSeparator || prefix.startsWith("."); | |
| 476 | const lowerPrefix = prefix.toLowerCase(); | |
| 477 | const entries: Array<{ readonly name: string; readonly fullPath: string }> = []; | |
| 478 | for (const dirent of dirents) { | |
| 479 | if ( | |
| 480 | dirent.isDirectory() && | |
| 481 | dirent.name.toLowerCase().startsWith(lowerPrefix) && | |
| 482 | (showHidden || !dirent.name.startsWith(".")) | |
| 483 | ) { | |
| 484 | entries.push({ | |
| 485 | name: dirent.name, | |
| 486 | fullPath: path.join(parentPath, dirent.name), | |
| 487 | }); | |
| 488 | } | |
| 489 | } | |
| 490 | ||
| 491 | return { | |
| 492 | parentPath, | |
| 493 | entries: entries.toSorted((left, right) => left.name.localeCompare(right.name)), | |
| 494 | }; | |
| 495 | }, | |
| 496 | ); | |
| 497 | ||
| 498 | const search: WorkspaceEntriesShape["search"] = Effect.fn("WorkspaceEntries.search")( | |
| 499 | function* (input) { | |
| 500 | const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); | |
| 501 | return yield* Cache.get(workspaceIndexCache, normalizedCwd).pipe( | |
| 502 | Effect.map((index) => { | |
| 503 | const normalizedQuery = normalizeSearchQuery(input.query, { | |
| 504 | trimLeadingPattern: /^[@./]+/, | |
| 505 | }); | |
| 506 | const limit = Math.max(0, Math.floor(input.limit)); | |
| 507 | const rankedEntries: RankedWorkspaceEntry[] = []; | |
| 508 | let matchedEntryCount = 0; | |
| 509 | ||
| 510 | for (const entry of index.entries) { | |
| 511 | const score = scoreEntry(entry, normalizedQuery); | |
| 512 | if (score === null) { | |
| 513 | continue; | |
| 514 | } | |
| 515 | ||
| 516 | matchedEntryCount += 1; | |
| 517 | insertRankedSearchResult( | |
| 518 | rankedEntries, | |
| 519 | { item: entry, score, tieBreaker: entry.path }, | |
| 520 | limit, | |
| 521 | ); | |
| 522 | } | |
| 523 | ||
| 524 | return { | |
| 525 | entries: rankedEntries.map((candidate) => candidate.item), | |
| 526 | truncated: index.truncated || matchedEntryCount > limit, | |
| 527 | }; | |
| 528 | }), | |
| 529 | ); | |
| 530 | }, | |
| 531 | ); | |
| 532 | ||
| 533 | const list: WorkspaceEntriesShape["list"] = Effect.fn("WorkspaceEntries.list")(function* (input) { | |
| 534 | const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); | |
| 535 | return yield* Cache.get(workspaceIndexCache, normalizedCwd).pipe( | |
| 536 | Effect.map((index) => ({ | |
| 537 | entries: index.entries.map(({ path: entryPath, kind, parentPath }) => ({ | |
| 538 | path: entryPath, | |
| 539 | kind, | |
| 540 | ...(parentPath ? { parentPath } : {}), | |
| 541 | })), | |
| 542 | truncated: index.truncated, | |
| 543 | })), | |
| 544 | ); | |
| 545 | }); | |
| 546 | ||
| 547 | return { | |
| 548 | browse, | |
| 549 | invalidate, | |
| 550 | list, | |
| 551 | search, | |
| 552 | } satisfies WorkspaceEntriesShape; | |
| 553 | }); | |
| 554 | ||
| 555 | export const WorkspaceEntriesLive = Layer.effect(WorkspaceEntries, makeWorkspaceEntries); |
apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts+10-20| ... | ... | @@ -8,20 +8,19 @@ import * as Path from "effect/Path"; |
| 8 | 8 | import { ServerConfig } from "../../config.ts"; |
| 9 | 9 | import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; |
| 10 | 10 | import * as VcsProcess from "../../vcs/VcsProcess.ts"; |
| 11 | import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; | |
| 11 | import * as WorkspaceEntries from "../WorkspaceEntries.ts"; | |
| 12 | 12 | import { WorkspaceFileSystem } from "../Services/WorkspaceFileSystem.ts"; |
| 13 | import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts"; | |
| 14 | 13 | import { WorkspaceFileSystemLive } from "./WorkspaceFileSystem.ts"; |
| 15 | 14 | import { WorkspacePathsLive } from "./WorkspacePaths.ts"; |
| 16 | 15 | |
| 17 | 16 | const ProjectLayer = WorkspaceFileSystemLive.pipe( |
| 18 | 17 | Layer.provide(WorkspacePathsLive), |
| 19 | Layer.provide(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), | |
| 18 | Layer.provide(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive))), | |
| 20 | 19 | ); |
| 21 | 20 | |
| 22 | 21 | const TestLayer = Layer.empty.pipe( |
| 23 | 22 | Layer.provideMerge(ProjectLayer), |
| 24 | Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), | |
| 23 | Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive))), | |
| 25 | 24 | Layer.provideMerge(WorkspacePathsLive), |
| 26 | 25 | Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))), |
| 27 | 26 | Layer.provide( |
| ... | ... | @@ -53,7 +52,7 @@ const writeTextFile = Effect.fn("writeTextFile")(function* ( |
| 53 | 52 | yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); |
| 54 | 53 | }); |
| 55 | 54 | |
| 56 | it.layer(TestLayer)("WorkspaceFileSystemLive", (it) => { | |
| 55 | it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (it) => { | |
| 57 | 56 | describe("readFile", () => { |
| 58 | 57 | it.effect("reads UTF-8 files relative to the workspace root", () => |
| 59 | 58 | Effect.gen(function* () { |
| ... | ... | @@ -135,20 +134,15 @@ it.layer(TestLayer)("WorkspaceFileSystemLive", (it) => { |
| 135 | 134 | |
| 136 | 135 | it.effect("invalidates workspace entry search cache after writes", () => |
| 137 | 136 | Effect.gen(function* () { |
| 138 | const workspaceEntries = yield* WorkspaceEntries; | |
| 137 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; | |
| 139 | 138 | const workspaceFileSystem = yield* WorkspaceFileSystem; |
| 140 | 139 | const cwd = yield* makeTempDir; |
| 141 | 140 | yield* writeTextFile(cwd, "src/existing.ts", "export {};\n"); |
| 142 | 141 | |
| 143 | const beforeWrite = yield* workspaceEntries.search({ | |
| 144 | cwd, | |
| 145 | query: "rpc", | |
| 146 | limit: 10, | |
| 147 | }); | |
| 148 | expect(beforeWrite).toEqual({ | |
| 149 | entries: [], | |
| 150 | truncated: false, | |
| 151 | }); | |
| 142 | const beforeWrite = yield* workspaceEntries.list({ cwd }); | |
| 143 | expect(beforeWrite.entries.some((entry) => entry.path === "plans/effect-rpc.md")).toBe( | |
| 144 | false, | |
| 145 | ); | |
| 152 | 146 | |
| 153 | 147 | yield* workspaceFileSystem.writeFile({ |
| 154 | 148 | cwd, |
| ... | ... | @@ -156,11 +150,7 @@ it.layer(TestLayer)("WorkspaceFileSystemLive", (it) => { |
| 156 | 150 | contents: "# Plan\n", |
| 157 | 151 | }); |
| 158 | 152 | |
| 159 | const afterWrite = yield* workspaceEntries.search({ | |
| 160 | cwd, | |
| 161 | query: "rpc", | |
| 162 | limit: 10, | |
| 163 | }); | |
| 153 | const afterWrite = yield* workspaceEntries.list({ cwd }); | |
| 164 | 154 | expect(afterWrite.entries).toEqual( |
| 165 | 155 | expect.arrayContaining([expect.objectContaining({ path: "plans/effect-rpc.md" })]), |
| 166 | 156 | ); |
apps/server/src/workspace/Layers/WorkspaceFileSystem.ts+3-3| ... | ... | @@ -11,7 +11,7 @@ import { |
| 11 | 11 | WorkspaceFileSystemError, |
| 12 | 12 | type WorkspaceFileSystemShape, |
| 13 | 13 | } from "../Services/WorkspaceFileSystem.ts"; |
| 14 | import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; | |
| 14 | import * as WorkspaceEntries from "../WorkspaceEntries.ts"; | |
| 15 | 15 | import { WorkspacePaths } from "../Services/WorkspacePaths.ts"; |
| 16 | 16 | |
| 17 | 17 | const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; |
| ... | ... | @@ -20,7 +20,7 @@ export const makeWorkspaceFileSystem = Effect.gen(function* () { |
| 20 | 20 | const fileSystem = yield* FileSystem.FileSystem; |
| 21 | 21 | const path = yield* Path.Path; |
| 22 | 22 | const workspacePaths = yield* WorkspacePaths; |
| 23 | const workspaceEntries = yield* WorkspaceEntries; | |
| 23 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; | |
| 24 | 24 | |
| 25 | 25 | const readFile: WorkspaceFileSystemShape["readFile"] = Effect.fn("WorkspaceFileSystem.readFile")( |
| 26 | 26 | function* (input) { |
| ... | ... | @@ -114,7 +114,7 @@ export const makeWorkspaceFileSystem = Effect.gen(function* () { |
| 114 | 114 | }), |
| 115 | 115 | ), |
| 116 | 116 | ); |
| 117 | yield* workspaceEntries.invalidate(input.cwd); | |
| 117 | yield* workspaceEntries.refresh(input.cwd); | |
| 118 | 118 | return { relativePath: target.relativePath }; |
| 119 | 119 | }); |
| 120 | 120 | return { readFile, writeFile } satisfies WorkspaceFileSystemShape; |
apps/server/src/workspace/Services/WorkspaceEntries.ts deleted-81| ... | ... | @@ -1,81 +0,0 @@ |
| 1 | /** | |
| 2 | * WorkspaceEntries - Effect service contract for cached workspace entry search. | |
| 3 | * | |
| 4 | * Owns indexed workspace entry search plus cache invalidation for workspace | |
| 5 | * roots when the underlying filesystem changes. | |
| 6 | * | |
| 7 | * @module WorkspaceEntries | |
| 8 | */ | |
| 9 | import * as Schema from "effect/Schema"; | |
| 10 | import * as Context from "effect/Context"; | |
| 11 | import type * as Effect from "effect/Effect"; | |
| 12 | ||
| 13 | import type { | |
| 14 | FilesystemBrowseInput, | |
| 15 | FilesystemBrowseResult, | |
| 16 | ProjectListEntriesInput, | |
| 17 | ProjectListEntriesResult, | |
| 18 | ProjectSearchEntriesInput, | |
| 19 | ProjectSearchEntriesResult, | |
| 20 | } from "@t3tools/contracts"; | |
| 21 | ||
| 22 | export class WorkspaceEntriesError extends Schema.TaggedErrorClass<WorkspaceEntriesError>()( | |
| 23 | "WorkspaceEntriesError", | |
| 24 | { | |
| 25 | cwd: Schema.String, | |
| 26 | operation: Schema.String, | |
| 27 | detail: Schema.String, | |
| 28 | cause: Schema.optional(Schema.Defect()), | |
| 29 | }, | |
| 30 | ) {} | |
| 31 | ||
| 32 | export class WorkspaceEntriesBrowseError extends Schema.TaggedErrorClass<WorkspaceEntriesBrowseError>()( | |
| 33 | "WorkspaceEntriesBrowseError", | |
| 34 | { | |
| 35 | cwd: Schema.optional(Schema.String), | |
| 36 | partialPath: Schema.String, | |
| 37 | operation: Schema.String, | |
| 38 | detail: Schema.String, | |
| 39 | cause: Schema.optional(Schema.Defect()), | |
| 40 | }, | |
| 41 | ) {} | |
| 42 | ||
| 43 | /** | |
| 44 | * WorkspaceEntriesShape - Service API for workspace entry search and cache | |
| 45 | * invalidation. | |
| 46 | */ | |
| 47 | export interface WorkspaceEntriesShape { | |
| 48 | /** | |
| 49 | * Browse matching directories for the provided partial path. | |
| 50 | */ | |
| 51 | readonly browse: ( | |
| 52 | input: FilesystemBrowseInput, | |
| 53 | ) => Effect.Effect<FilesystemBrowseResult, WorkspaceEntriesBrowseError>; | |
| 54 | ||
| 55 | /** | |
| 56 | * List the cached workspace index for a project root. | |
| 57 | */ | |
| 58 | readonly list: ( | |
| 59 | input: ProjectListEntriesInput, | |
| 60 | ) => Effect.Effect<ProjectListEntriesResult, WorkspaceEntriesError>; | |
| 61 | ||
| 62 | /** | |
| 63 | * Search indexed workspace entries for files and directories matching the | |
| 64 | * provided query. | |
| 65 | */ | |
| 66 | readonly search: ( | |
| 67 | input: ProjectSearchEntriesInput, | |
| 68 | ) => Effect.Effect<ProjectSearchEntriesResult, WorkspaceEntriesError>; | |
| 69 | ||
| 70 | /** | |
| 71 | * Drop any cached workspace entries for the given workspace root. | |
| 72 | */ | |
| 73 | readonly invalidate: (cwd: string) => Effect.Effect<void>; | |
| 74 | } | |
| 75 | ||
| 76 | /** | |
| 77 | * WorkspaceEntries - Service tag for cached workspace entry search. | |
| 78 | */ | |
| 79 | export class WorkspaceEntries extends Context.Service<WorkspaceEntries, WorkspaceEntriesShape>()( | |
| 80 | "t3/workspace/Services/WorkspaceEntries", | |
| 81 | ) {} |
apps/server/src/workspace/WorkspaceEntries.test.ts created+385| ... | ... | @@ -0,0 +1,385 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | |
| 2 | import fsPromises from "node:fs/promises"; | |
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 4 | import { FileFinder } from "@ff-labs/fff-node"; | |
| 5 | import { it, afterEach, describe, expect, vi } from "@effect/vitest"; | |
| 6 | import * as Effect from "effect/Effect"; | |
| 7 | import * as FileSystem from "effect/FileSystem"; | |
| 8 | import * as Layer from "effect/Layer"; | |
| 9 | import * as Path from "effect/Path"; | |
| 10 | import * as PlatformError from "effect/PlatformError"; | |
| 11 | ||
| 12 | import { ServerConfig } from "../config.ts"; | |
| 13 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 14 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 15 | import * as WorkspaceEntries from "./WorkspaceEntries.ts"; | |
| 16 | import { WorkspacePathsLive } from "./Layers/WorkspacePaths.ts"; | |
| 17 | ||
| 18 | const TestLayer = Layer.empty.pipe( | |
| 19 | Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePathsLive))), | |
| 20 | Layer.provideMerge(WorkspacePathsLive), | |
| 21 | Layer.provideMerge(VcsProcess.layer), | |
| 22 | Layer.provide( | |
| 23 | ServerConfig.layerTest(process.cwd(), { | |
| 24 | prefix: "t3-workspace-entries-test-", | |
| 25 | }), | |
| 26 | ), | |
| 27 | Layer.provideMerge(NodeServices.layer), | |
| 28 | ); | |
| 29 | ||
| 30 | const makeTempDir = Effect.fn(function* (opts?: { prefix?: string; git?: boolean }) { | |
| 31 | const fileSystem = yield* FileSystem.FileSystem; | |
| 32 | const dir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 33 | prefix: opts?.prefix ?? "t3code-workspace-entries-", | |
| 34 | }); | |
| 35 | if (opts?.git) { | |
| 36 | yield* git(dir, ["init"]); | |
| 37 | } | |
| 38 | return dir; | |
| 39 | }); | |
| 40 | ||
| 41 | function writeTextFile( | |
| 42 | cwd: string, | |
| 43 | relativePath: string, | |
| 44 | contents = "", | |
| 45 | ): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> { | |
| 46 | return Effect.gen(function* () { | |
| 47 | const fileSystem = yield* FileSystem.FileSystem; | |
| 48 | const path = yield* Path.Path; | |
| 49 | const absolutePath = path.join(cwd, relativePath); | |
| 50 | yield* fileSystem.makeDirectory(path.dirname(absolutePath), { recursive: true }); | |
| 51 | yield* fileSystem.writeFileString(absolutePath, contents); | |
| 52 | }); | |
| 53 | } | |
| 54 | ||
| 55 | const git = (cwd: string, args: ReadonlyArray<string>, env?: NodeJS.ProcessEnv) => | |
| 56 | Effect.gen(function* () { | |
| 57 | const process = yield* VcsProcess.VcsProcess; | |
| 58 | const result = yield* process.run({ | |
| 59 | operation: "WorkspaceEntries.test.git", | |
| 60 | command: "git", | |
| 61 | cwd, | |
| 62 | args, | |
| 63 | ...(env ? { env } : {}), | |
| 64 | timeoutMs: 10_000, | |
| 65 | }); | |
| 66 | return result.stdout.trim(); | |
| 67 | }); | |
| 68 | ||
| 69 | const searchWorkspaceEntries = (input: { cwd: string; query: string; limit: number }) => | |
| 70 | Effect.gen(function* () { | |
| 71 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; | |
| 72 | return yield* workspaceEntries.search(input); | |
| 73 | }); | |
| 74 | ||
| 75 | const appendSeparator = (input: string) => | |
| 76 | Effect.map(HostProcessPlatform, (platform) => | |
| 77 | input.endsWith("/") || input.endsWith("\\") | |
| 78 | ? input | |
| 79 | : `${input}${platform === "win32" ? "\\" : "/"}`, | |
| 80 | ); | |
| 81 | ||
| 82 | it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { | |
| 83 | afterEach(() => { | |
| 84 | vi.restoreAllMocks(); | |
| 85 | }); | |
| 86 | ||
| 87 | describe("list", () => { | |
| 88 | it.effect("returns the complete cached workspace index", () => | |
| 89 | Effect.gen(function* () { | |
| 90 | const cwd = yield* makeTempDir(); | |
| 91 | yield* writeTextFile(cwd, "src/components/Composer.tsx"); | |
| 92 | yield* writeTextFile(cwd, "README.md"); | |
| 93 | yield* writeTextFile(cwd, "node_modules/pkg/index.js"); | |
| 94 | ||
| 95 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; | |
| 96 | const result = yield* workspaceEntries.list({ cwd }); | |
| 97 | ||
| 98 | expect(result.entries).toEqual( | |
| 99 | expect.arrayContaining([ | |
| 100 | { path: "src", kind: "directory" }, | |
| 101 | { path: "src/components", kind: "directory" }, | |
| 102 | { | |
| 103 | path: "src/components/Composer.tsx", | |
| 104 | kind: "file", | |
| 105 | }, | |
| 106 | { path: "README.md", kind: "file" }, | |
| 107 | ]), | |
| 108 | ); | |
| 109 | expect(result.entries.some((entry) => entry.path.startsWith("node_modules"))).toBe(false); | |
| 110 | expect(result.truncated).toBe(false); | |
| 111 | }), | |
| 112 | ); | |
| 113 | }); | |
| 114 | ||
| 115 | describe("search", () => { | |
| 116 | it.effect("returns files and directories relative to cwd", () => | |
| 117 | Effect.gen(function* () { | |
| 118 | const cwd = yield* makeTempDir(); | |
| 119 | yield* writeTextFile(cwd, "src/components/Composer.tsx"); | |
| 120 | yield* writeTextFile(cwd, "src/index.ts"); | |
| 121 | yield* writeTextFile(cwd, "README.md"); | |
| 122 | yield* writeTextFile(cwd, ".git/HEAD"); | |
| 123 | yield* writeTextFile(cwd, "node_modules/pkg/index.js"); | |
| 124 | ||
| 125 | const result = yield* searchWorkspaceEntries({ cwd, query: "", limit: 100 }); | |
| 126 | const paths = result.entries.map((entry) => entry.path); | |
| 127 | ||
| 128 | expect(paths).toContain("src"); | |
| 129 | expect(paths).toContain("src/components"); | |
| 130 | expect(paths).toContain("src/components/Composer.tsx"); | |
| 131 | expect(paths).toContain("README.md"); | |
| 132 | expect(paths.some((entryPath) => entryPath.startsWith(".git"))).toBe(false); | |
| 133 | expect(paths.some((entryPath) => entryPath.startsWith("node_modules"))).toBe(false); | |
| 134 | expect(result.truncated).toBe(false); | |
| 135 | }), | |
| 136 | ); | |
| 137 | ||
| 138 | it.effect("filters and ranks entries by query", () => | |
| 139 | Effect.gen(function* () { | |
| 140 | const cwd = yield* makeTempDir({ prefix: "t3code-workspace-query-" }); | |
| 141 | yield* writeTextFile(cwd, "src/components/Composer.tsx"); | |
| 142 | yield* writeTextFile(cwd, "src/components/composePrompt.ts"); | |
| 143 | yield* writeTextFile(cwd, "docs/composition.md"); | |
| 144 | ||
| 145 | const result = yield* searchWorkspaceEntries({ cwd, query: "compo", limit: 5 }); | |
| 146 | ||
| 147 | expect(result.entries.length).toBeGreaterThan(0); | |
| 148 | expect(result.entries.some((entry) => entry.path === "src/components")).toBe(true); | |
| 149 | expect(result.entries.every((entry) => entry.path.toLowerCase().includes("compo"))).toBe( | |
| 150 | true, | |
| 151 | ); | |
| 152 | }), | |
| 153 | ); | |
| 154 | ||
| 155 | it.effect("supports fuzzy subsequence queries for composer path search", () => | |
| 156 | Effect.gen(function* () { | |
| 157 | const cwd = yield* makeTempDir({ prefix: "t3code-workspace-fuzzy-query-" }); | |
| 158 | yield* writeTextFile(cwd, "src/components/Composer.tsx"); | |
| 159 | yield* writeTextFile(cwd, "src/components/composePrompt.ts"); | |
| 160 | yield* writeTextFile(cwd, "docs/composition.md"); | |
| 161 | ||
| 162 | const result = yield* searchWorkspaceEntries({ cwd, query: "cmp", limit: 10 }); | |
| 163 | const paths = result.entries.map((entry) => entry.path); | |
| 164 | ||
| 165 | expect(result.entries.length).toBeGreaterThan(0); | |
| 166 | expect(paths).toContain("src/components"); | |
| 167 | expect(paths).toContain("src/components/Composer.tsx"); | |
| 168 | }), | |
| 169 | ); | |
| 170 | ||
| 171 | it.effect("prioritizes exact basename matches ahead of broader path matches", () => | |
| 172 | Effect.gen(function* () { | |
| 173 | const cwd = yield* makeTempDir({ prefix: "t3code-workspace-exact-ranking-" }); | |
| 174 | yield* writeTextFile(cwd, "src/components/Composer.tsx"); | |
| 175 | yield* writeTextFile(cwd, "docs/composer.tsx-notes.md"); | |
| 176 | ||
| 177 | const result = yield* searchWorkspaceEntries({ cwd, query: "Composer.tsx", limit: 5 }); | |
| 178 | ||
| 179 | expect(result.entries[0]?.path).toBe("src/components/Composer.tsx"); | |
| 180 | }), | |
| 181 | ); | |
| 182 | ||
| 183 | it.effect("tracks truncation without sorting every fuzzy match", () => | |
| 184 | Effect.gen(function* () { | |
| 185 | const cwd = yield* makeTempDir({ prefix: "t3code-workspace-fuzzy-limit-" }); | |
| 186 | yield* writeTextFile(cwd, "src/components/Composer.tsx"); | |
| 187 | yield* writeTextFile(cwd, "src/components/composePrompt.ts"); | |
| 188 | yield* writeTextFile(cwd, "docs/composition.md"); | |
| 189 | ||
| 190 | const result = yield* searchWorkspaceEntries({ cwd, query: "cmp", limit: 1 }); | |
| 191 | ||
| 192 | expect(result.entries).toHaveLength(1); | |
| 193 | expect(result.truncated).toBe(true); | |
| 194 | }), | |
| 195 | ); | |
| 196 | ||
| 197 | it.effect("excludes gitignored paths for git repositories", () => | |
| 198 | Effect.gen(function* () { | |
| 199 | const cwd = yield* makeTempDir({ prefix: "t3code-workspace-gitignore-", git: true }); | |
| 200 | yield* writeTextFile(cwd, ".gitignore", ".convex/\nconvex/\nignored.txt\n"); | |
| 201 | yield* writeTextFile(cwd, "src/keep.ts", "export {};"); | |
| 202 | yield* writeTextFile(cwd, "ignored.txt", "ignore me"); | |
| 203 | yield* writeTextFile(cwd, ".convex/local-storage/data.json", "{}"); | |
| 204 | yield* writeTextFile(cwd, "convex/UOoS-l/convex_local_storage/modules/data.json", "{}"); | |
| 205 | ||
| 206 | const result = yield* searchWorkspaceEntries({ cwd, query: "", limit: 100 }); | |
| 207 | const paths = result.entries.map((entry) => entry.path); | |
| 208 | ||
| 209 | expect(paths).toContain("src"); | |
| 210 | expect(paths).toContain("src/keep.ts"); | |
| 211 | expect(paths).not.toContain("ignored.txt"); | |
| 212 | expect(paths.some((entryPath) => entryPath.startsWith(".convex/"))).toBe(false); | |
| 213 | expect(paths.some((entryPath) => entryPath.startsWith("convex/"))).toBe(false); | |
| 214 | }), | |
| 215 | ); | |
| 216 | ||
| 217 | it.effect("excludes tracked paths that match ignore rules", () => | |
| 218 | Effect.gen(function* () { | |
| 219 | const cwd = yield* makeTempDir({ | |
| 220 | prefix: "t3code-workspace-tracked-gitignore-", | |
| 221 | git: true, | |
| 222 | }); | |
| 223 | yield* writeTextFile(cwd, ".convex/local-storage/data.json", "{}"); | |
| 224 | yield* writeTextFile(cwd, "src/keep.ts", "export {};"); | |
| 225 | yield* git(cwd, ["add", ".convex/local-storage/data.json", "src/keep.ts"]); | |
| 226 | yield* writeTextFile(cwd, ".gitignore", ".convex/\n"); | |
| 227 | ||
| 228 | const result = yield* searchWorkspaceEntries({ cwd, query: "", limit: 100 }); | |
| 229 | const paths = result.entries.map((entry) => entry.path); | |
| 230 | ||
| 231 | expect(paths).toContain("src"); | |
| 232 | expect(paths).toContain("src/keep.ts"); | |
| 233 | expect(paths.some((entryPath) => entryPath.startsWith(".convex/"))).toBe(false); | |
| 234 | }), | |
| 235 | ); | |
| 236 | ||
| 237 | it.effect("excludes .convex in non-git workspaces", () => | |
| 238 | Effect.gen(function* () { | |
| 239 | const cwd = yield* makeTempDir({ prefix: "t3code-workspace-non-git-convex-" }); | |
| 240 | yield* writeTextFile(cwd, ".convex/local-storage/data.json", "{}"); | |
| 241 | yield* writeTextFile(cwd, "src/keep.ts", "export {};"); | |
| 242 | ||
| 243 | const result = yield* searchWorkspaceEntries({ cwd, query: "", limit: 100 }); | |
| 244 | const paths = result.entries.map((entry) => entry.path); | |
| 245 | ||
| 246 | expect(paths).toContain("src"); | |
| 247 | expect(paths).toContain("src/keep.ts"); | |
| 248 | expect(paths.some((entryPath) => entryPath.startsWith(".convex/"))).toBe(false); | |
| 249 | }), | |
| 250 | ); | |
| 251 | ||
| 252 | it.effect("supports typo-resistant file search through fff", () => | |
| 253 | Effect.gen(function* () { | |
| 254 | const cwd = yield* makeTempDir({ prefix: "t3code-workspace-fff-typo-" }); | |
| 255 | yield* writeTextFile(cwd, "src/components/Composer.tsx"); | |
| 256 | ||
| 257 | const result = yield* searchWorkspaceEntries({ cwd, query: "compoesr", limit: 10 }); | |
| 258 | ||
| 259 | expect(result.entries).toEqual( | |
| 260 | expect.arrayContaining([ | |
| 261 | expect.objectContaining({ path: "src/components/Composer.tsx" }), | |
| 262 | ]), | |
| 263 | ); | |
| 264 | }), | |
| 265 | ); | |
| 266 | ||
| 267 | it.effect("rebuilds the cached index after refresh fails", () => | |
| 268 | Effect.gen(function* () { | |
| 269 | const cwd = yield* makeTempDir({ prefix: "t3code-workspace-refresh-failure-" }); | |
| 270 | yield* writeTextFile(cwd, "src/index.ts", "export {};\n"); | |
| 271 | ||
| 272 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; | |
| 273 | const createSpy = vi.spyOn(FileFinder, "create"); | |
| 274 | yield* workspaceEntries.list({ cwd }); | |
| 275 | expect(createSpy).toHaveBeenCalledTimes(1); | |
| 276 | ||
| 277 | vi.spyOn(FileFinder.prototype, "scanFiles").mockReturnValueOnce({ | |
| 278 | ok: false, | |
| 279 | error: "scan failed", | |
| 280 | }); | |
| 281 | yield* workspaceEntries.refresh(cwd); | |
| 282 | ||
| 283 | yield* workspaceEntries.list({ cwd }); | |
| 284 | expect(createSpy).toHaveBeenCalledTimes(2); | |
| 285 | }), | |
| 286 | ); | |
| 287 | }); | |
| 288 | ||
| 289 | describe("browse", () => { | |
| 290 | it.effect("returns matching directories and excludes files", () => | |
| 291 | Effect.gen(function* () { | |
| 292 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; | |
| 293 | const path = yield* Path.Path; | |
| 294 | const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-prefix-" }); | |
| 295 | yield* writeTextFile(cwd, "alphabet.txt", "ignore me"); | |
| 296 | yield* writeTextFile(cwd, "alpha/index.ts", "export {};\n"); | |
| 297 | yield* writeTextFile(cwd, "alpine/index.ts", "export {};\n"); | |
| 298 | ||
| 299 | const result = yield* workspaceEntries.browse({ | |
| 300 | partialPath: path.join(cwd, "alp"), | |
| 301 | }); | |
| 302 | ||
| 303 | expect(result).toEqual({ | |
| 304 | parentPath: cwd, | |
| 305 | entries: [ | |
| 306 | { name: "alpha", fullPath: path.join(cwd, "alpha") }, | |
| 307 | { name: "alpine", fullPath: path.join(cwd, "alpine") }, | |
| 308 | ], | |
| 309 | }); | |
| 310 | }), | |
| 311 | ); | |
| 312 | ||
| 313 | it.effect("shows dot directories in directory mode and hidden-prefix mode", () => | |
| 314 | Effect.gen(function* () { | |
| 315 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; | |
| 316 | const path = yield* Path.Path; | |
| 317 | const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-hidden-" }); | |
| 318 | yield* writeTextFile(cwd, ".config/settings.json", "{}"); | |
| 319 | yield* writeTextFile(cwd, "config/settings.json", "{}"); | |
| 320 | const cwdWithSeparator = yield* appendSeparator(cwd); | |
| 321 | ||
| 322 | const directoryResult = yield* workspaceEntries.browse({ | |
| 323 | partialPath: cwdWithSeparator, | |
| 324 | }); | |
| 325 | const hiddenPrefixResult = yield* workspaceEntries.browse({ | |
| 326 | partialPath: `${cwdWithSeparator}.c`, | |
| 327 | }); | |
| 328 | ||
| 329 | expect(directoryResult.entries.map((entry) => entry.name)).toEqual([".config", "config"]); | |
| 330 | expect(hiddenPrefixResult).toEqual({ | |
| 331 | parentPath: cwd, | |
| 332 | entries: [{ name: ".config", fullPath: path.join(cwd, ".config") }], | |
| 333 | }); | |
| 334 | }), | |
| 335 | ); | |
| 336 | ||
| 337 | it.effect("supports relative paths when cwd is provided", () => | |
| 338 | Effect.gen(function* () { | |
| 339 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; | |
| 340 | const path = yield* Path.Path; | |
| 341 | const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-relative-" }); | |
| 342 | yield* writeTextFile(cwd, "packages/pkg.json", "{}"); | |
| 343 | ||
| 344 | const result = yield* workspaceEntries.browse({ | |
| 345 | cwd, | |
| 346 | partialPath: "./pack", | |
| 347 | }); | |
| 348 | ||
| 349 | expect(result).toEqual({ | |
| 350 | parentPath: cwd, | |
| 351 | entries: [{ name: "packages", fullPath: path.join(cwd, "packages") }], | |
| 352 | }); | |
| 353 | }), | |
| 354 | ); | |
| 355 | ||
| 356 | it.effect("rejects relative paths without cwd", () => | |
| 357 | Effect.gen(function* () { | |
| 358 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; | |
| 359 | ||
| 360 | const error = yield* workspaceEntries | |
| 361 | .browse({ | |
| 362 | partialPath: "./src", | |
| 363 | }) | |
| 364 | .pipe(Effect.flip); | |
| 365 | ||
| 366 | expect(error.detail).toBe("Relative filesystem browse paths require a current project."); | |
| 367 | }), | |
| 368 | ); | |
| 369 | ||
| 370 | it.effect("returns an empty listing when the OS denies directory access", () => | |
| 371 | Effect.gen(function* () { | |
| 372 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; | |
| 373 | const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-eacces-" }); | |
| 374 | ||
| 375 | const denied = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }); | |
| 376 | vi.spyOn(fsPromises, "readdir").mockRejectedValueOnce(denied); | |
| 377 | ||
| 378 | const result = yield* workspaceEntries.browse({ | |
| 379 | partialPath: yield* appendSeparator(cwd), | |
| 380 | }); | |
| 381 | expect(result).toEqual({ parentPath: cwd, entries: [] }); | |
| 382 | }), | |
| 383 | ); | |
| 384 | }); | |
| 385 | }); |
apps/server/src/workspace/WorkspaceEntries.ts created+252| ... | ... | @@ -0,0 +1,252 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | |
| 2 | import * as NodeFSP from "node:fs/promises"; | |
| 3 | import * as NodeOS from "node:os"; | |
| 4 | ||
| 5 | import * as Context from "effect/Context"; | |
| 6 | import * as Effect from "effect/Effect"; | |
| 7 | import * as Layer from "effect/Layer"; | |
| 8 | import * as Path from "effect/Path"; | |
| 9 | import * as RcMap from "effect/RcMap"; | |
| 10 | import * as Schema from "effect/Schema"; | |
| 11 | ||
| 12 | import type { | |
| 13 | FilesystemBrowseInput, | |
| 14 | FilesystemBrowseResult, | |
| 15 | ProjectListEntriesInput, | |
| 16 | ProjectListEntriesResult, | |
| 17 | ProjectSearchEntriesInput, | |
| 18 | ProjectSearchEntriesResult, | |
| 19 | } from "@t3tools/contracts"; | |
| 20 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 21 | import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; | |
| 22 | ||
| 23 | import * as WorkspacePaths from "./Services/WorkspacePaths.ts"; | |
| 24 | import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; | |
| 25 | ||
| 26 | export class WorkspaceEntriesError extends Schema.TaggedErrorClass<WorkspaceEntriesError>()( | |
| 27 | "WorkspaceEntriesError", | |
| 28 | { | |
| 29 | cwd: Schema.String, | |
| 30 | operation: Schema.String, | |
| 31 | detail: Schema.String, | |
| 32 | cause: Schema.optional(Schema.Defect()), | |
| 33 | }, | |
| 34 | ) {} | |
| 35 | ||
| 36 | export class WorkspaceEntriesBrowseError extends Schema.TaggedErrorClass<WorkspaceEntriesBrowseError>()( | |
| 37 | "WorkspaceEntriesBrowseError", | |
| 38 | { | |
| 39 | cwd: Schema.optional(Schema.String), | |
| 40 | partialPath: Schema.String, | |
| 41 | operation: Schema.String, | |
| 42 | detail: Schema.String, | |
| 43 | cause: Schema.optional(Schema.Defect()), | |
| 44 | }, | |
| 45 | ) {} | |
| 46 | ||
| 47 | export class WorkspaceEntries extends Context.Service< | |
| 48 | WorkspaceEntries, | |
| 49 | { | |
| 50 | readonly browse: ( | |
| 51 | input: FilesystemBrowseInput, | |
| 52 | ) => Effect.Effect<FilesystemBrowseResult, WorkspaceEntriesBrowseError>; | |
| 53 | readonly list: ( | |
| 54 | input: ProjectListEntriesInput, | |
| 55 | ) => Effect.Effect<ProjectListEntriesResult, WorkspaceEntriesError>; | |
| 56 | readonly search: ( | |
| 57 | input: ProjectSearchEntriesInput, | |
| 58 | ) => Effect.Effect<ProjectSearchEntriesResult, WorkspaceEntriesError>; | |
| 59 | readonly refresh: (cwd: string) => Effect.Effect<void>; | |
| 60 | } | |
| 61 | >()("t3/workspace/WorkspaceEntries") {} | |
| 62 | ||
| 63 | function expandHomePath(input: string, path: Path.Path): string { | |
| 64 | if (input === "~") { | |
| 65 | return NodeOS.homedir(); | |
| 66 | } | |
| 67 | if (input.startsWith("~/") || input.startsWith("~\\")) { | |
| 68 | return path.join(NodeOS.homedir(), input.slice(2)); | |
| 69 | } | |
| 70 | return input; | |
| 71 | } | |
| 72 | ||
| 73 | const resolveBrowseTarget = ( | |
| 74 | input: FilesystemBrowseInput, | |
| 75 | path: Path.Path, | |
| 76 | ): Effect.Effect<string, WorkspaceEntriesBrowseError> => | |
| 77 | Effect.gen(function* () { | |
| 78 | const platform = yield* HostProcessPlatform; | |
| 79 | if (platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) { | |
| 80 | return yield* new WorkspaceEntriesBrowseError({ | |
| 81 | cwd: input.cwd, | |
| 82 | partialPath: input.partialPath, | |
| 83 | operation: "workspaceEntries.resolveBrowseTarget", | |
| 84 | detail: "Windows-style paths are only supported on Windows.", | |
| 85 | }); | |
| 86 | } | |
| 87 | ||
| 88 | if (!isExplicitRelativePath(input.partialPath)) { | |
| 89 | return path.resolve(expandHomePath(input.partialPath, path)); | |
| 90 | } | |
| 91 | ||
| 92 | if (!input.cwd) { | |
| 93 | return yield* new WorkspaceEntriesBrowseError({ | |
| 94 | cwd: input.cwd, | |
| 95 | partialPath: input.partialPath, | |
| 96 | operation: "workspaceEntries.resolveBrowseTarget", | |
| 97 | detail: "Relative filesystem browse paths require a current project.", | |
| 98 | }); | |
| 99 | } | |
| 100 | ||
| 101 | return path.resolve(expandHomePath(input.cwd, path), input.partialPath); | |
| 102 | }); | |
| 103 | ||
| 104 | const make = Effect.gen(function* () { | |
| 105 | const path = yield* Path.Path; | |
| 106 | const workspacePaths = yield* WorkspacePaths.WorkspacePaths; | |
| 107 | const workspaceSearchIndexes = yield* WorkspaceSearchIndex.WorkspaceSearchIndexMap; | |
| 108 | ||
| 109 | const normalizeWorkspaceRoot = Effect.fn("WorkspaceEntries.normalizeWorkspaceRoot")(function* ( | |
| 110 | cwd: string, | |
| 111 | ): Effect.fn.Return<string, WorkspaceEntriesError> { | |
| 112 | return yield* workspacePaths.normalizeWorkspaceRoot(cwd).pipe( | |
| 113 | Effect.mapError( | |
| 114 | (cause) => | |
| 115 | new WorkspaceEntriesError({ | |
| 116 | cwd, | |
| 117 | operation: "workspaceEntries.normalizeWorkspaceRoot", | |
| 118 | detail: cause.message, | |
| 119 | cause, | |
| 120 | }), | |
| 121 | ), | |
| 122 | ); | |
| 123 | }); | |
| 124 | ||
| 125 | const refresh: WorkspaceEntries["Service"]["refresh"] = Effect.fn("WorkspaceEntries.refresh")( | |
| 126 | function* (cwd) { | |
| 127 | const normalizedCwd = yield* normalizeWorkspaceRoot(cwd).pipe( | |
| 128 | Effect.orElseSucceed(() => cwd), | |
| 129 | ); | |
| 130 | if (!(yield* RcMap.has(workspaceSearchIndexes.rcMap, normalizedCwd))) { | |
| 131 | return; | |
| 132 | } | |
| 133 | yield* Effect.gen(function* () { | |
| 134 | const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; | |
| 135 | yield* searchIndex.refresh(); | |
| 136 | }).pipe( | |
| 137 | Effect.provide(workspaceSearchIndexes.get(normalizedCwd)), | |
| 138 | Effect.catch((cause) => | |
| 139 | Effect.gen(function* () { | |
| 140 | yield* Effect.logWarning("Failed to refresh workspace search index", { | |
| 141 | cwd, | |
| 142 | cause, | |
| 143 | }); | |
| 144 | yield* workspaceSearchIndexes.invalidate(normalizedCwd); | |
| 145 | }), | |
| 146 | ), | |
| 147 | ); | |
| 148 | }, | |
| 149 | ); | |
| 150 | ||
| 151 | const browse: WorkspaceEntries["Service"]["browse"] = Effect.fn("WorkspaceEntries.browse")( | |
| 152 | function* (input) { | |
| 153 | const resolvedInputPath = yield* resolveBrowseTarget(input, path); | |
| 154 | const endsWithSeparator = /[\\/]$/.test(input.partialPath) || input.partialPath === "~"; | |
| 155 | const parentPath = endsWithSeparator ? resolvedInputPath : path.dirname(resolvedInputPath); | |
| 156 | const prefix = endsWithSeparator ? "" : path.basename(resolvedInputPath); | |
| 157 | ||
| 158 | const dirents = yield* Effect.tryPromise({ | |
| 159 | try: () => NodeFSP.readdir(parentPath, { withFileTypes: true }), | |
| 160 | catch: (cause) => | |
| 161 | new WorkspaceEntriesBrowseError({ | |
| 162 | cwd: input.cwd, | |
| 163 | partialPath: input.partialPath, | |
| 164 | operation: "workspaceEntries.browse.readDirectory", | |
| 165 | detail: `Unable to browse '${parentPath}': ${cause instanceof Error ? cause.message : String(cause)}`, | |
| 166 | cause, | |
| 167 | }), | |
| 168 | }).pipe( | |
| 169 | Effect.catchIf( | |
| 170 | (error) => { | |
| 171 | const code = (error.cause as NodeJS.ErrnoException | undefined)?.code; | |
| 172 | return code === "EACCES" || code === "EPERM"; | |
| 173 | }, | |
| 174 | () => Effect.succeed([]), | |
| 175 | ), | |
| 176 | ); | |
| 177 | ||
| 178 | const showHidden = endsWithSeparator || prefix.startsWith("."); | |
| 179 | const lowerPrefix = prefix.toLowerCase(); | |
| 180 | const entries: Array<{ readonly name: string; readonly fullPath: string }> = []; | |
| 181 | for (const dirent of dirents) { | |
| 182 | if ( | |
| 183 | dirent.isDirectory() && | |
| 184 | dirent.name.toLowerCase().startsWith(lowerPrefix) && | |
| 185 | (showHidden || !dirent.name.startsWith(".")) | |
| 186 | ) { | |
| 187 | entries.push({ | |
| 188 | name: dirent.name, | |
| 189 | fullPath: path.join(parentPath, dirent.name), | |
| 190 | }); | |
| 191 | } | |
| 192 | } | |
| 193 | ||
| 194 | return { | |
| 195 | parentPath, | |
| 196 | entries: entries.toSorted((left, right) => left.name.localeCompare(right.name)), | |
| 197 | }; | |
| 198 | }, | |
| 199 | ); | |
| 200 | ||
| 201 | const search: WorkspaceEntries["Service"]["search"] = Effect.fn("WorkspaceEntries.search")( | |
| 202 | function* (input) { | |
| 203 | const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); | |
| 204 | const normalizedQuery = input.query | |
| 205 | .trim() | |
| 206 | .toLowerCase() | |
| 207 | .replace(/^[@./]+/, ""); | |
| 208 | return yield* Effect.gen(function* () { | |
| 209 | const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; | |
| 210 | return yield* searchIndex.search(normalizedQuery, input.limit); | |
| 211 | }).pipe( | |
| 212 | Effect.provide(workspaceSearchIndexes.get(normalizedCwd)), | |
| 213 | Effect.mapError( | |
| 214 | (cause) => | |
| 215 | new WorkspaceEntriesError({ | |
| 216 | cwd: input.cwd, | |
| 217 | operation: "workspaceEntries.search", | |
| 218 | detail: cause.message, | |
| 219 | cause, | |
| 220 | }), | |
| 221 | ), | |
| 222 | ); | |
| 223 | }, | |
| 224 | ); | |
| 225 | ||
| 226 | const list: WorkspaceEntries["Service"]["list"] = Effect.fn("WorkspaceEntries.list")( | |
| 227 | function* (input) { | |
| 228 | const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); | |
| 229 | return yield* Effect.gen(function* () { | |
| 230 | const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; | |
| 231 | return yield* searchIndex.list(); | |
| 232 | }).pipe( | |
| 233 | Effect.provide(workspaceSearchIndexes.get(normalizedCwd)), | |
| 234 | Effect.mapError( | |
| 235 | (cause) => | |
| 236 | new WorkspaceEntriesError({ | |
| 237 | cwd: input.cwd, | |
| 238 | operation: "workspaceEntries.list", | |
| 239 | detail: cause.message, | |
| 240 | cause, | |
| 241 | }), | |
| 242 | ), | |
| 243 | ); | |
| 244 | }, | |
| 245 | ); | |
| 246 | ||
| 247 | return WorkspaceEntries.of({ browse, list, refresh, search }); | |
| 248 | }); | |
| 249 | ||
| 250 | export const layer = Layer.effect(WorkspaceEntries, make).pipe( | |
| 251 | Layer.provide(WorkspaceSearchIndex.WorkspaceSearchIndexMap.layer), | |
| 252 | ); |
apps/server/src/workspace/WorkspaceSearchIndex.ts created+245| ... | ... | @@ -0,0 +1,245 @@ |
| 1 | import { FileFinder, type MixedItem, type MixedSearchResult } from "@ff-labs/fff-node"; | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | import * as LayerMap from "effect/LayerMap"; | |
| 6 | import * as Schedule from "effect/Schedule"; | |
| 7 | import * as Schema from "effect/Schema"; | |
| 8 | ||
| 9 | import type { | |
| 10 | ProjectEntry, | |
| 11 | ProjectListEntriesResult, | |
| 12 | ProjectSearchEntriesResult, | |
| 13 | } from "@t3tools/contracts"; | |
| 14 | ||
| 15 | const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; | |
| 16 | const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2; | |
| 17 | const WORKSPACE_INDEX_SCAN_TIMEOUT = "15 seconds"; | |
| 18 | const WORKSPACE_INDEX_IDLE_TTL = "15 minutes"; | |
| 19 | const WORKSPACE_INDEX_SCAN_POLL_INTERVAL = "50 millis"; | |
| 20 | ||
| 21 | export class WorkspaceSearchIndexCreateFailed extends Schema.TaggedErrorClass<WorkspaceSearchIndexCreateFailed>()( | |
| 22 | "WorkspaceSearchIndexCreateFailed", | |
| 23 | { | |
| 24 | cwd: Schema.String, | |
| 25 | reason: Schema.String, | |
| 26 | }, | |
| 27 | ) { | |
| 28 | override get message(): string { | |
| 29 | return `Failed to create the workspace search index for '${this.cwd}': ${this.reason}`; | |
| 30 | } | |
| 31 | } | |
| 32 | ||
| 33 | export class WorkspaceSearchIndexScanTimedOut extends Schema.TaggedErrorClass<WorkspaceSearchIndexScanTimedOut>()( | |
| 34 | "WorkspaceSearchIndexScanTimedOut", | |
| 35 | { | |
| 36 | cwd: Schema.String, | |
| 37 | timeout: Schema.String, | |
| 38 | }, | |
| 39 | ) { | |
| 40 | override get message(): string { | |
| 41 | return `Workspace search index for '${this.cwd}' did not finish scanning within ${this.timeout}`; | |
| 42 | } | |
| 43 | } | |
| 44 | ||
| 45 | export class WorkspaceSearchIndexSearchFailed extends Schema.TaggedErrorClass<WorkspaceSearchIndexSearchFailed>()( | |
| 46 | "WorkspaceSearchIndexSearchFailed", | |
| 47 | { | |
| 48 | cwd: Schema.String, | |
| 49 | reason: Schema.String, | |
| 50 | }, | |
| 51 | ) { | |
| 52 | override get message(): string { | |
| 53 | return `Workspace search failed for '${this.cwd}': ${this.reason}`; | |
| 54 | } | |
| 55 | } | |
| 56 | ||
| 57 | export class WorkspaceSearchIndexRefreshFailed extends Schema.TaggedErrorClass<WorkspaceSearchIndexRefreshFailed>()( | |
| 58 | "WorkspaceSearchIndexRefreshFailed", | |
| 59 | { | |
| 60 | cwd: Schema.String, | |
| 61 | reason: Schema.String, | |
| 62 | }, | |
| 63 | ) { | |
| 64 | override get message(): string { | |
| 65 | return `Failed to refresh the workspace search index for '${this.cwd}': ${this.reason}`; | |
| 66 | } | |
| 67 | } | |
| 68 | ||
| 69 | export type WorkspaceSearchIndexError = | |
| 70 | | WorkspaceSearchIndexCreateFailed | |
| 71 | | WorkspaceSearchIndexScanTimedOut | |
| 72 | | WorkspaceSearchIndexSearchFailed | |
| 73 | | WorkspaceSearchIndexRefreshFailed; | |
| 74 | ||
| 75 | export class WorkspaceSearchIndex extends Context.Service< | |
| 76 | WorkspaceSearchIndex, | |
| 77 | { | |
| 78 | readonly list: () => Effect.Effect<ProjectListEntriesResult, WorkspaceSearchIndexSearchFailed>; | |
| 79 | readonly search: ( | |
| 80 | query: string, | |
| 81 | limit: number, | |
| 82 | ) => Effect.Effect<ProjectSearchEntriesResult, WorkspaceSearchIndexSearchFailed>; | |
| 83 | readonly refresh: () => Effect.Effect< | |
| 84 | void, | |
| 85 | WorkspaceSearchIndexRefreshFailed | WorkspaceSearchIndexScanTimedOut | |
| 86 | >; | |
| 87 | } | |
| 88 | >()("t3/workspace/WorkspaceSearchIndex") {} | |
| 89 | ||
| 90 | function toPosixPath(input: string): string { | |
| 91 | return input.replaceAll("\\", "/"); | |
| 92 | } | |
| 93 | ||
| 94 | function trimDirectorySeparator(input: string): string { | |
| 95 | return input.endsWith("/") ? input.slice(0, -1) : input; | |
| 96 | } | |
| 97 | ||
| 98 | function parentPathOf(input: string): string | undefined { | |
| 99 | const separatorIndex = input.lastIndexOf("/"); | |
| 100 | return separatorIndex === -1 ? undefined : input.slice(0, separatorIndex); | |
| 101 | } | |
| 102 | ||
| 103 | function toProjectEntry(item: MixedItem): ProjectEntry | null { | |
| 104 | const normalizedPath = trimDirectorySeparator(toPosixPath(item.item.relativePath)); | |
| 105 | if (!normalizedPath) { | |
| 106 | return null; | |
| 107 | } | |
| 108 | ||
| 109 | return { | |
| 110 | path: normalizedPath, | |
| 111 | kind: item.type, | |
| 112 | }; | |
| 113 | } | |
| 114 | ||
| 115 | function mapMixedSearchResult( | |
| 116 | result: MixedSearchResult, | |
| 117 | limit: number, | |
| 118 | ): { readonly entries: ProjectEntry[]; readonly truncated: boolean } { | |
| 119 | const entries: ProjectEntry[] = []; | |
| 120 | for (const item of result.items) { | |
| 121 | const entry = toProjectEntry(item); | |
| 122 | if (entry) { | |
| 123 | entries.push(entry); | |
| 124 | } | |
| 125 | if (entries.length >= limit) { | |
| 126 | break; | |
| 127 | } | |
| 128 | } | |
| 129 | ||
| 130 | const rootDirectoryCount = result.items.some( | |
| 131 | (item) => item.type === "directory" && item.item.relativePath.length === 0, | |
| 132 | ) | |
| 133 | ? 1 | |
| 134 | : 0; | |
| 135 | return { | |
| 136 | entries, | |
| 137 | truncated: result.totalMatched - rootDirectoryCount > limit, | |
| 138 | }; | |
| 139 | } | |
| 140 | ||
| 141 | function withDirectoryAncestors(entries: ReadonlyArray<ProjectEntry>): ProjectEntry[] { | |
| 142 | const entryByPath = new Map(entries.map((entry) => [entry.path, entry])); | |
| 143 | for (const entry of entries) { | |
| 144 | let parentPath = parentPathOf(entry.path); | |
| 145 | while (parentPath) { | |
| 146 | if (!entryByPath.has(parentPath)) { | |
| 147 | entryByPath.set(parentPath, { path: parentPath, kind: "directory" }); | |
| 148 | } | |
| 149 | parentPath = parentPathOf(parentPath); | |
| 150 | } | |
| 151 | } | |
| 152 | return [...entryByPath.values()]; | |
| 153 | } | |
| 154 | ||
| 155 | const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (cwd: string) { | |
| 156 | const result = FileFinder.create({ | |
| 157 | basePath: cwd, | |
| 158 | disableMmapCache: true, | |
| 159 | disableContentIndexing: true, | |
| 160 | aiMode: false, | |
| 161 | enableFsRootScanning: true, | |
| 162 | enableHomeDirScanning: true, | |
| 163 | }); | |
| 164 | if (result.ok) return result.value; | |
| 165 | return yield* new WorkspaceSearchIndexCreateFailed({ cwd, reason: result.error }); | |
| 166 | }); | |
| 167 | ||
| 168 | const waitForScan = Effect.fn("WorkspaceSearchIndex.waitForScan")(function* ( | |
| 169 | cwd: string, | |
| 170 | finder: FileFinder, | |
| 171 | ) { | |
| 172 | yield* Effect.sync(() => finder.isScanning()).pipe( | |
| 173 | Effect.repeat({ | |
| 174 | while: (scanning) => scanning, | |
| 175 | schedule: Schedule.spaced(WORKSPACE_INDEX_SCAN_POLL_INTERVAL), | |
| 176 | }), | |
| 177 | Effect.timeoutOrElse({ | |
| 178 | duration: WORKSPACE_INDEX_SCAN_TIMEOUT, | |
| 179 | orElse: () => | |
| 180 | new WorkspaceSearchIndexScanTimedOut({ cwd, timeout: WORKSPACE_INDEX_SCAN_TIMEOUT }), | |
| 181 | }), | |
| 182 | ); | |
| 183 | }); | |
| 184 | ||
| 185 | const makeWorkspaceSearchIndex = (cwd: string) => | |
| 186 | Effect.acquireRelease(createFinder(cwd), (finder) => Effect.sync(() => finder.destroy())).pipe( | |
| 187 | Effect.tap((finder) => waitForScan(cwd, finder)), | |
| 188 | Effect.map((finder) => { | |
| 189 | const runMixedSearch = Effect.fn("WorkspaceSearchIndex.runMixedSearch")(function* ( | |
| 190 | query: string, | |
| 191 | pageSize: number, | |
| 192 | ) { | |
| 193 | const result = yield* Effect.sync(() => finder.mixedSearch(query, { pageSize })); | |
| 194 | if (!result.ok) { | |
| 195 | return yield* new WorkspaceSearchIndexSearchFailed({ cwd, reason: result.error }); | |
| 196 | } | |
| 197 | return result.value; | |
| 198 | }); | |
| 199 | ||
| 200 | const refresh: WorkspaceSearchIndex["Service"]["refresh"] = Effect.fn( | |
| 201 | "WorkspaceSearchIndex.refresh", | |
| 202 | )(function* () { | |
| 203 | const result = yield* Effect.sync(() => finder.scanFiles()); | |
| 204 | if (!result.ok) { | |
| 205 | return yield* new WorkspaceSearchIndexRefreshFailed({ cwd, reason: result.error }); | |
| 206 | } | |
| 207 | yield* waitForScan(cwd, finder); | |
| 208 | }); | |
| 209 | ||
| 210 | const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")( | |
| 211 | function* () { | |
| 212 | const result = yield* runMixedSearch("", WORKSPACE_INDEX_PAGE_SIZE); | |
| 213 | const mapped = mapMixedSearchResult(result, WORKSPACE_INDEX_MAX_ENTRIES); | |
| 214 | const sortedEntries = withDirectoryAncestors(mapped.entries).toSorted((left, right) => | |
| 215 | left.path.localeCompare(right.path), | |
| 216 | ); | |
| 217 | const entries = sortedEntries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES); | |
| 218 | return { | |
| 219 | entries, | |
| 220 | truncated: mapped.truncated || entries.length < sortedEntries.length, | |
| 221 | }; | |
| 222 | }, | |
| 223 | ); | |
| 224 | ||
| 225 | const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn( | |
| 226 | "WorkspaceSearchIndex.search", | |
| 227 | )(function* (query, limit) { | |
| 228 | const result = yield* runMixedSearch(query, Math.max(1, limit + 1)); | |
| 229 | return mapMixedSearchResult(result, limit); | |
| 230 | }); | |
| 231 | ||
| 232 | return WorkspaceSearchIndex.of({ list, refresh, search }); | |
| 233 | }), | |
| 234 | ); | |
| 235 | ||
| 236 | const workspaceSearchIndexLayer = (cwd: string) => | |
| 237 | Layer.effect(WorkspaceSearchIndex, makeWorkspaceSearchIndex(cwd)); | |
| 238 | ||
| 239 | export class WorkspaceSearchIndexMap extends LayerMap.Service<WorkspaceSearchIndexMap>()( | |
| 240 | "t3/workspace/WorkspaceSearchIndexMap", | |
| 241 | { | |
| 242 | lookup: workspaceSearchIndexLayer, | |
| 243 | idleTimeToLive: WORKSPACE_INDEX_IDLE_TTL, | |
| 244 | }, | |
| 245 | ) {} |
apps/server/src/ws.ts+2-2| ... | ... | @@ -78,7 +78,7 @@ import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; |
| 78 | 78 | import * as PreviewManager from "./preview/Manager.ts"; |
| 79 | 79 | import { issueAssetUrl } from "./assets/AssetAccess.ts"; |
| 80 | 80 | import * as PortScanner from "./preview/PortScanner.ts"; |
| 81 | import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; | |
| 81 | import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; | |
| 82 | 82 | import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; |
| 83 | 83 | import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; |
| 84 | 84 | import { VcsStatusBroadcaster } from "./vcs/VcsStatusBroadcaster.ts"; |
| ... | ... | @@ -272,7 +272,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => |
| 272 | 272 | const lifecycleEvents = yield* ServerLifecycleEvents; |
| 273 | 273 | const serverSettings = yield* ServerSettingsService; |
| 274 | 274 | const startup = yield* ServerRuntimeStartup; |
| 275 | const workspaceEntries = yield* WorkspaceEntries; | |
| 275 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; | |
| 276 | 276 | const workspaceFileSystem = yield* WorkspaceFileSystem; |
| 277 | 277 | const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; |
| 278 | 278 | const repositoryIdentityResolver = yield* RepositoryIdentityResolver; |
apps/web/src/components/ChatView.browser.tsx-1| ... | ... | @@ -4432,7 +4432,6 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 4432 | 4432 | { |
| 4433 | 4433 | path: "path/to/package.json", |
| 4434 | 4434 | kind: "file", |
| 4435 | parentPath: "path/to", | |
| 4436 | 4435 | }, |
| 4437 | 4436 | ], |
| 4438 | 4437 | truncated: false, |
apps/web/src/components/chat/ChatComposer.tsx+1-1| ... | ... | @@ -928,7 +928,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 928 | 928 | path: entry.path, |
| 929 | 929 | pathKind: entry.kind, |
| 930 | 930 | label: basenameOfPath(entry.path), |
| 931 | description: entry.parentPath ?? "", | |
| 931 | description: entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))), | |
| 932 | 932 | })); |
| 933 | 933 | } |
| 934 | 934 | if (composerTrigger.kind === "slash-command") { |
packages/client-runtime/src/composerPathSearchState.ts+1-6| ... | ... | @@ -8,7 +8,6 @@ import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; |
| 8 | 8 | export interface ComposerPathSearchEntry { |
| 9 | 9 | readonly path: string; |
| 10 | 10 | readonly kind: "file" | "directory"; |
| 11 | readonly parentPath?: string; | |
| 12 | 11 | } |
| 13 | 12 | |
| 14 | 13 | export interface ComposerPathSearchState { |
| ... | ... | @@ -84,11 +83,7 @@ export function getComposerPathSearchTargetKey(target: ComposerPathSearchTarget) |
| 84 | 83 | function toSearchEntries( |
| 85 | 84 | entries: ProjectSearchEntriesResult["entries"], |
| 86 | 85 | ): ReadonlyArray<ComposerPathSearchEntry> { |
| 87 | return entries.map((entry) => ({ | |
| 88 | path: entry.path, | |
| 89 | kind: entry.kind === "directory" ? "directory" : "file", | |
| 90 | ...(entry.parentPath !== undefined ? { parentPath: entry.parentPath } : {}), | |
| 91 | })); | |
| 86 | return entries; | |
| 92 | 87 | } |
| 93 | 88 | |
| 94 | 89 | export function createComposerPathSearchManager(config: { |
packages/contracts/src/project.ts-1| ... | ... | @@ -17,7 +17,6 @@ const ProjectEntryKind = Schema.Literals(["file", "directory"]); |
| 17 | 17 | export const ProjectEntry = Schema.Struct({ |
| 18 | 18 | path: TrimmedNonEmptyString, |
| 19 | 19 | kind: ProjectEntryKind, |
| 20 | parentPath: Schema.optional(TrimmedNonEmptyString), | |
| 21 | 20 | }); |
| 22 | 21 | export type ProjectEntry = typeof ProjectEntry.Type; |
| 23 | 22 |
pnpm-lock.yaml+202| ... | ... | @@ -395,6 +395,9 @@ importers: |
| 395 | 395 | '@effect/sql-sqlite-bun': |
| 396 | 396 | specifier: 4.0.0-beta.78 |
| 397 | 397 | version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) |
| 398 | '@ff-labs/fff-node': | |
| 399 | specifier: ^0.9.4 | |
| 400 | version: 0.9.4 | |
| 398 | 401 | '@opencode-ai/sdk': |
| 399 | 402 | specifier: ^1.3.15 |
| 400 | 403 | version: 1.15.13 |
| ... | ... | @@ -2374,6 +2377,52 @@ packages: |
| 2374 | 2377 | resolution: {integrity: sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==} |
| 2375 | 2378 | hasBin: true |
| 2376 | 2379 | |
| 2380 | '@ff-labs/fff-bin-darwin-arm64@0.9.4': | |
| 2381 | resolution: {integrity: sha512-xyivu2xB++O5xXDx5Qm50JsU2aXt8YgXlGVhH/HE7UMYDrE6L6f1RYdYs8Y0bn0D3D0+bFBrN5ELPszK9E4Wbw==} | |
| 2382 | cpu: [arm64] | |
| 2383 | os: [darwin] | |
| 2384 | ||
| 2385 | '@ff-labs/fff-bin-darwin-x64@0.9.4': | |
| 2386 | resolution: {integrity: sha512-xLooAhCnTDCipPSMMZz7kGF3lhRHx6aP5fb6DJ0Ipyw/w/UWJb+xITJFszUl/QnIBoJ/qjDc93/FZMo1dk6gVA==} | |
| 2387 | cpu: [x64] | |
| 2388 | os: [darwin] | |
| 2389 | ||
| 2390 | '@ff-labs/fff-bin-linux-arm64-gnu@0.9.4': | |
| 2391 | resolution: {integrity: sha512-m5+8vA+1veaUUWonwva1WsU6m1HRm8CpYUzr06KDB65mewlmPbqz7+Fh7hjEfiD8C4mHVHe6RysULvAH1yhsdw==} | |
| 2392 | cpu: [arm64] | |
| 2393 | os: [linux] | |
| 2394 | ||
| 2395 | '@ff-labs/fff-bin-linux-arm64-musl@0.9.4': | |
| 2396 | resolution: {integrity: sha512-EMeWm7CSTVkizy4ZEzUkLDP024tVcbCUthduuIhekFQRDsiaAze0YboIylWb9HBHJCZlCCoZrWAl4nnJbsX7AA==} | |
| 2397 | cpu: [arm64] | |
| 2398 | os: [linux] | |
| 2399 | ||
| 2400 | '@ff-labs/fff-bin-linux-x64-gnu@0.9.4': | |
| 2401 | resolution: {integrity: sha512-pglE0uLkhnlE6bStXqfgUjYTSj+2sVwXaPfoA0QksidAsQor6NRt8004mygzC9DPubgHq5B9QezPfEwigKaP9Q==} | |
| 2402 | cpu: [x64] | |
| 2403 | os: [linux] | |
| 2404 | ||
| 2405 | '@ff-labs/fff-bin-linux-x64-musl@0.9.4': | |
| 2406 | resolution: {integrity: sha512-VNKxgl8qs3aTfXViX7lqRK1aLu311h8dtBFqG4Scv+9Oi7WprybUp5L7IZ8sxKERaDAaiJMXHodXa1c90QdK8w==} | |
| 2407 | cpu: [x64] | |
| 2408 | os: [linux] | |
| 2409 | ||
| 2410 | '@ff-labs/fff-bin-win32-arm64@0.9.4': | |
| 2411 | resolution: {integrity: sha512-uFEt0aNL54vQxq1ivjxRuo+thnhS4wLqa4INl4VXnXJUmwB42XXxD+gsj7vzhBLLx4cFf0aWgy/+TVDR8yjZtQ==} | |
| 2412 | cpu: [arm64] | |
| 2413 | os: [win32] | |
| 2414 | ||
| 2415 | '@ff-labs/fff-bin-win32-x64@0.9.4': | |
| 2416 | resolution: {integrity: sha512-Yd2Eyxj+slWv+0QDW9/xBpu9FXq+hwD0rXQD5184/88d+xwWCLKhEP2w8I6OO9XCg+kLT79UJb+k0WwXUtBtMw==} | |
| 2417 | cpu: [x64] | |
| 2418 | os: [win32] | |
| 2419 | ||
| 2420 | '@ff-labs/fff-node@0.9.4': | |
| 2421 | resolution: {integrity: sha512-yUphwzcVBuNrg1iEAE5XVPOWfCLuq/BE1scUxtHMmLo+e1KuKWBRgSKFXGjNBfL8lV3c5VGwIgK66aBqmhqVBQ==} | |
| 2422 | engines: {node: '>=18.0.0'} | |
| 2423 | cpu: [x64, arm64] | |
| 2424 | os: [darwin, linux, win32] | |
| 2425 | ||
| 2377 | 2426 | '@floating-ui/core@1.7.5': |
| 2378 | 2427 | resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} |
| 2379 | 2428 | |
| ... | ... | @@ -4804,6 +4853,72 @@ packages: |
| 4804 | 4853 | '@xterm/xterm@6.0.0': |
| 4805 | 4854 | resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} |
| 4806 | 4855 | |
| 4856 | '@yuuang/ffi-rs-android-arm64@1.3.2': | |
| 4857 | resolution: {integrity: sha512-eDYLT0kVBkp7e2BwdRDmt6N1rkeDPUHDefk3ZX0/nok+GLsqfy1WBoSL3Yg7HVXN1EyW8OBVc2uK8Zq8HbmaSA==} | |
| 4858 | engines: {node: '>= 12'} | |
| 4859 | cpu: [arm64] | |
| 4860 | os: [android] | |
| 4861 | ||
| 4862 | '@yuuang/ffi-rs-darwin-arm64@1.3.2': | |
| 4863 | resolution: {integrity: sha512-kRdgPaOM6TfuC5wHUwstlatk4HNie2lwSLJWQL2LiAUIJ7+96CoiWUNVhwBcFrhdfxhnWenYS6F668CV0vit8Q==} | |
| 4864 | engines: {node: '>= 12'} | |
| 4865 | cpu: [arm64] | |
| 4866 | os: [darwin] | |
| 4867 | ||
| 4868 | '@yuuang/ffi-rs-darwin-x64@1.3.2': | |
| 4869 | resolution: {integrity: sha512-O3AlVgre8FQcZRJe44Xs7A6iDLumoPXqbw40+eJCa2gyXaXyLPdHoWrS1W9rBCa1QZRRnG7zRulPVFw8C5uo8g==} | |
| 4870 | engines: {node: '>= 12'} | |
| 4871 | cpu: [x64] | |
| 4872 | os: [darwin] | |
| 4873 | ||
| 4874 | '@yuuang/ffi-rs-linux-arm-gnueabihf@1.3.2': | |
| 4875 | resolution: {integrity: sha512-IXiNdTbIcTCPny5eeElijFWYeKSJjQWSjt9ZyJNdLHYiB1Np+XD6K7wNZS6EOMgMelhW1kQE62T654skGkVDIA==} | |
| 4876 | engines: {node: '>= 12'} | |
| 4877 | cpu: [arm] | |
| 4878 | os: [linux] | |
| 4879 | ||
| 4880 | '@yuuang/ffi-rs-linux-arm64-gnu@1.3.2': | |
| 4881 | resolution: {integrity: sha512-gWFO6xufUK9lPYUqDvKa6IR243dPqdetgl9Q7HrZWaDu7wLo06QQrosw8QTzndafQnOcBKm6LoLujmGCfTgJOA==} | |
| 4882 | engines: {node: '>= 12'} | |
| 4883 | cpu: [arm64] | |
| 4884 | os: [linux] | |
| 4885 | ||
| 4886 | '@yuuang/ffi-rs-linux-arm64-musl@1.3.2': | |
| 4887 | resolution: {integrity: sha512-lejvOSqypPziQH5rzfkDlJ6e92qhWbDutE9ttOO6z5I2k83zoh9iZhZWhaXSU5VqgQpcshRkrbtXb9gy1ft5dA==} | |
| 4888 | engines: {node: '>= 12'} | |
| 4889 | cpu: [arm64] | |
| 4890 | os: [linux] | |
| 4891 | ||
| 4892 | '@yuuang/ffi-rs-linux-x64-gnu@1.3.2': | |
| 4893 | resolution: {integrity: sha512-s8VCFazaJKmgY2hgMTpWk4TtBY/zy5ovbaGgwyY0FvBD0YvyhcET4IrMsDJpHhFVTPCYfKZ1dN45clD/YiFp6g==} | |
| 4894 | engines: {node: '>= 12'} | |
| 4895 | cpu: [x64] | |
| 4896 | os: [linux] | |
| 4897 | ||
| 4898 | '@yuuang/ffi-rs-linux-x64-musl@1.3.2': | |
| 4899 | resolution: {integrity: sha512-Ahr5chfKZKWUik20bEZRug+be57LZ2yYrtolyjSRoo7A4ZniBUHBZUNWm6TD6i0CJayqyxWeVk/XiaABD8bY0w==} | |
| 4900 | engines: {node: '>= 12'} | |
| 4901 | cpu: [x64] | |
| 4902 | os: [linux] | |
| 4903 | ||
| 4904 | '@yuuang/ffi-rs-win32-arm64-msvc@1.3.2': | |
| 4905 | resolution: {integrity: sha512-yhpLcj0qel5VNlpzxPZfNmi7+rEX8444QHjUP6WWLxdRfqPllROu/Cp3OpkBpw3BLdxfcDhWkjWMD5QsJN0Pvg==} | |
| 4906 | engines: {node: '>= 12'} | |
| 4907 | cpu: [arm64] | |
| 4908 | os: [win32] | |
| 4909 | ||
| 4910 | '@yuuang/ffi-rs-win32-ia32-msvc@1.3.2': | |
| 4911 | resolution: {integrity: sha512-BFVSbdtg/7mJBw5kQFOPKFiA+SF7z3240HpzHN81Umm4Bp4dWkyx0msYn8+Q7/BBJiLQ4F6bi3Nftk58YA9r9w==} | |
| 4912 | engines: {node: '>= 12'} | |
| 4913 | cpu: [x64, ia32] | |
| 4914 | os: [win32] | |
| 4915 | ||
| 4916 | '@yuuang/ffi-rs-win32-x64-msvc@1.3.2': | |
| 4917 | resolution: {integrity: sha512-ZL5MJ76n2rjwGo26kCWW7wK6QT/cee00Rx8pfW79pz6vM6jqfhoE7zTnwFiw4aOQUes9+HUc5DeeJ3z+Vb9oLg==} | |
| 4918 | engines: {node: '>= 12'} | |
| 4919 | cpu: [x64] | |
| 4920 | os: [win32] | |
| 4921 | ||
| 4807 | 4922 | '@zxcvbn-ts/core@3.0.4': |
| 4808 | 4923 | resolution: {integrity: sha512-aQeiT0F09FuJaAqNrxynlAwZ2mW/1MdXakKWNmGM1Qp/VaY6CnB/GfnMS2T8gB2231Esp1/maCWd8vTG4OuShw==} |
| 4809 | 4924 | |
| ... | ... | @@ -6353,6 +6468,9 @@ packages: |
| 6353 | 6468 | fetch-nodeshim@0.4.10: |
| 6354 | 6469 | resolution: {integrity: sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==} |
| 6355 | 6470 | |
| 6471 | ffi-rs@1.3.2: | |
| 6472 | resolution: {integrity: sha512-4s8dX9VbBw/jd5NOuE3EJRqXaIVdjMyiumeeDzrOhtjQRwp6Bz2za7iksWXTnvTQKV/tTdm1s1w7mObe92zPjQ==} | |
| 6473 | ||
| 6356 | 6474 | filelist@1.0.6: |
| 6357 | 6475 | resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} |
| 6358 | 6476 | |
| ... | ... | @@ -11712,6 +11830,43 @@ snapshots: |
| 11712 | 11830 | chalk: 4.1.2 |
| 11713 | 11831 | js-yaml: 4.2.0 |
| 11714 | 11832 | |
| 11833 | '@ff-labs/fff-bin-darwin-arm64@0.9.4': | |
| 11834 | optional: true | |
| 11835 | ||
| 11836 | '@ff-labs/fff-bin-darwin-x64@0.9.4': | |
| 11837 | optional: true | |
| 11838 | ||
| 11839 | '@ff-labs/fff-bin-linux-arm64-gnu@0.9.4': | |
| 11840 | optional: true | |
| 11841 | ||
| 11842 | '@ff-labs/fff-bin-linux-arm64-musl@0.9.4': | |
| 11843 | optional: true | |
| 11844 | ||
| 11845 | '@ff-labs/fff-bin-linux-x64-gnu@0.9.4': | |
| 11846 | optional: true | |
| 11847 | ||
| 11848 | '@ff-labs/fff-bin-linux-x64-musl@0.9.4': | |
| 11849 | optional: true | |
| 11850 | ||
| 11851 | '@ff-labs/fff-bin-win32-arm64@0.9.4': | |
| 11852 | optional: true | |
| 11853 | ||
| 11854 | '@ff-labs/fff-bin-win32-x64@0.9.4': | |
| 11855 | optional: true | |
| 11856 | ||
| 11857 | '@ff-labs/fff-node@0.9.4': | |
| 11858 | dependencies: | |
| 11859 | ffi-rs: 1.3.2 | |
| 11860 | optionalDependencies: | |
| 11861 | '@ff-labs/fff-bin-darwin-arm64': 0.9.4 | |
| 11862 | '@ff-labs/fff-bin-darwin-x64': 0.9.4 | |
| 11863 | '@ff-labs/fff-bin-linux-arm64-gnu': 0.9.4 | |
| 11864 | '@ff-labs/fff-bin-linux-arm64-musl': 0.9.4 | |
| 11865 | '@ff-labs/fff-bin-linux-x64-gnu': 0.9.4 | |
| 11866 | '@ff-labs/fff-bin-linux-x64-musl': 0.9.4 | |
| 11867 | '@ff-labs/fff-bin-win32-arm64': 0.9.4 | |
| 11868 | '@ff-labs/fff-bin-win32-x64': 0.9.4 | |
| 11869 | ||
| 11715 | 11870 | '@floating-ui/core@1.7.5': |
| 11716 | 11871 | dependencies: |
| 11717 | 11872 | '@floating-ui/utils': 0.2.11 |
| ... | ... | @@ -13938,6 +14093,39 @@ snapshots: |
| 13938 | 14093 | |
| 13939 | 14094 | '@xterm/xterm@6.0.0': {} |
| 13940 | 14095 | |
| 14096 | '@yuuang/ffi-rs-android-arm64@1.3.2': | |
| 14097 | optional: true | |
| 14098 | ||
| 14099 | '@yuuang/ffi-rs-darwin-arm64@1.3.2': | |
| 14100 | optional: true | |
| 14101 | ||
| 14102 | '@yuuang/ffi-rs-darwin-x64@1.3.2': | |
| 14103 | optional: true | |
| 14104 | ||
| 14105 | '@yuuang/ffi-rs-linux-arm-gnueabihf@1.3.2': | |
| 14106 | optional: true | |
| 14107 | ||
| 14108 | '@yuuang/ffi-rs-linux-arm64-gnu@1.3.2': | |
| 14109 | optional: true | |
| 14110 | ||
| 14111 | '@yuuang/ffi-rs-linux-arm64-musl@1.3.2': | |
| 14112 | optional: true | |
| 14113 | ||
| 14114 | '@yuuang/ffi-rs-linux-x64-gnu@1.3.2': | |
| 14115 | optional: true | |
| 14116 | ||
| 14117 | '@yuuang/ffi-rs-linux-x64-musl@1.3.2': | |
| 14118 | optional: true | |
| 14119 | ||
| 14120 | '@yuuang/ffi-rs-win32-arm64-msvc@1.3.2': | |
| 14121 | optional: true | |
| 14122 | ||
| 14123 | '@yuuang/ffi-rs-win32-ia32-msvc@1.3.2': | |
| 14124 | optional: true | |
| 14125 | ||
| 14126 | '@yuuang/ffi-rs-win32-x64-msvc@1.3.2': | |
| 14127 | optional: true | |
| 14128 | ||
| 13941 | 14129 | '@zxcvbn-ts/core@3.0.4': |
| 13942 | 14130 | dependencies: |
| 13943 | 14131 | fastest-levenshtein: 1.0.16 |
| ... | ... | @@ -15732,6 +15920,20 @@ snapshots: |
| 15732 | 15920 | |
| 15733 | 15921 | fetch-nodeshim@0.4.10: {} |
| 15734 | 15922 | |
| 15923 | ffi-rs@1.3.2: | |
| 15924 | optionalDependencies: | |
| 15925 | '@yuuang/ffi-rs-android-arm64': 1.3.2 | |
| 15926 | '@yuuang/ffi-rs-darwin-arm64': 1.3.2 | |
| 15927 | '@yuuang/ffi-rs-darwin-x64': 1.3.2 | |
| 15928 | '@yuuang/ffi-rs-linux-arm-gnueabihf': 1.3.2 | |
| 15929 | '@yuuang/ffi-rs-linux-arm64-gnu': 1.3.2 | |
| 15930 | '@yuuang/ffi-rs-linux-arm64-musl': 1.3.2 | |
| 15931 | '@yuuang/ffi-rs-linux-x64-gnu': 1.3.2 | |
| 15932 | '@yuuang/ffi-rs-linux-x64-musl': 1.3.2 | |
| 15933 | '@yuuang/ffi-rs-win32-arm64-msvc': 1.3.2 | |
| 15934 | '@yuuang/ffi-rs-win32-ia32-msvc': 1.3.2 | |
| 15935 | '@yuuang/ffi-rs-win32-x64-msvc': 1.3.2 | |
| 15936 | ||
| 15735 | 15937 | filelist@1.0.6: |
| 15736 | 15938 | dependencies: |
| 15737 | 15939 | minimatch: 5.1.9 |